aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components/FilesListVirtual.vue
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/components/FilesListVirtual.vue')
-rw-r--r--apps/files/src/components/FilesListVirtual.vue1035
1 files changed, 1035 insertions, 0 deletions
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
new file mode 100644
index 00000000000..47b8ef19b19
--- /dev/null
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -0,0 +1,1035 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <VirtualList ref="table"
+ :data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
+ :data-key="'source'"
+ :data-sources="nodes"
+ :grid-mode="userConfig.grid_view"
+ :extra-props="{
+ isMimeAvailable,
+ isMtimeAvailable,
+ isSizeAvailable,
+ nodes,
+ }"
+ :scroll-to-index="scrollToIndex"
+ :caption="caption">
+ <template #filters>
+ <FileListFilters />
+ </template>
+
+ <template v-if="!isNoneSelected" #header-overlay>
+ <span class="files-list__selected">
+ {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
+ </span>
+ <FilesListTableHeaderActions :current-view="currentView"
+ :selected-nodes="selectedNodes" />
+ </template>
+
+ <template #before>
+ <!-- Headers -->
+ <FilesListHeader v-for="header in headers"
+ :key="header.id"
+ :current-folder="currentFolder"
+ :current-view="currentView"
+ :header="header" />
+ </template>
+
+ <!-- Thead-->
+ <template #header>
+ <!-- Table header and sort buttons -->
+ <FilesListTableHeader ref="thead"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes" />
+ </template>
+
+ <!-- Body replacement if no files are available -->
+ <template #empty>
+ <slot name="empty" />
+ </template>
+
+ <!-- Tfoot-->
+ <template #footer>
+ <FilesListTableFooter :current-view="currentView"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes"
+ :summary="summary" />
+ </template>
+ </VirtualList>
+</template>
+
+<script lang="ts">
+import type { UserConfig } from '../types'
+import type { Node as NcNode } from '@nextcloud/files'
+import type { ComponentPublicInstance, PropType } from 'vue'
+
+import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
+import { showError } from '@nextcloud/dialogs'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { n, t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { useActiveStore } from '../store/active.ts'
+import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+import { useSelectionStore } from '../store/selection.js'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import logger from '../logger.ts'
+
+import FileEntry from './FileEntry.vue'
+import FileEntryGrid from './FileEntryGrid.vue'
+import FileListFilters from './FileListFilters.vue'
+import FilesListHeader from './FilesListHeader.vue'
+import FilesListTableFooter from './FilesListTableFooter.vue'
+import FilesListTableHeader from './FilesListTableHeader.vue'
+import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
+import VirtualList from './VirtualList.vue'
+
+export default defineComponent({
+ name: 'FilesListVirtual',
+
+ components: {
+ FileListFilters,
+ FilesListHeader,
+ FilesListTableFooter,
+ FilesListTableHeader,
+ VirtualList,
+ FilesListTableHeaderActions,
+ },
+
+ props: {
+ currentView: {
+ type: View,
+ required: true,
+ },
+ currentFolder: {
+ type: Folder,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<NcNode[]>,
+ required: true,
+ },
+ summary: {
+ type: String,
+ required: true,
+ },
+ },
+
+ setup() {
+ const activeStore = useActiveStore()
+ const selectionStore = useSelectionStore()
+ const userConfigStore = useUserConfigStore()
+
+ const fileListWidth = useFileListWidth()
+ const { fileId, openDetails, openFile } = useRouteParameters()
+
+ return {
+ fileId,
+ fileListWidth,
+ headers: useFileListHeaders(),
+ openDetails,
+ openFile,
+
+ activeStore,
+ selectionStore,
+ userConfigStore,
+
+ n,
+ t,
+ }
+ },
+
+ data() {
+ return {
+ FileEntry,
+ FileEntryGrid,
+ scrollToIndex: 0,
+ }
+ },
+
+ computed: {
+ userConfig(): UserConfig {
+ return this.userConfigStore.userConfig
+ },
+
+ isMimeAvailable() {
+ if (!this.userConfig.show_mime_column) {
+ return false
+ }
+ // Hide mime column on narrow screens
+ if (this.fileListWidth < 1024) {
+ return false
+ }
+ return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream')
+ },
+ isMtimeAvailable() {
+ // Hide mtime column on narrow screens
+ if (this.fileListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.mtime !== undefined)
+ },
+ isSizeAvailable() {
+ // Hide size column on narrow screens
+ if (this.fileListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.size !== undefined)
+ },
+
+ cantUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
+ },
+
+ isQuotaExceeded() {
+ return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
+ },
+
+ caption() {
+ const defaultCaption = t('files', 'List of files and folders.')
+ const viewCaption = this.currentView.caption || defaultCaption
+ const cantUploadCaption = this.cantUpload ? t('files', 'You do not have permission to upload or create files here.') : null
+ const quotaExceededCaption = this.isQuotaExceeded ? t('files', 'You have used your space quota and cannot upload files anymore.') : null
+ const sortableCaption = t('files', 'Column headers with buttons are sortable.')
+ const virtualListNote = t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.')
+ return [
+ viewCaption,
+ cantUploadCaption,
+ quotaExceededCaption,
+ sortableCaption,
+ virtualListNote,
+ ].filter(Boolean).join('\n')
+ },
+
+ selectedNodes() {
+ return this.selectionStore.selected
+ },
+
+ isNoneSelected() {
+ return this.selectedNodes.length === 0
+ },
+
+ isEmpty() {
+ return this.nodes.length === 0
+ },
+ },
+
+ watch: {
+ // If nodes gets populated and we have a fileId,
+ // an openFile or openDetails, we fire the appropriate actions.
+ isEmpty() {
+ this.handleOpenQueries()
+ },
+ fileId() {
+ this.handleOpenQueries()
+ },
+ openFile() {
+ this.handleOpenQueries()
+ },
+ openDetails() {
+ this.handleOpenQueries()
+ },
+ },
+
+ created() {
+ useHotKey('Escape', this.unselectFile, {
+ stop: true,
+ prevent: true,
+ })
+
+ useHotKey(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'], this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
+ mounted() {
+ // Add events on parent to cover both the table and DragAndDrop notice
+ const mainContent = window.document.querySelector('main.app-content') as HTMLElement
+ mainContent.addEventListener('dragover', this.onDragOver)
+ subscribe('files:sidebar:closed', this.onSidebarClosed)
+ },
+
+ beforeDestroy() {
+ const mainContent = window.document.querySelector('main.app-content') as HTMLElement
+ mainContent.removeEventListener('dragover', this.onDragOver)
+ unsubscribe('files:sidebar:closed', this.onSidebarClosed)
+ },
+
+ methods: {
+ handleOpenQueries() {
+ // If the list is empty, or we don't have a fileId,
+ // there's nothing to be done.
+ if (this.isEmpty || !this.fileId) {
+ return
+ }
+
+ logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', {
+ nodes: this.nodes,
+ fileId: this.fileId,
+ openFile: this.openFile,
+ openDetails: this.openDetails,
+ })
+
+ if (this.openFile) {
+ this.handleOpenFile(this.fileId)
+ }
+
+ if (this.openDetails) {
+ this.openSidebarForFile(this.fileId)
+ }
+
+ if (this.fileId) {
+ this.scrollToFile(this.fileId, false)
+ }
+ },
+
+ openSidebarForFile(fileId) {
+ // Open the sidebar for the given URL fileid
+ // iif we just loaded the app.
+ const node = this.nodes.find(n => n.fileid === fileId) as NcNode
+ if (node && sidebarAction?.enabled?.([node], this.currentView)) {
+ logger.debug('Opening sidebar on file ' + node.path, { node })
+ sidebarAction.exec(node, this.currentView, this.currentFolder.path)
+ return
+ }
+ logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
+ },
+
+ scrollToFile(fileId: number|null, warn = true) {
+ if (fileId) {
+ // Do not uselessly scroll to the top of the list.
+ if (fileId === this.currentFolder.fileid) {
+ return
+ }
+
+ const index = this.nodes.findIndex(node => node.fileid === fileId)
+ if (warn && index === -1 && fileId !== this.currentFolder.fileid) {
+ showError(t('files', 'File not found'))
+ }
+
+ this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
+ }
+ },
+
+ /**
+ * Unselect the current file and clear open parameters from the URL
+ */
+ unselectFile() {
+ const query = { ...this.$route.query }
+ delete query.openfile
+ delete query.opendetails
+
+ this.activeStore.activeNode = undefined
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
+ query,
+ true,
+ )
+ },
+
+ // When sidebar is closed, we remove the openDetails parameter from the URL
+ onSidebarClosed() {
+ if (this.openDetails) {
+ const query = { ...this.$route.query }
+ delete query.opendetails
+ window.OCP.Files.Router.goToRoute(
+ null,
+ this.$route.params,
+ query,
+ )
+ }
+ },
+
+ /**
+ * Handle opening a file (e.g. by ?openfile=true)
+ * @param fileId File to open
+ */
+ async handleOpenFile(fileId: number) {
+ const node = this.nodes.find(n => n.fileid === fileId) as NcNode
+ if (node === undefined) {
+ return
+ }
+
+ if (node.type === FileType.File) {
+ const defaultAction = getFileActions()
+ // Get only default actions (visible and hidden)
+ .filter((action) => !!action?.default)
+ // Find actions that are either always enabled or enabled for the current node
+ .filter((action) => !action.enabled || action.enabled([node], this.currentView))
+ .filter((action) => action.id !== 'download')
+ // Sort enabled default actions by order
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ // Get the first one
+ .at(0)
+
+ // Some file types do not have a default action (e.g. they can only be downloaded)
+ // So if there is an enabled default action, so execute it
+ if (defaultAction) {
+ logger.debug('Opening file ' + node.path, { node })
+ return await defaultAction.exec(node, this.currentView, this.currentFolder.path)
+ }
+ }
+ // The file is either a folder or has no default action other than downloading
+ // in this case we need to open the details instead and remove the route from the history
+ logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
+ window.OCP.Files.Router.goToRoute(
+ null,
+ this.$route.params,
+ { ...this.$route.query, openfile: undefined, opendetails: '' },
+ true, // silent update of the URL
+ )
+ },
+
+ onDragOver(event: DragEvent) {
+ // Detect if we're only dragging existing files or not
+ const isForeignFile = event.dataTransfer?.types.includes('Files')
+ if (isForeignFile) {
+ // Only handle uploading of existing Nextcloud files
+ // See DragAndDropNotice for handling of foreign files
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const tableElement = (this.$refs.table as ComponentPublicInstance<typeof VirtualList>).$el
+ const tableTop = tableElement.getBoundingClientRect().top
+ const tableBottom = tableTop + tableElement.getBoundingClientRect().height
+
+ // If reaching top, scroll up. Using 100 because of the floating header
+ if (event.clientY < tableTop + 100) {
+ tableElement.scrollTop = tableElement.scrollTop - 25
+ return
+ }
+
+ // If reaching bottom, scroll down
+ if (event.clientY > tableBottom - 50) {
+ tableElement.scrollTop = tableElement.scrollTop + 25
+ }
+ },
+
+ onKeyDown(event: KeyboardEvent) {
+ // Up and down arrow keys
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
+ const columnCount = this.$refs.table?.columnCount ?? 1
+ const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
+ const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount
+ if (nextIndex < 0 || nextIndex >= this.nodes.length) {
+ return
+ }
+
+ const nextNode = this.nodes[nextIndex]
+
+ if (nextNode && nextNode?.fileid) {
+ this.setActiveNode(nextNode)
+ }
+ }
+
+ // if grid mode, left and right arrow keys
+ if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
+ const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1
+ if (nextIndex < 0 || nextIndex >= this.nodes.length) {
+ return
+ }
+
+ const nextNode = this.nodes[nextIndex]
+
+ if (nextNode && nextNode?.fileid) {
+ this.setActiveNode(nextNode)
+ }
+ }
+ },
+
+ setActiveNode(node: NcNode & { fileid: number }) {
+ logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid })
+ this.scrollToFile(node.fileid)
+
+ // Remove openfile and opendetails from the URL
+ const query = { ...this.$route.query }
+ delete query.openfile
+ delete query.opendetails
+
+ this.activeStore.activeNode = node
+
+ // Silent update of the URL
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...this.$route.params, fileid: String(node.fileid) },
+ query,
+ true,
+ )
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list {
+ --row-height: 44px;
+ --cell-margin: 14px;
+
+ --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
+ --checkbox-size: 24px;
+ --clickable-area: var(--default-clickable-area);
+ --icon-preview-size: 24px;
+
+ --fixed-block-start-position: var(--default-clickable-area);
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ height: 100%;
+ will-change: scroll-position;
+
+ &:has(.file-list-filters__active) {
+ --fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small));
+ }
+
+ & :deep() {
+ // Table head, body and footer
+ tbody {
+ will-change: padding;
+ contain: layout paint style;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ // Necessary for virtual scrolling absolute
+ position: relative;
+
+ /* Hover effect on tbody lines only */
+ tr {
+ contain: strict;
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-dark);
+ }
+ }
+ }
+
+ // Before table and thead
+ .files-list__before {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .files-list__selected {
+ padding-inline-end: 12px;
+ white-space: nowrap;
+ }
+
+ .files-list__table {
+ display: block;
+
+ &.files-list__table--with-thead-overlay {
+ // Hide the table header below the overlay
+ margin-block-start: calc(-1 * var(--row-height));
+ }
+
+ // Visually hide the table when there are no files
+ &--hidden {
+ visibility: hidden;
+ z-index: -1;
+ opacity: 0;
+ }
+ }
+
+ .files-list__filters {
+ // Pinned on top when scrolling above table header
+ position: sticky;
+ top: 0;
+ // ensure there is a background to hide the file list on scroll
+ background-color: var(--color-main-background);
+ z-index: 10;
+ // fixed the size
+ padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
+ height: var(--fixed-block-start-position);
+ width: 100%;
+ }
+
+ .files-list__thead-overlay {
+ // Pinned on top when scrolling
+ position: sticky;
+ top: var(--fixed-block-start-position);
+ // Save space for a row checkbox
+ margin-inline-start: var(--row-height);
+ // More than .files-list__thead
+ z-index: 20;
+
+ display: flex;
+ align-items: center;
+
+ // Reuse row styles
+ background-color: var(--color-main-background);
+ border-block-end: 1px solid var(--color-border);
+ height: var(--row-height);
+ flex: 0 0 var(--row-height);
+ }
+
+ .files-list__thead,
+ .files-list__tfoot {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ background-color: var(--color-main-background);
+ }
+
+ // Table header
+ .files-list__thead {
+ // Pinned on top when scrolling
+ position: sticky;
+ z-index: 10;
+ top: var(--fixed-block-start-position);
+ }
+
+ // Empty content
+ .files-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ tr {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ border-block-end: 1px solid var(--color-border);
+ box-sizing: border-box;
+ user-select: none;
+ height: var(--row-height);
+ }
+
+ td, th {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ justify-content: start;
+ width: var(--row-height);
+ height: var(--row-height);
+ margin: 0;
+ padding: 0;
+ color: var(--color-text-maxcontrast);
+ border: none;
+
+ // Columns should try to add any text
+ // node wrapped in a span. That should help
+ // with the ellipsis on overflow.
+ span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .files-list__row--failed {
+ position: absolute;
+ display: block;
+ top: 0;
+ inset-inline: 0;
+ bottom: 0;
+ opacity: .1;
+ z-index: -1;
+ background: var(--color-error);
+ }
+
+ .files-list__row-checkbox {
+ justify-content: center;
+
+ .checkbox-radio-switch {
+ display: flex;
+ justify-content: center;
+
+ --icon-size: var(--checkbox-size);
+
+ label.checkbox-radio-switch__label {
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ margin: 0;
+ padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2);
+ }
+
+ .checkbox-radio-switch__icon {
+ margin: 0 !important;
+ }
+ }
+ }
+
+ .files-list__row {
+ &:hover, &:focus, &:active, &--active, &--dragover {
+ // WCAG AA compliant
+ background-color: var(--color-background-hover);
+ // text-maxcontrast have been designed to pass WCAG AA over
+ // a white background, we need to adjust then.
+ --color-text-maxcontrast: var(--color-main-text);
+ > * {
+ --color-border: var(--color-border-dark);
+ }
+
+ // Hover state of the row should also change the favorite markers background
+ .favorite-marker-icon svg path {
+ stroke: var(--color-background-hover);
+ }
+ }
+
+ &--dragover * {
+ // Prevent dropping on row children
+ pointer-events: none;
+ }
+ }
+
+ // Entry preview or mime icon
+ .files-list__row-icon {
+ position: relative;
+ display: flex;
+ overflow: visible;
+ align-items: center;
+ // No shrinking or growing allowed
+ flex: 0 0 var(--icon-preview-size);
+ justify-content: center;
+ width: var(--icon-preview-size);
+ height: 100%;
+ // Show same padding as the checkbox right padding for visual balance
+ margin-inline-end: var(--checkbox-padding);
+ color: var(--color-primary-element);
+
+ // Icon is also clickable
+ * {
+ cursor: pointer;
+ }
+
+ & > span {
+ justify-content: flex-start;
+
+ &:not(.files-list__row-icon-favorite) svg {
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
+
+ // Slightly increase the size of the folder icon
+ &.folder-icon,
+ &.folder-open-icon {
+ margin: -3px;
+ svg {
+ width: calc(var(--icon-preview-size) + 6px);
+ height: calc(var(--icon-preview-size) + 6px);
+ }
+ }
+ }
+
+ &-preview-container {
+ position: relative; // Needed for the blurshash to be positioned correctly
+ overflow: hidden;
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ border-radius: var(--border-radius);
+ }
+
+ &-blurhash {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+ }
+
+ &-preview {
+ // Center and contain the preview
+ object-fit: contain;
+ object-position: center;
+
+ height: 100%;
+ width: 100%;
+
+ /* Preview not loaded animation effect */
+ &:not(.files-list__row-icon-preview--loaded) {
+ background: var(--color-loading-dark);
+ // animation: preview-gradient-fade 1.2s ease-in-out infinite;
+ }
+ }
+
+ &-favorite {
+ position: absolute;
+ top: 0px;
+ inset-inline-end: -10px;
+ }
+
+ // File and folder overlay
+ &-overlay {
+ position: absolute;
+ max-height: calc(var(--icon-preview-size) * 0.6);
+ max-width: calc(var(--icon-preview-size) * 0.6);
+ color: var(--color-primary-element-text);
+ // better alignment with the folder icon
+ margin-block-start: 2px;
+
+ // Improve icon contrast with a background for files
+ &--file {
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border-radius: 100%;
+ }
+ }
+ }
+
+ // Entry link
+ .files-list__row-name {
+ // Prevent link from overflowing
+ overflow: hidden;
+ // Take as much space as possible
+ flex: 1 1 auto;
+
+ button.files-list__row-name-link {
+ display: flex;
+ align-items: center;
+ text-align: start;
+ // Fill cell height and width
+ width: 100%;
+ height: 100%;
+ // Necessary for flex grow to work
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+
+ // Already added to the inner text, see rule below
+ &:focus-visible {
+ outline: none !important;
+ }
+
+ // Keyboard indicator a11y
+ &:focus .files-list__row-name-text {
+ outline: var(--border-width-input-focused) solid var(--color-main-text) !important;
+ border-radius: var(--border-radius-element);
+ }
+ &:focus:not(:focus-visible) .files-list__row-name-text {
+ outline: none !important;
+ }
+ }
+
+ .files-list__row-name-text {
+ color: var(--color-main-text);
+ // Make some space for the outline
+ padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
+ padding-inline-start: -10px;
+ // Align two name and ext
+ display: inline-flex;
+ }
+
+ .files-list__row-name-ext {
+ color: var(--color-text-maxcontrast);
+ // always show the extension
+ overflow: visible;
+ }
+ }
+
+ // Rename form
+ .files-list__row-rename {
+ width: 100%;
+ max-width: 600px;
+ input {
+ width: 100%;
+ // Align with text, 0 - padding - border
+ margin-inline-start: -8px;
+ padding: 2px 6px;
+ border-width: 2px;
+
+ &:invalid {
+ // Show red border on invalid input
+ border-color: var(--color-error);
+ color: red;
+ }
+ }
+ }
+
+ .files-list__row-actions {
+ // take as much space as necessary
+ width: auto;
+
+ // Add margin to all cells after the actions
+ & ~ td,
+ & ~ th {
+ margin: 0 var(--cell-margin);
+ }
+
+ button {
+ .button-vue__text {
+ // Remove bold from default button styling
+ font-weight: normal;
+ }
+ }
+ }
+
+ .files-list__row-action--inline {
+ margin-inline-end: 7px;
+ }
+
+ .files-list__row-mime,
+ .files-list__row-mtime,
+ .files-list__row-size {
+ color: var(--color-text-maxcontrast);
+ }
+
+ .files-list__row-size {
+ width: calc(var(--row-height) * 2);
+ // Right align content/text
+ justify-content: flex-end;
+ }
+
+ .files-list__row-mtime {
+ width: calc(var(--row-height) * 2.5);
+ }
+
+ .files-list__row-mime {
+ width: calc(var(--row-height) * 3.5);
+ }
+
+ .files-list__row-column-custom {
+ width: calc(var(--row-height) * 2.5);
+ }
+ }
+}
+
+@media screen and (max-width: 512px) {
+ .files-list :deep(.files-list__filters) {
+ // Reduce padding on mobile
+ padding-inline: var(--default-grid-baseline, 4px);
+ }
+}
+
+</style>
+
+<style lang="scss">
+// Grid mode
+.files-list--grid tbody.files-list__tbody {
+ --item-padding: 16px;
+ --icon-preview-size: 166px;
+ --name-height: var(--default-clickable-area);
+ --mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline));
+ --row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2);
+ --row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2);
+ --checkbox-padding: 0px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, var(--row-width));
+
+ align-content: center;
+ align-items: center;
+ justify-content: space-around;
+ justify-items: center;
+
+ tr {
+ display: flex;
+ flex-direction: column;
+ width: var(--row-width);
+ height: var(--row-height);
+ border: none;
+ border-radius: var(--border-radius-large);
+ padding: var(--item-padding);
+ }
+
+ // Checkbox in the top left
+ .files-list__row-checkbox {
+ position: absolute;
+ z-index: 9;
+ top: calc(var(--item-padding) / 2);
+ inset-inline-start: calc(var(--item-padding) / 2);
+ overflow: hidden;
+ --checkbox-container-size: 44px;
+ width: var(--checkbox-container-size);
+ height: var(--checkbox-container-size);
+
+ // Add a background to the checkbox so we do not see the image through it.
+ .checkbox-radio-switch__content::after {
+ content: '';
+ width: 16px;
+ height: 16px;
+ position: absolute;
+ inset-inline-start: 50%;
+ margin-inline-start: -8px;
+ z-index: -1;
+ background: var(--color-main-background);
+ }
+ }
+
+ // Star icon in the top right
+ .files-list__row-icon-favorite {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+
+ .files-list__row-name {
+ display: flex;
+ flex-direction: column;
+ width: var(--icon-preview-size);
+ height: calc(var(--icon-preview-size) + var(--name-height));
+ // Ensure that the name outline is visible.
+ overflow: visible;
+
+ span.files-list__row-icon {
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
+
+ .files-list__row-name-text {
+ margin: 0;
+ // Ensure that the outline is not too close to the text.
+ margin-inline-start: -4px;
+ padding: 0px 4px;
+ }
+ }
+
+ .files-list__row-mtime {
+ width: var(--icon-preview-size);
+ height: var(--mtime-height);
+ font-size: var(--font-size-small);
+ }
+
+ .files-list__row-actions {
+ position: absolute;
+ inset-inline-end: calc(var(--clickable-area) / 4);
+ inset-block-end: calc(var(--mtime-height) / 2);
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+}
+
+@media screen and (max-width: 768px) {
+ // there is no mtime
+ .files-list--grid tbody.files-list__tbody {
+ --mtime-height: 0px;
+
+ // so we move the action to the name
+ .files-list__row-actions {
+ inset-block-end: var(--item-padding);
+ }
+
+ // and we need to keep space on the name for the actions
+ .files-list__row-name-text {
+ padding-inline-end: var(--clickable-area) !important;
+ }
+ }
+}
+</style>