diff options
Diffstat (limited to 'apps/files/src/components')
33 files changed, 996 insertions, 383 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index a14c6545118..8458fd65f3d 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -42,9 +42,9 @@ 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/dist/Components/NcBreadcrumb.js' -import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +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' diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index 23ebf7cd296..c7684d5c205 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -85,7 +85,7 @@ export default defineComponent({ 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 don’t have permission to upload or create files here') + return this.t('files', 'You do not have permission to upload or create files here.') } return null }, @@ -235,7 +235,7 @@ export default defineComponent({ justify-content: center; width: 100%; // Breadcrumbs height + row thead height - min-height: calc(58px + 55px); + min-height: calc(58px + 44px); margin: 0; user-select: none; color: var(--color-text-maxcontrast); diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue index 7c9c6f4f1a7..72fd98d43fb 100644 --- a/apps/files/src/components/DragAndDropPreview.vue +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -92,7 +92,7 @@ export default Vue.extend({ </script> <style lang="scss"> -$size: 32px; +$size: 28px; $stack-shift: 6px; .files-list-drag-image { @@ -102,24 +102,24 @@ $stack-shift: 6px; display: flex; overflow: hidden; align-items: center; - height: 44px; - padding: 6px 12px; + height: $size + $stack-shift; + padding: $stack-shift $stack-shift * 2; background: var(--color-main-background); &__icon, - .files-list__row-icon { + .files-list__row-icon-preview-container { display: flex; overflow: hidden; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: $size - $stack-shift; + height: $size - $stack-shift;; border-radius: var(--border-radius); } &__icon { overflow: visible; - margin-inline-end: 12px; + margin-inline-end: $stack-shift * 2; img { max-width: 100%; @@ -138,13 +138,15 @@ $stack-shift: 6px; display: flex; // Stack effect if more than one element - .files-list__row-icon + .files-list__row-icon { + // Max 3 elements + > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container { margin-top: $stack-shift; - margin-inline-start: $stack-shift - $size; - & + .files-list__row-icon { + 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) + * { diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 7541c0f0631..d66c3fa0ed7 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -49,6 +49,15 @@ :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" @@ -64,7 +73,9 @@ class="files-list__row-mtime" data-cy-files-list-row-mtime @click="openDetailsIfAvailable"> - <NcDateTime v-if="mtime" :timestamp="mtime" :ignore-seconds="true" /> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> <span v-else>{{ t('files', 'Unknown date') }}</span> </td> @@ -83,11 +94,11 @@ </template> <script lang="ts"> +import { FileType, formatFileSize } from '@nextcloud/files' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' -import { formatFileSize } from '@nextcloud/files' -import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js' -import moment from '@nextcloud/moment' -import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' +import { t } from '@nextcloud/l10n' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' import { useNavigation } from '../composables/useNavigation.ts' import { useFileListWidth } from '../composables/useFileListWidth.ts' @@ -122,6 +133,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isSizeAvailable: { type: Boolean, default: false, @@ -185,6 +200,36 @@ export default defineComponent({ 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) { @@ -206,26 +251,6 @@ export default defineComponent({ color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, } }, - - 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 - }, - - mtimeTitle() { - if (this.source.mtime) { - return moment(this.source.mtime).format('LLL') - } - return '' - }, }, created() { diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue index 84f9fd828fc..c66cb8fbd7f 100644 --- a/apps/files/src/components/FileEntry/FavoriteIcon.vue +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -11,7 +11,7 @@ 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/dist/Components/NcIconSvgWrapper.js' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' /** * A favorite icon to be used for overlaying favorite entries like the file preview / icon @@ -56,8 +56,8 @@ export default defineComponent({ :deep() { svg { // We added a stroke for a11y so we must increase the size to include the stroke - width: 26px !important; - height: 26px !important; + width: 20px !important; + height: 20px !important; // Override NcIconSvgWrapper defaults of 20px max-width: unset !important; diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index 4e7bec88452..5c537d878fe 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -22,33 +22,68 @@ type="tertiary" :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" :inline="enabledInlineActions.length" - :open.sync="openedMenu" - @close="openedSubmenu = null"> - <!-- Default actions list--> - <NcActionButton v-for="action in enabledMenuActions" + :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--menu`]: isMenu(action.id) + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), }" - :close-after-click="!isMenu(action.id)" + :close-after-click="!isValidMenu(action)" :data-cy-files-list-row-action="action.id" - :is-menu="isMenu(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)" :size="18" /> - <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> </template> - {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }} + {{ 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" @click="onBackToMenuClick(openedSubmenu)"> + <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)"> <template #icon> <ArrowLeftIcon /> </template> @@ -63,10 +98,11 @@ 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)" :size="18" /> + <NcLoadingIcon v-if="isLoadingAction(action)" /> <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> </template> {{ actionDisplayName(action) }} @@ -82,22 +118,23 @@ import type { FileAction, Node } from '@nextcloud/files' import { DefaultType, NodeStatus } from '@nextcloud/files' import { defineComponent, inject } from 'vue' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' -import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js' import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' import CustomElementRender from '../CustomElementRender.vue' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +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({ @@ -113,6 +150,8 @@ export default defineComponent({ NcLoadingIcon, }, + mixins: [actionsMixins], + props: { opened: { type: Boolean, @@ -146,12 +185,6 @@ export default defineComponent({ } }, - data() { - return { - openedSubmenu: null as FileAction | null, - } - }, - computed: { isActive() { return this.activeStore?.activeNode?.source === this.source.source @@ -209,16 +242,12 @@ export default defineComponent({ return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent))) }, - enabledSubmenuActions() { - return this.enabledFileActions - .filter(action => action.parent) - .reduce((arr, action) => { - if (!arr[action.parent!]) { - arr[action.parent!] = [] - } - arr[action.parent!].push(action) - return arr - }, {} as Record<string, FileAction[]>) + renderedNonDestructiveActions() { + return this.enabledMenuActions.filter(action => !action.destructive) + }, + + renderedDestructiveActions() { + return this.enabledMenuActions.filter(action => action.destructive) }, openedMenu: { @@ -238,14 +267,10 @@ export default defineComponent({ getBoundariesElement() { return document.querySelector('.app-content > .files-list') }, - - mountType() { - return this.source.attributes['mount-type'] - }, }, watch: { - // Close any submenu when the menu is closed + // Close any submenu when the menu state changes openedMenu() { this.openedSubmenu = null }, @@ -287,7 +312,7 @@ export default defineComponent({ return this.activeStore?.activeAction?.id === action.id }, - async onActionClick(action, isSubmenu = false) { + async onActionClick(action) { // If the action is a submenu, we open it if (this.enabledSubmenuActions[action.id]) { this.openedSubmenu = action @@ -295,34 +320,10 @@ export default defineComponent({ } // Make sure we set the node as active - this.activeStore.setActiveNode(this.source) + this.activeStore.activeNode = this.source // Execute the action await executeAction(action) - - // If that was a submenu, we just go back after the action - if (isSubmenu) { - this.openedSubmenu = null - } - }, - - isMenu(id: string) { - return this.enabledSubmenuActions[id]?.length > 0 - }, - - async onBackToMenuClick(action: FileAction) { - this.openedSubmenu = null - // Wait for first render - await this.$nextTick() - - // Focus the previous menu action button - this.$nextTick(() => { - // Focus the action button - const menuAction = this.$refs[`action-${action.id}`]?.[0] - if (menuAction) { - menuAction.$el.querySelector('button')?.focus() - } - }) }, onKeyDown(event: KeyboardEvent) { @@ -341,6 +342,16 @@ export default defineComponent({ 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> @@ -363,13 +374,26 @@ main.app-content[style*="mouse-pos-x"] .v-popper__popper { } </style> -<style lang="scss" scoped> -:deep(.button-vue--icon-and-text, .files-list__row-action-sharing-status) { - .button-vue__text { - color: var(--color-primary-element); +<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); } - .button-vue__icon { - color: var(--color-primary-element); + + // 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 index 1b6112373f4..5b80a971118 100644 --- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -21,11 +21,11 @@ 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 { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +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' diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index 2a727b55c29..418f9581eb6 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -23,7 +23,6 @@ <component :is="linkTo.is" v-else ref="basename" - :aria-hidden="isRenaming" class="files-list__row-name-link" data-cy-files-list-row-name-link v-bind="linkTo.params"> @@ -31,7 +30,7 @@ <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 class="files-list__row-name-ext" v-text="extension" /> + <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" /> </span> </component> </template> @@ -45,13 +44,14 @@ import { FileType, NodeStatus } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import { defineComponent, inject } from 'vue' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcTextField from '@nextcloud/vue/components/NcTextField' -import { useNavigation } from '../../composables/useNavigation' +import { getFilenameValidity } from '../../utils/filenameValidity.ts' import { useFileListWidth } from '../../composables/useFileListWidth.ts' -import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useNavigation } from '../../composables/useNavigation.ts' import { useRenamingStore } from '../../store/renaming.ts' -import { getFilenameValidity } from '../../utils/filenameValidity.ts' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useUserConfigStore } from '../../store/userconfig.ts' import logger from '../../logger.ts' export default defineComponent({ @@ -96,6 +96,7 @@ export default defineComponent({ const { directory } = useRouteParameters() const filesListWidth = useFileListWidth() const renamingStore = useRenamingStore() + const userConfigStore = useUserConfigStore() const defaultFileAction = inject<FileAction | undefined>('defaultFileAction') @@ -106,6 +107,7 @@ export default defineComponent({ filesListWidth, renamingStore, + userConfigStore, } }, @@ -117,11 +119,11 @@ export default defineComponent({ return this.isRenaming && this.filesListWidth < 512 }, newName: { - get() { - return this.renamingStore.newName + get(): string { + return this.renamingStore.newNodeName }, - set(newName) { - this.renamingStore.newName = newName + set(newName: string) { + this.renamingStore.newNodeName = newName }, }, @@ -249,7 +251,9 @@ export default defineComponent({ try { const status = await this.renamingStore.rename() if (status) { - showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) + showSuccess( + t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }), + ) this.$nextTick(() => { const nameContainer = this.$refs.basename as HTMLElement | undefined nameContainer?.focus() diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue index 2d5844f851f..3d0fffe7584 100644 --- a/apps/files/src/components/FileEntry/FileEntryPreview.vue +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -21,6 +21,7 @@ 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" @@ -63,7 +64,7 @@ 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/Network.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' @@ -147,6 +148,17 @@ export default defineComponent({ 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 diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue index bf007e2be6c..1bd0572f53b 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -51,7 +51,9 @@ class="files-list__row-mtime" data-cy-files-list-row-mtime @click="openDetailsIfAvailable"> - <NcDateTime v-if="source.mtime" :timestamp="source.mtime" :ignore-seconds="true" /> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> </td> <!-- Actions --> @@ -66,7 +68,7 @@ <script lang="ts"> import { defineComponent } from 'vue' -import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' import { useNavigation } from '../composables/useNavigation.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index d949a907d43..735490c45b3 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -133,11 +133,16 @@ export default defineComponent({ return this.source.status === NodeStatus.FAILED }, - canDrag() { + 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 } @@ -150,11 +155,16 @@ export default defineComponent({ return canDrag(this.source) }, - canDrop() { + 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 @@ -168,25 +178,52 @@ export default defineComponent({ return this.actionsMenuStore.opened === this.uniqueId.toString() }, set(opened) { - this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : null + // 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 }, }, - mtimeOpacity() { - const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days + 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 + }, - const mtime = this.source.mtime?.getTime?.() - if (!mtime) { + mtimeOpacity() { + if (!this.mtime) { return {} } - // 1 = today, 0 = 31 days ago - const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime)) - if (ratio < 0) { + // 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) ${ratio}%, var(--color-text-maxcontrast))`, + color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`, } }, @@ -235,21 +272,16 @@ export default defineComponent({ }, openedMenu() { - if (this.openedMenu === false) { - // TODO: This timeout can be removed once `close` event only triggers after the transition - // ref: https://github.com/nextcloud-libraries/nextcloud-vue/pull/6065 - window.setTimeout(() => { - if (this.openedMenu) { - // was reopened while the animation run - return - } - // Reset any right menu position potentially set - const root = document.getElementById('app-content-vue') - if (root !== null) { - root.style.removeProperty('--mouse-pos-x') - root.style.removeProperty('--mouse-pos-y') - } - }, 300) + // 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') + } } }, }, @@ -274,6 +306,11 @@ export default defineComponent({ 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) { @@ -282,6 +319,7 @@ export default defineComponent({ 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 { @@ -311,9 +349,14 @@ export default defineComponent({ 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 || Boolean(event.button & 4) + 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)) { @@ -325,7 +368,9 @@ export default defineComponent({ : generateUrl('/f/{fileId}', { fileId: this.fileid }) event.preventDefault() event.stopPropagation() - window.open(url, metaKeyPressed ? '_self' : undefined) + + // 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 } @@ -442,7 +487,7 @@ export default defineComponent({ logger.debug('Dropped', { event, folder, selection, fileTree }) // Check whether we're uploading files - if (fileTree.contents.length > 0) { + if (selection.length === 0 && fileTree.contents.length > 0) { await onDropExternalFiles(fileTree, folder, contents.contents) return } diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue index 447ae7abdaa..bd3ac867ed5 100644 --- a/apps/files/src/components/FileListFilter/FileListFilter.vue +++ b/apps/files/src/components/FileListFilter/FileListFilter.vue @@ -24,9 +24,9 @@ <script setup lang="ts"> import { t } from '@nextcloud/l10n' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' defineProps<{ isActive: boolean diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue index a69e1782c2d..3a843b2bc3e 100644 --- a/apps/files/src/components/FileListFilter/FileListFilterModified.vue +++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue @@ -7,7 +7,7 @@ :filter-name="t('files', 'Modified')" @reset-filter="resetFilter"> <template #icon> - <NcIconSvgWrapper :path="mdiCalendarRange" /> + <NcIconSvgWrapper :path="mdiCalendarRangeOutline" /> </template> <NcActionButton v-for="preset of timePresets" :key="preset.id" @@ -25,12 +25,12 @@ import type { PropType } from 'vue' import type { ITimePreset } from '../../filters/ModifiedFilter.ts' -import { mdiCalendarRange } from '@mdi/js' +import { mdiCalendarRangeOutline } from '@mdi/js' import { translate as t } from '@nextcloud/l10n' import { defineComponent } from 'vue' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import FileListFilter from './FileListFilter.vue' export default defineComponent({ @@ -50,7 +50,7 @@ export default defineComponent({ setup() { return { // icons used in template - mdiCalendarRange, + mdiCalendarRangeOutline, } }, 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 index c71ec94f662..d3ad791513f 100644 --- a/apps/files/src/components/FileListFilter/FileListFilterType.vue +++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue @@ -8,7 +8,7 @@ :filter-name="t('files', 'Type')" @reset-filter="resetFilter"> <template #icon> - <NcIconSvgWrapper :path="mdiFile" /> + <NcIconSvgWrapper :path="mdiFileOutline" /> </template> <NcActionButton v-for="fileType of typePresets" :key="fileType.id" @@ -27,12 +27,12 @@ import type { PropType } from 'vue' import type { ITypePreset } from '../../filters/TypeFilter.ts' -import { mdiFile } from '@mdi/js' +import { mdiFileOutline } from '@mdi/js' import { translate as t } from '@nextcloud/l10n' import { defineComponent } from 'vue' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import FileListFilter from './FileListFilter.vue' export default defineComponent({ @@ -57,7 +57,7 @@ export default defineComponent({ setup() { return { - mdiFile, + mdiFileOutline, t, } }, diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue index 1bd1dd627af..7f0d71fd85a 100644 --- a/apps/files/src/components/FileListFilters.vue +++ b/apps/files/src/components/FileListFilters.vue @@ -32,8 +32,8 @@ import { t } from '@nextcloud/l10n' import { computed, ref, watchEffect } from 'vue' import { useFiltersStore } from '../store/filters.ts' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcChip from '@nextcloud/vue/dist/Components/NcChip.js' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcChip from '@nextcloud/vue/components/NcChip' const filterStore = useFiltersStore() const visualFilters = computed(() => filterStore.filtersWithUI) diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 96d465a23d2..31458398028 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -9,6 +9,13 @@ </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 @@ -19,21 +26,29 @@ export default { name: 'FilesListHeader', props: { header: { - type: Object, + type: Object as PropType<Header>, required: true, }, currentFolder: { - type: Object, + type: Object as PropType<Folder>, required: true, }, currentView: { - type: Object, + 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) + return this.header.enabled?.(this.currentFolder, this.currentView) ?? true }, }, watch: { @@ -41,15 +56,45 @@ export default { if (!enabled) { return } - this.header.updated(this.currentFolder, this.currentView) + // 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) }, - currentFolder() { - this.header.updated(this.currentFolder, this.currentView) + currentView(view: View) { + this.queueUpdate(this.currentFolder, view) }, }, + mounted() { - console.debug('Mounted', this.header.id) - this.header.render(this.$refs.mount, this.currentFolder, this.currentView) + 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 index bf545aacf4f..9e8cdc159ee 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -21,6 +21,10 @@ <!-- 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"> @@ -60,6 +64,10 @@ export default defineComponent({ type: View, required: true, }, + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, @@ -141,7 +149,7 @@ export default defineComponent({ <style scoped lang="scss"> // Scoped row tr { - margin-bottom: max(25vh, var(--body-container-margin)); + margin-bottom: var(--body-container-margin); border-top: 1px solid var(--color-border); // Prevent hover effect on the whole row background-color: transparent !important; diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index b7818a9a40f..23e631199eb 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -24,6 +24,14 @@ <!-- 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" @@ -58,10 +66,10 @@ import type { Node } from '@nextcloud/files' import type { PropType } from 'vue' import type { FileSource } from '../types.ts' -import { defineComponent } from 'vue' import { translate as t } from '@nextcloud/l10n' -import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +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' @@ -83,6 +91,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, @@ -171,7 +183,7 @@ export default defineComponent({ }, methods: { - ariaSortForMode(mode: string): ARIAMixin['ariaSort'] { + ariaSortForMode(mode: string): 'ascending'|'descending'|null { if (this.sortingMode === mode) { return this.isAscSorting ? 'ascending' : 'descending' } diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index 16d99f974dd..53b7e7ef21b 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -6,16 +6,26 @@ <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="inlineActions" - :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null" - :open.sync="openedMenu"> - <NcActionButton v-for="action in enabledActions" + :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" - :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */" - :class="'files-list__row-actions-batch-' + 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" /> @@ -23,30 +33,61 @@ </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 { Node, View } from '@nextcloud/files' +import type { FileAction, Node, View } from '@nextcloud/files' import type { PropType } from 'vue' import type { FileSource } from '../types' -import { NodeStatus, getFileActions } from '@nextcloud/files' +import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' import { defineComponent } from 'vue' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +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 @@ -56,12 +97,15 @@ export default defineComponent({ name: 'FilesListTableHeaderActions', components: { + ArrowLeftIcon, NcActions, NcActionButton, NcIconSvgWrapper, NcLoadingIcon, }, + mixins: [actionsMixins], + props: { currentView: { type: Object as PropType<View>, @@ -80,6 +124,8 @@ export default defineComponent({ const fileListWidth = useFileListWidth() const { directory } = useRouteParameters() + const boundariesElement = document.getElementById('app-content-vue') + return { directory, fileListWidth, @@ -87,6 +133,8 @@ export default defineComponent({ actionsMenuStore, filesStore, selectionStore, + + boundariesElement, } }, @@ -97,13 +145,78 @@ export default defineComponent({ }, computed: { - enabledActions() { + enabledFileActions(): FileAction[] { return actions - .filter(action => action.execBatch) + // 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)) @@ -148,6 +261,12 @@ export default defineComponent({ }, 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) @@ -186,7 +305,7 @@ export default defineComponent({ return } - showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) + showError(this.t('files', '"{displayName}" failed on some elements', { displayName })) return } diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue index b2fa411e318..d2e14a5495f 100644 --- a/apps/files/src/components/FilesListTableHeaderButton.vue +++ b/apps/files/src/components/FilesListTableHeaderButton.vue @@ -25,7 +25,7 @@ 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/dist/Components/NcButton.js' +import NcButton from '@nextcloud/vue/components/NcButton' import filesSortingMixin from '../mixins/filesSorting.ts' diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 2442dd98190..47b8ef19b19 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -9,6 +9,7 @@ :data-sources="nodes" :grid-mode="userConfig.grid_view" :extra-props="{ + isMimeAvailable, isMtimeAvailable, isSizeAvailable, nodes, @@ -20,14 +21,16 @@ </template> <template v-if="!isNoneSelected" #header-overlay> - <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span> + <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 sortedHeaders" + <FilesListHeader v-for="header in headers" :key="header.id" :current-folder="currentFolder" :current-view="currentView" @@ -39,15 +42,22 @@ <!-- 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" @@ -57,24 +67,25 @@ </template> <script lang="ts"> -import type { ComponentPublicInstance, PropType } from 'vue' -import type { Node as NcNode } from '@nextcloud/files' import type { UserConfig } from '../types' +import type { Node as NcNode } from '@nextcloud/files' +import type { ComponentPublicInstance, PropType } from 'vue' -import { defineComponent } from 'vue' -import { getFileListHeaders, Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' +import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { translate as t } from '@nextcloud/l10n' -import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js' +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 { getSummaryFor } from '../utils/fileUtils' 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' @@ -83,7 +94,6 @@ import FilesListHeader from './FilesListHeader.vue' import FilesListTableFooter from './FilesListTableFooter.vue' import FilesListTableHeader from './FilesListTableHeader.vue' import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' -import logger from '../logger.ts' import VirtualList from './VirtualList.vue' export default defineComponent({ @@ -111,6 +121,10 @@ export default defineComponent({ type: Array as PropType<NcNode[]>, required: true, }, + summary: { + type: String, + required: true, + }, }, setup() { @@ -124,6 +138,7 @@ export default defineComponent({ return { fileId, fileListWidth, + headers: useFileListHeaders(), openDetails, openFile, @@ -131,6 +146,7 @@ export default defineComponent({ selectionStore, userConfigStore, + n, t, } }, @@ -139,9 +155,7 @@ export default defineComponent({ return { FileEntry, FileEntryGrid, - headers: getFileListHeaders(), scrollToIndex: 0, - openFileId: null as number|null, } }, @@ -150,10 +164,16 @@ export default defineComponent({ return this.userConfigStore.userConfig }, - summary() { - return getSummaryFor(this.nodes) + 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) { @@ -169,14 +189,6 @@ export default defineComponent({ return this.nodes.some(node => node.size !== undefined) }, - sortedHeaders() { - if (!this.currentFolder || !this.currentView) { - return [] - } - - return [...this.headers].sort((a, b) => a.order - b.order) - }, - cantUpload() { return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0 }, @@ -188,7 +200,7 @@ export default defineComponent({ caption() { const defaultCaption = t('files', 'List of files and folders.') const viewCaption = this.currentView.caption || defaultCaption - const cantUploadCaption = this.cantUpload ? t('files', 'You don’t have permission to upload or create files here.') : null + 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.') @@ -208,38 +220,26 @@ export default defineComponent({ isNoneSelected() { return this.selectedNodes.length === 0 }, + + isEmpty() { + return this.nodes.length === 0 + }, }, watch: { - fileId: { - handler(fileId) { - this.scrollToFile(fileId, false) - }, - immediate: true, + // If nodes gets populated and we have a fileId, + // an openFile or openDetails, we fire the appropriate actions. + isEmpty() { + this.handleOpenQueries() }, - - openFile: { - handler() { - // wait for scrolling and updating the actions to settle - this.$nextTick(() => { - if (this.fileId && this.openFile) { - this.handleOpenFile(this.fileId) - } - }) - }, - immediate: true, + fileId() { + this.handleOpenQueries() }, - - openDetails: { - handler() { - // wait for scrolling and updating the actions to settle - this.$nextTick(() => { - if (this.fileId && this.openDetails) { - this.openSidebarForFile(this.fileId) - } - }) - }, - immediate: true, + openFile() { + this.handleOpenQueries() + }, + openDetails() { + this.handleOpenQueries() }, }, @@ -269,6 +269,33 @@ export default defineComponent({ }, 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. @@ -276,7 +303,9 @@ export default defineComponent({ 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) { @@ -292,6 +321,7 @@ export default defineComponent({ } this.scrollToIndex = Math.max(0, index) + logger.debug('Scrolling to file ' + fileId, { fileId, index }) } }, @@ -303,7 +333,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.clearActiveNode() + this.activeStore.activeNode = undefined window.OCP.Files.Router.goToRoute( null, { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') }, @@ -329,30 +359,40 @@ export default defineComponent({ * Handle opening a file (e.g. by ?openfile=true) * @param fileId File to open */ - handleOpenFile(fileId: number|null) { - if (fileId === null) { - return - } - + async handleOpenFile(fileId: number) { const node = this.nodes.find(n => n.fileid === fileId) as NcNode - if (node === undefined || node.type === FileType.Folder) { + if (node === undefined) { return } - logger.debug('Opening file ' + node.path, { node }) - this.openFileId = fileId - 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)) - // 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 - defaultAction?.exec(node, this.currentView, this.currentFolder.path) + 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) { @@ -425,7 +465,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.setActiveNode(node) + this.activeStore.activeNode = node // Silent update of the URL window.OCP.Files.Router.goToRoute( @@ -441,15 +481,17 @@ export default defineComponent({ <style scoped lang="scss"> .files-list { - --row-height: 55px; + --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: 32px; + --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; @@ -497,6 +539,13 @@ export default defineComponent({ // 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 { @@ -528,6 +577,7 @@ export default defineComponent({ 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, @@ -536,7 +586,6 @@ export default defineComponent({ flex-direction: column; width: 100%; background-color: var(--color-main-background); - } // Table header @@ -547,6 +596,16 @@ export default defineComponent({ 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; @@ -716,8 +775,8 @@ export default defineComponent({ // File and folder overlay &-overlay { position: absolute; - max-height: calc(var(--icon-preview-size) * 0.5); - max-width: calc(var(--icon-preview-size) * 0.5); + 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; @@ -822,22 +881,28 @@ export default defineComponent({ 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) * 1.5); + width: calc(var(--row-height) * 2); // Right align content/text justify-content: flex-end; } .files-list__row-mtime { - width: calc(var(--row-height) * 2); + 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); + width: calc(var(--row-height) * 2.5); } } } @@ -853,12 +918,11 @@ export default defineComponent({ <style lang="scss"> // Grid mode -tbody.files-list__tbody.files-list__tbody--grid { - --half-clickable-area: calc(var(--clickable-area) / 2); +.files-list--grid tbody.files-list__tbody { --item-padding: 16px; --icon-preview-size: 166px; - --name-height: 32px; - --mtime-height: 16px; + --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; @@ -940,15 +1004,32 @@ tbody.files-list__tbody.files-list__tbody--grid { .files-list__row-mtime { width: var(--icon-preview-size); height: var(--mtime-height); - font-size: calc(var(--default-font-size) - 4px); + font-size: var(--font-size-small); } .files-list__row-actions { position: absolute; - inset-inline-end: calc(var(--half-clickable-area) / 2); + 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 index f05ad389f50..c29bc00c67f 100644 --- a/apps/files/src/components/FilesNavigationItem.vue +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -42,8 +42,8 @@ import type { View } from '@nextcloud/files' import { defineComponent } from 'vue' import { Fragment } from 'vue-frag' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +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' @@ -89,7 +89,7 @@ export default defineComponent({ return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) } - return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids + return this.filterVisible(this.views[this.parent.id] ?? []) }, style() { @@ -103,11 +103,15 @@ export default defineComponent({ }, 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.views[view.id]?.length > 0 + return this.filterVisible(this.views[view.id] ?? []).length > 0 }, /** diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..e34d4bf0971 --- /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 globally by filename …') + } + return t('files', 'Search here by filename …') +}) +</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', 'Filter and search from this location') }} + </NcActionButton> + <NcActionButton close-after-click @click="searchStore.scope = 'globally'"> + <template #icon> + <NcIconSvgWrapper :path="mdiSearchWeb" /> + </template> + {{ t('files', 'Search globally') }} + </NcActionButton> + </NcActions> + </template> + </NcAppNavigationSearch> +</template> diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue index d9baeeb1b07..b5a792d9029 100644 --- a/apps/files/src/components/LegacyView.vue +++ b/apps/files/src/components/LegacyView.vue @@ -33,7 +33,7 @@ export default { }, methods: { setFileInfo(fileInfo) { - this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo)) + this.component.setFileInfo(fileInfo) }, }, } diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index 389d3f346da..46c8e5c9af4 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -33,9 +33,9 @@ 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 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' @@ -58,7 +58,7 @@ export default { computed: { storageStatsTitle() { const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false) - const quotaByte = formatFileSize(this.storageStats?.quota, false, false) + const quotaByte = formatFileSize(this.storageStats?.total, false, false) // If no quota set if (this.storageStats?.quota < 0) { diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue index b766b230022..ca10935940d 100644 --- a/apps/files/src/components/NewNodeDialog.vue +++ b/apps/files/src/components/NewNodeDialog.vue @@ -26,6 +26,11 @@ :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> @@ -35,12 +40,13 @@ import type { ComponentPublicInstance, PropType } from 'vue' import { getUniqueName } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { extname } from 'path' -import { nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue' import { getFilenameValidity } from '../utils/filenameValidity.ts' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +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({ /** @@ -89,6 +95,11 @@ 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 */ diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue index 88dfd27ce5c..d86e5da9d20 100644 --- a/apps/files/src/components/SidebarTab.vue +++ b/apps/files/src/components/SidebarTab.vue @@ -21,8 +21,8 @@ </template> <script> -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' export default { name: 'SidebarTab', diff --git a/apps/files/src/components/TemplateFiller.vue b/apps/files/src/components/TemplateFiller.vue index bd3c28d585f..3f1db8dfd58 100644 --- a/apps/files/src/components/TemplateFiller.vue +++ b/apps/files/src/components/TemplateFiller.vue @@ -4,19 +4,24 @@ --> <template> - <NcModal> + <NcModal label-id="template-field-modal__label"> <div class="template-field-modal__content"> <form> - <h3>{{ t('files', 'Fill template fields') }}</h3> + <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)" :field="field" @input="trackInput" /> + <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…')" /> + <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields …')" /> <NcButton aria-label="Submit button" type="primary" @click="submit"> @@ -28,8 +33,10 @@ <script> import { defineComponent } from 'vue' -import { NcModal, NcButton, NcLoadingIcon } from '@nextcloud/vue' -import { translate as t } from '@nextcloud/l10n' +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' @@ -80,6 +87,9 @@ export default defineComponent({ return `Template${fieldComponentType}Field` }, + fieldHasLabel(field) { + return field.name || field.alias + }, async submit() { this.loading = true diff --git a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue index 632944f1bab..18536171bd2 100644 --- a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue +++ b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue @@ -16,7 +16,7 @@ <script lang="ts"> import { defineComponent } from 'vue' -import { NcCheckboxRadioSwitch } from '@nextcloud/vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' export default defineComponent({ name: 'TemplateCheckboxField', @@ -40,7 +40,7 @@ export default defineComponent({ computed: { fieldLabel() { - const label = this.field.name ?? this.field.alias ?? 'Unknown field' + const label = this.field.name || this.field.alias return label.charAt(0).toUpperCase() + label.slice(1) }, diff --git a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue index 7246b2743d6..f49819f7e7c 100644 --- a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue +++ b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue @@ -21,7 +21,7 @@ <script lang="ts"> import { defineComponent } from 'vue' -import { NcTextField } from '@nextcloud/vue' +import NcTextField from '@nextcloud/vue/components/NcTextField' export default defineComponent({ name: 'TemplateRichTextField', @@ -45,7 +45,7 @@ export default defineComponent({ computed: { fieldLabel() { - const label = this.field.name ?? this.field.alias ?? 'Unknown field' + const label = this.field.name || this.field.alias return (label.charAt(0).toUpperCase() + label.slice(1)) }, diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue index 6b8e0eb77ba..3d668da8144 100644 --- a/apps/files/src/components/TransferOwnershipDialogue.vue +++ b/apps/files/src/components/TransferOwnershipDialogue.vue @@ -18,7 +18,7 @@ {{ t('files', 'Change') }} </NcButton> </p> - <p class="new-owner-row"> + <p class="new-owner"> <label for="targetUser"> <span>{{ t('files', 'New owner') }}</span> </label> @@ -27,9 +27,7 @@ :options="formatedUserSuggestions" :multiple="false" :loading="loadingUsers" - label="displayName" :user-select="true" - class="middle-align" @search="findUserDebounced" /> </p> <p> @@ -48,9 +46,9 @@ import axios from '@nextcloud/axios' import debounce from 'debounce' import { generateOcsUrl } from '@nextcloud/router' import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' import Vue from 'vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcButton from '@nextcloud/vue/components/NcButton' import logger from '../logger.ts' @@ -90,6 +88,7 @@ export default { user: user.uid, displayName: user.displayName, icon: 'icon-user', + subname: user.shareWithDisplayNameUnique, } }) }, @@ -156,6 +155,7 @@ export default { Vue.set(this.userSuggestions, user.value.shareWith, { uid: user.value.shareWith, displayName: user.label, + shareWithDisplayNameUnique: user.shareWithDisplayNameUnique, }) }) } catch (error) { @@ -203,18 +203,15 @@ 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-wrap: wrap; + flex-direction: column; + max-width: 400px; label { display: flex; @@ -225,11 +222,6 @@ p { margin-inline-end: 8px; } } - - .multiselect { - flex-grow: 1; - max-width: 280px; - } } .transfer-select-row { diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index b3d1b1002f4..4746fedf863 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -3,13 +3,16 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <div class="files-list" data-cy-files-list> + <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 class="files-list__filters"> + <div ref="filters" class="files-list__filters"> <slot name="filters" /> </div> @@ -17,7 +20,18 @@ <slot name="header-overlay" /> </div> - <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }"> + <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 }} @@ -31,7 +45,6 @@ <!-- Body --> <tbody :style="tbodyStyle" class="files-list__tbody" - :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'" data-cy-files-list-tbody> <component :is="dataComponent" v-for="({key, item}, i) in renderedItems" @@ -42,7 +55,7 @@ </tbody> <!-- Footer --> - <tfoot v-show="isReady" + <tfoot ref="footer" class="files-list__tfoot" data-cy-files-list-tfoot> <slot name="footer" /> @@ -118,6 +131,7 @@ export default defineComponent({ return { index: this.scrollToIndex, beforeHeight: 0, + footerHeight: 0, headerHeight: 0, tableHeight: 0, resizeObserver: null as ResizeObserver | null, @@ -143,18 +157,35 @@ export default defineComponent({ itemHeight() { // Align with css in FilesListVirtual // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) - return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55 + 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 }, - rowCount() { - return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1 + /** + * The number of rows currently (fully!) visible + */ + visibleRows(): number { + return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight) }, - columnCount() { + + /** + * 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 } @@ -217,16 +248,18 @@ export default defineComponent({ * The total number of rows that are available */ totalRowCount() { - return Math.floor(this.dataSources.length / this.columnCount) + return Math.ceil(this.dataSources.length / this.columnCount) }, tbodyStyle() { - const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length - const lastIndex = this.dataSources.length - this.startIndex - this.shownItems - const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount) + // 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 { - paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`, - paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`, + paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`, minHeight: `${this.totalRowCount * this.itemHeight}px`, } }, @@ -238,15 +271,14 @@ export default defineComponent({ totalRowCount() { if (this.scrollToIndex) { - this.$nextTick(() => this.scrollTo(this.scrollToIndex)) + this.scrollTo(this.scrollToIndex) } }, columnCount(columnCount, oldColumnCount) { if (oldColumnCount === 0) { - // We're initializing, the scroll position - // is handled on mounted - console.debug('VirtualList: columnCount is 0, skipping scroll') + // 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, @@ -256,30 +288,28 @@ export default defineComponent({ }, mounted() { - const before = this.$refs?.before as HTMLElement - const root = this.$el as HTMLElement - const thead = this.$refs?.thead as HTMLElement + this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]> this.resizeObserver = new ResizeObserver(debounce(() => { - this.beforeHeight = before?.clientHeight ?? 0 - this.headerHeight = thead?.clientHeight ?? 0 - this.tableHeight = root?.clientHeight ?? 0 + this.updateHeightVariables() logger.debug('VirtualList: resizeObserver updated') this.onScroll() - }, 100, { immediate: false })) - - this.resizeObserver.observe(before) - this.resizeObserver.observe(root) - this.resizeObserver.observe(thead) - - if (this.scrollToIndex) { - this.scrollTo(this.scrollToIndex) - } - - // Adding scroll listener AFTER the initial scroll to index - this.$el.addEventListener('scroll', this.onScroll, { passive: true }) - - this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]> + }, 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() { @@ -290,21 +320,60 @@ export default defineComponent({ methods: { scrollTo(index: number) { - if (!this.$el) { + if (!this.$el || this.index === index) { return } - // Check if the content is smaller than the viewport, meaning no scrollbar - const targetRow = Math.ceil(this.dataSources.length / this.columnCount) - if (targetRow < this.rowCount) { - logger.debug('VirtualList: Skip scrolling, nothing to scroll', { index, targetRow, rowCount: this.rowCount }) + // 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 } - // Scroll to one row and a half before the index - const scrollTop = this.indexToScrollPos(index) - logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount, beforeHeight: this.beforeHeight }) - this.$el.scrollTop = scrollTop + // 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() { @@ -333,7 +402,22 @@ export default defineComponent({ // Convert index to scroll position // It should be the opposite of `scrollPosToIndex` indexToScrollPos(index: number): number { - return (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight + 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 }, }, }) |