diff options
Diffstat (limited to 'apps/files/src/components')
20 files changed, 2079 insertions, 1422 deletions
diff --git a/apps/files/src/components/CustomSvgIconRender.vue b/apps/files/src/components/CustomSvgIconRender.vue deleted file mode 100644 index 4edb51806d1..00000000000 --- a/apps/files/src/components/CustomSvgIconRender.vue +++ /dev/null @@ -1,68 +0,0 @@ -<!-- - - @copyright Copyright (c) 2023 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/>. - - - --> -<template> - <span class="custom-svg-icon" /> -</template> - -<script> -// eslint-disable-next-line import/named -import { sanitize } from 'dompurify' - -export default { - name: 'CustomSvgIconRender', - props: { - svg: { - type: String, - required: true, - }, - }, - watch: { - svg() { - this.$el.innerHTML = sanitize(this.svg) - }, - }, - mounted() { - this.$el.innerHTML = sanitize(this.svg) - }, -} -</script> -<style lang="scss" scoped> -.custom-svg-icon { - display: flex; - align-items: center; - align-self: center; - justify-content: center; - justify-self: center; - width: 44px; - height: 44px; - opacity: 1; - - ::v-deep svg { - // mdi icons have a size of 24px - // 22px results in roughly 16px inner size - height: 22px; - width: 22px; - fill: currentColor; - } -} - -</style> diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue new file mode 100644 index 00000000000..d5f93dac256 --- /dev/null +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -0,0 +1,155 @@ +<!-- + - @copyright Copyright (c) 2023 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/>. + - + --> +<template> + <div class="files-list__drag-drop-notice" + :class="{ 'files-list__drag-drop-notice--dragover': dragover }" + @drop="onDrop"> + <div class="files-list__drag-drop-notice-wrapper"> + <TrayArrowDownIcon :size="48" /> + <h3 class="files-list-drag-drop-notice__title"> + {{ t('files', 'Drag and drop files here to upload') }} + </h3> + </div> + </div> +</template> + +<script lang="ts"> +import type { Upload } from '@nextcloud/upload' +import { join } from 'path' +import { showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { getUploader } from '@nextcloud/upload' +import Vue from 'vue' + +import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' + +import logger from '../logger.js' + +export default Vue.extend({ + name: 'DragAndDropNotice', + + components: { + TrayArrowDownIcon, + }, + + props: { + currentFolder: { + type: Object, + required: true, + }, + dragover: { + type: Boolean, + default: false, + }, + }, + + methods: { + onDrop(event: DragEvent) { + this.$emit('update:dragover', false) + + if (this.$el.querySelector('tbody')?.contains(event.target as Node)) { + return + } + + event.preventDefault() + event.stopPropagation() + + if (event.dataTransfer && event.dataTransfer.files?.length > 0) { + const uploader = getUploader() + uploader.destination = this.currentFolder + + // Start upload + logger.debug(`Uploading files to ${this.currentFolder.path}`) + const promises = [...event.dataTransfer.files].map((file: File) => { + return uploader.upload(file.name, file) as Promise<Upload> + }) + + // Process finished uploads + Promise.all(promises).then((uploads) => { + logger.debug('Upload terminated', { uploads }) + showSuccess(t('files', 'Upload successful')) + + // Scroll to last upload if terminated + const lastUpload = uploads[uploads.length - 1] + if (lastUpload?.response?.headers?.['oc-fileid']) { + this.$router.push(Object.assign({}, this.$route, { + params: { + // Remove instanceid from header response + fileid: parseInt(lastUpload.response?.headers?.['oc-fileid']), + }, + })) + } + }) + } + }, + t, + }, +}) +</script> + +<style lang="scss" scoped> +.files-list__drag-drop-notice { + position: absolute; + z-index: 9999; + top: 0; + right: 0; + left: 0; + display: none; + align-items: center; + justify-content: center; + width: 100%; + // Breadcrumbs height + row thead height + min-height: calc(58px + 55px); + margin: 0; + user-select: none; + color: var(--color-text-maxcontrast); + background-color: var(--color-main-background); + + &--dragover { + display: flex; + border-color: black; + } + + h3 { + margin-left: 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); + } + + &__close { + position: absolute !important; + top: 10px; + right: 10px; + } +} + +</style> diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 2ca02edc5b8..df18dcae016 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -21,7 +21,7 @@ --> <template> - <tr :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}" + <tr :class="{'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" @@ -37,125 +37,40 @@ <span v-if="source.attributes.failed" class="files-list__row--failed" /> <!-- Checkbox --> - <td class="files-list__row-checkbox"> - <NcLoadingIcon v-if="isLoading" /> - <NcCheckboxRadioSwitch v-else-if="visible" - :aria-label="t('files', 'Select the row for {displayName}', { displayName })" - :checked="isSelected" - @update:checked="onSelectionChange" /> - </td> + <FileEntryCheckbox :display-name="displayName" + :fileid="fileid" + :is-loading="isLoading" + :nodes="nodes" /> <!-- Link to file --> <td class="files-list__row-name" data-cy-files-list-row-name> <!-- Icon or preview --> - <span class="files-list__row-icon" @click="execDefaultAction"> - <template v-if="source.type === 'folder'"> - <FolderOpenIcon v-if="dragover" /> - <template v-else> - <FolderIcon /> - <OverlayIcon :is="folderOverlay" - v-if="folderOverlay" - class="files-list__row-icon-overlay" /> - </template> - </template> - - <!-- Decorative image, should not be aria documented --> - <img v-else-if="previewUrl && backgroundFailed !== true" - ref="previewImg" - alt="" - class="files-list__row-icon-preview" - :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" - :src="previewUrl" - @error="backgroundFailed = true" - @load="backgroundFailed = false"> - - <FileIcon v-else /> - - <!-- Favorite icon --> - <span v-if="isFavorite" - class="files-list__row-icon-favorite" - :aria-label="t('files', 'Favorite')"> - <FavoriteIcon :aria-hidden="true" /> - </span> - </span> - - <!-- Rename input --> - <form v-if="isRenaming" - v-on-click-outside="stopRenaming" - :aria-hidden="!isRenaming" - :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="checkInputValidity" - @keyup.esc="stopRenaming" /> - </form> - - <a v-else - ref="basename" - :aria-hidden="isRenaming" - class="files-list__row-name-link" - data-cy-files-list-row-name-link - v-bind="linkTo" - @click="execDefaultAction"> - <!-- File name --> - <span class="files-list__row-name-text"> - <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues--> - <span class="files-list__row-name-" v-text="displayName" /> - <span class="files-list__row-name-ext" v-text="extension" /> - </span> - </a> + <FileEntryPreview ref="preview" + :source="source" + :dragover="dragover" + @click.native="execDefaultAction" /> + + <FileEntryName ref="name" + :display-name="displayName" + :extension="extension" + :files-list-width="filesListWidth" + :nodes="nodes" + :source="source" + @click="execDefaultAction" /> </td> <!-- Actions --> - <td v-show="!isRenamingSmallScreen" + <FileEntryActions v-show="!isRenamingSmallScreen" + ref="actions" :class="`files-list__row-actions-${uniqueId}`" - 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 v-if="visible" - ref="actionsMenu" - :boundaries-element="getBoundariesElement()" - :container="getBoundariesElement()" - :disabled="isLoading" - :force-name="true" - :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" - :inline="enabledInlineActions.length" - :open.sync="openedMenu"> - <NcActionButton v-for="action in enabledMenuActions" - :key="action.id" - :class="'files-list__row-action-' + action.id" - :close-after-click="true" - :data-cy-files-list-row-action="action.id" - :title="action.title?.([source], currentView)" - @click="onActionClick(action)"> - <template #icon> - <NcLoadingIcon v-if="loading === action.id" :size="18" /> - <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" /> - </template> - {{ actionDisplayName(action) }} - </NcActionButton> - </NcActions> - </td> + :files-list-width="filesListWidth" + :loading.sync="loading" + :opened.sync="openedMenu" + :source="source" /> <!-- Size --> - <td v-if="isSizeAvailable" - :style="{ opacity: sizeOpacity }" + <td v-if="!compact && isSizeAvailable" + :style="sizeOpacity" class="files-list__row-size" data-cy-files-list-row-size @click="openDetailsIfAvailable"> @@ -163,7 +78,8 @@ </td> <!-- Mtime --> - <td v-if="isMtimeAvailable" + <td v-if="!compact && isMtimeAvailable" + :style="mtimeOpacity" class="files-list__row-mtime" data-cy-files-list-row-mtime @click="openDetailsIfAvailable"> @@ -177,94 +93,56 @@ class="files-list__row-column-custom" :data-cy-files-list-row-column-custom="column.id" @click="openDetailsIfAvailable"> - <CustomElementRender v-if="visible" - :current-view="currentView" + <CustomElementRender :current-view="currentView" :render="column.render" :source="source" /> </td> </tr> </template> -<script lang='ts'> +<script lang="ts"> import type { PropType } from 'vue' -import { emit } from '@nextcloud/event-bus' -import { extname } from 'path' -import { generateUrl } from '@nextcloud/router' -import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File, FileAction, NodeStatus, Node } from '@nextcloud/files' -import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate } from '@nextcloud/l10n' -import { Type as ShareType } from '@nextcloud/sharing' +import { extname, join } from 'path' +import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' +import { getUploader } from '@nextcloud/upload' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' import { vOnClickOutside } from '@vueuse/components' -import axios from '@nextcloud/axios' import moment from '@nextcloud/moment' import Vue from 'vue' -import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.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 TagIcon from 'vue-material-design-icons/Tag.vue' -import LinkIcon from 'vue-material-design-icons/Link.vue' -import NetworkIcon from 'vue-material-design-icons/Network.vue' -import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' - import { action as sidebarAction } from '../actions/sidebarAction.ts' import { getDragAndDropPreview } from '../utils/dragUtils.ts' import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' -import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts' import { hashCode } from '../utils/hashUtils.ts' +import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' -import { useKeyboardStore } from '../store/keyboard.ts' import { useRenamingStore } from '../store/renaming.ts' import { useSelectionStore } from '../store/selection.ts' -import { useUserConfigStore } from '../store/userconfig.ts' import CustomElementRender from './CustomElementRender.vue' -import CustomSvgIconRender from './CustomSvgIconRender.vue' -import FavoriteIcon from './FavoriteIcon.vue' +import FileEntryActions from './FileEntry/FileEntryActions.vue' +import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' +import FileEntryName from './FileEntry/FileEntryName.vue' +import FileEntryPreview from './FileEntry/FileEntryPreview.vue' import logger from '../logger.js' -// The registered actions list -const actions = getFileActions() - Vue.directive('onClickOutside', vOnClickOutside) export default Vue.extend({ name: 'FileEntry', components: { - AccountGroupIcon, - AccountPlusIcon, CustomElementRender, - CustomSvgIconRender, - FavoriteIcon, - FileIcon, - FolderIcon, - FolderOpenIcon, - KeyIcon, - LinkIcon, - NcActionButton, - NcActions, - NcCheckboxRadioSwitch, - NcLoadingIcon, - NcTextField, - NetworkIcon, - TagIcon, + FileEntryActions, + FileEntryCheckbox, + FileEntryName, + FileEntryPreview, }, props: { - visible: { - type: Boolean, - default: false, - }, isMtimeAvailable: { type: Boolean, default: false, @@ -274,11 +152,7 @@ export default Vue.extend({ default: false, }, source: { - type: [Folder, File, Node] as PropType<Node>, - required: true, - }, - index: { - type: Number, + type: [Folder, NcFile, Node] as PropType<Node>, required: true, }, nodes: { @@ -289,48 +163,41 @@ export default Vue.extend({ type: Number, default: 0, }, + compact: { + type: Boolean, + default: false, + }, }, setup() { const actionsMenuStore = useActionsMenuStore() const draggingStore = useDragAndDropStore() const filesStore = useFilesStore() - const keyboardStore = useKeyboardStore() const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() - const userConfigStore = useUserConfigStore() return { actionsMenuStore, draggingStore, filesStore, - keyboardStore, renamingStore, selectionStore, - userConfigStore, } }, data() { return { - backgroundFailed: undefined, loading: '', dragover: false, - - NodeStatus, } }, computed: { - userConfig() { - return this.userConfigStore.userConfig - }, - - currentView() { - return this.$navigation.active + currentView(): View { + return this.$navigation.active as View }, columns() { // Hide columns if the list is too small - if (this.filesListWidth < 512) { + if (this.filesListWidth < 512 || this.compact) { return [] } return this.currentView?.columns || [] @@ -346,6 +213,12 @@ export default Vue.extend({ fileid() { return this.source?.fileid?.toString?.() }, + uniqueId() { + return hashCode(this.source.source) + }, + isLoading() { + return this.source.status === NodeStatus.LOADING + }, extension() { if (this.source.attributes?.displayName) { @@ -365,102 +238,52 @@ export default Vue.extend({ size() { const size = parseInt(this.source.size, 10) || 0 if (typeof size !== 'number' || size < 0) { - return this.t('files', 'Pending') + return t('files', 'Pending') } return formatFileSize(size, true) }, sizeOpacity() { - // Whatever theme is active, the contrast will pass WCAG AA - // with color main text over main background and an opacity of 0.7 - const minOpacity = 0.7 const maxOpacitySize = 10 * 1024 * 1024 const size = parseInt(this.source.size, 10) || 0 if (!size || size < 0) { - return minOpacity + return {} } - return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2) + const ratio = Math.round(Math.min(100, 100 * Math.pow((this.source.size / maxOpacitySize), 2))) + return { + color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, + } }, mtime() { if (this.source.mtime) { return moment(this.source.mtime).fromNow() } - return this.t('files_trashbin', 'A long time ago') - }, - mtimeTitle() { - if (this.source.mtime) { - return moment(this.source.mtime).format('LLL') - } - return '' + return t('files_trashbin', 'A long time ago') }, + mtimeOpacity() { + const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days - 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 + const mtime = this.source.mtime?.getTime?.() + if (!mtime) { + return {} } - // Link and mail shared folders - const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[] - if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) { - return LinkIcon + // 1 = today, 0 = 31 days ago + const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime)) + if (ratio < 0) { + return {} } - - // 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 + return { + color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, } - - return null }, - - linkTo() { - if (this.source.attributes.failed) { - return { - title: this.t('files', 'This node is unavailable'), - is: 'span', - } - } - - if (this.enabledDefaultActions.length > 0) { - const action = this.enabledDefaultActions[0] - const displayName = action.displayName([this.source], this.currentView) - return { - title: displayName, - role: 'button', - } - } - - if (this.source?.permissions & Permission.READ) { - return { - download: this.source.basename, - href: this.source.source, - title: this.t('files', 'Download file {name}', { name: this.displayName }), - } - } - - return { - is: 'span', + mtimeTitle() { + if (this.source.mtime) { + return moment(this.source.mtime).format('LLL') } + return '' }, draggingFiles() { @@ -473,124 +296,12 @@ export default Vue.extend({ return this.selectedFiles.includes(this.fileid) }, - cropPreviews() { - return this.userConfig.crop_image_previews - }, - previewUrl() { - if (this.source.type === FileType.Folder) { - return null - } - - if (this.backgroundFailed === true) { - return null - } - - try { - const previewUrl = this.source.attributes.previewUrl - || generateUrl('/core/preview?fileid={fileid}', { - fileid: this.fileid, - }) - const url = new URL(window.location.origin + previewUrl) - - // Request tiny previews - url.searchParams.set('x', '32') - url.searchParams.set('y', '32') - url.searchParams.set('mimeFallback', 'true') - - // Handle cropping - url.searchParams.set('a', this.cropPreviews === true ? '0' : '1') - return url.href - } catch (e) { - return null - } - }, - - // Sorted actions that are enabled for this node - enabledActions() { - if (this.source.attributes.failed) { - return [] - } - - return actions - .filter(action => !action.enabled || action.enabled([this.source], this.currentView)) - .sort((a, b) => (a.order || 0) - (b.order || 0)) - }, - - // Enabled action that are displayed inline - enabledInlineActions() { - if (this.filesListWidth < 768) { - return [] - } - return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView)) - }, - - // Enabled action that are displayed inline with a custom render function - enabledRenderActions() { - if (!this.visible) { - return [] - } - return this.enabledActions.filter(action => typeof action.renderInline === 'function') - }, - - // Default actions - enabledDefaultActions() { - return this.enabledActions.filter(action => !!action?.default) - }, - - // Actions shown in the menu - enabledMenuActions() { - return [ - // Showing inline first for the NcActions inline prop - ...this.enabledInlineActions, - // Then the rest - ...this.enabledActions.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) - }) - }, - openedMenu: { - get() { - return this.actionsMenuStore.opened === this.uniqueId - }, - set(opened) { - this.actionsMenuStore.opened = opened ? this.uniqueId : null - }, - }, - - uniqueId() { - return hashCode(this.source.source) - }, - - isFavorite() { - return this.source.attributes.favorite === 1 - }, - isLoading() { - return this.source.status === NodeStatus.LOADING - }, - - renameLabel() { - const matchLabel: Record<FileType, string> = { - [FileType.File]: t('files', 'File name'), - [FileType.Folder]: t('files', 'Folder name'), - } - return matchLabel[this.source.type] - }, - isRenaming() { return this.renamingStore.renamingNode === this.source }, isRenamingSmallScreen() { return this.isRenaming && this.filesListWidth < 512 }, - newName: { - get() { - return this.renamingStore.newName - }, - set(newName) { - this.renamingStore.newName = newName - }, - }, isActive() { return this.fileid === this.currentFileId?.toString?.() @@ -598,7 +309,7 @@ export default Vue.extend({ canDrag() { const canDrag = (node: Node): boolean => { - return (node.permissions & Permission.UPDATE) !== 0 + return (node?.permissions & Permission.UPDATE) !== 0 } // If we're dragging a selection, we need to check all files @@ -621,6 +332,15 @@ export default Vue.extend({ return (this.source.permissions & Permission.CREATE) !== 0 }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === this.uniqueId + }, + set(opened) { + this.actionsMenuStore.opened = opened ? this.uniqueId : null + }, + }, }, watch: { @@ -631,16 +351,6 @@ export default Vue.extend({ source() { this.resetState() }, - - /** - * If renaming starts, select the file name - * in the input, without the extension. - */ - isRenaming(renaming) { - if (renaming) { - this.startRenaming() - } - }, }, beforeDestroy() { @@ -652,96 +362,12 @@ export default Vue.extend({ // Reset loading state this.loading = '' - // Reset background state - this.backgroundFailed = undefined - if (this.$refs.previewImg) { - this.$refs.previewImg.src = '' - } + this.$refs.preview.reset() // Close menu this.openedMenu = false }, - async onActionClick(action) { - const displayName = action.displayName([this.source], this.currentView) - try { - // Set the loading marker - this.loading = action.id - Vue.set(this.source, 'status', NodeStatus.LOADING) - - const success = await action.exec(this.source, this.currentView, this.currentDir) - - // If the action returns null, we stay silent - if (success === null) { - return - } - - if (success) { - showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName })) - return - } - showError(this.t('files', '"{displayName}" action failed', { displayName })) - } catch (e) { - logger.error('Error while executing action', { action, e }) - showError(this.t('files', '"{displayName}" action failed', { displayName })) - } finally { - // Reset the loading marker - this.loading = '' - Vue.set(this.source, 'status', undefined) - } - }, - execDefaultAction(event) { - if (this.enabledDefaultActions.length > 0) { - event.preventDefault() - event.stopPropagation() - // Execute the first default action if any - this.enabledDefaultActions[0].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) - } - }, - - 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.fileid) - - const start = Math.min(newSelectedIndex, lastSelectedIndex) - const end = Math.max(lastSelectedIndex, newSelectedIndex) - - const lastSelection = this.selectionStore.lastSelection - const filesToSelect = this.nodes - .map(file => file.fileid?.toString?.()) - .slice(start, end + 1) - - // If already selected, update the new selection _without_ the current file - const selection = [...lastSelection, ...filesToSelect] - .filter(fileid => !isAlreadySelected || fileid !== this.fileid) - - 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.fileid] - : this.selectedFiles.filter(fileid => fileid !== this.fileid) - - logger.debug('Updating selection', { selection }) - this.selectionStore.set(selection) - this.selectionStore.setLastIndex(newSelectedIndex) - }, - // Open the actions menu on right click onRightClick(event) { // If already opened, fallback to default browser @@ -758,165 +384,21 @@ export default Vue.extend({ event.stopPropagation() }, - /** - * Check if the file name is valid and update the - * input validity using browser's native validation. - * @param event the keyup event - */ - checkInputValidity(event?: KeyboardEvent) { - const input = event.target as HTMLInputElement - const newName = this.newName.trim?.() || '' - logger.debug('Checking input validity', { newName }) - try { - this.isFileNameValid(newName) - input.setCustomValidity('') - input.title = '' - } catch (e) { - input.setCustomValidity(e.message) - input.title = e.message - } finally { - input.reportValidity() - } - }, - isFileNameValid(name) { - const trimmedName = name.trim() - if (trimmedName === '.' || trimmedName === '..') { - throw new Error(this.t('files', '"{name}" is an invalid file name.', { name })) - } else if (trimmedName.length === 0) { - throw new Error(this.t('files', 'File name cannot be empty.')) - } else if (trimmedName.indexOf('/') !== -1) { - throw new Error(this.t('files', '"/" is not allowed inside a file name.')) - } else if (trimmedName.match(OC.config.blacklist_files_regex)) { - throw new Error(this.t('files', '"{name}" is not an allowed filetype.', { name })) - } else if (this.checkIfNodeExists(name)) { - throw new Error(this.t('files', '{newName} already exists.', { newName: name })) - } - - return true - }, - checkIfNodeExists(name) { - return this.nodes.find(node => node.basename === name && node !== this.source) - }, - - startRenaming() { - this.$nextTick(() => { - // Using split to get the true string length - const extLength = (this.source.extension || '').split('').length - const length = this.source.basename.split('').length - extLength - const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input - if (!input) { - logger.error('Could not find the rename input') - return - } - input.setSelectionRange(0, length) - input.focus() - - // 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 oldName = this.source.basename - const oldSource = this.source.source - const newName = this.newName.trim?.() || '' - if (newName === '') { - showError(this.t('files', 'Name cannot be empty')) - return - } - - if (oldName === newName) { - this.stopRenaming() - return - } - - // Checking if already exists - if (this.checkIfNodeExists(newName)) { - showError(this.t('files', 'Another entry with the same name already exists')) - return - } - - // Set loading state - this.loading = 'renaming' - Vue.set(this.source, 'status', NodeStatus.LOADING) - - // Update node - this.source.rename(newName) - - try { - await axios({ - method: 'MOVE', - url: oldSource, - headers: { - Destination: encodeURI(this.source.source), - }, - }) - - // Success 🎉 - emit('files:node:updated', this.source) - emit('files:node:renamed', this.source) - showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) - - // Reset the renaming store - this.stopRenaming() - this.$nextTick(() => { - this.$refs.basename.focus() - }) - } catch (error) { - logger.error('Error while renaming file', { error }) - this.source.rename(oldName) - this.$refs.renameInput.focus() - - // TODO: 409 means current folder does not exist, redirect ? - if (error?.response?.status === 404) { - showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName })) - return - } else if (error?.response?.status === 412) { - showError(this.t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir })) - return - } - - // Unknown error - showError(this.t('files', 'Could not rename "{oldName}"', { oldName })) - } finally { - this.loading = false - Vue.set(this.source, 'status', undefined) - } - }, - - /** - * 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') + execDefaultAction(...args) { + this.$refs.actions.execDefaultAction(...args) }, - actionDisplayName(action: FileAction) { - if (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 + openDetailsIfAvailable(event) { + event.preventDefault() + event.stopPropagation() + if (sidebarAction?.enabled?.([this.source], this.currentView)) { + sidebarAction.exec(this.source, this.currentView, this.currentDir) } - return action.displayName([this.source], this.currentView) }, onDragOver(event: DragEvent) { this.dragover = this.canDrop if (!this.canDrop) { - event.preventDefault() - event.stopPropagation() event.dataTransfer.dropEffect = 'none' return } @@ -929,9 +411,13 @@ export default Vue.extend({ } }, onDragLeave(event: DragEvent) { - if (this.$el.contains(event.target) && event.target !== this.$el) { + // 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 }, @@ -960,7 +446,7 @@ export default Vue.extend({ .map(fileid => this.filesStore.getNode(fileid)) as Node[] const image = await getDragAndDropPreview(nodes) - event.dataTransfer.setDragImage(image, -10, -10) + event.dataTransfer?.setDragImage(image, -10, -10) }, onDragEnd() { this.draggingStore.reset() @@ -969,6 +455,9 @@ export default Vue.extend({ }, async onDrop(event) { + event.preventDefault() + event.stopPropagation() + // If another button is pressed, cancel it // This allows cancelling the drag with the right click if (!this.canDrop || event.button !== 0) { @@ -980,6 +469,16 @@ export default Vue.extend({ logger.debug('Dropped', { event, selection: this.draggingFiles }) + // Check whether we're uploading files + if (event.dataTransfer?.files?.length > 0) { + const uploader = getUploader() + event.dataTransfer.files.forEach((file: File) => { + uploader.upload(join(this.source.path, file.name), file) + }) + logger.debug(`Uploading files to ${this.source.path}`) + return + } + const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[] nodes.forEach(async (node: Node) => { Vue.set(node, 'status', NodeStatus.LOADING) @@ -989,9 +488,9 @@ export default Vue.extend({ } catch (error) { logger.error('Error while moving file', { error }) if (isCopy) { - showError(this.t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) + showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) } else { - showError(this.t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' })) + showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' })) } } finally { Vue.set(node, 'status', undefined) @@ -1006,48 +505,8 @@ export default Vue.extend({ } }, - t: translate, + t, formatFileSize, }, }) </script> - -<style scoped lang='scss'> -/* Hover effect on tbody lines only */ -tr { - &:hover, - &:focus { - background-color: var(--color-background-dark); - } -} - -// Folder overlay -.files-list__row-icon-overlay { - position: absolute; - max-height: 18px; - max-width: 18px; - color: var(--color-main-background); - // better alignment with the folder icon - margin-top: 2px; -} - -/* Preview not loaded animation effect */ -.files-list__row-icon-preview:not(.files-list__row-icon-preview--loaded) { - background: var(--color-loading-dark); - // animation: preview-gradient-fade 1.2s ease-in-out infinite; -} -</style> - -<style> -/* @keyframes preview-gradient-fade { - 0% { - opacity: 1; - } - 50% { - opacity: 0.5; - } - 100% { - opacity: 1; - } -} */ -</style> diff --git a/apps/files/src/components/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue index 4d48b11e579..6eb1fbd8edd 100644 --- a/apps/files/src/components/FavoriteIcon.vue +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -20,12 +20,12 @@ - --> <template> - <CustomSvgIconRender class="favorite-marker-icon" :svg="StarSvg" /> + <NcIconSvgWrapper class="favorite-marker-icon" :svg="StarSvg" /> </template> <script> import StarSvg from '@mdi/svg/svg/star.svg?raw' -import CustomSvgIconRender from './CustomSvgIconRender.vue' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' /** * A favorite icon to be used for overlaying favorite entries like the file preview / icon @@ -41,33 +41,37 @@ import CustomSvgIconRender from './CustomSvgIconRender.vue' export default { name: 'FavoriteIcon', components: { - CustomSvgIconRender, + NcIconSvgWrapper, }, data() { return { StarSvg, } }, - mounted() { + 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') - el.setAttribute('width', '25') - el.setAttribute('height', '25') }, } </script> <style lang="scss" scoped> .favorite-marker-icon { color: #a08b00; - width: fit-content; - height: fit-content; + // 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: 26px; - height: 26px; + width: 26px !important; + height: 26px !important; + + // Override NcIconSvgWrapper defaults of 20px + max-width: unset !important; + max-height: unset !important; // Sow a border around the icon for better contrast path { diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue new file mode 100644 index 00000000000..bd4649cdee5 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -0,0 +1,243 @@ +<!-- + - @copyright Copyright (c) 2023 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/>. + - + --> +<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" + :disabled="isLoading || loading !== ''" + :force-name="true" + :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" + :inline="enabledInlineActions.length" + :open.sync="openedMenu"> + <NcActionButton v-for="action in enabledMenuActions" + :key="action.id" + :class="'files-list__row-action-' + action.id" + :close-after-click="true" + :data-cy-files-list-row-action="action.id" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </NcActions> + </td> +</template> + +<script lang="ts"> +import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n'; +import Vue, { PropType } 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 CustomElementRender from '../CustomElementRender.vue' +import logger from '../../logger.js' + +// The registered actions list +const actions = getFileActions() + +export default Vue.extend({ + name: 'FileEntryActions', + + components: { + NcActionButton, + NcActions, + NcIconSvgWrapper, + NcLoadingIcon, + CustomElementRender, + }, + + props: { + filesListWidth: { + type: Number, + required: true, + }, + loading: { + type: String, + required: true, + }, + opened: { + type: Boolean, + default: false, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + return { + } + }, + + computed: { + currentDir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') + }, + currentView(): View { + return this.$navigation.active as View + }, + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + // Sorted actions that are enabled for this node + enabledActions() { + if (this.source.attributes.failed) { + return [] + } + + return actions + .filter(action => !action.enabled || action.enabled([this.source], this.currentView)) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + // Enabled action that are displayed inline + enabledInlineActions() { + if (this.filesListWidth < 768 || this.gridMode) { + return [] + } + return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView)) + }, + + // Enabled action that are displayed inline with a custom render function + enabledRenderActions() { + if (this.gridMode) { + return [] + } + return this.enabledActions.filter(action => typeof action.renderInline === 'function') + }, + + // Default actions + enabledDefaultActions() { + return this.enabledActions.filter(action => !!action?.default) + }, + + // Actions shown in the menu + enabledMenuActions() { + return [ + // Showing inline first for the NcActions inline prop + ...this.enabledInlineActions, + // Then the rest + ...this.enabledActions.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) + }) + }, + + 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 > table.files-list') + }, + }, + + methods: { + actionDisplayName(action: FileAction) { + if (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) + }, + + async onActionClick(action) { + const displayName = action.displayName([this.source], this.currentView) + try { + // Set the loading marker + this.$emit('update:loading', action.id) + Vue.set(this.source, 'status', NodeStatus.LOADING) + + const success = await action.exec(this.source, this.currentView, this.currentDir) + + // If the action returns null, we stay silent + if (success === null) { + return + } + + if (success) { + showSuccess(t('files', '"{displayName}" action executed successfully', { displayName })) + return + } + showError(t('files', '"{displayName}" action failed', { displayName })) + } catch (e) { + logger.error('Error while executing action', { action, e }) + showError(t('files', '"{displayName}" action failed', { displayName })) + } finally { + // Reset the loading marker + this.$emit('update:loading', '') + Vue.set(this.source, 'status', undefined) + } + }, + execDefaultAction(event) { + if (this.enabledDefaultActions.length > 0) { + event.preventDefault() + event.stopPropagation() + // Execute the first default action if any + this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir) + } + }, + + t, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue new file mode 100644 index 00000000000..961e4bf2266 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -0,0 +1,131 @@ +<!-- + - @copyright Copyright (c) 2023 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/>. + - + --> +<template> + <td class="files-list__row-checkbox"> + <NcLoadingIcon v-if="isLoading" /> + <NcCheckboxRadioSwitch v-else + :aria-label="t('files', 'Select the row for {displayName}', { displayName })" + :checked="isSelected" + @update:checked="onSelectionChange" /> + </td> +</template> + +<script lang="ts"> +import { Node } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import Vue, { PropType } from 'vue' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' + +import { useKeyboardStore } from '../../store/keyboard.ts' +import { useSelectionStore } from '../../store/selection.ts' +import logger from '../../logger.js' + +export default Vue.extend({ + name: 'FileEntryCheckbox', + + components: { + NcCheckboxRadioSwitch, + NcLoadingIcon, + }, + + props: { + displayName: { + type: String, + required: true, + }, + fileid: { + type: String, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + }, + + setup() { + const selectionStore = useSelectionStore() + const keyboardStore = useKeyboardStore() + return { + keyboardStore, + selectionStore, + } + }, + + computed: { + selectedFiles() { + return this.selectionStore.selected + }, + isSelected() { + return this.selectedFiles.includes(this.fileid) + }, + index() { + return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid)) + }, + }, + + 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.fileid) + + const start = Math.min(newSelectedIndex, lastSelectedIndex) + const end = Math.max(lastSelectedIndex, newSelectedIndex) + + const lastSelection = this.selectionStore.lastSelection + const filesToSelect = this.nodes + .map(file => file.fileid?.toString?.()) + .slice(start, end + 1) + + // If already selected, update the new selection _without_ the current file + const selection = [...lastSelection, ...filesToSelect] + .filter(fileid => !isAlreadySelected || fileid !== this.fileid) + + 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.fileid] + : this.selectedFiles.filter(fileid => fileid !== this.fileid) + + logger.debug('Updating selection', { selection }) + this.selectionStore.set(selection) + this.selectionStore.setLastIndex(newSelectedIndex) + }, + + t, + }, +}) +</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..e54eacdbe9e --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -0,0 +1,330 @@ +<!-- + - @copyright Copyright (c) 2023 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/>. + - + --> +<template> + <!-- Rename input --> + <form v-if="isRenaming" + v-on-click-outside="stopRenaming" + :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="checkInputValidity" + @keyup.esc="stopRenaming" /> + </form> + + <a v-else + ref="basename" + :aria-hidden="isRenaming" + class="files-list__row-name-link" + data-cy-files-list-row-name-link + v-bind="linkTo" + @click="$emit('click', $event)"> + <!-- File name --> + <span class="files-list__row-name-text"> + <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="displayName" /> + <span class="files-list__row-name-ext" v-text="extension" /> + </span> + </a> +</template> + +<script lang="ts"> +import { emit } from '@nextcloud/event-bus' +import { FileType, NodeStatus, Permission } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import Vue, { PropType } from 'vue' + +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' + +import { useRenamingStore } from '../../store/renaming.ts' +import logger from '../../logger.js' + +const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string + +export default Vue.extend({ + name: 'FileEntryName', + + components: { + NcTextField, + }, + + props: { + displayName: { + type: String, + required: true, + }, + extension: { + type: String, + required: true, + }, + filesListWidth: { + type: Number, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + const renamingStore = useRenamingStore() + return { + renamingStore, + } + }, + + computed: { + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + newName: { + get() { + return this.renamingStore.newName + }, + set(newName) { + this.renamingStore.newName = newName + }, + }, + + renameLabel() { + const matchLabel: Record<FileType, string> = { + [FileType.File]: t('files', 'File name'), + [FileType.Folder]: t('files', 'Folder name'), + } + return matchLabel[this.source.type] + }, + + linkTo() { + if (this.source.attributes.failed) { + return { + title: t('files', 'This node is unavailable'), + is: 'span', + } + } + + const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions + if (enabledDefaultActions?.length > 0) { + const action = enabledDefaultActions[0] + const displayName = action.displayName([this.source], this.currentView) + return { + title: displayName, + role: 'button', + } + } + + if (this.source?.permissions & Permission.READ) { + return { + download: this.source.basename, + href: this.source.source, + title: t('files', 'Download file {name}', { name: this.displayName }), + } + } + + return { + is: 'span', + } + }, + }, + + watch: { + /** + * If renaming starts, select the file name + * in the input, without the extension. + * @param renaming + */ + isRenaming(renaming: boolean) { + if (renaming) { + this.startRenaming() + } + }, + }, + + methods: { + /** + * Check if the file name is valid and update the + * input validity using browser's native validation. + * @param event the keyup event + */ + checkInputValidity(event?: KeyboardEvent) { + const input = event.target as HTMLInputElement + const newName = this.newName.trim?.() || '' + logger.debug('Checking input validity', { newName }) + try { + this.isFileNameValid(newName) + input.setCustomValidity('') + input.title = '' + } catch (e) { + input.setCustomValidity(e.message) + input.title = e.message + } finally { + input.reportValidity() + } + }, + isFileNameValid(name) { + const trimmedName = name.trim() + if (trimmedName === '.' || trimmedName === '..') { + throw new Error(t('files', '"{name}" is an invalid file name.', { name })) + } else if (trimmedName.length === 0) { + throw new Error(t('files', 'File name cannot be empty.')) + } else if (trimmedName.indexOf('/') !== -1) { + throw new Error(t('files', '"/" is not allowed inside a file name.')) + } else if (trimmedName.match(OC.config.blacklist_files_regex)) { + throw new Error(t('files', '"{name}" is not an allowed filetype.', { name })) + } else if (this.checkIfNodeExists(name)) { + throw new Error(t('files', '{newName} already exists.', { newName: name })) + } + + const toCheck = trimmedName.split('') + toCheck.forEach(char => { + if (forbiddenCharacters.indexOf(char) !== -1) { + throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char })) + } + }) + + return true + }, + checkIfNodeExists(name) { + return this.nodes.find(node => node.basename === name && node !== this.source) + }, + + startRenaming() { + this.$nextTick(() => { + // Using split to get the true string length + const extLength = (this.source.extension || '').split('').length + const length = this.source.basename.split('').length - extLength + const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input + if (!input) { + logger.error('Could not find the rename input') + return + } + input.setSelectionRange(0, length) + input.focus() + + // 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 oldName = this.source.basename + const oldEncodedSource = this.source.encodedSource + const newName = this.newName.trim?.() || '' + if (newName === '') { + showError(t('files', 'Name cannot be empty')) + return + } + + if (oldName === newName) { + this.stopRenaming() + return + } + + // Checking if already exists + if (this.checkIfNodeExists(newName)) { + showError(t('files', 'Another entry with the same name already exists')) + return + } + + // Set loading state + this.loading = 'renaming' + Vue.set(this.source, 'status', NodeStatus.LOADING) + + // Update node + this.source.rename(newName) + + logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource }) + try { + await axios({ + method: 'MOVE', + url: oldEncodedSource, + headers: { + Destination: this.source.encodedSource, + }, + }) + + // Success 🎉 + emit('files:node:updated', this.source) + emit('files:node:renamed', this.source) + showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) + + // Reset the renaming store + this.stopRenaming() + this.$nextTick(() => { + this.$refs.basename.focus() + }) + } catch (error) { + logger.error('Error while renaming file', { error }) + this.source.rename(oldName) + this.$refs.renameInput.focus() + + // TODO: 409 means current folder does not exist, redirect ? + if (error?.response?.status === 404) { + showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName })) + return + } else if (error?.response?.status === 412) { + showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir })) + return + } + + // Unknown error + showError(t('files', 'Could not rename "{oldName}"', { oldName })) + } finally { + this.loading = false + Vue.set(this.source, 'status', undefined) + } + }, + + + t, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue new file mode 100644 index 00000000000..8a7af255ec2 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -0,0 +1,215 @@ +<!-- + - @copyright Copyright (c) 2023 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/>. + - + --> +<template> + <span class="files-list__row-icon"> + <template v-if="source.type === 'folder'"> + <FolderOpenIcon v-once v-if="dragover" /> + <template v-else> + <FolderIcon v-once /> + <OverlayIcon :is="folderOverlay" + v-if="folderOverlay" + class="files-list__row-icon-overlay" /> + </template> + </template> + + <!-- Decorative image, should not be aria documented --> + <img v-else-if="previewUrl && backgroundFailed !== true" + ref="previewImg" + alt="" + class="files-list__row-icon-preview" + :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" + :src="previewUrl" + @error="backgroundFailed = true" + @load="backgroundFailed = false"> + + <FileIcon v-once v-else /> + + <!-- Favorite icon --> + <span v-if="isFavorite" + class="files-list__row-icon-favorite" + :aria-label="t('files', 'Favorite')"> + <FavoriteIcon v-once /> + </span> + </span> +</template> + +<script lang="ts"> +import type { UserConfig } from '../../types.ts' + +import { File, Folder, Node, FileType } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { translate as t } from '@nextcloud/l10n' +import { Type as ShareType } from '@nextcloud/sharing' +import Vue, { PropType } 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/Network.vue' +import TagIcon from 'vue-material-design-icons/Tag.vue' + +import { useUserConfigStore } from '../../store/userconfig.ts' +import FavoriteIcon from './FavoriteIcon.vue' + +export default Vue.extend({ + name: 'FileEntryPreview', + + components: { + AccountGroupIcon, + AccountPlusIcon, + 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() + return { + userConfigStore, + } + }, + + data() { + return { + backgroundFailed: undefined as boolean | undefined, + } + }, + + computed: { + fileid() { + return this.source?.fileid?.toString?.() + }, + 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 + } + + try { + const previewUrl = this.source.attributes.previewUrl + || generateUrl('/core/preview?fileId={fileid}', { + fileid: this.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') + + // Handle cropping + url.searchParams.set('a', this.cropPreviews === true ? '0' : '1') + return url.href + } catch (e) { + 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.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_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 + } + + return null + }, + }, + + methods: { + reset() { + // Reset background state + this.backgroundFailed = undefined + if (this.$refs.previewImg) { + this.$refs.previewImg.src = '' + } + }, + + t, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue new file mode 100644 index 00000000000..def818eea2f --- /dev/null +++ b/apps/files/src/components/FileEntryGrid.vue @@ -0,0 +1,408 @@ +<!-- + - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license AGPL-3.0-or-later + - + - 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/>. + - + --> + +<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="source.attributes.failed" class="files-list__row--failed" /> + + <!-- Checkbox --> + <FileEntryCheckbox :display-name="displayName" + :fileid="fileid" + :is-loading="isLoading" + :nodes="nodes" /> + + <!-- 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" + @click.native="execDefaultAction" /> + + <FileEntryName ref="name" + :display-name="displayName" + :extension="extension" + :files-list-width="filesListWidth" + :grid-mode="true" + :nodes="nodes" + :source="source" + @click="execDefaultAction" /> + </td> + + <!-- Actions --> + <FileEntryActions ref="actions" + :class="`files-list__row-actions-${uniqueId}`" + :files-list-width="filesListWidth" + :grid-mode="true" + :loading.sync="loading" + :opened.sync="openedMenu" + :source="source" /> + </tr> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' + +import { extname, join } from 'path' +import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' +import { getUploader } from '@nextcloud/upload' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { vOnClickOutside } from '@vueuse/components' +import Vue from 'vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { getDragAndDropPreview } from '../utils/dragUtils.ts' +import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' +import { hashCode } from '../utils/hashUtils.ts' +import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.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 FileEntryActions from './FileEntry/FileEntryActions.vue' +import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' +import FileEntryName from './FileEntry/FileEntryName.vue' +import FileEntryPreview from './FileEntry/FileEntryPreview.vue' +import logger from '../logger.js' + +Vue.directive('onClickOutside', vOnClickOutside) + +export default Vue.extend({ + name: 'FileEntryGrid', + + components: { + FileEntryActions, + FileEntryCheckbox, + FileEntryName, + FileEntryPreview, + }, + + inheritAttrs: false, + props: { + source: { + type: [Folder, NcFile, Node] as PropType<Node>, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + filesListWidth: { + type: Number, + default: 0, + }, + }, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const draggingStore = useDragAndDropStore() + const filesStore = useFilesStore() + const renamingStore = useRenamingStore() + const selectionStore = useSelectionStore() + return { + actionsMenuStore, + draggingStore, + filesStore, + renamingStore, + selectionStore, + } + }, + + data() { + return { + loading: '', + dragover: false, + } + }, + + computed: { + currentView(): View { + return this.$navigation.active as View + }, + + currentDir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') + }, + currentFileId() { + return this.$route.params?.fileid || this.$route.query?.fileid || null + }, + fileid() { + return this.source?.fileid?.toString?.() + }, + uniqueId() { + return hashCode(this.source.source) + }, + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + extension() { + if (this.source.attributes?.displayName) { + return extname(this.source.attributes.displayName) + } + return this.source.extension || '' + }, + displayName() { + const ext = this.extension + const name = (this.source.attributes.displayName + || this.source.basename) + + // Strip extension from name if defined + return !ext ? name : name.slice(0, 0 - ext.length) + }, + + draggingFiles() { + return this.draggingStore.dragging + }, + selectedFiles() { + return this.selectionStore.selected + }, + isSelected() { + return this.selectedFiles.includes(this.fileid) + }, + + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + + isActive() { + return this.fileid === this.currentFileId?.toString?.() + }, + + canDrag() { + 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(fileid => this.filesStore.getNode(fileid)) as Node[] + return nodes.every(canDrag) + } + return canDrag(this.source) + }, + + canDrop() { + if (this.source.type !== FileType.Folder) { + return false + } + + // If the current folder is also being dragged, we can't drop it on itself + if (this.draggingFiles.includes(this.fileid)) { + return false + } + + return (this.source.permissions & Permission.CREATE) !== 0 + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === this.uniqueId + }, + set(opened) { + this.actionsMenuStore.opened = opened ? this.uniqueId : null + }, + }, + }, + + watch: { + /** + * When the source changes, reset the preview + * and fetch the new one. + */ + source() { + this.resetState() + }, + }, + + beforeDestroy() { + this.resetState() + }, + + methods: { + resetState() { + // Reset loading state + this.loading = '' + + 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 + } + + // 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 + + // Prevent any browser defaults + event.preventDefault() + event.stopPropagation() + }, + + execDefaultAction(...args) { + this.$refs.actions.execDefaultAction(...args) + }, + + 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) { + event.preventDefault() + event.stopPropagation() + return + } + + logger.debug('Drag started') + + // 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.fileid)) { + this.draggingStore.set(this.selectedFiles) + } else { + this.draggingStore.set([this.fileid]) + } + + const nodes = this.draggingStore.dragging + .map(fileid => this.filesStore.getNode(fileid)) 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) { + event.preventDefault() + event.stopPropagation() + + // If another button is pressed, cancel it + // This allows cancelling the drag with the right click + if (!this.canDrop || event.button !== 0) { + return + } + + const isCopy = event.ctrlKey + this.dragover = false + + logger.debug('Dropped', { event, selection: this.draggingFiles }) + + // Check whether we're uploading files + if (event.dataTransfer?.files?.length > 0) { + const uploader = getUploader() + event.dataTransfer.files.forEach((file: File) => { + uploader.upload(join(this.source.path, file.name), file) + }) + logger.debug(`Uploading files to ${this.source.path}`) + return + } + + const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[] + nodes.forEach(async (node: Node) => { + Vue.set(node, 'status', NodeStatus.LOADING) + try { + // TODO: resolve potential conflicts prior and force overwrite + await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE) + } catch (error) { + logger.error('Error while moving file', { error }) + if (isCopy) { + showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) + } else { + showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' })) + } + } finally { + Vue.set(node, 'status', undefined) + } + }) + + // Reset selection after we dropped the files + // if the dropped files are within the selection + if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) { + logger.debug('Dropped selection, resetting select store...') + this.selectionStore.reset() + } + }, + + t, + }, +}) +</script> diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue deleted file mode 100644 index 3a89970a26d..00000000000 --- a/apps/files/src/components/FilesListFooter.vue +++ /dev/null @@ -1,175 +0,0 @@ -<!-- - - @copyright Copyright (c) 2023 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/>. - - - --> -<template> - <tr> - <th class="files-list__row-checkbox"> - <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" /> - - <!-- 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 Vue from 'vue' -import { formatFileSize } from '@nextcloud/files' -import { translate } from '@nextcloud/l10n' - -import { useFilesStore } from '../store/files.ts' -import { usePathsStore } from '../store/paths.ts' - -export default Vue.extend({ - name: 'FilesListFooter', - - components: { - }, - - props: { - isMtimeAvailable: { - type: Boolean, - default: false, - }, - isSizeAvailable: { - type: Boolean, - default: false, - }, - nodes: { - type: Array, - required: true, - }, - summary: { - type: String, - default: '', - }, - filesListWidth: { - type: Number, - default: 0, - }, - }, - - setup() { - const pathsStore = usePathsStore() - const filesStore = useFilesStore() - return { - filesStore, - pathsStore, - } - }, - - computed: { - currentView() { - return this.$navigation.active - }, - - dir() { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') - }, - - currentFolder() { - if (!this.currentView?.id) { - return - } - - if (this.dir === '/') { - return this.filesStore.getRoot(this.currentView.id) - } - const fileId = this.pathsStore.getPath(this.currentView.id, this.dir) - 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 { - padding-bottom: 300px; - 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/FilesListHeaderActions.vue b/apps/files/src/components/FilesListHeaderActions.vue deleted file mode 100644 index dfe892af772..00000000000 --- a/apps/files/src/components/FilesListHeaderActions.vue +++ /dev/null @@ -1,226 +0,0 @@ -<!-- - - @copyright Copyright (c) 2023 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/>. - - - --> -<template> - <th class="files-list__column files-list__row-actions-batch" colspan="2"> - <NcActions ref="actionsMenu" - :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" - :key="action.id" - :class="'files-list__row-actions-batch-' + action.id" - @click="onActionClick(action)"> - <template #icon> - <NcLoadingIcon v-if="loading === action.id" :size="18" /> - <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" /> - </template> - {{ action.displayName(nodes, currentView) }} - </NcActionButton> - </NcActions> - </th> -</template> - -<script lang="ts"> -import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate } from '@nextcloud/l10n' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import Vue from 'vue' - -import { getFileActions, useActionsMenuStore } from '../store/actionsmenu.ts' -import { useFilesStore } from '../store/files.ts' -import { useSelectionStore } from '../store/selection.ts' -import filesListWidthMixin from '../mixins/filesListWidth.ts' -import CustomSvgIconRender from './CustomSvgIconRender.vue' -import logger from '../logger.js' -import { NodeStatus } from '@nextcloud/files' - -// The registered actions list -const actions = getFileActions() - -export default Vue.extend({ - name: 'FilesListHeaderActions', - - components: { - CustomSvgIconRender, - NcActions, - NcActionButton, - NcLoadingIcon, - }, - - mixins: [ - filesListWidthMixin, - ], - - props: { - currentView: { - type: Object, - required: true, - }, - selectedNodes: { - type: Array, - default: () => ([]), - }, - }, - - setup() { - const actionsMenuStore = useActionsMenuStore() - const filesStore = useFilesStore() - const selectionStore = useSelectionStore() - return { - actionsMenuStore, - filesStore, - selectionStore, - } - }, - - data() { - return { - loading: null, - } - }, - - computed: { - dir() { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') - }, - enabledActions() { - return actions - .filter(action => action.execBatch) - .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView)) - .sort((a, b) => (a.order || 0) - (b.order || 0)) - }, - - nodes() { - return this.selectedNodes - .map(fileid => this.getNode(fileid)) - .filter(node => 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.filesListWidth < 512) { - return 0 - } - if (this.filesListWidth < 768) { - return 1 - } - if (this.filesListWidth < 1024) { - return 2 - } - return 3 - }, - }, - - methods: { - /** - * Get a cached note from the store - * - * @param {number} fileId the file id to get - * @return {Folder|File} - */ - getNode(fileId) { - return this.filesStore.getNode(fileId) - }, - - async onActionClick(action) { - const displayName = action.displayName(this.nodes, this.currentView) - const selectionIds = this.selectedNodes - try { - // Set loading markers - this.loading = action.id - this.nodes.forEach(node => { - Vue.set(node, 'status', NodeStatus.LOADING) - }) - - // Dispatch action execution - const results = await action.execBatch(this.nodes, this.currentView, this.dir) - - // 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 failedIds = selectionIds - .filter((fileid, index) => results[index] === false) - this.selectionStore.set(failedIds) - - showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) - return - } - - // Show success message and clear selection - showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName })) - this.selectionStore.reset() - } catch (e) { - logger.error('Error while executing action', { action, e }) - showError(this.t('files', '"{displayName}" action failed', { displayName })) - } finally { - // Remove loading markers - this.loading = null - this.nodes.forEach(node => { - Vue.set(node, 'status', undefined) - }) - } - }, - - t: translate, - }, -}) -</script> - -<style scoped lang="scss"> -.files-list__row-actions-batch { - flex: 1 1 100% !important; - - // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged - ::v-deep .button-vue__wrapper { - width: 100%; - span.button-vue__text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } -} -</style> diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue deleted file mode 100644 index 9aac83a185d..00000000000 --- a/apps/files/src/components/FilesListHeaderButton.vue +++ /dev/null @@ -1,122 +0,0 @@ -<!-- - - @copyright Copyright (c) 2023 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/>. - - - --> -<template> - <NcButton :aria-label="sortAriaLabel(name)" - :class="{'files-list__column-sort-button--active': sortingMode === mode}" - class="files-list__column-sort-button" - type="tertiary" - @click.stop.prevent="toggleSortBy(mode)"> - <!-- Sort icon before text as size is align right --> - <MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" /> - <MenuDown v-else slot="icon" /> - {{ name }} - </NcButton> -</template> - -<script lang="ts"> -import { translate } from '@nextcloud/l10n' -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 Vue from 'vue' - -import filesSortingMixin from '../mixins/filesSorting.ts' - -export default Vue.extend({ - name: 'FilesListHeaderButton', - - components: { - MenuDown, - MenuUp, - NcButton, - }, - - mixins: [ - filesSortingMixin, - ], - - props: { - name: { - type: String, - required: true, - }, - mode: { - type: String, - required: true, - }, - }, - - methods: { - sortAriaLabel(column) { - const direction = this.isAscSorting - ? this.t('files', 'ascending') - : this.t('files', 'descending') - return this.t('files', 'Sort list by {column} ({direction})', { - column, - direction, - }) - }, - - t: translate, - }, -}) -</script> - -<style lang="scss"> -.files-list__column-sort-button { - // Compensate for cells margin - margin: 0 calc(var(--cell-margin) * -1); - // Reverse padding - padding: 0 4px 0 16px !important; - - // Icon after text - .button-vue__wrapper { - flex-direction: row-reverse; - // Take max inner width for text overflow ellipsis - // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged - width: 100%; - } - - .button-vue__icon { - transition-timing-function: linear; - transition-duration: .1s; - transition-property: opacity; - opacity: 0; - } - - // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged - .button-vue__text { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - &--active, - &:hover, - &:focus, - &:active { - .button-vue__icon { - opacity: 1 !important; - } - } -} -</style> diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue index 3e8f49deace..bca4604d57d 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -159,17 +159,16 @@ export default Vue.extend({ <style scoped lang="scss"> // Scoped row tr { - padding-bottom: 300px; + margin-bottom: 300px; 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; + 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 index 52060d2589e..e619acf0623 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -34,6 +34,7 @@ <template v-else> <!-- Link to file --> <th class="files-list__column files-list__row-name files-list__column--sortable" + :aria-sort="ariaSortForMode('basename')" @click.stop.prevent="toggleSortBy('basename')"> <!-- Icon or preview --> <span class="files-list__row-icon" /> @@ -48,21 +49,24 @@ <!-- Size --> <th v-if="isSizeAvailable" :class="{'files-list__column--sortable': isSizeAvailable}" - class="files-list__column files-list__row-size"> + class="files-list__column files-list__row-size" + :aria-sort="ariaSortForMode('size')"> <FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" /> </th> <!-- Mtime --> <th v-if="isMtimeAvailable" :class="{'files-list__column--sortable': isMtimeAvailable}" - class="files-list__column files-list__row-mtime"> + class="files-list__column files-list__row-mtime" + :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)"> + :class="classForColumn(column)" + :aria-sort="ariaSortForMode(column.id)"> <FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" /> <span v-else> {{ column.title }} @@ -173,6 +177,13 @@ export default Vue.extend({ }, methods: { + ariaSortForMode(mode: string): ARIAMixin['ariaSort'] { + if (this.sortingMode === mode) { + return this.isAscSorting ? 'ascending' : 'descending' + } + return null + }, + classForColumn(column) { return { 'files-list__column': true, diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index e5247fb4b94..3b364a0b83d 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -33,7 +33,7 @@ @click="onActionClick(action)"> <template #icon> <NcLoadingIcon v-if="loading === action.id" :size="18" /> - <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" /> </template> {{ action.displayName(nodes, currentView) }} </NcActionButton> @@ -47,6 +47,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' 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 Vue from 'vue' @@ -54,7 +55,6 @@ import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useFilesStore } from '../store/files.ts' import { useSelectionStore } from '../store/selection.ts' import filesListWidthMixin from '../mixins/filesListWidth.ts' -import CustomSvgIconRender from './CustomSvgIconRender.vue' import logger from '../logger.js' // The registered actions list @@ -64,9 +64,9 @@ export default Vue.extend({ name: 'FilesListTableHeaderActions', components: { - CustomSvgIconRender, NcActions, NcActionButton, + NcIconSvgWrapper, NcLoadingIcon, }, diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue index ebd1abb4314..659aee8e456 100644 --- a/apps/files/src/components/FilesListTableHeaderButton.vue +++ b/apps/files/src/components/FilesListTableHeaderButton.vue @@ -22,6 +22,7 @@ <template> <NcButton :aria-label="sortAriaLabel(name)" :class="{'files-list__column-sort-button--active': sortingMode === mode}" + :alignment="mode !== 'size' ? 'start-reverse' : undefined" class="files-list__column-sort-button" type="tertiary" @click.stop.prevent="toggleSortBy(mode)"> @@ -67,12 +68,8 @@ export default Vue.extend({ methods: { sortAriaLabel(column) { - const direction = this.isAscSorting - ? this.t('files', 'ascending') - : this.t('files', 'descending') - return this.t('files', 'Sort list by {column} ({direction})', { + return this.t('files', 'Sort list by {column}', { column, - direction, }) }, @@ -85,16 +82,6 @@ export default Vue.extend({ .files-list__column-sort-button { // Compensate for cells margin margin: 0 calc(var(--cell-margin) * -1); - // Reverse padding - padding: 0 4px 0 16px !important; - - // Icon after text - .button-vue__wrapper { - flex-direction: row-reverse; - // Take max inner width for text overflow ellipsis - // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged - width: 100%; - } .button-vue__icon { transition-timing-function: linear; diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 9a55b9cdde4..7ada3e705ee 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -20,63 +20,80 @@ - --> <template> - <VirtualList :data-component="FileEntry" - :data-key="'source'" - :data-sources="nodes" - :item-height="56" - :extra-props="{ - isMtimeAvailable, - isSizeAvailable, - nodes, - filesListWidth, - }" - :scroll-to-index="scrollToIndex"> - <!-- Accessibility description and headers --> - <template #before> - <!-- Accessibility description --> - <caption class="hidden-visually"> - {{ currentView.caption || t('files', 'List of files and folders.') }} - {{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }} - </caption> - - <!-- Headers --> - <FilesListHeader v-for="header in sortedHeaders" - :key="header.id" - :current-folder="currentFolder" - :current-view="currentView" - :header="header" /> - </template> - - <!-- Thead--> - <template #header> - <FilesListTableHeader :files-list-width="filesListWidth" - :is-mtime-available="isMtimeAvailable" - :is-size-available="isSizeAvailable" - :nodes="nodes" /> - </template> - - <!-- Tfoot--> - <template #footer> - <FilesListTableFooter :files-list-width="filesListWidth" - :is-mtime-available="isMtimeAvailable" - :is-size-available="isSizeAvailable" - :nodes="nodes" - :summary="summary" /> - </template> - </VirtualList> + <Fragment> + <!-- Drag and drop notice --> + <DragAndDropNotice v-if="canUpload && filesListWidth >= 512" + :current-folder="currentFolder" + :dragover.sync="dragover" + :style="{ height: dndNoticeHeight }" /> + + <VirtualList ref="table" + :data-component="userConfig.grid_view ? FileEntryGrid : FileEntry" + :data-key="'source'" + :data-sources="nodes" + :grid-mode="userConfig.grid_view" + :extra-props="{ + isMtimeAvailable, + isSizeAvailable, + nodes, + filesListWidth, + }" + :scroll-to-index="scrollToIndex" + @scroll="onScroll"> + <!-- Accessibility description and headers --> + <template #before> + <!-- Accessibility description --> + <caption class="hidden-visually"> + {{ currentView.caption || t('files', 'List of files and folders.') }} + {{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }} + </caption> + + <!-- Headers --> + <FilesListHeader v-for="header in sortedHeaders" + :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="filesListWidth" + :is-mtime-available="isMtimeAvailable" + :is-size-available="isSizeAvailable" + :nodes="nodes" /> + </template> + + <!-- Tfoot--> + <template #footer> + <FilesListTableFooter :files-list-width="filesListWidth" + :is-mtime-available="isMtimeAvailable" + :is-size-available="isSizeAvailable" + :nodes="nodes" + :summary="summary" /> + </template> + </VirtualList> + </Fragment> </template> <script lang="ts"> +import type { Node as NcNode } from '@nextcloud/files' import type { PropType } from 'vue' -import type { Node } from '@nextcloud/files' +import type { UserConfig } from '../types.ts' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' -import { getFileListHeaders, Folder, View } from '@nextcloud/files' +import { Fragment } from 'vue-frag' +import { getFileListHeaders, Folder, View, Permission } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' import Vue from 'vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useUserConfigStore } from '../store/userconfig.ts' +import DragAndDropNotice from './DragAndDropNotice.vue' import FileEntry from './FileEntry.vue' +import FileEntryGrid from './FileEntryGrid.vue' import FilesListHeader from './FilesListHeader.vue' import FilesListTableFooter from './FilesListTableFooter.vue' import FilesListTableHeader from './FilesListTableHeader.vue' @@ -88,9 +105,11 @@ export default Vue.extend({ name: 'FilesListVirtual', components: { + DragAndDropNotice, FilesListHeader, - FilesListTableHeader, FilesListTableFooter, + FilesListTableHeader, + Fragment, VirtualList, }, @@ -108,26 +127,40 @@ export default Vue.extend({ required: true, }, nodes: { - type: Array as PropType<Node[]>, + type: Array as PropType<NcNode[]>, required: true, }, }, + setup() { + const userConfigStore = useUserConfigStore() + return { + userConfigStore, + } + }, + data() { return { FileEntry, + FileEntryGrid, headers: getFileListHeaders(), scrollToIndex: 0, + dragover: false, + dndNoticeHeight: 0, } }, computed: { + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + files() { return this.nodes.filter(node => node.type === 'file') }, fileId() { - return parseInt(this.$route.params.fileid || this.$route.query.fileid) || null + return parseInt(this.$route.params.fileid) || null }, summaryFile() { @@ -163,40 +196,99 @@ export default Vue.extend({ return [...this.headers].sort((a, b) => a.order - b.order) }, + + canUpload() { + return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0 + }, + }, + + watch: { + fileId(fileId) { + this.scrollToFile(fileId, false) + }, }, mounted() { - // Scroll to the file if it's in the url - if (this.fileId) { - const index = this.nodes.findIndex(node => node.fileid === this.fileId) - if (index === -1 && this.fileId !== this.currentFolder.fileid) { - showError(this.t('files', 'File not found')) - } - this.scrollToIndex = Math.max(0, index) - } + // 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) + mainContent.addEventListener('dragleave', this.onDragLeave) + + this.scrollToFile(this.fileId) + this.openSidebarForFile(this.fileId) + }, + methods: { // Open the file sidebar if we have the room for it - if (document.documentElement.clientWidth > 1024) { - // Don't open the sidebar for the current folder - if (this.currentFolder.fileid === this.fileId) { - return + // but don't open the sidebar for the current folder + openSidebarForFile(fileId) { + if (document.documentElement.clientWidth > 1024 && this.currentFolder.fileid !== 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) + } } + }, - // Open the sidebar for the given URL fileid - // iif we just loaded the app. - const node = this.nodes.find(n => n.fileid === this.fileId) as Node - if (node && sidebarAction?.enabled?.([node], this.currentView)) { - logger.debug('Opening sidebar on file ' + node.path, { node }) - sidebarAction.exec(node, this.currentView, this.currentFolder.path) + scrollToFile(fileId: number, warn = true) { + if (fileId) { + const index = this.nodes.findIndex(node => node.fileid === fileId) + if (warn && index === -1 && fileId !== this.currentFolder.fileid) { + showError(this.t('files', 'File not found')) + } + this.scrollToIndex = Math.max(0, index) } - } - }, + }, - methods: { getFileId(node) { return node.fileid }, + onDragOver(event: DragEvent) { + // Detect if we're only dragging existing files or not + const isForeignFile = event.dataTransfer?.types.includes('Files') + if (isForeignFile) { + this.dragover = true + } else { + this.dragover = false + } + + event.preventDefault() + event.stopPropagation() + + const tableTop = this.$refs.table.$el.getBoundingClientRect().top + const tableBottom = tableTop + this.$refs.table.$el.getBoundingClientRect().height + + // If reaching top, scroll up. Using 100 because of the floating header + if (event.clientY < tableTop + 100) { + this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop - 25 + return + } + + // If reaching bottom, scroll down + if (event.clientY > tableBottom - 50) { + this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop + 25 + } + }, + 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 + }, + + onScroll() { + // Update the sticky position of the thead to adapt to the scroll + this.dndNoticeHeight = (this.$refs.thead.$el?.getBoundingClientRect?.()?.top ?? 0) + 'px' + }, + t, }, }) @@ -215,15 +307,27 @@ export default Vue.extend({ display: block; overflow: auto; height: 100%; + will-change: scroll-position; &::v-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 @@ -232,6 +336,15 @@ export default Vue.extend({ flex-direction: column; } + .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 @@ -240,12 +353,9 @@ export default Vue.extend({ top: 0; } - .files-list__thead, + // Table footer .files-list__tfoot { - display: flex; - width: 100%; - background-color: var(--color-main-background); - + min-height: 300px; } tr { @@ -255,7 +365,9 @@ export default Vue.extend({ width: 100%; user-select: none; border-bottom: 1px solid var(--color-border); + box-sizing: border-box; user-select: none; + height: var(--row-height); } td, th { @@ -316,7 +428,11 @@ export default Vue.extend({ .files-list__row { &:hover, &:focus, &:active, &--active, &--dragover { - background-color: var(--color-background-dark); + // 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); } @@ -377,10 +493,15 @@ export default Vue.extend({ width: var(--icon-preview-size); height: var(--icon-preview-size); border-radius: var(--border-radius); - background-repeat: no-repeat; // Center and contain the preview - background-position: center; - background-size: contain; + object-fit: contain; + object-position: center; + + /* 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 { @@ -388,6 +509,16 @@ export default Vue.extend({ top: 0px; right: -10px; } + + // Folder overlay + &-overlay { + position: absolute; + max-height: calc(var(--icon-preview-size) * 0.5); + max-width: calc(var(--icon-preview-size) * 0.5); + color: var(--color-main-background); + // better alignment with the folder icon + margin-top: 2px; + } } // Entry link @@ -430,6 +561,8 @@ export default Vue.extend({ .files-list__row-name-ext { color: var(--color-text-maxcontrast); + // always show the extension + overflow: visible; } } @@ -453,6 +586,7 @@ export default Vue.extend({ } .files-list__row-actions { + // take as much space as necessary width: auto; // Add margin to all cells after the actions @@ -475,19 +609,12 @@ export default Vue.extend({ .files-list__row-mtime, .files-list__row-size { - // Right align text - justify-content: flex-end; + color: var(--color-text-maxcontrast); + } + .files-list__row-size { width: calc(var(--row-height) * 1.5); - // opacity varies with the size - color: var(--color-main-text); - - // Icon is before text since size is right aligned - .files-list__column-sort-button { - padding: 0 16px 0 4px !important; - .button-vue__wrapper { - flex-direction: row; - } - } + // Right align content/text + justify-content: flex-end; } .files-list__row-mtime { @@ -500,3 +627,91 @@ export default Vue.extend({ } } </style> + +<style lang="scss"> +// Grid mode +tbody.files-list__tbody.files-list__tbody--grid { + --half-clickable-area: calc(var(--clickable-area) / 2); + --row-width: 160px; + // We use half of the clickable area as visual balance margin + --row-height: calc(var(--row-width) - var(--half-clickable-area)); + --icon-preview-size: calc(var(--row-width) - var(--clickable-area)); + --checkbox-padding: 0px; + + display: grid; + grid-template-columns: repeat(auto-fill, var(--row-width)); + grid-gap: 15px; + row-gap: 15px; + + align-content: center; + align-items: center; + justify-content: space-around; + justify-items: center; + + tr { + width: var(--row-width); + height: calc(var(--row-height) + var(--clickable-area)); + border: none; + border-radius: var(--border-radius); + } + + // Checkbox in the top left + .files-list__row-checkbox { + position: absolute; + z-index: 9; + top: 0; + left: 0; + overflow: hidden; + width: var(--clickable-area); + height: var(--clickable-area); + border-radius: var(--half-clickable-area); + } + + // Star icon in the top right + .files-list__row-icon-favorite { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--clickable-area); + height: var(--clickable-area); + } + + .files-list__row-name { + display: grid; + justify-content: stretch; + width: 100%; + height: 100%; + grid-auto-rows: var(--row-height) var(--clickable-area); + + span.files-list__row-icon { + width: 100%; + height: 100%; + // Visual balance, we use half of the clickable area + // as a margin around the preview + padding-top: var(--half-clickable-area); + } + + a.files-list__row-name-link { + // Minus action menu + width: calc(100% - var(--clickable-area)); + height: var(--clickable-area); + } + + .files-list__row-name-text { + margin: 0; + padding-right: 0; + } + } + + .files-list__row-actions { + position: absolute; + right: 0; + bottom: 0; + width: var(--clickable-area); + height: var(--clickable-area); + } +} +</style> diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index 4a877049fa8..25bdcde1b45 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -51,8 +51,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?.quota, false, false) // If no quota set if (this.storageStats?.quota < 0) { diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue index 037c4fd4f68..66043220223 100644 --- a/apps/files/src/components/TransferOwnershipDialogue.vue +++ b/apps/files/src/components/TransferOwnershipDialogue.vue @@ -25,7 +25,9 @@ <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"> @@ -225,10 +227,12 @@ p { } .new-owner-row { display: flex; + flex-wrap: wrap; label { display: flex; align-items: center; + margin-bottom: calc(var(--default-grid-baseline) * 2); span { margin-right: 8px; @@ -244,5 +248,9 @@ p { span { margin-right: 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 index 511053b2fa1..a579cfcc8f3 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -11,11 +11,13 @@ </thead> <!-- Body --> - <tbody :style="tbodyStyle" class="files-list__tbody" data-cy-files-list-tbody> + <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="(item, i) in renderedItems" - :key="i" - :visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)" + v-for="({key, item}, i) in renderedItems" + :key="key" :source="item" :index="i" v-bind="extraProps" /> @@ -23,7 +25,6 @@ <!-- Footer --> <tfoot v-show="isReady" - ref="tfoot" class="files-list__tfoot" data-cy-files-list-tfoot> <slot name="footer" /> @@ -32,16 +33,23 @@ </template> <script lang="ts"> -import { File, Folder, debounce } from 'debounce' -import Vue from 'vue' +import type { File, Folder, Node } from '@nextcloud/files' +import { debounce } from 'debounce' +import Vue, { PropType } from 'vue' + +import filesListWidthMixin from '../mixins/filesListWidth.ts' import logger from '../logger.js' -// Items to render before and after the visible area -const bufferItems = 3 +interface RecycledPoolItem { + key: string, + item: Node, +} export default Vue.extend({ name: 'VirtualList', + mixins: [filesListWidthMixin], + props: { dataComponent: { type: [Object, Function], @@ -52,26 +60,25 @@ export default Vue.extend({ required: true, }, dataSources: { - type: Array as () => (File | Folder)[], - required: true, - }, - itemHeight: { - type: Number, + type: Array as PropType<(File | Folder)[]>, required: true, }, extraProps: { - type: Object, + type: Object as PropType<Record<string, unknown>>, default: () => ({}), }, scrollToIndex: { type: Number, default: 0, }, + gridMode: { + type: Boolean, + default: false, + }, }, data() { return { - bufferItems, index: this.scrollToIndex, beforeHeight: 0, headerHeight: 0, @@ -86,60 +93,126 @@ export default Vue.extend({ return this.tableHeight > 0 }, + // Items to render before and after the visible area + bufferItems() { + if (this.gridMode) { + return this.columnCount + } + return 3 + }, + + itemHeight() { + // Align with css in FilesListVirtual + // 138px + 44px (name) + 15px (grid gap) + return this.gridMode ? (138 + 44 + 15) : 55 + }, + // Grid mode only + itemWidth() { + // 160px + 15px grid gap + return 160 + 15 + }, + + rowCount() { + return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1 + }, + columnCount() { + if (!this.gridMode) { + return 1 + } + return Math.floor(this.filesListWidth / this.itemWidth) + }, + startIndex() { - return Math.max(0, this.index - bufferItems) + return Math.max(0, this.index - this.bufferItems) }, shownItems() { - return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2 + // 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(): (File | Folder)[] { + renderedItems(): RecycledPoolItem[] { if (!this.isReady) { return [] } - return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) + + 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 } + }) }, tbodyStyle() { - const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length + const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length const lastIndex = this.dataSources.length - this.startIndex - this.shownItems - const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex) + const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount) return { - paddingTop: `${this.startIndex * this.itemHeight}px`, + paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`, paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`, } }, }, watch: { - scrollToIndex() { - this.index = this.scrollToIndex - this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight + scrollToIndex(index) { + this.scrollTo(index) + }, + columnCount(columnCount, oldColumnCount) { + if (oldColumnCount === 0) { + // We're initializing, the scroll position + // is handled on mounted + console.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() { const before = this.$refs?.before as HTMLElement const root = this.$el as HTMLElement - const tfoot = this.$refs?.tfoot as HTMLElement const thead = this.$refs?.thead as HTMLElement this.resizeObserver = new ResizeObserver(debounce(() => { this.beforeHeight = before?.clientHeight ?? 0 this.headerHeight = thead?.clientHeight ?? 0 this.tableHeight = root?.clientHeight ?? 0 - logger.debug('VirtualList resizeObserver updated') + logger.debug('VirtualList: resizeObserver updated') this.onScroll() }, 100, false)) this.resizeObserver.observe(before) this.resizeObserver.observe(root) - this.resizeObserver.observe(tfoot) this.resizeObserver.observe(thead) - this.$el.addEventListener('scroll', this.onScroll) - if (this.scrollToIndex) { - this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight + 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, any> }, beforeDestroy() { @@ -149,14 +222,24 @@ export default Vue.extend({ }, methods: { + scrollTo(index: number) { + this.index = index + // Scroll to one row and a half before the index + const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight + logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount }) + this.$el.scrollTop = scrollTop + }, + onScroll() { - // Max 0 to prevent negative index - this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight)) + this._onScrollHandle ??= requestAnimationFrame(() => { + this._onScrollHandle = null; + const topScroll = this.$el.scrollTop - this.beforeHeight + const index = Math.floor(topScroll / this.itemHeight) * this.columnCount + // Max 0 to prevent negative index + this.index = Math.max(0, index) + this.$emit('scroll') + }); }, }, }) </script> - -<style scoped> - -</style> |