diff options
Diffstat (limited to 'apps/files/src/components')
38 files changed, 6611 insertions, 195 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue new file mode 100644 index 00000000000..8458fd65f3d --- /dev/null +++ b/apps/files/src/components/BreadCrumbs.vue @@ -0,0 +1,310 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcBreadcrumbs data-cy-files-content-breadcrumbs + :aria-label="t('files', 'Current directory path')" + class="files-list__breadcrumbs" + :class="{ 'files-list__breadcrumbs--with-progress': wrapUploadProgressBar }"> + <!-- Current path sections --> + <NcBreadcrumb v-for="(section, index) in sections" + :key="section.dir" + v-bind="section" + dir="auto" + :to="section.to" + :force-icon-text="index === 0 && fileListWidth >= 486" + :title="titleForSection(index, section)" + :aria-description="ariaForSection(section)" + @click.native="onClick(section.to)" + @dragover.native="onDragOver($event, section.dir)" + @drop="onDrop($event, section.dir)"> + <template v-if="index === 0" #icon> + <NcIconSvgWrapper :size="20" + :svg="viewIcon" /> + </template> + </NcBreadcrumb> + + <!-- Forward the actions slot --> + <template #actions> + <slot name="actions" /> + </template> + </NcBreadcrumbs> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { FileSource } from '../types.ts' + +import { basename } from 'path' +import { defineComponent } from 'vue' +import { Permission } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import HomeSvg from '@mdi/svg/svg/home.svg?raw' +import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb' +import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +import { useNavigation } from '../composables/useNavigation.ts' +import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { showError } from '@nextcloud/dialogs' +import { useDragAndDropStore } from '../store/dragging.ts' +import { useFilesStore } from '../store/files.ts' +import { usePathsStore } from '../store/paths.ts' +import { useSelectionStore } from '../store/selection.ts' +import { useUploaderStore } from '../store/uploader.ts' +import logger from '../logger' + +export default defineComponent({ + name: 'BreadCrumbs', + + components: { + NcBreadcrumbs, + NcBreadcrumb, + NcIconSvgWrapper, + }, + + props: { + path: { + type: String, + default: '/', + }, + }, + + setup() { + const draggingStore = useDragAndDropStore() + const filesStore = useFilesStore() + const pathsStore = usePathsStore() + const selectionStore = useSelectionStore() + const uploaderStore = useUploaderStore() + const fileListWidth = useFileListWidth() + const { currentView, views } = useNavigation() + + return { + draggingStore, + filesStore, + pathsStore, + selectionStore, + uploaderStore, + + currentView, + fileListWidth, + views, + } + }, + + computed: { + dirs(): string[] { + const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`) + // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc + const paths: string[] = this.path.split('/').filter(Boolean).map(cumulativePath('/')) + // Strip away trailing slash + return ['/', ...paths.map((path: string) => path.replace(/^(.+)\/$/, '$1'))] + }, + + sections() { + return this.dirs.map((dir: string, index: number) => { + const source = this.getFileSourceFromPath(dir) + const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined + return { + dir, + exact: true, + name: this.getDirDisplayName(dir), + to: this.getTo(dir, node), + // disable drop on current directory + disableDrop: index === this.dirs.length - 1, + } + }) + }, + + isUploadInProgress(): boolean { + return this.uploaderStore.queue.length !== 0 + }, + + // Hide breadcrumbs if an upload is ongoing + wrapUploadProgressBar(): boolean { + // if an upload is ongoing, and on small screens / mobile, then + // show the progress bar for the upload below breadcrumbs + return this.isUploadInProgress && this.fileListWidth < 512 + }, + + // used to show the views icon for the first breadcrumb + viewIcon(): string { + return this.currentView?.icon ?? HomeSvg + }, + + selectedFiles() { + return this.selectionStore.selected as FileSource[] + }, + + draggingFiles() { + return this.draggingStore.dragging as FileSource[] + }, + }, + + methods: { + getNodeFromSource(source: FileSource): Node | undefined { + return this.filesStore.getNode(source) + }, + getFileSourceFromPath(path: string): FileSource | null { + return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null + }, + getDirDisplayName(path: string): string { + if (path === '/') { + return this.currentView?.name || t('files', 'Home') + } + + const source = this.getFileSourceFromPath(path) + const node = source ? this.getNodeFromSource(source) : undefined + return node?.displayname || basename(path) + }, + + getTo(dir: string, node?: Node): Record<string, unknown> { + if (dir === '/') { + return { + ...this.$route, + params: { view: this.currentView?.id }, + query: {}, + } + } + if (node === undefined) { + const view = this.views.find(view => view.params?.dir === dir) + return { + ...this.$route, + params: { fileid: view?.params?.fileid ?? '' }, + query: { dir }, + } + } + return { + ...this.$route, + params: { fileid: String(node.fileid) }, + query: { dir: node.path }, + } + }, + + onClick(to) { + if (to?.query?.dir === this.$route.query.dir) { + this.$emit('reload') + } + }, + + onDragOver(event: DragEvent, path: string) { + if (!event.dataTransfer) { + return + } + + // Cannot drop on the current directory + if (path === this.dirs[this.dirs.length - 1]) { + event.dataTransfer.dropEffect = 'none' + return + } + + // Handle copy/move drag and drop + if (event.ctrlKey) { + event.dataTransfer.dropEffect = 'copy' + } else { + event.dataTransfer.dropEffect = 'move' + } + }, + + async onDrop(event: DragEvent, path: string) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.items?.length) { + return + } + + // Do not stop propagation, so the main content + // drop event can be triggered too and clear the + // dragover state on the DragAndDropNotice component. + event.preventDefault() + + // Caching the selection + const selection = this.draggingFiles + const items = [...event.dataTransfer?.items || []] as DataTransferItem[] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + const canDrop = (folder.permissions & Permission.CREATE) !== 0 + const isCopy = event.ctrlKey + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (!canDrop || event.button !== 0) { + return + } + + logger.debug('Dropped', { event, folder, selection, fileTree }) + + // Check whether we're uploading files + if (fileTree.contents.length > 0) { + await onDropExternalFiles(fileTree, folder, contents.contents) + return + } + + // Else we're moving/copying files + const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[] + await onDropInternalFiles(nodes, folder, contents.contents, isCopy) + + // Reset selection after we dropped the files + // if the dropped files are within the selection + if (selection.some(source => this.selectedFiles.includes(source))) { + logger.debug('Dropped selection, resetting select store...') + this.selectionStore.reset() + } + }, + + titleForSection(index, section) { + if (section?.to?.query?.dir === this.$route.query.dir) { + return t('files', 'Reload current directory') + } else if (index === 0) { + return t('files', 'Go to the "{dir}" directory', section) + } + return null + }, + + ariaForSection(section) { + if (section?.to?.query?.dir === this.$route.query.dir) { + return t('files', 'Reload current directory') + } + return null + }, + + t, + }, +}) +</script> + +<style lang="scss" scoped> +.files-list__breadcrumbs { + // Take as much space as possible + flex: 1 1 100% !important; + width: 100%; + height: 100%; + margin-block: 0; + margin-inline: 10px; + min-width: 0; + + :deep() { + a { + cursor: pointer !important; + } + } + + &--with-progress { + flex-direction: column !important; + align-items: flex-start !important; + } +} +</style> diff --git a/apps/files/src/components/CustomElementRender.vue b/apps/files/src/components/CustomElementRender.vue new file mode 100644 index 00000000000..b08d3ba5ee5 --- /dev/null +++ b/apps/files/src/components/CustomElementRender.vue @@ -0,0 +1,54 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <span /> +</template> + +<script lang="ts"> +/** + * This component is used to render custom + * elements provided by an API. Vue doesn't allow + * to directly render an HTMLElement, so we can do + * this magic here. + */ +export default { + name: 'CustomElementRender', + props: { + source: { + type: Object, + required: true, + }, + currentView: { + type: Object, + required: true, + }, + render: { + type: Function, + required: true, + }, + }, + watch: { + source() { + this.updateRootElement() + }, + currentView() { + this.updateRootElement() + }, + }, + mounted() { + this.updateRootElement() + }, + methods: { + async updateRootElement() { + const element = await this.render(this.source, this.currentView) + if (element) { + this.$el.replaceChildren(element) + } else { + this.$el.replaceChildren() + } + }, + }, +} +</script> diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue new file mode 100644 index 00000000000..c7684d5c205 --- /dev/null +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -0,0 +1,262 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div v-show="dragover" + data-cy-files-drag-drop-area + class="files-list__drag-drop-notice" + @drop="onDrop"> + <div class="files-list__drag-drop-notice-wrapper"> + <template v-if="canUpload && !isQuotaExceeded"> + <TrayArrowDownIcon :size="48" /> + <h3 class="files-list-drag-drop-notice__title"> + {{ t('files', 'Drag and drop files here to upload') }} + </h3> + </template> + + <!-- Not permitted to drop files here --> + <template v-else> + <h3 class="files-list-drag-drop-notice__title"> + {{ cantUploadLabel }} + </h3> + </template> + </div> + </div> +</template> + +<script lang="ts"> +import type { Folder } from '@nextcloud/files' + +import { Permission } from '@nextcloud/files' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { UploadStatus } from '@nextcloud/upload' +import { defineComponent, type PropType } from 'vue' +import debounce from 'debounce' + +import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' + +import { useNavigation } from '../composables/useNavigation' +import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' +import logger from '../logger.ts' +import type { RawLocation } from 'vue-router' + +export default defineComponent({ + name: 'DragAndDropNotice', + + components: { + TrayArrowDownIcon, + }, + + props: { + currentFolder: { + type: Object as PropType<Folder>, + required: true, + }, + }, + + setup() { + const { currentView } = useNavigation() + + return { + currentView, + } + }, + + data() { + return { + dragover: false, + } + }, + + computed: { + /** + * 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 + }, + + cantUploadLabel() { + if (this.isQuotaExceeded) { + return this.t('files', 'Your have used your space quota and cannot upload files anymore') + } else if (!this.canUpload) { + return this.t('files', 'You do not have permission to upload or create files here.') + } + return null + }, + + /** + * Debounced function to reset the drag over state + * Required as Firefox has a bug where no dragleave is emitted: + * https://bugzilla.mozilla.org/show_bug.cgi?id=656164 + */ + resetDragOver() { + return debounce(() => { + this.dragover = false + }, 3000) + }, + }, + + mounted() { + // Add events on parent to cover both the table and DragAndDrop notice + const mainContent = window.document.getElementById('app-content-vue') as HTMLElement + mainContent.addEventListener('dragover', this.onDragOver) + mainContent.addEventListener('dragleave', this.onDragLeave) + mainContent.addEventListener('drop', this.onContentDrop) + }, + + beforeDestroy() { + const mainContent = window.document.getElementById('app-content-vue') as HTMLElement + mainContent.removeEventListener('dragover', this.onDragOver) + mainContent.removeEventListener('dragleave', this.onDragLeave) + mainContent.removeEventListener('drop', this.onContentDrop) + }, + + methods: { + onDragOver(event: DragEvent) { + // Needed to keep the drag/drop events chain working + event.preventDefault() + + const isForeignFile = event.dataTransfer?.types.includes('Files') + if (isForeignFile) { + // Only handle uploading of outside files (not Nextcloud files) + this.dragover = true + this.resetDragOver() + } + }, + + onDragLeave(event: DragEvent) { + // Counter bubbling, make sure we're ending the drag + // only when we're leaving the current element + // Avoid flickering + const currentTarget = event.currentTarget as HTMLElement + if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) { + return + } + + if (this.dragover) { + this.dragover = false + this.resetDragOver.clear() + } + }, + + onContentDrop(event: DragEvent) { + logger.debug('Drag and drop cancelled, dropped on empty space', { event }) + event.preventDefault() + if (this.dragover) { + this.dragover = false + this.resetDragOver.clear() + } + }, + + async onDrop(event: DragEvent) { + // cantUploadLabel is null if we can upload + if (this.cantUploadLabel) { + showError(this.cantUploadLabel) + return + } + + if (this.$el.querySelector('tbody')?.contains(event.target as Node)) { + return + } + + event.preventDefault() + event.stopPropagation() + + // Caching the selection + const items: DataTransferItem[] = [...event.dataTransfer?.items || []] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(this.currentFolder.path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (event.button) { + return + } + + logger.debug('Dropped', { event, folder, fileTree }) + + // Check whether we're uploading files + const uploads = await onDropExternalFiles(fileTree, folder, contents.contents) + + // Scroll to last successful upload in current directory if terminated + const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED + && !upload.file.webkitRelativePath.includes('/') + && upload.response?.headers?.['oc-fileid'] + // Only use the last ID if it's in the current folder + && upload.source.replace(folder.source, '').split('/').length === 2) + + if (lastUpload !== undefined) { + logger.debug('Scrolling to last upload in current folder', { lastUpload }) + const location: RawLocation = { + path: this.$route.path, + // Keep params but change file id + params: { + ...this.$route.params, + fileid: String(lastUpload.response!.headers['oc-fileid']), + }, + query: { + ...this.$route.query, + }, + } + // Remove open file from query + delete location.query.openfile + this.$router.push(location) + } + + this.dragover = false + this.resetDragOver.clear() + }, + + t, + }, +}) +</script> + +<style lang="scss" scoped> +.files-list__drag-drop-notice { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + // Breadcrumbs height + row thead height + min-height: calc(58px + 44px); + margin: 0; + user-select: none; + color: var(--color-text-maxcontrast); + background-color: var(--color-main-background); + border-color: black; + + h3 { + margin-inline-start: 16px; + color: inherit; + } + + &-wrapper { + display: flex; + align-items: center; + justify-content: center; + height: 15vh; + max-height: 70%; + padding: 0 5vw; + border: 2px var(--color-border-dark) dashed; + border-radius: var(--border-radius-large); + } +} + +</style> diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue new file mode 100644 index 00000000000..72fd98d43fb --- /dev/null +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -0,0 +1,165 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="files-list-drag-image"> + <span class="files-list-drag-image__icon"> + <span ref="previewImg" /> + <FolderIcon v-if="isSingleFolder" /> + <FileMultipleIcon v-else /> + </span> + <span class="files-list-drag-image__name">{{ name }}</span> + </div> +</template> + +<script lang="ts"> +import { FileType, Node, formatFileSize } from '@nextcloud/files' +import Vue from 'vue' + +import FileMultipleIcon from 'vue-material-design-icons/FileMultiple.vue' +import FolderIcon from 'vue-material-design-icons/Folder.vue' + +import { getSummaryFor } from '../utils/fileUtils.ts' + +export default Vue.extend({ + name: 'DragAndDropPreview', + + components: { + FileMultipleIcon, + FolderIcon, + }, + + data() { + return { + nodes: [] as Node[], + } + }, + + computed: { + isSingleNode() { + return this.nodes.length === 1 + }, + isSingleFolder() { + return this.isSingleNode + && this.nodes[0].type === FileType.Folder + }, + + name() { + if (!this.size) { + return this.summary + } + return `${this.summary} – ${this.size}` + }, + size() { + const totalSize = this.nodes.reduce((total, node) => total + node.size || 0, 0) + const size = parseInt(totalSize, 10) || 0 + if (typeof size !== 'number' || size < 0) { + return null + } + return formatFileSize(size, true) + }, + summary(): string { + if (this.isSingleNode) { + const node = this.nodes[0] + return node.attributes?.displayname || node.basename + } + + return getSummaryFor(this.nodes) + }, + }, + + methods: { + update(nodes: Node[]) { + this.nodes = nodes + this.$refs.previewImg.replaceChildren() + + // Clone icon node from the list + nodes.slice(0, 3).forEach(node => { + const preview = document.querySelector(`[data-cy-files-list-row-fileid="${node.fileid}"] .files-list__row-icon img`) + if (preview) { + const previewElmt = this.$refs.previewImg as HTMLElement + previewElmt.appendChild(preview.parentNode.cloneNode(true)) + } + }) + + this.$nextTick(() => { + this.$emit('loaded', this.$el) + }) + }, + }, +}) +</script> + +<style lang="scss"> +$size: 28px; +$stack-shift: 6px; + +.files-list-drag-image { + position: absolute; + top: -9999px; + inset-inline-start: -9999px; + display: flex; + overflow: hidden; + align-items: center; + height: $size + $stack-shift; + padding: $stack-shift $stack-shift * 2; + background: var(--color-main-background); + + &__icon, + .files-list__row-icon-preview-container { + display: flex; + overflow: hidden; + align-items: center; + justify-content: center; + width: $size - $stack-shift; + height: $size - $stack-shift;; + border-radius: var(--border-radius); + } + + &__icon { + overflow: visible; + margin-inline-end: $stack-shift * 2; + + img { + max-width: 100%; + max-height: 100%; + } + + .material-design-icon { + color: var(--color-text-maxcontrast); + &.folder-icon { + color: var(--color-primary-element); + } + } + + // Previews container + > span { + display: flex; + + // Stack effect if more than one element + // Max 3 elements + > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container { + margin-top: $stack-shift; + margin-inline-start: $stack-shift * 2 - $size; + & + .files-list__row-icon-preview-container { + margin-top: $stack-shift * 2; + } + } + + // If we have manually clone the preview, + // let's hide any fallback icons + &:not(:empty) + * { + display: none; + } + } + } + + &__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +</style> diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue new file mode 100644 index 00000000000..d66c3fa0ed7 --- /dev/null +++ b/apps/files/src/components/FileEntry.vue @@ -0,0 +1,276 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <tr :class="{ + 'files-list__row--dragover': dragover, + 'files-list__row--loading': isLoading, + 'files-list__row--active': isActive, + }" + data-cy-files-list-row + :data-cy-files-list-row-fileid="fileid" + :data-cy-files-list-row-name="source.basename" + :draggable="canDrag" + class="files-list__row" + v-on="rowListeners"> + <!-- Failed indicator --> + <span v-if="isFailedSource" class="files-list__row--failed" /> + + <!-- Checkbox --> + <FileEntryCheckbox :fileid="fileid" + :is-loading="isLoading" + :nodes="nodes" + :source="source" /> + + <!-- Link to file --> + <td class="files-list__row-name" data-cy-files-list-row-name> + <!-- Icon or preview --> + <FileEntryPreview ref="preview" + :source="source" + :dragover="dragover" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + + <FileEntryName ref="name" + :basename="basename" + :extension="extension" + :nodes="nodes" + :source="source" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + </td> + + <!-- Actions --> + <FileEntryActions v-show="!isRenamingSmallScreen" + ref="actions" + :class="`files-list__row-actions-${uniqueId}`" + :opened.sync="openedMenu" + :source="source" /> + + <!-- Mime --> + <td v-if="isMimeAvailable" + :title="mime" + class="files-list__row-mime" + data-cy-files-list-row-mime + @click="openDetailsIfAvailable"> + <span>{{ mime }}</span> + </td> + + <!-- Size --> + <td v-if="!compact && isSizeAvailable" + :style="sizeOpacity" + class="files-list__row-size" + data-cy-files-list-row-size + @click="openDetailsIfAvailable"> + <span>{{ size }}</span> + </td> + + <!-- Mtime --> + <td v-if="!compact && isMtimeAvailable" + :style="mtimeOpacity" + class="files-list__row-mtime" + data-cy-files-list-row-mtime + @click="openDetailsIfAvailable"> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> + <span v-else>{{ t('files', 'Unknown date') }}</span> + </td> + + <!-- View columns --> + <td v-for="column in columns" + :key="column.id" + :class="`files-list__row-${currentView.id}-${column.id}`" + class="files-list__row-column-custom" + :data-cy-files-list-row-column-custom="column.id" + @click="openDetailsIfAvailable"> + <CustomElementRender :current-view="currentView" + :render="column.render" + :source="source" /> + </td> + </tr> +</template> + +<script lang="ts"> +import { FileType, formatFileSize } from '@nextcloud/files' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' + +import { useNavigation } from '../composables/useNavigation.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActionsMenuStore } from '../store/actionsmenu.ts' +import { useDragAndDropStore } from '../store/dragging.ts' +import { useFilesStore } from '../store/files.ts' +import { useRenamingStore } from '../store/renaming.ts' +import { useSelectionStore } from '../store/selection.ts' + +import CustomElementRender from './CustomElementRender.vue' +import FileEntryActions from './FileEntry/FileEntryActions.vue' +import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' +import FileEntryMixin from './FileEntryMixin.ts' +import FileEntryName from './FileEntry/FileEntryName.vue' +import FileEntryPreview from './FileEntry/FileEntryPreview.vue' + +export default defineComponent({ + name: 'FileEntry', + + components: { + CustomElementRender, + FileEntryActions, + FileEntryCheckbox, + FileEntryName, + FileEntryPreview, + NcDateTime, + }, + + mixins: [ + FileEntryMixin, + ], + + props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, + isSizeAvailable: { + type: Boolean, + default: false, + }, + }, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const draggingStore = useDragAndDropStore() + const filesStore = useFilesStore() + const renamingStore = useRenamingStore() + const selectionStore = useSelectionStore() + const filesListWidth = useFileListWidth() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + + return { + actionsMenuStore, + draggingStore, + filesStore, + renamingStore, + selectionStore, + + currentDir, + currentFileId, + currentView, + filesListWidth, + } + }, + + computed: { + /** + * Conditionally add drag and drop listeners + * Do not add drag start and over listeners on renaming to allow to drag and drop text + */ + rowListeners() { + const conditionals = this.isRenaming + ? {} + : { + dragstart: this.onDragStart, + dragover: this.onDragOver, + } + + return { + ...conditionals, + contextmenu: this.onRightClick, + dragleave: this.onDragLeave, + dragend: this.onDragEnd, + drop: this.onDrop, + } + }, + columns() { + // Hide columns if the list is too small + if (this.filesListWidth < 512 || this.compact) { + return [] + } + return this.currentView.columns || [] + }, + + mime() { + if (this.source.type === FileType.Folder) { + return this.t('files', 'Folder') + } + + if (!this.source.mime || this.source.mime === 'application/octet-stream') { + return t('files', 'Unknown file type') + } + + if (window.OC?.MimeTypeList?.names?.[this.source.mime]) { + return window.OC.MimeTypeList.names[this.source.mime] + } + + const baseType = this.source.mime.split('/')[0] + const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || '' + if (baseType === 'image') { + return t('files', '{ext} image', { ext }) + } + if (baseType === 'video') { + return t('files', '{ext} video', { ext }) + } + if (baseType === 'audio') { + return t('files', '{ext} audio', { ext }) + } + if (baseType === 'text') { + return t('files', '{ext} text', { ext }) + } + + return this.source.mime + }, + size() { + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { + return this.t('files', 'Pending') + } + return formatFileSize(size, true) + }, + + sizeOpacity() { + const maxOpacitySize = 10 * 1024 * 1024 + + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { + return {} + } + + const ratio = Math.round(Math.min(100, 100 * Math.pow((size / maxOpacitySize), 2))) + return { + color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, + } + }, + }, + + created() { + useHotKey('Enter', this.triggerDefaultAction, { + stop: true, + prevent: true, + }) + }, + + methods: { + formatFileSize, + + triggerDefaultAction() { + // Don't react to the event if the file row is not active + if (!this.isActive) { + return + } + + this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir) + }, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue new file mode 100644 index 00000000000..e22b30f4378 --- /dev/null +++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue @@ -0,0 +1,45 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <span :aria-hidden="!title" + :aria-label="title" + class="material-design-icon collectives-icon" + role="img" + v-bind="$attrs" + @click="$emit('click', $event)"> + <svg :fill="fillColor" + class="material-design-icon__svg" + :width="size" + :height="size" + viewBox="0 0 16 16"> + <path d="M2.9,8.8c0-1.2,0.4-2.4,1.2-3.3L0.3,6c-0.2,0-0.3,0.3-0.1,0.4l2.7,2.6C2.9,9,2.9,8.9,2.9,8.8z" /> + <path d="M8,3.7c0.7,0,1.3,0.1,1.9,0.4L8.2,0.6c-0.1-0.2-0.3-0.2-0.4,0L6.1,4C6.7,3.8,7.3,3.7,8,3.7z" /> + <path d="M3.7,11.5L3,15.2c0,0.2,0.2,0.4,0.4,0.3l3.3-1.7C5.4,13.4,4.4,12.6,3.7,11.5z" /> + <path d="M15.7,6l-3.7-0.5c0.7,0.9,1.2,2,1.2,3.3c0,0.1,0,0.2,0,0.3l2.7-2.6C15.9,6.3,15.9,6.1,15.7,6z" /> + <path d="M12.3,11.5c-0.7,1.1-1.8,1.9-3,2.2l3.3,1.7c0.2,0.1,0.4-0.1,0.4-0.3L12.3,11.5z" /> + <path d="M9.6,10.1c-0.4,0.5-1,0.8-1.6,0.8c-1.1,0-2-0.9-2.1-2C5.9,7.7,6.8,6.7,8,6.7c0.6,0,1.1,0.3,1.5,0.7 c0.1,0.1,0.1,0.1,0.2,0.1h1.4c0.2,0,0.4-0.2,0.3-0.5c-0.7-1.3-2.1-2.2-3.8-2.1C5.8,5,4.3,6.6,4.1,8.5C4,10.8,5.8,12.7,8,12.7 c1.6,0,2.9-0.9,3.5-2.3c0.1-0.2-0.1-0.4-0.3-0.4H9.9C9.8,10,9.7,10,9.6,10.1z" /> + </svg> + </span> +</template> + +<script> +export default { + name: 'CollectivesIcon', + props: { + title: { + type: String, + default: '', + }, + fillColor: { + type: String, + default: 'currentColor', + }, + size: { + type: Number, + default: 24, + }, + }, +} +</script> diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue new file mode 100644 index 00000000000..c66cb8fbd7f --- /dev/null +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -0,0 +1,76 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcIconSvgWrapper class="favorite-marker-icon" :name="t('files', 'Favorite')" :svg="StarSvg" /> +</template> + +<script lang="ts"> +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import StarSvg from '@mdi/svg/svg/star.svg?raw' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +/** + * A favorite icon to be used for overlaying favorite entries like the file preview / icon + * It has a stroke around the star icon to ensure enough contrast for accessibility. + * + * If the background has a hover state you might want to also apply it to the stroke like this: + * ```scss + * .parent:hover :deep(.favorite-marker-icon svg path) { + * stroke: var(--color-background-hover); + * } + * ``` + */ +export default defineComponent({ + name: 'FavoriteIcon', + components: { + NcIconSvgWrapper, + }, + data() { + return { + StarSvg, + } + }, + async mounted() { + await this.$nextTick() + // MDI default viewBox is "0 0 24 24" but we add a stroke of 10px so we must adjust it + const el = this.$el.querySelector('svg') + el?.setAttribute?.('viewBox', '-4 -4 30 30') + }, + methods: { + t, + }, +}) +</script> + +<style lang="scss" scoped> +.favorite-marker-icon { + color: var(--color-favorite); + // Override NcIconSvgWrapper defaults (clickable area) + min-width: unset !important; + min-height: unset !important; + + :deep() { + svg { + // We added a stroke for a11y so we must increase the size to include the stroke + width: 20px !important; + height: 20px !important; + + // Override NcIconSvgWrapper defaults of 20px + max-width: unset !important; + max-height: unset !important; + + // Sow a border around the icon for better contrast + path { + stroke: var(--color-main-background); + stroke-width: 8px; + stroke-linejoin: round; + paint-order: stroke; + } + } + } +} +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue new file mode 100644 index 00000000000..5c537d878fe --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -0,0 +1,399 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <td class="files-list__row-actions" + data-cy-files-list-row-actions> + <!-- Render actions --> + <CustomElementRender v-for="action in enabledRenderActions" + :key="action.id" + :class="'files-list__row-action-' + action.id" + :current-view="currentView" + :render="action.renderInline" + :source="source" + class="files-list__row-action--inline" /> + + <!-- Menu actions --> + <NcActions ref="actionsMenu" + :boundaries-element="getBoundariesElement" + :container="getBoundariesElement" + :force-name="true" + type="tertiary" + :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" + :inline="enabledInlineActions.length" + :open="openedMenu" + @close="onMenuClose" + @closed="onMenuClosed"> + <!-- Non-destructive actions list --> + <!-- Please keep this block in sync with the destructive actions block below --> + <NcActionButton v-for="action, index in renderedNonDestructiveActions" + :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" + :class="{ + [`files-list__row-action-${action.id}`]: true, + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-row-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + + <!-- Destructive actions list --> + <template v-if="renderedDestructiveActions.length > 0"> + <NcActionSeparator /> + <NcActionButton v-for="action, index in renderedDestructiveActions" + :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" + :class="{ + [`files-list__row-action-${action.id}`]: true, + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + 'files-list__row-action--destructive': true, + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-row-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + + <!-- Submenu actions list--> + <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> + <!-- Back to top-level button --> + <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)"> + <template #icon> + <ArrowLeftIcon /> + </template> + {{ t('files', 'Back') }} + </NcActionButton> + <NcActionSeparator /> + + <!-- Submenu actions --> + <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]" + :key="action.id" + :class="`files-list__row-action-${action.id}`" + class="files-list__row-action--submenu" + close-after-click + :data-cy-files-list-row-action="action.id" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + </NcActions> + </td> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { FileAction, Node } from '@nextcloud/files' + +import { DefaultType, NodeStatus } from '@nextcloud/files' +import { defineComponent, inject } from 'vue' +import { t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' + +import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' +import CustomElementRender from '../CustomElementRender.vue' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { executeAction } from '../../utils/actionUtils.ts' +import { useActiveStore } from '../../store/active.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import actionsMixins from '../../mixins/actionsMixin.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryActions', + + components: { + ArrowLeftIcon, + CustomElementRender, + NcActionButton, + NcActions, + NcActionSeparator, + NcIconSvgWrapper, + NcLoadingIcon, + }, + + mixins: [actionsMixins], + + props: { + opened: { + type: Boolean, + default: false, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory: currentDir } = useRouteParameters() + + const activeStore = useActiveStore() + const filesListWidth = useFileListWidth() + const enabledFileActions = inject<FileAction[]>('enabledFileActions', []) + return { + activeStore, + currentDir, + currentView, + enabledFileActions, + filesListWidth, + t, + } + }, + + computed: { + isActive() { + return this.activeStore?.activeNode?.source === this.source.source + }, + + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + // Enabled action that are displayed inline + enabledInlineActions() { + if (this.filesListWidth < 768 || this.gridMode) { + return [] + } + return this.enabledFileActions.filter(action => { + try { + return action?.inline?.(this.source, this.currentView) + } catch (error) { + logger.error('Error while checking if action is inline', { action, error }) + return false + } + }) + }, + + // Enabled action that are displayed inline with a custom render function + enabledRenderActions() { + if (this.gridMode) { + return [] + } + return this.enabledFileActions.filter(action => typeof action.renderInline === 'function') + }, + + // Actions shown in the menu + enabledMenuActions() { + // If we're in a submenu, only render the inline + // actions before the filtered submenu + if (this.openedSubmenu) { + return this.enabledInlineActions + } + + const actions = [ + // Showing inline first for the NcActions inline prop + ...this.enabledInlineActions, + // Then the rest + ...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'), + ].filter((value, index, self) => { + // Then we filter duplicates to prevent inline actions to be shown twice + return index === self.findIndex(action => action.id === value.id) + }) + + // Generate list of all top-level actions ids + const topActionsIds = actions.filter(action => !action.parent).map(action => action.id) as string[] + + // Filter actions that are not top-level AND have a valid parent + return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent))) + }, + + renderedNonDestructiveActions() { + return this.enabledMenuActions.filter(action => !action.destructive) + }, + + renderedDestructiveActions() { + return this.enabledMenuActions.filter(action => action.destructive) + }, + + openedMenu: { + get() { + return this.opened + }, + set(value) { + this.$emit('update:opened', value) + }, + }, + + /** + * Making this a function in case the files-list + * reference changes in the future. That way we're + * sure there is one at the time we call it. + */ + getBoundariesElement() { + return document.querySelector('.app-content > .files-list') + }, + }, + + watch: { + // Close any submenu when the menu state changes + openedMenu() { + this.openedSubmenu = null + }, + }, + + created() { + useHotKey('Escape', this.onKeyDown, { + stop: true, + prevent: true, + }) + + useHotKey('a', this.onKeyDown, { + stop: true, + prevent: true, + }) + }, + + methods: { + actionDisplayName(action: FileAction) { + try { + if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') { + // if an inline action is rendered in the menu for + // lack of space we use the title first if defined + const title = action.title([this.source], this.currentView) + if (title) return title + } + return action.displayName([this.source], this.currentView) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + // Not ideal, but better than nothing + return action.id + } + }, + + isLoadingAction(action: FileAction) { + if (!this.isActive) { + return false + } + return this.activeStore?.activeAction?.id === action.id + }, + + async onActionClick(action) { + // If the action is a submenu, we open it + if (this.enabledSubmenuActions[action.id]) { + this.openedSubmenu = action + return + } + + // Make sure we set the node as active + this.activeStore.activeNode = this.source + + // Execute the action + await executeAction(action) + }, + + onKeyDown(event: KeyboardEvent) { + // Don't react to the event if the file row is not active + if (!this.isActive) { + return + } + + // ESC close the action menu if opened + if (event.key === 'Escape' && this.openedMenu) { + this.openedMenu = false + } + + // a open the action menu + if (event.key === 'a' && !this.openedMenu) { + this.openedMenu = true + } + }, + + onMenuClose() { + // We reset the submenu state when the menu is closing + this.openedSubmenu = null + }, + + onMenuClosed() { + // We reset the actions menu state when the menu is finally closed + this.openedMenu = false + }, + }, +}) +</script> + +<style lang="scss"> +// Allow right click to define the position of the menu +// only if defined +main.app-content[style*="mouse-pos-x"] .v-popper__popper { + transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important; + + // If the menu is too close to the bottom, we move it up + &[data-popper-placement="top"] { + // 34px added to align with the top of the cursor + transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh + 34px), 0px) !important; + } + // Hide arrow if floating + .v-popper__arrow-container { + display: none; + } +} +</style> + +<style scoped lang="scss"> +.files-list__row-action { + --max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline)); + + // inline icons can have clickable area size so they still fit into the row + &.files-list__row-action--inline { + --max-icon-size: var(--default-clickable-area); + } + + // Some icons exceed the default size so we need to enforce a max width and height + .files-list__row-action-icon :deep(svg) { + max-height: var(--max-icon-size) !important; + max-width: var(--max-icon-size) !important; + } + + &.files-list__row-action--destructive { + ::deep(button) { + color: var(--color-error) !important; + } + } +} + +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue new file mode 100644 index 00000000000..5b80a971118 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -0,0 +1,173 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <td class="files-list__row-checkbox" + @keyup.esc.exact="resetSelection"> + <NcLoadingIcon v-if="isLoading" :name="loadingLabel" /> + <NcCheckboxRadioSwitch v-else + :aria-label="ariaLabel" + :checked="isSelected" + data-cy-files-list-row-checkbox + @update:checked="onSelectionChange" /> + </td> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../../types.ts' + +import { FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { useActiveStore } from '../../store/active.ts' +import { useKeyboardStore } from '../../store/keyboard.ts' +import { useSelectionStore } from '../../store/selection.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryCheckbox', + + components: { + NcCheckboxRadioSwitch, + NcLoadingIcon, + }, + + props: { + fileid: { + type: Number, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + }, + + setup() { + const selectionStore = useSelectionStore() + const keyboardStore = useKeyboardStore() + const activeStore = useActiveStore() + + return { + activeStore, + keyboardStore, + selectionStore, + t, + } + }, + + computed: { + isActive() { + return this.activeStore.activeNode?.source === this.source.source + }, + + selectedFiles() { + return this.selectionStore.selected + }, + isSelected() { + return this.selectedFiles.includes(this.source.source) + }, + index() { + return this.nodes.findIndex((node: Node) => node.source === this.source.source) + }, + isFile() { + return this.source.type === FileType.File + }, + ariaLabel() { + return this.isFile + ? t('files', 'Toggle selection for file "{displayName}"', { displayName: this.source.basename }) + : t('files', 'Toggle selection for folder "{displayName}"', { displayName: this.source.basename }) + }, + loadingLabel() { + return this.isFile + ? t('files', 'File is loading') + : t('files', 'Folder is loading') + }, + }, + + created() { + // ctrl+space toggle selection + useHotKey(' ', this.onToggleSelect, { + stop: true, + prevent: true, + ctrl: true, + }) + + // ctrl+shift+space toggle range selection + useHotKey(' ', this.onToggleSelect, { + stop: true, + prevent: true, + ctrl: true, + shift: true, + }) + }, + + methods: { + onSelectionChange(selected: boolean) { + const newSelectedIndex = this.index + const lastSelectedIndex = this.selectionStore.lastSelectedIndex + + // Get the last selected and select all files in between + if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) { + const isAlreadySelected = this.selectedFiles.includes(this.source.source) + + const start = Math.min(newSelectedIndex, lastSelectedIndex) + const end = Math.max(lastSelectedIndex, newSelectedIndex) + + const lastSelection = this.selectionStore.lastSelection + const filesToSelect = this.nodes + .map(file => file.source) + .slice(start, end + 1) + .filter(Boolean) as FileSource[] + + // If already selected, update the new selection _without_ the current file + const selection = [...lastSelection, ...filesToSelect] + .filter(source => !isAlreadySelected || source !== this.source.source) + + logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected }) + // Keep previous lastSelectedIndex to be use for further shift selections + this.selectionStore.set(selection) + return + } + + const selection = selected + ? [...this.selectedFiles, this.source.source] + : this.selectedFiles.filter(source => source !== this.source.source) + + logger.debug('Updating selection', { selection }) + this.selectionStore.set(selection) + this.selectionStore.setLastIndex(newSelectedIndex) + }, + + resetSelection() { + this.selectionStore.reset() + }, + + onToggleSelect() { + // Don't react if the node is not active + if (!this.isActive) { + return + } + + logger.debug('Toggling selection for file', { source: this.source }) + this.onSelectionChange(!this.isSelected) + }, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue new file mode 100644 index 00000000000..418f9581eb6 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -0,0 +1,288 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <!-- Rename input --> + <form v-if="isRenaming" + ref="renameForm" + v-on-click-outside="onRename" + :aria-label="t('files', 'Rename file')" + class="files-list__row-rename" + @submit.prevent.stop="onRename"> + <NcTextField ref="renameInput" + :label="renameLabel" + :autofocus="true" + :minlength="1" + :required="true" + :value.sync="newName" + enterkeyhint="done" + @keyup.esc="stopRenaming" /> + </form> + + <component :is="linkTo.is" + v-else + ref="basename" + class="files-list__row-name-link" + data-cy-files-list-row-name-link + v-bind="linkTo.params"> + <!-- Filename --> + <span class="files-list__row-name-text" dir="auto"> + <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="basename" /> + <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" /> + </span> + </component> +</template> + +<script lang="ts"> +import type { FileAction, Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { showError, showSuccess } from '@nextcloud/dialogs' +import { FileType, NodeStatus } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent, inject } from 'vue' + +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import { getFilenameValidity } from '../../utils/filenameValidity.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation.ts' +import { useRenamingStore } from '../../store/renaming.ts' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryName', + + components: { + NcTextField, + }, + + props: { + /** + * The filename without extension + */ + basename: { + type: String, + required: true, + }, + /** + * The extension of the filename + */ + extension: { + type: String, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory } = useRouteParameters() + const filesListWidth = useFileListWidth() + const renamingStore = useRenamingStore() + const userConfigStore = useUserConfigStore() + + const defaultFileAction = inject<FileAction | undefined>('defaultFileAction') + + return { + currentView, + defaultFileAction, + directory, + filesListWidth, + + renamingStore, + userConfigStore, + } + }, + + computed: { + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + newName: { + get(): string { + return this.renamingStore.newNodeName + }, + set(newName: string) { + this.renamingStore.newNodeName = newName + }, + }, + + renameLabel() { + const matchLabel: Record<FileType, string> = { + [FileType.File]: t('files', 'Filename'), + [FileType.Folder]: t('files', 'Folder name'), + } + return matchLabel[this.source.type] + }, + + linkTo() { + if (this.source.status === NodeStatus.FAILED) { + return { + is: 'span', + params: { + title: t('files', 'This node is unavailable'), + }, + } + } + + if (this.defaultFileAction) { + const displayName = this.defaultFileAction.displayName([this.source], this.currentView) + return { + is: 'button', + params: { + 'aria-label': displayName, + title: displayName, + tabindex: '0', + }, + } + } + + // nothing interactive here, there is no default action + // so if not even the download action works we only can show the list entry + return { + is: 'span', + } + }, + }, + + watch: { + /** + * If renaming starts, select the filename + * in the input, without the extension. + * @param renaming + */ + isRenaming: { + immediate: true, + handler(renaming: boolean) { + if (renaming) { + this.startRenaming() + } + }, + }, + + newName() { + // Check validity of the new name + const newName = this.newName.trim?.() || '' + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + return + } + + let validity = getFilenameValidity(newName) + // Checking if already exists + if (validity === '' && this.checkIfNodeExists(newName)) { + validity = t('files', 'Another entry with the same name already exists.') + } + this.$nextTick(() => { + if (this.isRenaming) { + input.setCustomValidity(validity) + input.reportValidity() + } + }) + }, + }, + + methods: { + checkIfNodeExists(name: string) { + return this.nodes.find(node => node.basename === name && node !== this.source) + }, + + startRenaming() { + this.$nextTick(() => { + // Using split to get the true string length + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + logger.error('Could not find the rename input') + return + } + input.focus() + const length = this.source.basename.length - (this.source.extension ?? '').length + input.setSelectionRange(0, length) + + // Trigger a keyup event to update the input validity + input.dispatchEvent(new Event('keyup')) + }) + }, + + stopRenaming() { + if (!this.isRenaming) { + return + } + + // Reset the renaming store + this.renamingStore.$reset() + }, + + // Rename and move the file + async onRename() { + const newName = this.newName.trim?.() || '' + const form = this.$refs.renameForm as HTMLFormElement + if (!form.checkValidity()) { + showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName)) + return + } + + const oldName = this.source.basename + if (newName === oldName) { + this.stopRenaming() + return + } + + try { + const status = await this.renamingStore.rename() + if (status) { + showSuccess( + t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }), + ) + this.$nextTick(() => { + const nameContainer = this.$refs.basename as HTMLElement | undefined + nameContainer?.focus() + }) + } else { + // Was cancelled - meaning the renaming state is just reset + } + } catch (error) { + logger.error(error as Error) + showError((error as Error).message) + // And ensure we reset to the renaming state + this.startRenaming() + } + }, + + t, + }, +}) +</script> + +<style scoped lang="scss"> +button.files-list__row-name-link { + background-color: unset; + border: none; + font-weight: normal; + + &:active { + // No active styles - handled by the row entry + background-color: unset !important; + } +} +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue new file mode 100644 index 00000000000..3d0fffe7584 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -0,0 +1,300 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <span class="files-list__row-icon"> + <template v-if="source.type === 'folder'"> + <FolderOpenIcon v-if="dragover" v-once /> + <template v-else> + <FolderIcon v-once /> + <OverlayIcon :is="folderOverlay" + v-if="folderOverlay" + class="files-list__row-icon-overlay" /> + </template> + </template> + + <!-- Decorative images, should not be aria documented --> + <span v-else-if="previewUrl" class="files-list__row-icon-preview-container"> + <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)" + ref="canvas" + class="files-list__row-icon-blurhash" + aria-hidden="true" /> + <img v-if="backgroundFailed !== true" + :key="source.fileid" + ref="previewImg" + alt="" + class="files-list__row-icon-preview" + :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" + loading="lazy" + :src="previewUrl" + @error="onBackgroundError" + @load="onBackgroundLoad"> + </span> + + <FileIcon v-else v-once /> + + <!-- Favorite icon --> + <span v-if="isFavorite" class="files-list__row-icon-favorite"> + <FavoriteIcon v-once /> + </span> + + <OverlayIcon :is="fileOverlay" + v-if="fileOverlay" + class="files-list__row-icon-overlay files-list__row-icon-overlay--file" /> + </span> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { UserConfig } from '../../types.ts' + +import { Node, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' +import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' +import { decode } from 'blurhash' +import { defineComponent } from 'vue' + +import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue' +import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' +import FileIcon from 'vue-material-design-icons/File.vue' +import FolderIcon from 'vue-material-design-icons/Folder.vue' +import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue' +import KeyIcon from 'vue-material-design-icons/Key.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue' +import TagIcon from 'vue-material-design-icons/Tag.vue' +import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue' + +import CollectivesIcon from './CollectivesIcon.vue' +import FavoriteIcon from './FavoriteIcon.vue' + +import { isLivePhoto } from '../../services/LivePhotos' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryPreview', + + components: { + AccountGroupIcon, + AccountPlusIcon, + CollectivesIcon, + FavoriteIcon, + FileIcon, + FolderIcon, + FolderOpenIcon, + KeyIcon, + LinkIcon, + NetworkIcon, + TagIcon, + }, + + props: { + source: { + type: Object as PropType<Node>, + required: true, + }, + dragover: { + type: Boolean, + default: false, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + const userConfigStore = useUserConfigStore() + const isPublic = isPublicShare() + const publicSharingToken = getSharingToken() + + return { + userConfigStore, + + isPublic, + publicSharingToken, + } + }, + + data() { + return { + backgroundFailed: undefined as boolean | undefined, + backgroundLoaded: false, + } + }, + + computed: { + isFavorite(): boolean { + return this.source.attributes.favorite === 1 + }, + + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + cropPreviews(): boolean { + return this.userConfig.crop_image_previews === true + }, + + previewUrl() { + if (this.source.type === FileType.Folder) { + return null + } + + if (this.backgroundFailed === true) { + return null + } + + if (this.source.attributes['has-preview'] !== true + && this.source.mime !== undefined + && this.source.mime !== 'application/octet-stream' + ) { + const previewUrl = generateUrl('/core/mimeicon?mime={mime}', { + mime: this.source.mime, + }) + const url = new URL(window.location.origin + previewUrl) + return url.href + } + + try { + const previewUrl = this.source.attributes.previewUrl + || (this.isPublic + ? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', { + token: this.publicSharingToken, + file: this.source.path, + }) + : generateUrl('/core/preview?fileId={fileid}', { + fileid: String(this.source.fileid), + }) + ) + const url = new URL(window.location.origin + previewUrl) + + // Request tiny previews + url.searchParams.set('x', this.gridMode ? '128' : '32') + url.searchParams.set('y', this.gridMode ? '128' : '32') + url.searchParams.set('mimeFallback', 'true') + + // Etag to force refresh preview on change + const etag = this.source?.attributes?.etag || '' + url.searchParams.set('v', etag.slice(0, 6)) + + // Handle cropping + url.searchParams.set('a', this.cropPreviews === true ? '0' : '1') + return url.href + } catch (e) { + return null + } + }, + + fileOverlay() { + if (isLivePhoto(this.source)) { + return PlayCircleIcon + } + + return null + }, + + folderOverlay() { + if (this.source.type !== FileType.Folder) { + return null + } + + // Encrypted folders + if (this.source?.attributes?.['is-encrypted'] === 1) { + return KeyIcon + } + + // System tags + if (this.source?.attributes?.['is-tag']) { + return TagIcon + } + + // Link and mail shared folders + const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[] + if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) { + return LinkIcon + } + + // Shared folders + if (shareTypes.length > 0) { + return AccountPlusIcon + } + + switch (this.source?.attributes?.['mount-type']) { + case 'external': + case 'external-session': + return NetworkIcon + case 'group': + return AccountGroupIcon + case 'collective': + return CollectivesIcon + case 'shared': + return AccountPlusIcon + } + + return null + }, + + hasBlurhash() { + return this.source.attributes['metadata-blurhash'] !== undefined + }, + }, + + mounted() { + if (this.hasBlurhash && this.$refs.canvas) { + this.drawBlurhash() + } + }, + + methods: { + // Called from FileEntry + reset() { + // Reset background state to cancel any ongoing requests + this.backgroundFailed = undefined + this.backgroundLoaded = false + const previewImg = this.$refs.previewImg as HTMLImageElement | undefined + if (previewImg) { + previewImg.src = '' + } + }, + + onBackgroundLoad() { + this.backgroundFailed = false + this.backgroundLoaded = true + }, + + onBackgroundError(event) { + // Do not fail if we just reset the background + if (event.target?.src === '') { + return + } + this.backgroundFailed = true + this.backgroundLoaded = false + }, + + drawBlurhash() { + const canvas = this.$refs.canvas as HTMLCanvasElement + + const width = canvas.width + const height = canvas.height + + const pixels = decode(this.source.attributes['metadata-blurhash'], width, height) + + const ctx = canvas.getContext('2d') + if (ctx === null) { + logger.error('Cannot create context for blurhash canvas') + return + } + + const imageData = ctx.createImageData(width, height) + imageData.data.set(pixels) + ctx.putImageData(imageData, 0, 0) + }, + + t, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue new file mode 100644 index 00000000000..1bd0572f53b --- /dev/null +++ b/apps/files/src/components/FileEntryGrid.vue @@ -0,0 +1,135 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <tr :class="{'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}" + data-cy-files-list-row + :data-cy-files-list-row-fileid="fileid" + :data-cy-files-list-row-name="source.basename" + :draggable="canDrag" + class="files-list__row" + @contextmenu="onRightClick" + @dragover="onDragOver" + @dragleave="onDragLeave" + @dragstart="onDragStart" + @dragend="onDragEnd" + @drop="onDrop"> + <!-- Failed indicator --> + <span v-if="isFailedSource" class="files-list__row--failed" /> + + <!-- Checkbox --> + <FileEntryCheckbox :fileid="fileid" + :is-loading="isLoading" + :nodes="nodes" + :source="source" /> + + <!-- Link to file --> + <td class="files-list__row-name" data-cy-files-list-row-name> + <!-- Icon or preview --> + <FileEntryPreview ref="preview" + :dragover="dragover" + :grid-mode="true" + :source="source" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + + <FileEntryName ref="name" + :basename="basename" + :extension="extension" + :grid-mode="true" + :nodes="nodes" + :source="source" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + </td> + + <!-- Mtime --> + <td v-if="!compact && isMtimeAvailable" + :style="mtimeOpacity" + class="files-list__row-mtime" + data-cy-files-list-row-mtime + @click="openDetailsIfAvailable"> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> + </td> + + <!-- Actions --> + <FileEntryActions ref="actions" + :class="`files-list__row-actions-${uniqueId}`" + :grid-mode="true" + :opened.sync="openedMenu" + :source="source" /> + </tr> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' + +import NcDateTime from '@nextcloud/vue/components/NcDateTime' + +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActionsMenuStore } from '../store/actionsmenu.ts' +import { useDragAndDropStore } from '../store/dragging.ts' +import { useFilesStore } from '../store/files.ts' +import { useRenamingStore } from '../store/renaming.ts' +import { useSelectionStore } from '../store/selection.ts' +import FileEntryMixin from './FileEntryMixin.ts' +import FileEntryActions from './FileEntry/FileEntryActions.vue' +import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' +import FileEntryName from './FileEntry/FileEntryName.vue' +import FileEntryPreview from './FileEntry/FileEntryPreview.vue' + +export default defineComponent({ + name: 'FileEntryGrid', + + components: { + FileEntryActions, + FileEntryCheckbox, + FileEntryName, + FileEntryPreview, + NcDateTime, + }, + + mixins: [ + FileEntryMixin, + ], + + inheritAttrs: false, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const draggingStore = useDragAndDropStore() + const filesStore = useFilesStore() + const renamingStore = useRenamingStore() + const selectionStore = useSelectionStore() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + + return { + actionsMenuStore, + draggingStore, + filesStore, + renamingStore, + selectionStore, + + currentDir, + currentFileId, + currentView, + } + }, + + data() { + return { + gridMode: true, + } + }, +}) +</script> diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts new file mode 100644 index 00000000000..735490c45b3 --- /dev/null +++ b/apps/files/src/components/FileEntryMixin.ts @@ -0,0 +1,509 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { PropType } from 'vue' +import type { FileSource } from '../types.ts' + +import { extname } from 'path' +import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { isPublicShare } from '@nextcloud/sharing/public' +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { vOnClickOutside } from '@vueuse/components' +import Vue, { computed, defineComponent } from 'vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' +import { getDragAndDropPreview } from '../utils/dragUtils.ts' +import { hashCode } from '../utils/hashUtils.ts' +import { isDownloadable } from '../utils/permissions.ts' +import logger from '../logger.ts' + +Vue.directive('onClickOutside', vOnClickOutside) + +const actions = getFileActions() + +export default defineComponent({ + props: { + source: { + type: [Folder, NcFile, Node] as PropType<Node>, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + filesListWidth: { + type: Number, + default: 0, + }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + compact: { + type: Boolean, + default: false, + }, + }, + + provide() { + return { + defaultFileAction: computed(() => this.defaultFileAction), + enabledFileActions: computed(() => this.enabledFileActions), + } + }, + + data() { + return { + dragover: false, + gridMode: false, + } + }, + + computed: { + fileid() { + return this.source.fileid ?? 0 + }, + + uniqueId() { + return hashCode(this.source.source) + }, + + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + /** + * The display name of the current node + * Either the nodes filename or a custom display name (e.g. for shares) + */ + displayName() { + // basename fallback needed for apps using old `@nextcloud/files` prior 3.6.0 + return this.source.displayname || this.source.basename + }, + /** + * The display name without extension + */ + basename() { + if (this.extension === '') { + return this.displayName + } + return this.displayName.slice(0, 0 - this.extension.length) + }, + /** + * The extension of the file + */ + extension() { + if (this.source.type === FileType.Folder) { + return '' + } + + return extname(this.displayName) + }, + + draggingFiles() { + return this.draggingStore.dragging as FileSource[] + }, + selectedFiles() { + return this.selectionStore.selected as FileSource[] + }, + isSelected() { + return this.selectedFiles.includes(this.source.source) + }, + + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + + isActive() { + return String(this.fileid) === String(this.currentFileId) + }, + + /** + * Check if the source is in a failed state after an API request + */ + isFailedSource() { + return this.source.status === NodeStatus.FAILED + }, + + canDrag(): boolean { + if (this.isRenaming) { + return false + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return false + } + + const canDrag = (node: Node): boolean => { + return (node?.permissions & Permission.UPDATE) !== 0 + } + + // If we're dragging a selection, we need to check all files + if (this.selectedFiles.length > 0) { + const nodes = this.selectedFiles.map(source => this.filesStore.getNode(source)) as Node[] + return nodes.every(canDrag) + } + return canDrag(this.source) + }, + + canDrop(): boolean { + if (this.source.type !== FileType.Folder) { + return false + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return false + } + + // If the current folder is also being dragged, we can't drop it on itself + if (this.draggingFiles.includes(this.source.source)) { + return false + } + + return (this.source.permissions & Permission.CREATE) !== 0 + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === this.uniqueId.toString() + }, + set(opened) { + // If the menu is opened on another file entry, we ignore closed events + if (opened === false && this.actionsMenuStore.opened !== this.uniqueId.toString()) { + return + } + + // If opened, we specify the current file id + // else we set it to null to close the menu + this.actionsMenuStore.opened = opened + ? this.uniqueId.toString() + : null + }, + }, + + mtime() { + // If the mtime is not a valid date, return it as is + if (this.source.mtime && !isNaN(this.source.mtime.getDate())) { + return this.source.mtime + } + + if (this.source.crtime && !isNaN(this.source.crtime.getDate())) { + return this.source.crtime + } + + return null + }, + + mtimeOpacity() { + if (!this.mtime) { + return {} + } + + // The time when we start reducing the opacity + const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days + // everything older than the maxOpacityTime will have the same value + const timeDiff = Date.now() - this.mtime.getTime() + if (timeDiff < 0) { + // this means we have an invalid mtime which is in the future! + return {} + } + + // inversed time difference from 0 to maxOpacityTime (which would mean today) + const opacityTime = Math.max(0, maxOpacityTime - timeDiff) + // 100 = today, 0 = 31 days ago or older + const percentage = Math.round(opacityTime * 100 / maxOpacityTime) + return { + color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`, + } + }, + + /** + * Sorted actions that are enabled for this node + */ + enabledFileActions() { + if (this.source.status === NodeStatus.FAILED) { + return [] + } + + return actions + .filter(action => { + if (!action.enabled) { + return true + } + + // In case something goes wrong, since we don't want to break + // the entire list, we filter out actions that throw an error. + try { + return action.enabled([this.source], this.currentView) + } catch (error) { + logger.error('Error while checking action', { action, error }) + return false + } + }) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + defaultFileAction() { + return this.enabledFileActions.find((action) => action.default !== undefined) + }, + }, + + watch: { + /** + * When the source changes, reset the preview + * and fetch the new one. + * @param newSource The new value of the source prop + * @param oldSource The previous value + */ + source(newSource: Node, oldSource: Node) { + if (newSource.source !== oldSource.source) { + this.resetState() + } + }, + + openedMenu() { + // Checking if the menu is really closed and not + // just a change in the open state to another file entry. + if (this.actionsMenuStore.opened === null) { + // Reset any right menu position potentially set + logger.debug('All actions menu closed, resetting right menu position...') + const root = this.$el?.closest('main.app-content') as HTMLElement + if (root !== null) { + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + } + }, + }, + + beforeDestroy() { + this.resetState() + }, + + methods: { + resetState() { + // Reset the preview state + this.$refs?.preview?.reset?.() + + // Close menu + this.openedMenu = false + }, + + // Open the actions menu on right click + onRightClick(event) { + // If already opened, fallback to default browser + if (this.openedMenu) { + return + } + + // Ignore right click if the node is not available + if (this.isFailedSource) { + return + } + + // The grid mode is compact enough to not care about + // the actions menu mouse position + if (!this.gridMode) { + // Actions menu is contained within the app content + const root = this.$el?.closest('main.app-content') as HTMLElement + const contentRect = root.getBoundingClientRect() + // Using Math.min/max to prevent the menu from going out of the AppContent + // 200 = max width of the menu + logger.debug('Setting actions menu position...') + root.style.setProperty('--mouse-pos-x', Math.max(0, event.clientX - contentRect.left - 200) + 'px') + root.style.setProperty('--mouse-pos-y', Math.max(0, event.clientY - contentRect.top) + 'px') + } else { + // Reset any right menu position potentially set + const root = this.$el?.closest('main.app-content') as HTMLElement + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + + // If the clicked row is in the selection, open global menu + const isMoreThanOneSelected = this.selectedFiles.length > 1 + this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId.toString() + + // Prevent any browser defaults + event.preventDefault() + event.stopPropagation() + }, + + execDefaultAction(event: MouseEvent) { + // Ignore click if we are renaming + if (this.isRenaming) { + return + } + + // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4) + if (Boolean(event.button & 2) || event.button > 4) { + return + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return + } + + // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab + // also if there is no default action use this as a fallback + const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1 + if (metaKeyPressed || !this.defaultFileAction) { + // If no download permission, then we can not allow to download (direct link) the files + if (isPublicShare() && !isDownloadable(this.source)) { + return + } + + const url = isPublicShare() + ? this.source.encodedSource + : generateUrl('/f/{fileId}', { fileId: this.fileid }) + event.preventDefault() + event.stopPropagation() + + // Open the file in a new tab if the meta key or the middle mouse button is clicked + window.open(url, metaKeyPressed ? '_blank' : '_self') + return + } + + // every special case handled so just execute the default action + event.preventDefault() + event.stopPropagation() + // Execute the first default action if any + this.defaultFileAction.exec(this.source, this.currentView, this.currentDir) + }, + + openDetailsIfAvailable(event) { + event.preventDefault() + event.stopPropagation() + if (sidebarAction?.enabled?.([this.source], this.currentView)) { + sidebarAction.exec(this.source, this.currentView, this.currentDir) + } + }, + + onDragOver(event: DragEvent) { + this.dragover = this.canDrop + if (!this.canDrop) { + event.dataTransfer.dropEffect = 'none' + return + } + + // Handle copy/move drag and drop + if (event.ctrlKey) { + event.dataTransfer.dropEffect = 'copy' + } else { + event.dataTransfer.dropEffect = 'move' + } + }, + onDragLeave(event: DragEvent) { + // Counter bubbling, make sure we're ending the drag + // only when we're leaving the current element + const currentTarget = event.currentTarget as HTMLElement + if (currentTarget?.contains(event.relatedTarget as HTMLElement)) { + return + } + + this.dragover = false + }, + + async onDragStart(event: DragEvent) { + event.stopPropagation() + if (!this.canDrag || !this.fileid) { + event.preventDefault() + event.stopPropagation() + return + } + + logger.debug('Drag started', { event }) + + // Make sure that we're not dragging a file like the preview + event.dataTransfer?.clearData?.() + + // Reset any renaming + this.renamingStore.$reset() + + // Dragging set of files, if we're dragging a file + // that is already selected, we use the entire selection + if (this.selectedFiles.includes(this.source.source)) { + this.draggingStore.set(this.selectedFiles) + } else { + this.draggingStore.set([this.source.source]) + } + + const nodes = this.draggingStore.dragging + .map(source => this.filesStore.getNode(source)) as Node[] + + const image = await getDragAndDropPreview(nodes) + event.dataTransfer?.setDragImage(image, -10, -10) + }, + onDragEnd() { + this.draggingStore.reset() + this.dragover = false + logger.debug('Drag ended') + }, + + async onDrop(event: DragEvent) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.items?.length) { + return + } + + event.preventDefault() + event.stopPropagation() + + // Caching the selection + const selection = this.draggingFiles + const items = [...event.dataTransfer?.items || []] as DataTransferItem[] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(this.source.path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (!this.canDrop || event.button) { + return + } + + const isCopy = event.ctrlKey + this.dragover = false + + logger.debug('Dropped', { event, folder, selection, fileTree }) + + // Check whether we're uploading files + if (selection.length === 0 && fileTree.contents.length > 0) { + await onDropExternalFiles(fileTree, folder, contents.contents) + return + } + + // Else we're moving/copying files + const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[] + await onDropInternalFiles(nodes, folder, contents.contents, isCopy) + + // Reset selection after we dropped the files + // if the dropped files are within the selection + if (selection.some(source => this.selectedFiles.includes(source))) { + logger.debug('Dropped selection, resetting select store...') + this.selectionStore.reset() + } + }, + + t, + }, +}) diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue new file mode 100644 index 00000000000..bd3ac867ed5 --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilter.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcActions force-menu + :type="isActive ? 'secondary' : 'tertiary'" + :menu-name="filterName"> + <template #icon> + <slot name="icon" /> + </template> + <slot /> + + <template v-if="isActive"> + <NcActionSeparator /> + <NcActionButton class="files-list-filter__clear-button" + close-after-click + @click="$emit('reset-filter')"> + {{ t('files', 'Clear filter') }} + </NcActionButton> + </template> + </NcActions> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' + +defineProps<{ + isActive: boolean + filterName: string +}>() + +defineEmits<{ + (event: 'reset-filter'): void +}>() +</script> + +<style scoped> +.files-list-filter__clear-button :deep(.action-button__text) { + color: var(--color-error-text); +} + +:deep(.button-vue) { + font-weight: normal !important; + + * { + font-weight: normal !important; + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue new file mode 100644 index 00000000000..3a843b2bc3e --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue @@ -0,0 +1,107 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <FileListFilter :is-active="isActive" + :filter-name="t('files', 'Modified')" + @reset-filter="resetFilter"> + <template #icon> + <NcIconSvgWrapper :path="mdiCalendarRangeOutline" /> + </template> + <NcActionButton v-for="preset of timePresets" + :key="preset.id" + type="radio" + close-after-click + :model-value.sync="selectedOption" + :value="preset.id"> + {{ preset.label }} + </NcActionButton> + <!-- TODO: Custom time range --> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITimePreset } from '../../filters/ModifiedFilter.ts' + +import { mdiCalendarRangeOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + timePresets: { + type: Array as PropType<ITimePreset[]>, + required: true, + }, + }, + + setup() { + return { + // icons used in template + mdiCalendarRangeOutline, + } + }, + + data() { + return { + selectedOption: null as string | null, + timeRangeEnd: null as number | null, + timeRangeStart: null as number | null, + } + }, + + computed: { + /** + * Is the filter currently active + */ + isActive() { + return this.selectedOption !== null + }, + + currentPreset() { + return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null + }, + }, + + watch: { + selectedOption() { + if (this.selectedOption === null) { + this.$emit('update:preset') + } else { + const preset = this.currentPreset + this.$emit('update:preset', preset) + } + }, + }, + + methods: { + t, + + resetFilter() { + this.selectedOption = null + this.timeRangeEnd = null + this.timeRangeStart = null + }, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list-filter-time { + &__clear-button :deep(.action-button__text) { + color: var(--color-error-text); + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue new file mode 100644 index 00000000000..938be171f6d --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue @@ -0,0 +1,47 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcButton v-show="isVisible" @click="onClick"> + {{ t('files', 'Search everywhere') }} + </NcButton> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import { getPinia } from '../../store/index.ts' +import { useSearchStore } from '../../store/search.ts' + +const isVisible = ref(false) + +defineExpose({ + hideButton, + showButton, +}) + +/** + * Hide the button - called by the filter class + */ +function hideButton() { + isVisible.value = false +} + +/** + * Show the button - called by the filter class + */ +function showButton() { + isVisible.value = true +} + +/** + * Button click handler to make the filtering a global search. + */ +function onClick() { + const searchStore = useSearchStore(getPinia()) + searchStore.scope = 'globally' +} +</script> diff --git a/apps/files/src/components/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue new file mode 100644 index 00000000000..d3ad791513f --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue @@ -0,0 +1,122 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <FileListFilter class="file-list-filter-type" + :is-active="isActive" + :filter-name="t('files', 'Type')" + @reset-filter="resetFilter"> + <template #icon> + <NcIconSvgWrapper :path="mdiFileOutline" /> + </template> + <NcActionButton v-for="fileType of typePresets" + :key="fileType.id" + type="checkbox" + :model-value="selectedOptions.includes(fileType)" + @click="toggleOption(fileType)"> + <template #icon> + <NcIconSvgWrapper :svg="fileType.icon" /> + </template> + {{ fileType.label }} + </NcActionButton> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITypePreset } from '../../filters/TypeFilter.ts' + +import { mdiFileOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + name: 'FileListFilterType', + + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + presets: { + type: Array as PropType<ITypePreset[]>, + default: () => [], + }, + typePresets: { + type: Array as PropType<ITypePreset[]>, + required: true, + }, + }, + + setup() { + return { + mdiFileOutline, + t, + } + }, + + data() { + return { + selectedOptions: [] as ITypePreset[], + } + }, + + computed: { + isActive() { + return this.selectedOptions.length > 0 + }, + }, + + watch: { + /** Reset selected options if property is changed */ + presets() { + this.selectedOptions = this.presets ?? [] + }, + selectedOptions(newValue, oldValue) { + if (this.selectedOptions.length === 0) { + if (oldValue.length !== 0) { + this.$emit('update:presets') + } + } else { + this.$emit('update:presets', this.selectedOptions) + } + }, + }, + + mounted() { + this.selectedOptions = this.presets ?? [] + }, + + methods: { + resetFilter() { + this.selectedOptions = [] + }, + + /** + * Toggle option from selected option + * @param option The option to toggle + */ + toggleOption(option: ITypePreset) { + const idx = this.selectedOptions.indexOf(option) + if (idx !== -1) { + this.selectedOptions.splice(idx, 1) + } else { + this.selectedOptions.push(option) + } + }, + }, +}) +</script> + +<style> +.file-list-filter-type { + max-width: 220px; +} +</style> diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue new file mode 100644 index 00000000000..7f0d71fd85a --- /dev/null +++ b/apps/files/src/components/FileListFilters.vue @@ -0,0 +1,74 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="file-list-filters"> + <div class="file-list-filters__filter" data-cy-files-filters> + <span v-for="filter of visualFilters" + :key="filter.id" + ref="filterElements" /> + </div> + <ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')"> + <li v-for="(chip, index) of activeChips" :key="index"> + <NcChip :aria-label-close="t('files', 'Remove filter')" + :icon-svg="chip.icon" + :text="chip.text" + @close="chip.onclick"> + <template v-if="chip.user" #icon> + <NcAvatar disable-menu + :show-user-status="false" + :size="24" + :user="chip.user" /> + </template> + </NcChip> + </li> + </ul> + </div> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { computed, ref, watchEffect } from 'vue' +import { useFiltersStore } from '../store/filters.ts' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcChip from '@nextcloud/vue/components/NcChip' + +const filterStore = useFiltersStore() +const visualFilters = computed(() => filterStore.filtersWithUI) +const activeChips = computed(() => filterStore.activeChips) + +const filterElements = ref<HTMLElement[]>([]) +watchEffect(() => { + filterElements.value + .forEach((el, index) => visualFilters.value[index].mount(el)) +}) +</script> + +<style scoped lang="scss"> +.file-list-filters { + display: flex; + flex-direction: column; + gap: var(--default-grid-baseline); + height: 100%; + width: 100%; + + &__filter { + display: flex; + align-items: start; + justify-content: start; + gap: calc(var(--default-grid-baseline, 4px) * 2); + + > * { + flex: 0 1 fit-content; + } + } + + &__active { + display: flex; + flex-direction: row; + gap: calc(var(--default-grid-baseline, 4px) * 2); + } +} +</style> diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue new file mode 100644 index 00000000000..31458398028 --- /dev/null +++ b/apps/files/src/components/FilesListHeader.vue @@ -0,0 +1,100 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div v-show="enabled" :class="`files-list__header-${header.id}`"> + <span ref="mount" /> + </div> +</template> + +<script lang="ts"> +import type { Folder, Header, View } from '@nextcloud/files' +import type { PropType } from 'vue' + +import PQueue from 'p-queue' + +import logger from '../logger.ts' + +/** + * This component is used to render custom + * elements provided by an API. Vue doesn't allow + * to directly render an HTMLElement, so we can do + * this magic here. + */ +export default { + name: 'FilesListHeader', + props: { + header: { + type: Object as PropType<Header>, + required: true, + }, + currentFolder: { + type: Object as PropType<Folder>, + required: true, + }, + currentView: { + type: Object as PropType<View>, + required: true, + }, + }, + setup() { + // Create a queue to ensure that the header is only rendered once at a time + const queue = new PQueue({ concurrency: 1 }) + + return { + queue, + } + }, + computed: { + enabled() { + return this.header.enabled?.(this.currentFolder, this.currentView) ?? true + }, + }, + watch: { + enabled(enabled) { + if (!enabled) { + return + } + // If the header is enabled, we need to render it + logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header }) + this.queueUpdate(this.currentFolder, this.currentView) + }, + currentFolder(folder: Folder) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queueUpdate(folder, this.currentView) + }, + currentView(view: View) { + this.queueUpdate(this.currentFolder, view) + }, + }, + + mounted() { + logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header }) + const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + this.queue.add(initialRender).then(() => { + logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header }) + }).catch((error) => { + logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + destroyed() { + logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header }) + }, + + methods: { + queueUpdate(currentFolder: Folder, currentView: View) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queue.add(() => this.header.updated(currentFolder, currentView)) + .then(() => { + logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header }) + }) + .catch((error) => { + logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + }, +} +</script> diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue new file mode 100644 index 00000000000..9e8cdc159ee --- /dev/null +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -0,0 +1,164 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <tr> + <th class="files-list__row-checkbox"> + <!-- TRANSLATORS Label for a table footer which summarizes the columns of the table --> + <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span> + </th> + + <!-- Link to file --> + <td class="files-list__row-name"> + <!-- Icon or preview --> + <span class="files-list__row-icon" /> + + <!-- Summary --> + <span>{{ summary }}</span> + </td> + + <!-- Actions --> + <td class="files-list__row-actions" /> + + <!-- Mime --> + <td v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" /> + + <!-- Size --> + <td v-if="isSizeAvailable" + class="files-list__column files-list__row-size"> + <span>{{ totalSize }}</span> + </td> + + <!-- Mtime --> + <td v-if="isMtimeAvailable" + class="files-list__column files-list__row-mtime" /> + + <!-- Custom views columns --> + <th v-for="column in columns" + :key="column.id" + :class="classForColumn(column)"> + <span>{{ column.summary?.(nodes, currentView) }}</span> + </th> + </tr> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { View, formatFileSize } from '@nextcloud/files' +import { translate } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import { useFilesStore } from '../store/files.ts' +import { usePathsStore } from '../store/paths.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' + +export default defineComponent({ + name: 'FilesListTableFooter', + + props: { + currentView: { + type: View, + required: true, + }, + isMimeAvailable: { + type: Boolean, + default: false, + }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + isSizeAvailable: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + summary: { + type: String, + default: '', + }, + filesListWidth: { + type: Number, + default: 0, + }, + }, + + setup() { + const pathsStore = usePathsStore() + const filesStore = useFilesStore() + const { directory } = useRouteParameters() + return { + filesStore, + pathsStore, + directory, + } + }, + + computed: { + currentFolder() { + if (!this.currentView?.id) { + return + } + + if (this.directory === '/') { + return this.filesStore.getRoot(this.currentView.id) + } + const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)! + return this.filesStore.getNode(fileId) + }, + + columns() { + // Hide columns if the list is too small + if (this.filesListWidth < 512) { + return [] + } + return this.currentView?.columns || [] + }, + + totalSize() { + // If we have the size already, let's use it + if (this.currentFolder?.size) { + return formatFileSize(this.currentFolder.size, true) + } + + // Otherwise let's compute it + return formatFileSize(this.nodes.reduce((total, node) => total + (node.size ?? 0), 0), true) + }, + }, + + methods: { + classForColumn(column) { + return { + 'files-list__row-column-custom': true, + [`files-list__row-${this.currentView.id}-${column.id}`]: true, + } + }, + + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +// Scoped row +tr { + margin-bottom: var(--body-container-margin); + border-top: 1px solid var(--color-border); + // Prevent hover effect on the whole row + background-color: transparent !important; + border-bottom: none !important; + + td { + user-select: none; + // Make sure the cell colors don't apply to column headers + color: var(--color-text-maxcontrast) !important; + } +} +</style> diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue new file mode 100644 index 00000000000..23e631199eb --- /dev/null +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -0,0 +1,237 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <tr class="files-list__row-head"> + <th class="files-list__column files-list__row-checkbox" + @keyup.esc.exact="resetSelection"> + <NcCheckboxRadioSwitch v-bind="selectAllBind" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" /> + </th> + + <!-- Columns display --> + + <!-- Link to file --> + <th class="files-list__column files-list__row-name files-list__column--sortable" + :aria-sort="ariaSortForMode('basename')"> + <!-- Icon or preview --> + <span class="files-list__row-icon" /> + + <!-- Name --> + <FilesListTableHeaderButton :name="t('files', 'Name')" mode="basename" /> + </th> + + <!-- Actions --> + <th class="files-list__row-actions" /> + + <!-- Mime --> + <th v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" + :class="{ 'files-list__column--sortable': isMimeAvailable }" + :aria-sort="ariaSortForMode('mime')"> + <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" /> + </th> + + <!-- Size --> + <th v-if="isSizeAvailable" + class="files-list__column files-list__row-size" + :class="{ 'files-list__column--sortable': isSizeAvailable }" + :aria-sort="ariaSortForMode('size')"> + <FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" /> + </th> + + <!-- Mtime --> + <th v-if="isMtimeAvailable" + class="files-list__column files-list__row-mtime" + :class="{ 'files-list__column--sortable': isMtimeAvailable }" + :aria-sort="ariaSortForMode('mtime')"> + <FilesListTableHeaderButton :name="t('files', 'Modified')" mode="mtime" /> + </th> + + <!-- Custom views columns --> + <th v-for="column in columns" + :key="column.id" + :class="classForColumn(column)" + :aria-sort="ariaSortForMode(column.id)"> + <FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" /> + <span v-else> + {{ column.title }} + </span> + </th> + </tr> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../types.ts' + +import { translate as t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +import { useFilesStore } from '../store/files.ts' +import { useNavigation } from '../composables/useNavigation' +import { useSelectionStore } from '../store/selection.ts' +import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' +import filesSortingMixin from '../mixins/filesSorting.ts' +import logger from '../logger.ts' + +export default defineComponent({ + name: 'FilesListTableHeader', + + components: { + FilesListTableHeaderButton, + NcCheckboxRadioSwitch, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + isSizeAvailable: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + filesListWidth: { + type: Number, + default: 0, + }, + }, + + setup() { + const filesStore = useFilesStore() + const selectionStore = useSelectionStore() + const { currentView } = useNavigation() + + return { + filesStore, + selectionStore, + + currentView, + } + }, + + computed: { + columns() { + // Hide columns if the list is too small + if (this.filesListWidth < 512) { + return [] + } + return this.currentView?.columns || [] + }, + + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, + + selectAllBind() { + const label = t('files', 'Toggle selection for all files and folders') + return { + 'aria-label': label, + checked: this.isAllSelected, + indeterminate: this.isSomeSelected, + title: label, + } + }, + + selectedNodes() { + return this.selectionStore.selected + }, + + isAllSelected() { + return this.selectedNodes.length === this.nodes.length + }, + + isNoneSelected() { + return this.selectedNodes.length === 0 + }, + + isSomeSelected() { + return !this.isAllSelected && !this.isNoneSelected + }, + }, + + created() { + // ctrl+a selects all + useHotKey('a', this.onToggleAll, { + ctrl: true, + stop: true, + prevent: true, + }) + + // Escape key cancels selection + useHotKey('Escape', this.resetSelection, { + stop: true, + prevent: true, + }) + }, + + methods: { + ariaSortForMode(mode: string): 'ascending'|'descending'|null { + if (this.sortingMode === mode) { + return this.isAscSorting ? 'ascending' : 'descending' + } + return null + }, + + classForColumn(column) { + return { + 'files-list__column': true, + 'files-list__column--sortable': !!column.sort, + 'files-list__row-column-custom': true, + [`files-list__row-${this.currentView?.id}-${column.id}`]: true, + } + }, + + onToggleAll(selected = true) { + if (selected) { + const selection = this.nodes.map(node => node.source).filter(Boolean) as FileSource[] + logger.debug('Added all nodes to selection', { selection }) + this.selectionStore.setLastIndex(null) + this.selectionStore.set(selection) + } else { + logger.debug('Cleared selection') + this.selectionStore.reset() + } + }, + + resetSelection() { + if (this.isNoneSelected) { + return + } + this.selectionStore.reset() + }, + + t, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__column { + user-select: none; + // Make sure the cell colors don't apply to column headers + color: var(--color-text-maxcontrast) !important; + + &--sortable { + cursor: pointer; + } +} + +</style> diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue new file mode 100644 index 00000000000..6a808355c58 --- /dev/null +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -0,0 +1,337 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions> + <NcActions ref="actionsMenu" + container="#app-content-vue" + :boundaries-element="boundariesElement" + :disabled="!!loading || areSomeNodesLoading" + :force-name="true" + :inline="enabledInlineActions.length" + :menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null" + :open.sync="openedMenu" + @close="openedSubmenu = null"> + <!-- Default actions list--> + <NcActionButton v-for="action in enabledMenuActions" + :key="action.id" + :ref="`action-batch-${action.id}`" + :class="{ + [`files-list__row-actions-batch-${action.id}`]: true, + [`files-list__row-actions-batch--menu`]: isValidMenu(action) + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-selection-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */" + :title="action.title?.(nodes, currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" /> + </template> + {{ action.displayName(nodes, currentView) }} + </NcActionButton> + + <!-- Submenu actions list--> + <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> + <!-- Back to top-level button --> + <NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)"> + <template #icon> + <ArrowLeftIcon /> + </template> + {{ t('files', 'Back') }} + </NcActionButton> + <NcActionSeparator /> + + <!-- Submenu actions --> + <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]" + :key="action.id" + :class="`files-list__row-actions-batch-${action.id}`" + class="files-list__row-actions-batch--submenu" + close-after-click + :data-cy-files-list-selection-action="action.id" + :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */" + :title="action.title?.(nodes, currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" /> + </template> + {{ action.displayName(nodes, currentView) }} + </NcActionButton> + </template> + </NcActions> + </div> +</template> + +<script lang="ts"> +import type { FileAction, Node, View } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../types' + +import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useActionsMenuStore } from '../store/actionsmenu.ts' +import { useFilesStore } from '../store/files.ts' +import { useSelectionStore } from '../store/selection.ts' +import actionsMixins from '../mixins/actionsMixin.ts' +import logger from '../logger.ts' + +// The registered actions list +const actions = getFileActions() + +export default defineComponent({ + name: 'FilesListTableHeaderActions', + + components: { + ArrowLeftIcon, + NcActions, + NcActionButton, + NcIconSvgWrapper, + NcLoadingIcon, + }, + + mixins: [actionsMixins], + + props: { + currentView: { + type: Object as PropType<View>, + required: true, + }, + selectedNodes: { + type: Array as PropType<FileSource[]>, + default: () => ([]), + }, + }, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const filesStore = useFilesStore() + const selectionStore = useSelectionStore() + const fileListWidth = useFileListWidth() + const { directory } = useRouteParameters() + + const boundariesElement = document.getElementById('app-content-vue') + + return { + directory, + fileListWidth, + + actionsMenuStore, + filesStore, + selectionStore, + + boundariesElement, + } + }, + + data() { + return { + loading: null, + } + }, + + computed: { + enabledFileActions(): FileAction[] { + return actions + // We don't handle renderInline actions in this component + .filter(action => !action.renderInline) + // We don't handle actions that are not visible + .filter(action => action.default !== DefaultType.HIDDEN) + .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView)) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + /** + * Return the list of enabled actions that are + * allowed to be rendered inlined. + * This means that they are not within a menu, nor + * being the parent of submenu actions. + */ + enabledInlineActions(): FileAction[] { + return this.enabledFileActions + // Remove all actions that are not top-level actions + .filter(action => action.parent === undefined) + // Remove all actions that are not batch actions + .filter(action => action.execBatch !== undefined) + // Remove all top-menu entries + .filter(action => !this.isValidMenu(action)) + // Return a maximum actions to fit the screen + .slice(0, this.inlineActions) + }, + + /** + * Return the rest of enabled actions that are not + * rendered inlined. + */ + enabledMenuActions(): FileAction[] { + // If we're in a submenu, only render the inline + // actions before the filtered submenu + if (this.openedSubmenu) { + return this.enabledInlineActions + } + + // We filter duplicates to prevent inline actions to be shown twice + const actions = this.enabledFileActions.filter((value, index, self) => { + return index === self.findIndex(action => action.id === value.id) + }) + + // Generate list of all top-level actions ids + const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[] + + const menuActions = actions + .filter(action => { + // If the action is not a batch action, we need + // to make sure it's a top-level parent entry + // and that we have some children actions bound to it + if (!action.execBatch) { + return childrenActionsIds.includes(action.id) + } + + // Rendering second-level actions is done in the template + // when openedSubmenu is set. + if (action.parent) { + return false + } + + return true + }) + .filter(action => !this.enabledInlineActions.includes(action)) + + // Make sure we render the inline actions first + // and then the rest of the actions. + // We do NOT want nested actions to be rendered inlined + return [...this.enabledInlineActions, ...menuActions] + }, + + nodes() { + return this.selectedNodes + .map(source => this.getNode(source)) + .filter(Boolean) as Node[] + }, + + areSomeNodesLoading() { + return this.nodes.some(node => node.status === NodeStatus.LOADING) + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === 'global' + }, + set(opened) { + this.actionsMenuStore.opened = opened ? 'global' : null + }, + }, + + inlineActions() { + if (this.fileListWidth < 512) { + return 0 + } + if (this.fileListWidth < 768) { + return 1 + } + if (this.fileListWidth < 1024) { + return 2 + } + return 3 + }, + }, + + methods: { + /** + * Get a cached note from the store + * + * @param source The source of the node to get + */ + getNode(source: string): Node|undefined { + return this.filesStore.getNode(source) + }, + + async onActionClick(action) { + // If the action is a submenu, we open it + if (this.enabledSubmenuActions[action.id]) { + this.openedSubmenu = action + return + } + + let displayName = action.id + try { + displayName = action.displayName(this.nodes, this.currentView) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + + const selectionSources = this.selectedNodes + try { + // Set loading markers + this.loading = action.id + this.nodes.forEach(node => { + this.$set(node, 'status', NodeStatus.LOADING) + }) + + // Dispatch action execution + const results = await action.execBatch(this.nodes, this.currentView, this.directory) + + // Check if all actions returned null + if (!results.some(result => result !== null)) { + // If the actions returned null, we stay silent + this.selectionStore.reset() + return + } + + // Handle potential failures + if (results.some(result => result === false)) { + // Remove the failed ids from the selection + const failedSources = selectionSources + .filter((source, index) => results[index] === false) + this.selectionStore.set(failedSources) + + if (results.some(result => result === null)) { + // If some actions returned null, we assume that the dev + // is handling the error messages and we stay silent + return + } + + showError(this.t('files', '{displayName}: failed on some elements', { displayName })) + return + } + + // Show success message and clear selection + showSuccess(this.t('files', '{displayName}: done', { displayName })) + this.selectionStore.reset() + } catch (e) { + logger.error('Error while executing action', { action, e }) + showError(this.t('files', '{displayName}: failed', { displayName })) + } finally { + // Remove loading markers + this.loading = null + this.nodes.forEach(node => { + this.$set(node, 'status', undefined) + }) + } + }, + + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__row-actions-batch { + flex: 1 1 100% !important; + max-width: 100%; +} +</style> diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue new file mode 100644 index 00000000000..d2e14a5495f --- /dev/null +++ b/apps/files/src/components/FilesListTableHeaderButton.vue @@ -0,0 +1,91 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcButton :class="['files-list__column-sort-button', { + 'files-list__column-sort-button--active': sortingMode === mode, + 'files-list__column-sort-button--size': sortingMode === 'size', + }]" + :alignment="mode === 'size' ? 'end' : 'start-reverse'" + type="tertiary" + :title="name" + @click="toggleSortBy(mode)"> + <template #icon> + <MenuUp v-if="sortingMode !== mode || isAscSorting" class="files-list__column-sort-button-icon" /> + <MenuDown v-else class="files-list__column-sort-button-icon" /> + </template> + <span class="files-list__column-sort-button-text">{{ name }}</span> + </NcButton> +</template> + +<script lang="ts"> +import { translate } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import MenuDown from 'vue-material-design-icons/MenuDown.vue' +import MenuUp from 'vue-material-design-icons/MenuUp.vue' +import NcButton from '@nextcloud/vue/components/NcButton' + +import filesSortingMixin from '../mixins/filesSorting.ts' + +export default defineComponent({ + name: 'FilesListTableHeaderButton', + + components: { + MenuDown, + MenuUp, + NcButton, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + name: { + type: String, + required: true, + }, + mode: { + type: String, + required: true, + }, + }, + + methods: { + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__column-sort-button { + // Compensate for cells margin + margin: 0 calc(var(--button-padding, var(--cell-margin)) * -1); + min-width: calc(100% - 3 * var(--cell-margin))!important; + + &-text { + color: var(--color-text-maxcontrast); + font-weight: normal; + } + + &-icon { + color: var(--color-text-maxcontrast); + opacity: 0; + transition: opacity var(--animation-quick); + inset-inline-start: -10px; + } + + &--size &-icon { + inset-inline-start: 10px; + } + + &--active &-icon, + &:hover &-icon, + &:focus &-icon, + &:active &-icon { + opacity: 1; + } +} +</style> 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> diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue new file mode 100644 index 00000000000..c29bc00c67f --- /dev/null +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -0,0 +1,182 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Fragment> + <NcAppNavigationItem v-for="view in currentViews" + :key="view.id" + class="files-navigation__item" + allow-collapse + :loading="view.loading" + :data-cy-files-navigation-item="view.id" + :exact="useExactRouteMatching(view)" + :icon="view.iconClass" + :name="view.name" + :open="isExpanded(view)" + :pinned="view.sticky" + :to="generateToNavigation(view)" + :style="style" + @update:open="(open) => onOpen(open, view)"> + <template v-if="view.icon" #icon> + <NcIconSvgWrapper :svg="view.icon" /> + </template> + + <!-- Hack to force the collapse icon to be displayed --> + <li v-if="view.loadChildViews && !view.loaded" style="display: none" /> + + <!-- Recursively nest child views --> + <FilesNavigationItem v-if="hasChildViews(view)" + :parent="view" + :level="level + 1" + :views="filterView(views, parent.id)" /> + </NcAppNavigationItem> + </Fragment> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { View } from '@nextcloud/files' + +import { defineComponent } from 'vue' +import { Fragment } from 'vue-frag' + +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +import { useNavigation } from '../composables/useNavigation.js' +import { useViewConfigStore } from '../store/viewConfig.js' + +const maxLevel = 7 // Limit nesting to not exceed max call stack size + +export default defineComponent({ + name: 'FilesNavigationItem', + + components: { + Fragment, + NcAppNavigationItem, + NcIconSvgWrapper, + }, + + props: { + parent: { + type: Object as PropType<View>, + default: () => ({}), + }, + level: { + type: Number, + default: 0, + }, + views: { + type: Object as PropType<Record<string, View[]>>, + default: () => ({}), + }, + }, + + setup() { + const { currentView } = useNavigation() + const viewConfigStore = useViewConfigStore() + return { + currentView, + viewConfigStore, + } + }, + + computed: { + currentViews(): View[] { + if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level + return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) + .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) + } + return this.filterVisible(this.views[this.parent.id] ?? []) + }, + + style() { + if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level + return null + } + return { + 'padding-left': '16px', + } + }, + }, + + methods: { + filterVisible(views: View[]) { + return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true) + }, + + hasChildViews(view: View): boolean { + if (this.level >= maxLevel) { + return false + } + return this.filterVisible(this.views[view.id] ?? []).length > 0 + }, + + /** + * Only use exact route matching on routes with child views + * Because if a view does not have children (like the files view) then multiple routes might be matched for it + * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view + * @param view The view to check + */ + useExactRouteMatching(view: View): boolean { + return this.hasChildViews(view) + }, + + /** + * Generate the route to a view + * @param view View to generate "to" navigation for + */ + generateToNavigation(view: View) { + if (view.params) { + const { dir } = view.params + return { name: 'filelist', params: { ...view.params }, query: { dir } } + } + return { name: 'filelist', params: { view: view.id } } + }, + + /** + * Check if a view is expanded by user config + * or fallback to the default value. + * @param view View to check if expanded + */ + isExpanded(view: View): boolean { + return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean' + ? this.viewConfigStore.getConfig(view.id).expanded === true + : view.expanded === true + }, + + /** + * Expand/collapse a a view with children and permanently + * save this setting in the server. + * @param open True if open + * @param view View + */ + async onOpen(open: boolean, view: View) { + // Invert state + const isExpanded = this.isExpanded(view) + // Update the view expanded state, might not be necessary + view.expanded = !isExpanded + this.viewConfigStore.update(view.id, 'expanded', !isExpanded) + if (open && view.loadChildViews) { + await view.loadChildViews(view) + } + }, + + /** + * Return the view map with the specified view id removed + * + * @param viewMap Map of views + * @param id View id + */ + filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> { + return Object.fromEntries( + Object.entries(viewMap) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([viewId, _views]) => viewId !== id), + ) + }, + }, +}) +</script> diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..0890dffcb39 --- /dev/null +++ b/apps/files/src/components/FilesNavigationSearch.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnify, mdiSearchWeb } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { computed } from 'vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useSearchStore } from '../store/search.ts' +import { VIEW_ID } from '../views/search.ts' + +const { currentView } = useNavigation(true) +const searchStore = useSearchStore() + +/** + * When the route is changed from search view to something different + * we need to clear the search box. + */ +onBeforeNavigation((to, from, next) => { + if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) { + // we are leaving the search view so unset the query + searchStore.query = '' + searchStore.scope = 'filter' + } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) { + // fix the query if the user refreshed the view + if (searchStore.query && !to.query.query) { + // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3) + return next({ + ...to, + query: { + ...to.query, + query: searchStore.query, + }, + }) + } + } + next() +}) + +/** + * Are we currently on the search view. + * Needed to disable the action menu (we cannot change the search mode there) + */ +const isSearchView = computed(() => currentView.value.id === VIEW_ID) + +/** + * Different searchbox label depending if filtering or searching + */ +const searchLabel = computed(() => { + if (searchStore.scope === 'globally') { + return t('files', 'Search everywhere …') + } + return t('files', 'Search here …') +}) +</script> + +<template> + <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel"> + <template #actions> + <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView"> + <template #icon> + <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" /> + </template> + <NcActionButton close-after-click @click="searchStore.scope = 'filter'"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + {{ t('files', 'Search here') }} + </NcActionButton> + <NcActionButton close-after-click @click="searchStore.scope = 'globally'"> + <template #icon> + <NcIconSvgWrapper :path="mdiSearchWeb" /> + </template> + {{ t('files', 'Search everywhere') }} + </NcActionButton> + </NcActions> + </template> + </NcAppNavigationSearch> +</template> diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue index 4a50ed558f0..b5a792d9029 100644 --- a/apps/files/src/components/LegacyView.vue +++ b/apps/files/src/components/LegacyView.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div /> @@ -50,10 +33,8 @@ export default { }, methods: { setFileInfo(fileInfo) { - this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo)) + this.component.setFileInfo(fileInfo) }, }, } </script> -<style> -</style> diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index bfcbaea3776..46c8e5c9af4 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -1,6 +1,10 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> <template> <NcAppNavigationItem v-if="storageStats" - :aria-label="t('files', 'Storage informations')" + :aria-description="t('files', 'Storage information')" :class="{ 'app-navigation-entry__settings-quota--not-unlimited': storageStats.quota >= 0}" :loading="loadingStorageStats" :name="storageStatsTitle" @@ -13,25 +17,27 @@ <!-- Progress bar --> <NcProgressBar v-if="storageStats.quota >= 0" slot="extra" + :aria-label="t('files', 'Storage quota')" :error="storageStats.relative > 80" :value="Math.min(storageStats.relative, 100)" /> </NcAppNavigationItem> </template> <script> +import { debounce, throttle } from 'throttle-debounce' import { formatFileSize } from '@nextcloud/files' import { generateUrl } from '@nextcloud/router' import { loadState } from '@nextcloud/initial-state' import { showError } from '@nextcloud/dialogs' -import { debounce, throttle } from 'throttle-debounce' +import { subscribe } from '@nextcloud/event-bus' import { translate } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import ChartPie from 'vue-material-design-icons/ChartPie.vue' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' -import logger from '../logger.js' -import { subscribe } from '@nextcloud/event-bus' +import ChartPie from 'vue-material-design-icons/ChartPieOutline.vue' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' + +import logger from '../logger.ts' export default { name: 'NavigationQuota', @@ -51,8 +57,8 @@ export default { computed: { storageStatsTitle() { - const usedQuotaByte = formatFileSize(this.storageStats?.used) - const quotaByte = formatFileSize(this.storageStats?.quota) + const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false) + const quotaByte = formatFileSize(this.storageStats?.total, false, false) // If no quota set if (this.storageStats?.quota < 0) { @@ -80,15 +86,26 @@ export default { */ setInterval(this.throttleUpdateStorageStats, 60 * 1000) - subscribe('files:file:created', this.throttleUpdateStorageStats) - subscribe('files:file:deleted', this.throttleUpdateStorageStats) - subscribe('files:file:moved', this.throttleUpdateStorageStats) - subscribe('files:file:updated', this.throttleUpdateStorageStats) + subscribe('files:node:created', this.throttleUpdateStorageStats) + subscribe('files:node:deleted', this.throttleUpdateStorageStats) + subscribe('files:node:moved', this.throttleUpdateStorageStats) + subscribe('files:node:updated', this.throttleUpdateStorageStats) + }, - subscribe('files:folder:created', this.throttleUpdateStorageStats) - subscribe('files:folder:deleted', this.throttleUpdateStorageStats) - subscribe('files:folder:moved', this.throttleUpdateStorageStats) - subscribe('files:folder:updated', this.throttleUpdateStorageStats) + mounted() { + // If the user has a quota set, warn if the available account storage is <=0 + // + // NOTE: This doesn't catch situations where actual *server* + // disk (non-quota) space is low, but those should probably + // be handled differently anyway since a regular user can't + // can't do much about them (If we did want to indicate server disk + // space matters to users, we'd probably want to use a warning + // specific to that situation anyhow. So this covers warning covers + // our primary day-to-day concern (individual account quota usage). + // + if (this.storageStats?.quota > 0 && this.storageStats?.free === 0) { + this.showStorageFullWarning() + } }, methods: { @@ -105,7 +122,7 @@ export default { * Update the storage stats * Throttled at max 1 refresh per minute * - * @param {Event} [event = null] if user interaction + * @param {Event} [event] if user interaction */ async updateStorageStats(event = null) { if (this.loadingStorageStats) { @@ -118,6 +135,13 @@ export default { if (!response?.data?.data) { throw new Error('Invalid storage stats') } + + // Warn the user if the available account storage changed from > 0 to 0 + // (unless only because quota was intentionally set to 0 by admin in the interim) + if (this.storageStats?.free > 0 && response.data.data?.free === 0 && response.data.data?.quota > 0) { + this.showStorageFullWarning() + } + this.storageStats = response.data.data } catch (error) { logger.error('Could not refresh storage stats', { error }) @@ -130,6 +154,10 @@ export default { } }, + showStorageFullWarning() { + showError(this.t('files', 'Your storage is full, files can not be updated or synced anymore!')) + }, + t: translate, }, } @@ -139,15 +167,18 @@ export default { // User storage stats display .app-navigation-entry__settings-quota { // Align title with progress and icon - &--not-unlimited::v-deep .app-navigation-entry__title { - margin-top: -4px; + --app-navigation-quota-margin: calc((var(--default-clickable-area) - 24px) / 2); // 20px icon size and 4px progress bar + + &--not-unlimited :deep(.app-navigation-entry__name) { + line-height: 1; + margin-top: var(--app-navigation-quota-margin); } progress { position: absolute; - bottom: 10px; - margin-left: 44px; - width: calc(100% - 44px - 22px); + bottom: var(--app-navigation-quota-margin); + margin-inline-start: var(--default-clickable-area); + width: calc(100% - (1.5 * var(--default-clickable-area))); } } </style> diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue new file mode 100644 index 00000000000..ca10935940d --- /dev/null +++ b/apps/files/src/components/NewNodeDialog.vue @@ -0,0 +1,168 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog data-cy-files-new-node-dialog + :name="name" + :open="open" + close-on-click-outside + out-transition + @update:open="emit('close', null)"> + <template #actions> + <NcButton data-cy-files-new-node-dialog-submit + type="primary" + :disabled="validity !== ''" + @click="submit"> + {{ t('files', 'Create') }} + </NcButton> + </template> + <form ref="formElement" + class="new-node-dialog__form" + @submit.prevent="emit('close', localDefaultName)"> + <NcTextField ref="nameInput" + data-cy-files-new-node-dialog-input + :error="validity !== ''" + :helper-text="validity" + :label="label" + :value.sync="localDefaultName" /> + + <!-- Hidden file warning --> + <NcNoteCard v-if="isHiddenFileName" + type="warning" + :text="t('files', 'Files starting with a dot are hidden by default')" /> + </form> + </NcDialog> +</template> + +<script setup lang="ts"> +import type { ComponentPublicInstance, PropType } from 'vue' +import { getUniqueName } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { extname } from 'path' +import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { getFilenameValidity } from '../utils/filenameValidity.ts' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +const props = defineProps({ + /** + * The name to be used by default + */ + defaultName: { + type: String, + default: t('files', 'New folder'), + }, + /** + * Other files that are in the current directory + */ + otherNames: { + type: Array as PropType<string[]>, + default: () => [], + }, + /** + * Open state of the dialog + */ + open: { + type: Boolean, + default: true, + }, + /** + * Dialog name + */ + name: { + type: String, + default: t('files', 'Create new folder'), + }, + /** + * Input label + */ + label: { + type: String, + default: t('files', 'Folder name'), + }, +}) + +const emit = defineEmits<{ + (event: 'close', name: string | null): void +}>() + +const localDefaultName = ref<string>(props.defaultName) +const nameInput = ref<ComponentPublicInstance>() +const formElement = ref<HTMLFormElement>() +const validity = ref('') + +const isHiddenFileName = computed(() => { + // Check if the name starts with a dot, which indicates a hidden file + return localDefaultName.value.trim().startsWith('.') +}) + +/** + * Focus the filename input field + */ +function focusInput() { + nextTick(() => { + // get the input element + const input = nameInput.value?.$el.querySelector('input') + if (!props.open || !input) { + return + } + + // length of the basename + const length = localDefaultName.value.length - extname(localDefaultName.value).length + // focus the input + input.focus() + // and set the selection to the basename (name without extension) + input.setSelectionRange(0, length) + }) +} + +/** + * Trigger submit on the form + */ +function submit() { + formElement.value?.requestSubmit() +} + +// Reset local name on props change +watch(() => [props.defaultName, props.otherNames], () => { + localDefaultName.value = getUniqueName(props.defaultName, props.otherNames).trim() +}) + +// Validate the local name +watchEffect(() => { + if (props.otherNames.includes(localDefaultName.value.trim())) { + validity.value = t('files', 'This name is already in use.') + } else { + validity.value = getFilenameValidity(localDefaultName.value.trim()) + } + const input = nameInput.value?.$el.querySelector('input') + if (input) { + input.setCustomValidity(validity.value) + input.reportValidity() + } +}) + +// Ensure the input is focussed even if the dialog is already mounted but not open +watch(() => props.open, () => { + nextTick(() => { + focusInput() + }) +}) + +onMounted(() => { + // on mounted lets use the unique name + localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames).trim() + nextTick(() => focusInput()) +}) +</script> + +<style scoped> +.new-node-dialog__form { + /* Ensure the dialog does not jump when there is a validity error */ + min-height: calc(2 * var(--default-clickable-area)); +} +</style> diff --git a/apps/files/src/components/PersonalSettings.vue b/apps/files/src/components/PersonalSettings.vue index 1431ae4053a..b076b0c1e3d 100644 --- a/apps/files/src/components/PersonalSettings.vue +++ b/apps/files/src/components/PersonalSettings.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div id="files-personal-settings" class="section"> @@ -27,7 +11,7 @@ </template> <script> -import TransferOwnershipDialogue from './TransferOwnershipDialogue' +import TransferOwnershipDialogue from './TransferOwnershipDialogue.vue' export default { name: 'PersonalSettings', diff --git a/apps/files/src/components/Setting.vue b/apps/files/src/components/Setting.vue index c55a2841517..7a9ffb137a2 100644 --- a/apps/files/src/components/Setting.vue +++ b/apps/files/src/components/Setting.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2020 Gary Kim <gary@garykim.dev> - - - - @author Gary Kim <gary@garykim.dev> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div /> diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue index ac3cfba7d02..d86e5da9d20 100644 --- a/apps/files/src/components/SidebarTab.vue +++ b/apps/files/src/components/SidebarTab.vue @@ -1,25 +1,7 @@ - <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSidebarTab :id="id" ref="tab" @@ -39,8 +21,8 @@ </template> <script> -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' export default { name: 'SidebarTab', @@ -66,7 +48,7 @@ export default { }, icon: { type: String, - required: false, + default: '', }, /** diff --git a/apps/files/src/components/TemplateFiller.vue b/apps/files/src/components/TemplateFiller.vue new file mode 100644 index 00000000000..3f1db8dfd58 --- /dev/null +++ b/apps/files/src/components/TemplateFiller.vue @@ -0,0 +1,122 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcModal label-id="template-field-modal__label"> + <div class="template-field-modal__content"> + <form> + <h3 id="template-field-modal__label"> + {{ t('files', 'Fill template fields') }} + </h3> + + <div v-for="field in fields" :key="field.index"> + <component :is="getFieldComponent(field.type)" + v-if="fieldHasLabel(field)" + :field="field" + @input="trackInput" /> + </div> + </form> + </div> + + <div class="template-field-modal__buttons"> + <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields …')" /> + <NcButton aria-label="Submit button" + type="primary" + @click="submit"> + {{ t('files', 'Submit') }} + </NcButton> + </div> + </NcModal> +</template> + +<script> +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcModal from '@nextcloud/vue/components/NcModal' +import TemplateRichTextField from './TemplateFiller/TemplateRichTextField.vue' +import TemplateCheckboxField from './TemplateFiller/TemplateCheckboxField.vue' + +export default defineComponent({ + name: 'TemplateFiller', + + components: { + NcModal, + NcButton, + NcLoadingIcon, + TemplateRichTextField, + TemplateCheckboxField, + }, + + props: { + fields: { + type: Array, + default: () => [], + }, + onSubmit: { + type: Function, + default: async () => {}, + }, + }, + + data() { + return { + localFields: {}, + loading: false, + } + }, + + methods: { + t, + trackInput({ index, property, value }) { + if (!this.localFields[index]) { + this.localFields[index] = {} + } + + this.localFields[index][property] = value + }, + getFieldComponent(fieldType) { + const fieldComponentType = fieldType.split('-') + .map((str) => { + return str.charAt(0).toUpperCase() + str.slice(1) + }) + .join('') + + return `Template${fieldComponentType}Field` + }, + fieldHasLabel(field) { + return field.name || field.alias + }, + async submit() { + this.loading = true + + await this.onSubmit(this.localFields) + + this.$emit('close') + }, + }, +}) +</script> + +<style lang="scss" scoped> +$modal-margin: calc(var(--default-grid-baseline) * 4); + +.template-field-modal__content { + padding: $modal-margin; + + h3 { + text-align: center; + } +} + +.template-field-modal__buttons { + display: flex; + justify-content: flex-end; + gap: var(--default-grid-baseline); + margin: $modal-margin; + margin-top: 0; +} +</style> diff --git a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue new file mode 100644 index 00000000000..18536171bd2 --- /dev/null +++ b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue @@ -0,0 +1,68 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="template-field__checkbox"> + <NcCheckboxRadioSwitch :id="fieldId" + :checked.sync="value" + type="switch" + @update:checked="input"> + {{ fieldLabel }} + </NcCheckboxRadioSwitch> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +export default defineComponent({ + name: 'TemplateCheckboxField', + + components: { + NcCheckboxRadioSwitch, + }, + + props: { + field: { + type: Object, + default: () => {}, + }, + }, + + data() { + return { + value: this.field.checked ?? false, + } + }, + + computed: { + fieldLabel() { + const label = this.field.name || this.field.alias + + return label.charAt(0).toUpperCase() + label.slice(1) + }, + fieldId() { + return 'checkbox-field' + this.field.index + }, + }, + + methods: { + input() { + this.$emit('input', { + index: this.field.index, + property: 'checked', + value: this.value, + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.template-field__checkbox { + margin: 20px 0; +} +</style> diff --git a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue new file mode 100644 index 00000000000..f49819f7e7c --- /dev/null +++ b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue @@ -0,0 +1,77 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="template-field__text"> + <label :for="fieldId"> + {{ fieldLabel }} + </label> + + <NcTextField :id="fieldId" + type="text" + :value.sync="value" + :label="fieldLabel" + :label-outside="true" + :placeholder="field.content" + @input="input" /> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +export default defineComponent({ + name: 'TemplateRichTextField', + + components: { + NcTextField, + }, + + props: { + field: { + type: Object, + default: () => {}, + }, + }, + + data() { + return { + value: '', + } + }, + + computed: { + fieldLabel() { + const label = this.field.name || this.field.alias + + return (label.charAt(0).toUpperCase() + label.slice(1)) + }, + fieldId() { + return 'text-field' + this.field.index + }, + }, + + methods: { + input() { + this.$emit('input', { + index: this.field.index, + property: 'content', + value: this.value, + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.template-field__text { + margin: 20px 0; + + label { + font-weight: bold; + } +} +</style> diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue index ad152af9ea3..7927948d3af 100644 --- a/apps/files/src/components/TemplatePreview.vue +++ b/apps/files/src/components/TemplatePreview.vue @@ -1,35 +1,19 @@ <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <li class="template-picker__item"> <input :id="id" + ref="input" :checked="checked" type="radio" class="radio" name="template-picker" @change="onCheck"> - <label :for="id" class="template-picker__label"> + <label :for="id" class="template-picker__label" @click="onClick"> <div class="template-picker__preview" :class="failedPreview ? 'template-picker__preview--failed' : ''"> <img class="template-picker__image" @@ -47,9 +31,9 @@ </template> <script> +import { encodePath } from '@nextcloud/paths' import { generateUrl } from '@nextcloud/router' -import { encodeFilePath } from '../utils/fileUtils' -import { getToken, isPublic } from '../utils/davUtils' +import { isPublicShare, getSharingToken } from '@nextcloud/sharing/public' // preview width generation const previewWidth = 256 @@ -123,8 +107,8 @@ export default { return this.previewUrl } // TODO: find a nicer standard way of doing this? - if (isPublic()) { - return generateUrl(`/apps/files_sharing/publicpreview/${getToken()}?fileId=${this.fileid}&file=${encodeFilePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`) + if (isPublicShare()) { + return generateUrl(`/apps/files_sharing/publicpreview/${getSharingToken()}?fileId=${this.fileid}&file=${encodePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`) } return generateUrl(`/core/preview?fileId=${this.fileid}&x=${previewWidth}&y=${previewWidth}&a=1`) }, @@ -141,6 +125,14 @@ export default { onFailure() { this.failedPreview = true }, + focus() { + this.$refs.input?.focus() + }, + onClick() { + if (this.checked) { + this.$emit('confirm-click', this.fileid) + } + }, }, } </script> @@ -182,7 +174,7 @@ export default { border-radius: var(--border-radius-large); input:checked + label > & { - border-color: var(--color-primary); + border-color: var(--color-primary-element); } &--failed { @@ -209,12 +201,9 @@ export default { } &__title { - overflow: hidden; // also count preview border - max-width: calc(var(--width) + 2*2px); + max-width: calc(var(--width) + 2 * 2px); padding: var(--margin); - white-space: nowrap; - text-overflow: ellipsis; } } diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue index 67840b18829..3d668da8144 100644 --- a/apps/files/src/components/TransferOwnershipDialogue.vue +++ b/apps/files/src/components/TransferOwnershipDialogue.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div> @@ -25,41 +9,33 @@ <form @submit.prevent="submit"> <p class="transfer-select-row"> <span>{{ readableDirectory }}</span> - <NcButton v-if="directory === undefined" @click.prevent="start"> + <NcButton v-if="directory === undefined" + class="transfer-select-row__choose_button" + @click.prevent="start"> {{ t('files', 'Choose file or folder to transfer') }} </NcButton> <NcButton v-else @click.prevent="start"> {{ t('files', 'Change') }} </NcButton> - <span class="error">{{ directoryPickerError }}</span> </p> - <p class="new-owner-row"> + <p class="new-owner"> <label for="targetUser"> <span>{{ t('files', 'New owner') }}</span> </label> - <NcMultiselect id="targetUser" - v-model="selectedUser" + <NcSelect v-model="selectedUser" + input-id="targetUser" :options="formatedUserSuggestions" :multiple="false" - :searchable="true" - :placeholder="t('files', 'Search users')" - :preselect-first="true" - :preserve-search="true" :loading="loadingUsers" - track-by="user" - label="displayName" - :internal-search="false" - :clear-on-select="false" :user-select="true" - class="middle-align" - @search-change="findUserDebounced" /> + @search="findUserDebounced" /> </p> <p> - <input type="submit" - class="primary" - :value="submitButtonText" + <NcButton native-type="submit" + type="primary" :disabled="!canSubmit"> - <span class="error">{{ submitError }}</span> + {{ submitButtonText }} + </NcButton> </p> </form> </div> @@ -69,16 +45,15 @@ import axios from '@nextcloud/axios' import debounce from 'debounce' import { generateOcsUrl } from '@nextcloud/router' -import { getFilePickerBuilder, showSuccess } from '@nextcloud/dialogs' -import NcMultiselect from '@nextcloud/vue/dist/Components/NcMultiselect' +import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs' +import NcSelect from '@nextcloud/vue/components/NcSelect' import Vue from 'vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton' +import NcButton from '@nextcloud/vue/components/NcButton' -import logger from '../logger' +import logger from '../logger.ts' const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to transfer')) .setMultiSelect(false) - .setModal(true) .setType(1) .allowDirectories() .build() @@ -86,7 +61,7 @@ const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to trans export default { name: 'TransferOwnershipDialogue', components: { - NcMultiselect, + NcSelect, NcButton, }, data() { @@ -113,6 +88,7 @@ export default { user: user.uid, displayName: user.displayName, icon: 'icon-user', + subname: user.shareWithDisplayNameUnique, } }) }, @@ -152,6 +128,7 @@ export default { logger.error(`Selecting object for transfer aborted: ${error.message || 'Unknown error'}`, { error }) this.directoryPickerError = error.message || t('files', 'Unknown error') + showError(this.directoryPickerError) }) }, async findUser(query) { @@ -178,6 +155,7 @@ export default { Vue.set(this.userSuggestions, user.value.shareWith, { uid: user.value.shareWith, displayName: user.label, + shareWithDisplayNameUnique: user.shareWithDisplayNameUnique, }) }) } catch (error) { @@ -217,6 +195,7 @@ export default { } else { this.submitError = error.message || t('files', 'Unknown error') } + showError(this.submitError) }) }, }, @@ -224,33 +203,34 @@ export default { </script> <style scoped lang="scss"> -.middle-align { - vertical-align: middle; -} p { margin-top: 12px; margin-bottom: 12px; } -.new-owner-row { + +.new-owner { display: flex; + flex-direction: column; + max-width: 400px; label { display: flex; align-items: center; + margin-bottom: calc(var(--default-grid-baseline) * 2); span { - margin-right: 8px; + margin-inline-end: 8px; } } - - .multiselect { - flex-grow: 1; - max-width: 280px; - } } + .transfer-select-row { span { - margin-right: 8px; + margin-inline-end: 8px; + } + + &__choose_button { + width: min(100%, 400px) !important; } } </style> diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue new file mode 100644 index 00000000000..4746fedf863 --- /dev/null +++ b/apps/files/src/components/VirtualList.vue @@ -0,0 +1,424 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <div class="files-list" + :class="{ 'files-list--grid': gridMode }" + data-cy-files-list + @scroll.passive="onScroll"> + <!-- Header --> + <div ref="before" class="files-list__before"> + <slot name="before" /> + </div> + + <div ref="filters" class="files-list__filters"> + <slot name="filters" /> + </div> + + <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay"> + <slot name="header-overlay" /> + </div> + + <div v-if="dataSources.length === 0" + class="files-list__empty"> + <slot name="empty" /> + </div> + + <table :aria-hidden="dataSources.length === 0" + :inert="dataSources.length === 0" + class="files-list__table" + :class="{ + 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'], + 'files-list__table--hidden': dataSources.length === 0, + }"> + <!-- Accessibility table caption for screen readers --> + <caption v-if="caption" class="hidden-visually"> + {{ caption }} + </caption> + + <!-- Header --> + <thead ref="thead" class="files-list__thead" data-cy-files-list-thead> + <slot name="header" /> + </thead> + + <!-- Body --> + <tbody :style="tbodyStyle" + class="files-list__tbody" + data-cy-files-list-tbody> + <component :is="dataComponent" + v-for="({key, item}, i) in renderedItems" + :key="key" + :source="item" + :index="i" + v-bind="extraProps" /> + </tbody> + + <!-- Footer --> + <tfoot ref="footer" + class="files-list__tfoot" + data-cy-files-list-tfoot> + <slot name="footer" /> + </tfoot> + </table> + </div> +</template> + +<script lang="ts"> +import type { File, Folder, Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { defineComponent } from 'vue' +import debounce from 'debounce' + +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import logger from '../logger.ts' + +interface RecycledPoolItem { + key: string, + item: Node, +} + +type DataSource = File | Folder +type DataSourceKey = keyof DataSource + +export default defineComponent({ + name: 'VirtualList', + + props: { + dataComponent: { + type: [Object, Function], + required: true, + }, + dataKey: { + type: String as PropType<DataSourceKey>, + required: true, + }, + dataSources: { + type: Array as PropType<DataSource[]>, + required: true, + }, + extraProps: { + type: Object as PropType<Record<string, unknown>>, + default: () => ({}), + }, + scrollToIndex: { + type: Number, + default: 0, + }, + gridMode: { + type: Boolean, + default: false, + }, + /** + * Visually hidden caption for the table accessibility + */ + caption: { + type: String, + default: '', + }, + }, + + setup() { + const fileListWidth = useFileListWidth() + + return { + fileListWidth, + } + }, + + data() { + return { + index: this.scrollToIndex, + beforeHeight: 0, + footerHeight: 0, + headerHeight: 0, + tableHeight: 0, + resizeObserver: null as ResizeObserver | null, + } + }, + + computed: { + // Wait for measurements to be done before rendering + isReady() { + return this.tableHeight > 0 + }, + + // Items to render before and after the visible area + bufferItems() { + if (this.gridMode) { + // 1 row before and after in grid mode + return this.columnCount + } + // 3 rows before and after + return 3 + }, + + itemHeight() { + // Align with css in FilesListVirtual + // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 + }, + + // Grid mode only + itemWidth() { + // 166px + 16px x 2 (padding left and right) + return 166 + 16 + 16 + }, + + /** + * The number of rows currently (fully!) visible + */ + visibleRows(): number { + return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight) + }, + + /** + * Number of rows that will be rendered. + * This includes only visible + buffer rows. + */ + rowCount(): number { + return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1 + }, + + /** + * Number of columns. + * 1 for list view otherwise depending on the file list width. + */ + columnCount(): number { + if (!this.gridMode) { + return 1 + } + return Math.floor(this.fileListWidth / this.itemWidth) + }, + + /** + * Index of the first item to be rendered + * The index can be any file, not just the first one + * But the start index is the first item to be rendered, + * which needs to align with the column count + */ + startIndex() { + const firstColumnIndex = this.index - (this.index % this.columnCount) + return Math.max(0, firstColumnIndex - this.bufferItems) + }, + + /** + * Number of items to be rendered at the same time + * For list view this is the same as `rowCount`, for grid view this is `rowCount` * `columnCount` + */ + shownItems() { + // If in grid mode, we need to multiply the number of rows by the number of columns + if (this.gridMode) { + return this.rowCount * this.columnCount + } + + return this.rowCount + }, + + renderedItems(): RecycledPoolItem[] { + if (!this.isReady) { + return [] + } + + const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[] + + const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey])) + const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string) + const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key])) + + return items.map(item => { + const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey]) + // If defined, let's keep the key + if (index !== -1) { + return { + key: Object.keys(this.$_recycledPool)[index], + item, + } + } + + // Get and consume reusable key or generate a new one + const key = unusedKeys.pop() || Math.random().toString(36).substr(2) + this.$_recycledPool[key] = item[this.dataKey] + return { key, item } + }) + }, + + /** + * The total number of rows that are available + */ + totalRowCount() { + return Math.ceil(this.dataSources.length / this.columnCount) + }, + + tbodyStyle() { + // The number of (virtual) rows above the currently rendered ones. + // start index is aligned so this should always be an integer + const rowsAbove = Math.round(this.startIndex / this.columnCount) + // The number of (virtual) rows below the currently rendered ones. + const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount) + + return { + paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`, + minHeight: `${this.totalRowCount * this.itemHeight}px`, + } + }, + }, + watch: { + scrollToIndex(index) { + this.scrollTo(index) + }, + + totalRowCount() { + if (this.scrollToIndex) { + this.scrollTo(this.scrollToIndex) + } + }, + + columnCount(columnCount, oldColumnCount) { + if (oldColumnCount === 0) { + // We're initializing, the scroll position is handled on mounted + logger.debug('VirtualList: columnCount is 0, skipping scroll') + return + } + // If the column count changes in grid view, + // update the scroll position again + this.scrollTo(this.index) + }, + }, + + mounted() { + this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]> + + this.resizeObserver = new ResizeObserver(debounce(() => { + this.updateHeightVariables() + logger.debug('VirtualList: resizeObserver updated') + this.onScroll() + }, 100)) + this.resizeObserver.observe(this.$el) + this.resizeObserver.observe(this.$refs.before as HTMLElement) + this.resizeObserver.observe(this.$refs.filters as HTMLElement) + this.resizeObserver.observe(this.$refs.footer as HTMLElement) + + this.$nextTick(() => { + // Make sure height values are initialized + this.updateHeightVariables() + // If we need to scroll to an index we do so in the next tick. + // This is needed to apply updates from the initialization of the height variables + // which will update the tbody styles until next tick. + if (this.scrollToIndex) { + this.scrollTo(this.scrollToIndex) + } + }) + }, + + beforeDestroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } + }, + + methods: { + scrollTo(index: number) { + if (!this.$el || this.index === index) { + return + } + + // Check if the content is smaller (not equal! keep the footer in mind) than the viewport + // meaning there is no scrollbar + if (this.totalRowCount < this.visibleRows) { + logger.debug('VirtualList: Skip scrolling, nothing to scroll', { + index, + totalRows: this.totalRowCount, + visibleRows: this.visibleRows, + }) + return + } + + // We can not scroll further as the last page of rows + // For the grid view we also need to account for all columns in that row (columnCount - 1) + const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1) + // The scroll position + let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex)) + + // First we need to update the internal index for rendering. + // This will cause the <tbody> element to be resized allowing us to set the correct scroll position. + this.index = index + + // If this is not the first row we can add a half row from above. + // This is to help users understand the table is scrolled and not items did not just disappear. + // But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area) + if (index >= this.columnCount && index <= clampedIndex) { + scrollTop -= (this.itemHeight / 2) + // As we render one half row more we also need to adjust the internal index + this.index = index - this.columnCount + } else if (index > clampedIndex) { + // If we are on the last page we cannot scroll any further + // but we can at least scroll the footer into view + if (index <= (clampedIndex + this.columnCount)) { + // We only show have of the footer for the first of the last page + // To still show the previous row partly. Same reasoning as above: + // help the user understand that the table is scrolled not "magically trimmed" + scrollTop += this.footerHeight / 2 + } else { + // We reached the very end of the files list and we are focussing not the first visible row + // so all we now can do is scroll to the end (footer) + scrollTop += this.footerHeight + } + } + + // Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position + this.$nextTick(() => { + this.$el.scrollTop = scrollTop + logger.debug(`VirtualList: scrolling to index ${index}`, { + clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight, + }) + }) + }, + + onScroll() { + this._onScrollHandle ??= requestAnimationFrame(() => { + this._onScrollHandle = null + + const index = this.scrollPosToIndex(this.$el.scrollTop) + if (index === this.index) { + return + } + + // Max 0 to prevent negative index + this.index = Math.max(0, Math.floor(index)) + this.$emit('scroll') + }) + }, + + // Convert scroll position to index + // It should be the opposite of `indexToScrollPos` + scrollPosToIndex(scrollPos: number): number { + const topScroll = scrollPos - this.beforeHeight + // Max 0 to prevent negative index + return Math.max(0, Math.floor(topScroll / this.itemHeight)) * this.columnCount + }, + + // Convert index to scroll position + // It should be the opposite of `scrollPosToIndex` + indexToScrollPos(index: number): number { + return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight + }, + + /** + * Update the height variables. + * To be called by resize observer and `onMount` + */ + updateHeightVariables(): void { + this.tableHeight = this.$el?.clientHeight ?? 0 + this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0 + this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0 + + // Get the header height which consists of table header and filters + const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0 + const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0 + this.headerHeight = theadHeight + filterHeight + }, + }, +}) +</script> |