diff options
Diffstat (limited to 'apps/files/src/views/FilesList.vue')
-rw-r--r-- | apps/files/src/views/FilesList.vue | 909 |
1 files changed, 909 insertions, 0 deletions
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue new file mode 100644 index 00000000000..f9e517e92ee --- /dev/null +++ b/apps/files/src/views/FilesList.vue @@ -0,0 +1,909 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcAppContent :page-heading="pageHeading" data-cy-files-content> + <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }"> + <!-- Current folder breadcrumbs --> + <BreadCrumbs :path="directory" @reload="fetchContent"> + <template #actions> + <!-- Sharing button --> + <NcButton v-if="canShare && fileListWidth >= 512" + :aria-label="shareButtonLabel" + :class="{ 'files-list__header-share-button--shared': shareButtonType }" + :title="shareButtonLabel" + class="files-list__header-share-button" + type="tertiary" + @click="openSharingSidebar"> + <template #icon> + <LinkIcon v-if="shareButtonType === ShareType.Link" /> + <AccountPlusIcon v-else :size="20" /> + </template> + </NcButton> + + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + </template> + </BreadCrumbs> + + <!-- Secondary loading indicator --> + <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> + + <NcActions class="files-list__header-actions" + :inline="1" + type="tertiary" + force-name> + <NcActionButton v-for="action in enabledFileListActions" + :key="action.id" + :disabled="!!loadingAction" + :data-cy-files-list-action="action.id" + close-after-click + @click="execFileListAction(action)"> + <template #icon> + <NcLoadingIcon v-if="loadingAction === action.id" :size="18" /> + <NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView" + :svg="action.iconSvgInline(currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </NcActions> + + <NcButton v-if="fileListWidth >= 512 && enableGridView" + :aria-label="gridViewButtonLabel" + :title="gridViewButtonLabel" + class="files-list__header-grid-button" + type="tertiary" + @click="toggleGridView"> + <template #icon> + <ListViewIcon v-if="userConfig.grid_view" /> + <ViewGridIcon v-else /> + </template> + </NcButton> + </div> + + <!-- Drag and drop notice --> + <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" /> + + <!-- + Initial current view loading0. This should never happen, + views are supposed to be registered far earlier in the lifecycle. + In case the URL is bad or a view is missing, we show a loading icon. + --> + <NcLoadingIcon v-if="!currentView" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- File list - always mounted --> + <FilesListVirtual v-else + ref="filesListVirtual" + :current-folder="currentFolder" + :current-view="currentView" + :nodes="dirContentsSorted" + :summary="summary"> + <template #empty> + <!-- Initial loading --> + <NcLoadingIcon v-if="loading && !isRefreshing" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- Empty due to error --> + <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error> + <template #action> + <NcButton type="secondary" @click="fetchContent"> + <template #icon> + <IconReload :size="20" /> + </template> + {{ t('files', 'Retry') }} + </NcButton> + </template> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + + <!-- Custom empty view --> + <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper"> + <div ref="customEmptyView" /> + </div> + + <!-- Default empty directory view --> + <NcEmptyContent v-else + :name="currentView?.emptyTitle || t('files', 'No files in here')" + :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" + data-cy-files-content-empty> + <template v-if="directory !== '/'" #action> + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + <NcButton v-else :to="toPreviousDir" type="primary"> + {{ t('files', 'Go back') }} + </NcButton> + </template> + <template #icon> + <NcIconSvgWrapper :svg="currentView?.icon" /> + </template> + </NcEmptyContent> + </template> + </FilesListVirtual> + </NcAppContent> +</template> + +<script lang="ts"> +import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files' +import type { Upload } from '@nextcloud/upload' +import type { CancelablePromise } from 'cancelable-promise' +import type { ComponentPublicInstance } from 'vue' +import type { Route } from 'vue-router' +import type { UserConfig } from '../types.ts' + +import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' +import { translate as t } from '@nextcloud/l10n' +import { join, dirname, normalize, relative } from 'path' +import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' +import { ShareType } from '@nextcloud/sharing' +import { UploadPicker, UploadStatus } from '@nextcloud/upload' +import { loadState } from '@nextcloud/initial-state' +import { useThrottleFn } from '@vueuse/core' +import { defineComponent } from 'vue' + +import NcAppContent from '@nextcloud/vue/components/NcAppContent' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconReload from 'vue-material-design-icons/Reload.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue' +import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files.ts' +import { useFiltersStore } from '../store/filters.ts' +import { usePathsStore } from '../store/paths.ts' +import { useSelectionStore } from '../store/selection.ts' +import { useUploaderStore } from '../store/uploader.ts' +import { useUserConfigStore } from '../store/userconfig.ts' +import { useViewConfigStore } from '../store/viewConfig.ts' +import { humanizeWebDAVError } from '../utils/davUtils.ts' +import { getSummaryFor } from '../utils/fileUtils.ts' +import { defaultView } from '../utils/filesViews.ts' +import BreadCrumbs from '../components/BreadCrumbs.vue' +import DragAndDropNotice from '../components/DragAndDropNotice.vue' +import FilesListVirtual from '../components/FilesListVirtual.vue' +import filesSortingMixin from '../mixins/filesSorting.ts' +import logger from '../logger.ts' + +const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined + +export default defineComponent({ + name: 'FilesList', + + components: { + BreadCrumbs, + DragAndDropNotice, + FilesListVirtual, + LinkIcon, + ListViewIcon, + NcAppContent, + NcActions, + NcActionButton, + NcButton, + NcEmptyContent, + NcIconSvgWrapper, + NcLoadingIcon, + AccountPlusIcon, + UploadPicker, + ViewGridIcon, + IconAlertCircleOutline, + IconReload, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + isPublic: { + type: Boolean, + default: false, + }, + }, + + setup() { + const { currentView } = useNavigation() + const { directory, fileId } = useRouteParameters() + const fileListWidth = useFileListWidth() + + const activeStore = useActiveStore() + const filesStore = useFilesStore() + const filtersStore = useFiltersStore() + const pathsStore = usePathsStore() + const selectionStore = useSelectionStore() + const uploaderStore = useUploaderStore() + const userConfigStore = useUserConfigStore() + const viewConfigStore = useViewConfigStore() + + const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) + const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) + + return { + currentView, + directory, + fileId, + fileListWidth, + t, + + activeStore, + filesStore, + filtersStore, + pathsStore, + selectionStore, + uploaderStore, + userConfigStore, + viewConfigStore, + + // non reactive data + enableGridView, + forbiddenCharacters, + ShareType, + } + }, + + data() { + return { + loading: true, + loadingAction: null as string | null, + error: null as string | null, + promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null, + + dirContentsFiltered: [] as INode[], + } + }, + + computed: { + /** + * Get a callback function for the uploader to fetch directory contents for conflict resolution + */ + getContent() { + const view = this.currentView! + return async (path?: string) => { + // as the path is allowed to be undefined we need to normalize the path ('//' to '/') + const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`) + // Try cache first + const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath) + if (nodes.length > 0) { + return nodes + } + // If not found in the files store (cache) + // use the current view to fetch the content for the requested path + return (await view.getContents(normalizedPath)).contents + } + }, + + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + + pageHeading(): string { + const title = this.currentView?.name ?? t('files', 'Files') + + if (this.currentFolder === undefined || this.directory === '/') { + return title + } + return `${this.currentFolder.displayname} - ${title}` + }, + + /** + * The current folder. + */ + currentFolder(): Folder { + // Temporary fake folder to use until we have the first valid folder + // fetched and cached. This allow us to mount the FilesListVirtual + // at all time and avoid unmount/mount and undesired rendering issues. + const dummyFolder = new Folder({ + id: 0, + source: getRemoteURL() + getRootPath(), + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.NONE, + }) + + if (!this.currentView?.id) { + return dummyFolder + } + + return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder + }, + + dirContents(): Node[] { + return (this.currentFolder?._children || []) + .map(this.filesStore.getNode) + .filter((node: Node) => !!node) + }, + + /** + * The current directory contents. + */ + dirContentsSorted(): INode[] { + if (!this.currentView) { + return [] + } + + const customColumn = (this.currentView?.columns || []) + .find(column => column.id === this.sortingMode) + + // Custom column must provide their own sorting methods + if (customColumn?.sort && typeof customColumn.sort === 'function') { + const results = [...this.dirContentsFiltered].sort(customColumn.sort) + return this.isAscSorting ? results : results.reverse() + } + + const nodes = sortNodes(this.dirContentsFiltered, { + sortFavoritesFirst: this.userConfig.sort_favorites_first, + sortFoldersFirst: this.userConfig.sort_folders_first, + sortingMode: this.sortingMode, + sortingOrder: this.isAscSorting ? 'asc' : 'desc', + }) + + // TODO upstream this + if (this.currentView.id === 'files') { + nodes.sort((a, b) => { + const aa = relative(a.source, this.currentFolder!.source) === '..' + const bb = relative(b.source, this.currentFolder!.source) === '..' + if (aa && bb) { + return 0 + } else if (aa) { + return -1 + } + return 1 + }) + } + + return nodes + }, + + /** + * The current directory is empty. + */ + isEmptyDir(): boolean { + return this.dirContents.length === 0 + }, + + /** + * We are refreshing the current directory. + * But we already have a cached version of it + * that is not empty. + */ + isRefreshing(): boolean { + return this.currentFolder !== undefined + && !this.isEmptyDir + && this.loading + }, + + /** + * Route to the previous directory. + */ + toPreviousDir(): Route { + const dir = this.directory.split('/').slice(0, -1).join('/') || '/' + return { ...this.$route, query: { dir } } + }, + + shareTypesAttributes(): number[] | undefined { + if (!this.currentFolder?.attributes?.['share-types']) { + return undefined + } + return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[] + }, + shareButtonLabel() { + if (!this.shareTypesAttributes) { + return t('files', 'Share') + } + + if (this.shareButtonType === ShareType.Link) { + return t('files', 'Shared by link') + } + return t('files', 'Shared') + }, + shareButtonType(): ShareType | null { + if (!this.shareTypesAttributes) { + return null + } + + // If all types are links, show the link icon + if (this.shareTypesAttributes.some(type => type === ShareType.Link)) { + return ShareType.Link + } + + return ShareType.User + }, + + gridViewButtonLabel() { + return this.userConfig.grid_view + ? t('files', 'Switch to list view') + : t('files', 'Switch to grid view') + }, + + /** + * Check if the current folder has create permissions + */ + canUpload() { + return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0 + }, + isQuotaExceeded() { + return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 + }, + + /** + * Check if current folder has share permissions + */ + canShare() { + return isSharingEnabled && !this.isPublic + && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 + }, + + showCustomEmptyView() { + return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined + }, + + enabledFileListActions() { + if (!this.currentView || !this.currentFolder) { + return [] + } + + const actions = getFileListActions() + const enabledActions = actions + .filter(action => { + if (action.enabled === undefined) { + return true + } + return action.enabled( + this.currentView!, + this.dirContents, + this.currentFolder as Folder, + ) + }) + .toSorted((a, b) => a.order - b.order) + return enabledActions + }, + + /** + * Using the filtered content if filters are active + */ + summary() { + const hidden = this.dirContents.length - this.dirContentsFiltered.length + return getSummaryFor(this.dirContentsFiltered, hidden) + }, + + debouncedFetchContent() { + return useThrottleFn(this.fetchContent, 800, true) + }, + }, + + watch: { + /** + * Handle rendering the custom empty view + * @param show The current state if the custom empty view should be rendered + */ + showCustomEmptyView(show: boolean) { + if (show) { + this.$nextTick(() => { + const el = this.$refs.customEmptyView as HTMLDivElement + // We can cast here because "showCustomEmptyView" assets that current view is set + this.currentView!.emptyView!(el) + }) + } + }, + + currentFolder() { + this.activeStore.activeFolder = this.currentFolder + }, + + currentView(newView, oldView) { + if (newView?.id === oldView?.id) { + return + } + + logger.debug('View changed', { newView, oldView }) + this.selectionStore.reset() + this.fetchContent() + }, + + directory(newDir, oldDir) { + logger.debug('Directory changed', { newDir, oldDir }) + // TODO: preserve selection on browsing? + this.selectionStore.reset() + if (window.OCA.Files.Sidebar?.close) { + window.OCA.Files.Sidebar.close() + } + this.fetchContent() + + // Scroll to top, force virtual scroller to re-render + const filesListVirtual = this.$refs?.filesListVirtual as ComponentPublicInstance<typeof FilesListVirtual> | undefined + if (filesListVirtual?.$el) { + filesListVirtual.$el.scrollTop = 0 + } + }, + + dirContents(contents) { + logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents }) + emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents }) + // Also refresh the filtered content + this.filterDirContent() + }, + }, + + async mounted() { + subscribe('files:node:deleted', this.onNodeDeleted) + subscribe('files:node:updated', this.onUpdatedNode) + + // reload on settings change + subscribe('files:config:updated', this.fetchContent) + + // filter content if filter were changed + subscribe('files:filters:changed', this.filterDirContent) + + subscribe('files:search:updated', this.onUpdateSearch) + + // Finally, fetch the current directory contents + await this.fetchContent() + if (this.fileId) { + // If we have a fileId, let's check if the file exists + const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString()) + // If the file isn't in the current directory nor if + // the current directory is the file, we show an error + if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) { + showError(t('files', 'The file could not be found')) + } + } + }, + + unmounted() { + unsubscribe('files:node:deleted', this.onNodeDeleted) + unsubscribe('files:node:updated', this.onUpdatedNode) + unsubscribe('files:config:updated', this.fetchContent) + unsubscribe('files:filters:changed', this.filterDirContent) + unsubscribe('files:search:updated', this.onUpdateSearch) + }, + + methods: { + onUpdateSearch({ query, scope }) { + if (query && scope !== 'filter') { + this.debouncedFetchContent() + } + }, + + async fetchContent() { + this.loading = true + this.error = null + const dir = this.directory + const currentView = this.currentView + + if (!currentView) { + logger.debug('The current view does not exists or is not ready.', { currentView }) + + // If we still haven't a valid view, let's wait for the page to load + // then try again. Else redirect to the default view + window.addEventListener('DOMContentLoaded', () => { + if (!this.currentView) { + logger.warn('No current view after DOMContentLoaded, redirecting to the default view') + window.OCP.Files.Router.goToRoute(null, { view: defaultView() }) + } + }, { once: true }) + return + } + + logger.debug('Fetching contents for directory', { dir, currentView }) + + // If we have a cancellable promise ongoing, cancel it + if (this.promise && 'cancel' in this.promise) { + this.promise.cancel() + logger.debug('Cancelled previous ongoing fetch') + } + + // Fetch the current dir contents + this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot> + try { + const { folder, contents } = await this.promise + logger.debug('Fetched contents', { dir, folder, contents }) + + // Update store + this.filesStore.updateNodes(contents) + + // Define current directory children + // TODO: make it more official + this.$set(folder, '_children', contents.map(node => node.source)) + + // If we're in the root dir, define the root + if (dir === '/') { + this.filesStore.setRoot({ service: currentView.id, root: folder }) + } else { + // Otherwise, add the folder to the store + if (folder.fileid) { + this.filesStore.updateNodes([folder]) + this.pathsStore.addPath({ service: currentView.id, source: folder.source, path: dir }) + } else { + // If we're here, the view API messed up + logger.fatal('Invalid root folder returned', { dir, folder, currentView }) + } + } + + // Update paths store + const folders = contents.filter(node => node.type === 'folder') + folders.forEach((node) => { + this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) }) + }) + } catch (error) { + logger.error('Error while fetching content', { error }) + this.error = humanizeWebDAVError(error) + } finally { + this.loading = false + } + + }, + + /** + * Handle the node deleted event to reset open file + * @param node The deleted node + */ + onNodeDeleted(node: Node) { + if (node.fileid && node.fileid === this.fileId) { + if (node.fileid === this.currentFolder?.fileid) { + // Handle the edge case that the current directory is deleted + // in this case we need to keep the current view but move to the parent directory + window.OCP.Files.Router.goToRoute( + null, + { view: this.currentView!.id }, + { dir: this.currentFolder?.dirname ?? '/' }, + ) + } else { + // If the currently active file is deleted we need to remove the fileid and possible the `openfile` query + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: undefined }, + { ...this.$route.query, openfile: undefined }, + ) + } + } + }, + + /** + * The upload manager have finished handling the queue + * @param {Upload} upload the uploaded data + */ + onUpload(upload: Upload) { + // Let's only refresh the current Folder + // Navigating to a different folder will refresh it anyway + const needsRefresh = dirname(upload.source) === this.currentFolder!.source + + // TODO: fetch uploaded files data only + // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid + if (needsRefresh) { + // fetchContent will cancel the previous ongoing promise + this.fetchContent() + } + }, + + async onUploadFail(upload: Upload) { + const status = upload.response?.status || 0 + + if (upload.status === UploadStatus.CANCELLED) { + showWarning(t('files', 'Upload was cancelled by user')) + return + } + + // Check known status codes + if (status === 507) { + showError(t('files', 'Not enough free space')) + return + } else if (status === 404 || status === 409) { + showError(t('files', 'Target folder does not exist any more')) + return + } else if (status === 403) { + showError(t('files', 'Operation is blocked by access control')) + return + } + + // Else we try to parse the response error message + if (typeof upload.response?.data === 'string') { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(upload.response.data, 'text/xml') + const message = doc.getElementsByTagName('s:message')[0]?.textContent ?? '' + if (message.trim() !== '') { + // The server message is also translated + showError(t('files', 'Error during upload: {message}', { message })) + return + } + } catch (error) { + logger.error('Could not parse message', { error }) + } + } + + // Finally, check the status code if we have one + if (status !== 0) { + showError(t('files', 'Error during upload, status code {status}', { status })) + return + } + + showError(t('files', 'Unknown error during upload')) + }, + + /** + * Refreshes the current folder on update. + * + * @param node is the file/folder being updated. + */ + onUpdatedNode(node?: Node) { + if (node?.fileid === this.currentFolder?.fileid) { + this.fetchContent() + } + }, + + openSharingSidebar() { + if (!this.currentFolder) { + logger.debug('No current folder found for opening sharing sidebar') + return + } + + if (window?.OCA?.Files?.Sidebar?.setActiveTab) { + window.OCA.Files.Sidebar.setActiveTab('sharing') + } + sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path) + }, + + toggleGridView() { + this.userConfigStore.update('grid_view', !this.userConfig.grid_view) + }, + + filterDirContent() { + let nodes: INode[] = this.dirContents + for (const filter of this.filtersStore.sortedFilters) { + nodes = filter.filter(nodes) + } + this.dirContentsFiltered = nodes + }, + + actionDisplayName(action: FileListAction): string { + let displayName = action.id + try { + displayName = action.displayName(this.currentView!) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + return displayName + }, + + async execFileListAction(action: FileListAction) { + this.loadingAction = action.id + + const displayName = this.actionDisplayName(action) + try { + const success = await action.exec(this.source, this.dirContents, this.currentDir) + // If the action returns null, we stay silent + if (success === null || success === undefined) { + return + } + + if (success) { + showSuccess(t('files', '{displayName}: done', { displayName })) + return + } + showError(t('files', '{displayName}: failed', { displayName })) + } catch (error) { + logger.error('Error while executing action', { action, error }) + showError(t('files', '{displayName}: failed', { displayName })) + } finally { + this.loadingAction = null + } + }, + }, +}) +</script> + +<style scoped lang="scss"> +:global(.toast-loading-icon) { + // Reduce start margin (it was made for text but this is an icon) + margin-inline-start: -4px; + // 16px icon + 5px on both sides + min-width: 26px; +} + +.app-content { + // Virtual list needs to be full height and is scrollable + display: flex; + overflow: hidden; + flex-direction: column; + max-height: 100%; + position: relative !important; +} + +.files-list { + &__header { + display: flex; + align-items: center; + // Do not grow or shrink (vertically) + flex: 0 0; + max-width: 100%; + // Align with the navigation toggle icon + margin-block: var(--app-navigation-padding, 4px); + margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px); + + &--public { + // There is no navigation toggle on public shares + margin-inline: 0 var(--app-navigation-padding, 4px); + } + + >* { + // Do not grow or shrink (horizontally) + // Only the breadcrumbs shrinks + flex: 0 0; + } + + &-share-button { + color: var(--color-text-maxcontrast) !important; + + &--shared { + color: var(--color-main-text) !important; + } + } + + &-actions { + min-width: fit-content !important; + margin-inline: calc(var(--default-grid-baseline) * 2); + } + } + + &__before { + display: flex; + flex-direction: column; + gap: calc(var(--default-grid-baseline) * 2); + margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding)); + } + + &__empty-view-wrapper { + display: flex; + height: 100%; + } + + &__refresh-icon { + flex: 0 0 var(--default-clickable-area); + width: var(--default-clickable-area); + height: var(--default-clickable-area); + } + + &__loading-icon { + margin: auto; + } +} +</style> |