diff options
Diffstat (limited to 'apps/files/src/components')
-rw-r--r-- | apps/files/src/components/DragAndDropNotice.vue | 2 | ||||
-rw-r--r-- | apps/files/src/components/DragAndDropPreview.vue | 22 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FavoriteIcon.vue | 4 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryActions.vue | 56 | ||||
-rw-r--r-- | apps/files/src/components/FileListFilter/FileListFilterToSearch.vue | 47 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeader.vue | 52 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableHeaderActions.vue | 5 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 137 | ||||
-rw-r--r-- | apps/files/src/components/FilesNavigationItem.vue | 8 | ||||
-rw-r--r-- | apps/files/src/components/FilesNavigationSearch.vue | 86 | ||||
-rw-r--r-- | apps/files/src/components/NavigationQuota.vue | 2 | ||||
-rw-r--r-- | apps/files/src/components/NewNodeDialog.vue | 13 | ||||
-rw-r--r-- | apps/files/src/components/VirtualList.vue | 17 |
13 files changed, 372 insertions, 79 deletions
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index 38d07c94d80..c7684d5c205 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -235,7 +235,7 @@ export default defineComponent({ justify-content: center; width: 100%; // Breadcrumbs height + row thead height - min-height: calc(58px + 55px); + min-height: calc(58px + 44px); margin: 0; user-select: none; color: var(--color-text-maxcontrast); diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue index 7c9c6f4f1a7..72fd98d43fb 100644 --- a/apps/files/src/components/DragAndDropPreview.vue +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -92,7 +92,7 @@ export default Vue.extend({ </script> <style lang="scss"> -$size: 32px; +$size: 28px; $stack-shift: 6px; .files-list-drag-image { @@ -102,24 +102,24 @@ $stack-shift: 6px; display: flex; overflow: hidden; align-items: center; - height: 44px; - padding: 6px 12px; + height: $size + $stack-shift; + padding: $stack-shift $stack-shift * 2; background: var(--color-main-background); &__icon, - .files-list__row-icon { + .files-list__row-icon-preview-container { display: flex; overflow: hidden; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: $size - $stack-shift; + height: $size - $stack-shift;; border-radius: var(--border-radius); } &__icon { overflow: visible; - margin-inline-end: 12px; + margin-inline-end: $stack-shift * 2; img { max-width: 100%; @@ -138,13 +138,15 @@ $stack-shift: 6px; display: flex; // Stack effect if more than one element - .files-list__row-icon + .files-list__row-icon { + // Max 3 elements + > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container { margin-top: $stack-shift; - margin-inline-start: $stack-shift - $size; - & + .files-list__row-icon { + margin-inline-start: $stack-shift * 2 - $size; + & + .files-list__row-icon-preview-container { margin-top: $stack-shift * 2; } } + // If we have manually clone the preview, // let's hide any fallback icons &:not(:empty) + * { diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue index 87684758b43..c66cb8fbd7f 100644 --- a/apps/files/src/components/FileEntry/FavoriteIcon.vue +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -56,8 +56,8 @@ export default defineComponent({ :deep() { svg { // We added a stroke for a11y so we must increase the size to include the stroke - width: 26px !important; - height: 26px !important; + width: 20px !important; + height: 20px !important; // Override NcIconSvgWrapper defaults of 20px max-width: unset !important; diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index c130ab49c0a..5c537d878fe 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -25,15 +25,16 @@ :open="openedMenu" @close="onMenuClose" @closed="onMenuClosed"> - <!-- Default actions list--> - <NcActionButton v-for="action, index in enabledMenuActions" + <!-- Non-destructive actions list --> + <!-- Please keep this block in sync with the destructive actions block below --> + <NcActionButton v-for="action, index in renderedNonDestructiveActions" :key="action.id" :ref="`action-${action.id}`" class="files-list__row-action" :class="{ [`files-list__row-action-${action.id}`]: true, 'files-list__row-action--inline': index < enabledInlineActions.length, - 'files-list__row-action--menu': isValidMenu(action) + 'files-list__row-action--menu': isValidMenu(action), }" :close-after-click="!isValidMenu(action)" :data-cy-files-list-row-action="action.id" @@ -50,6 +51,35 @@ {{ actionDisplayName(action) }} </NcActionButton> + <!-- Destructive actions list --> + <template v-if="renderedDestructiveActions.length > 0"> + <NcActionSeparator /> + <NcActionButton v-for="action, index in renderedDestructiveActions" + :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" + :class="{ + [`files-list__row-action-${action.id}`]: true, + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + 'files-list__row-action--destructive': true, + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-row-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + <!-- Submenu actions list--> <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> <!-- Back to top-level button --> @@ -68,10 +98,11 @@ class="files-list__row-action--submenu" close-after-click :data-cy-files-list-row-action="action.id" + :aria-label="action.title?.([source], currentView)" :title="action.title?.([source], currentView)" @click="onActionClick(action)"> <template #icon> - <NcLoadingIcon v-if="isLoadingAction(action)" :size="18" /> + <NcLoadingIcon v-if="isLoadingAction(action)" /> <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> </template> {{ actionDisplayName(action) }} @@ -211,6 +242,14 @@ export default defineComponent({ return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent))) }, + renderedNonDestructiveActions() { + return this.enabledMenuActions.filter(action => !action.destructive) + }, + + renderedDestructiveActions() { + return this.enabledMenuActions.filter(action => action.destructive) + }, + openedMenu: { get() { return this.opened @@ -281,7 +320,7 @@ export default defineComponent({ } // Make sure we set the node as active - this.activeStore.setActiveNode(this.source) + this.activeStore.activeNode = this.source // Execute the action await executeAction(action) @@ -349,5 +388,12 @@ main.app-content[style*="mouse-pos-x"] .v-popper__popper { max-height: var(--max-icon-size) !important; max-width: var(--max-icon-size) !important; } + + &.files-list__row-action--destructive { + ::deep(button) { + color: var(--color-error) !important; + } + } } + </style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue new file mode 100644 index 00000000000..938be171f6d --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue @@ -0,0 +1,47 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcButton v-show="isVisible" @click="onClick"> + {{ t('files', 'Search everywhere') }} + </NcButton> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import { getPinia } from '../../store/index.ts' +import { useSearchStore } from '../../store/search.ts' + +const isVisible = ref(false) + +defineExpose({ + hideButton, + showButton, +}) + +/** + * Hide the button - called by the filter class + */ +function hideButton() { + isVisible.value = false +} + +/** + * Show the button - called by the filter class + */ +function showButton() { + isVisible.value = true +} + +/** + * Button click handler to make the filtering a global search. + */ +function onClick() { + const searchStore = useSearchStore(getPinia()) + searchStore.scope = 'globally' +} +</script> diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index cc8dafe344e..31458398028 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -12,6 +12,10 @@ import type { Folder, Header, View } from '@nextcloud/files' import type { PropType } from 'vue' +import PQueue from 'p-queue' + +import logger from '../logger.ts' + /** * This component is used to render custom * elements provided by an API. Vue doesn't allow @@ -34,6 +38,14 @@ export default { required: true, }, }, + setup() { + // Create a queue to ensure that the header is only rendered once at a time + const queue = new PQueue({ concurrency: 1 }) + + return { + queue, + } + }, computed: { enabled() { return this.header.enabled?.(this.currentFolder, this.currentView) ?? true @@ -44,15 +56,45 @@ export default { if (!enabled) { return } - this.header.updated(this.currentFolder, this.currentView) + // If the header is enabled, we need to render it + logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header }) + this.queueUpdate(this.currentFolder, this.currentView) + }, + currentFolder(folder: Folder) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queueUpdate(folder, this.currentView) }, - currentFolder() { - this.header.updated(this.currentFolder, this.currentView) + currentView(view: View) { + this.queueUpdate(this.currentFolder, view) }, }, + mounted() { - console.debug('Mounted', this.header.id) - this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header }) + const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + this.queue.add(initialRender).then(() => { + logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header }) + }).catch((error) => { + logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + destroyed() { + logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header }) + }, + + methods: { + queueUpdate(currentFolder: Folder, currentView: View) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queue.add(() => this.header.updated(currentFolder, currentView)) + .then(() => { + logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header }) + }) + .catch((error) => { + logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, }, } </script> diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index 4d2f2f361e6..53b7e7ef21b 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -6,6 +6,7 @@ <div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions> <NcActions ref="actionsMenu" container="#app-content-vue" + :boundaries-element="boundariesElement" :disabled="!!loading || areSomeNodesLoading" :force-name="true" :inline="enabledInlineActions.length" @@ -123,6 +124,8 @@ export default defineComponent({ const fileListWidth = useFileListWidth() const { directory } = useRouteParameters() + const boundariesElement = document.getElementById('app-content-vue') + return { directory, fileListWidth, @@ -130,6 +133,8 @@ export default defineComponent({ actionsMenuStore, filesStore, selectionStore, + + boundariesElement, } }, diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 8fdc87b154c..fbf614caede 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -21,7 +21,9 @@ </template> <template v-if="!isNoneSelected" #header-overlay> - <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span> + <span class="files-list__selected"> + {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }} + </span> <FilesListTableHeaderActions :current-view="currentView" :selected-nodes="selectedNodes" /> </template> @@ -46,6 +48,11 @@ :nodes="nodes" /> </template> + <!-- Body replacement if no files are available --> + <template #empty> + <slot name="empty" /> + </template> + <!-- Tfoot--> <template #footer> <FilesListTableFooter :current-view="currentView" @@ -63,12 +70,11 @@ import type { UserConfig } from '../types' import type { Node as NcNode } from '@nextcloud/files' import type { ComponentPublicInstance, PropType } from 'vue' -import type { Location } from 'vue-router' import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { translate as t } from '@nextcloud/l10n' +import { n, t } from '@nextcloud/l10n' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' @@ -79,6 +85,7 @@ import { useFileListWidth } from '../composables/useFileListWidth.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useSelectionStore } from '../store/selection.js' import { useUserConfigStore } from '../store/userconfig.ts' +import logger from '../logger.ts' import FileEntry from './FileEntry.vue' import FileEntryGrid from './FileEntryGrid.vue' @@ -88,7 +95,6 @@ import FilesListTableFooter from './FilesListTableFooter.vue' import FilesListTableHeader from './FilesListTableHeader.vue' import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' import VirtualList from './VirtualList.vue' -import logger from '../logger.ts' export default defineComponent({ name: 'FilesListVirtual', @@ -140,6 +146,7 @@ export default defineComponent({ selectionStore, userConfigStore, + n, t, } }, @@ -149,7 +156,6 @@ export default defineComponent({ FileEntry, FileEntryGrid, scrollToIndex: 0, - openFileId: null as number|null, } }, @@ -214,39 +220,26 @@ export default defineComponent({ isNoneSelected() { return this.selectedNodes.length === 0 }, + + isEmpty() { + return this.nodes.length === 0 + }, }, watch: { - fileId: { - handler(fileId) { - this.scrollToFile(fileId, false) - }, - immediate: true, + // If nodes gets populated and we have a fileId, + // an openFile or openDetails, we fire the appropriate actions. + isEmpty() { + this.handleOpenQueries() }, - - openFile: { - handler(openFile) { - if (!openFile || !this.fileId) { - return - } - - this.handleOpenFile(this.fileId) - }, - immediate: true, + fileId() { + this.handleOpenQueries() }, - - openDetails: { - handler(openDetails) { - // wait for scrolling and updating the actions to settle - this.$nextTick(() => { - if (!openDetails || !this.fileId) { - return - } - - this.openSidebarForFile(this.fileId) - }) - }, - immediate: true, + openFile() { + this.handleOpenQueries() + }, + openDetails() { + this.handleOpenQueries() }, }, @@ -276,6 +269,33 @@ export default defineComponent({ }, methods: { + handleOpenQueries() { + // If the list is empty, or we don't have a fileId, + // there's nothing to be done. + if (this.isEmpty || !this.fileId) { + return + } + + logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', { + nodes: this.nodes, + fileId: this.fileId, + openFile: this.openFile, + openDetails: this.openDetails, + }) + + if (this.openFile) { + this.handleOpenFile(this.fileId) + } + + if (this.openDetails) { + this.openSidebarForFile(this.fileId) + } + + if (this.fileId) { + this.scrollToFile(this.fileId, false) + } + }, + openSidebarForFile(fileId) { // Open the sidebar for the given URL fileid // iif we just loaded the app. @@ -285,7 +305,7 @@ export default defineComponent({ sidebarAction.exec(node, this.currentView, this.currentFolder.path) return } - logger.error(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) + logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) }, scrollToFile(fileId: number|null, warn = true) { @@ -301,6 +321,7 @@ export default defineComponent({ } this.scrollToIndex = Math.max(0, index) + logger.debug('Scrolling to file ' + fileId, { fileId, index }) } }, @@ -312,7 +333,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.clearActiveNode() + this.activeStore.activeNode = undefined window.OCP.Files.Router.goToRoute( null, { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') }, @@ -365,15 +386,13 @@ export default defineComponent({ } // The file is either a folder or has no default action other than downloading // in this case we need to open the details instead and remove the route from the history - const query = this.$route.query - delete query.openfile - query.opendetails = '' - logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node }) - await this.$router.replace({ - ...(this.$route as Location), - query, - }) + window.OCP.Files.Router.goToRoute( + null, + this.$route.params, + { ...this.$route.query, openfile: undefined, opendetails: '' }, + true, // silent update of the URL + ) }, onDragOver(event: DragEvent) { @@ -446,7 +465,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.setActiveNode(node) + this.activeStore.activeNode = node // Silent update of the URL window.OCP.Files.Router.goToRoute( @@ -462,15 +481,17 @@ export default defineComponent({ <style scoped lang="scss"> .files-list { - --row-height: 55px; + --row-height: 44px; --cell-margin: 14px; --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2); --checkbox-size: 24px; --clickable-area: var(--default-clickable-area); - --icon-preview-size: 32px; + --icon-preview-size: 24px; --fixed-block-start-position: var(--default-clickable-area); + display: flex; + flex-direction: column; overflow: auto; height: 100%; will-change: scroll-position; @@ -518,6 +539,13 @@ export default defineComponent({ // Hide the table header below the overlay margin-block-start: calc(-1 * var(--row-height)); } + + // Visually hide the table when there are no files + &--hidden { + visibility: hidden; + z-index: -1; + opacity: 0; + } } .files-list__filters { @@ -549,6 +577,7 @@ export default defineComponent({ background-color: var(--color-main-background); border-block-end: 1px solid var(--color-border); height: var(--row-height); + flex: 0 0 var(--row-height); } .files-list__thead, @@ -567,6 +596,16 @@ export default defineComponent({ top: var(--fixed-block-start-position); } + // Empty content + .files-list__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + tr { position: relative; display: flex; @@ -736,8 +775,8 @@ export default defineComponent({ // File and folder overlay &-overlay { position: absolute; - max-height: calc(var(--icon-preview-size) * 0.5); - max-width: calc(var(--icon-preview-size) * 0.5); + max-height: calc(var(--icon-preview-size) * 0.6); + max-width: calc(var(--icon-preview-size) * 0.6); color: var(--color-primary-element-text); // better alignment with the folder icon margin-block-start: 2px; @@ -855,7 +894,7 @@ export default defineComponent({ } .files-list__row-mtime { - width: calc(var(--row-height) * 2); + width: calc(var(--row-height) * 2.5); } .files-list__row-mime { diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue index 372a83e1441..c29bc00c67f 100644 --- a/apps/files/src/components/FilesNavigationItem.vue +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -89,7 +89,7 @@ export default defineComponent({ return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) } - return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids + return this.filterVisible(this.views[this.parent.id] ?? []) }, style() { @@ -103,11 +103,15 @@ export default defineComponent({ }, methods: { + filterVisible(views: View[]) { + return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true) + }, + hasChildViews(view: View): boolean { if (this.level >= maxLevel) { return false } - return this.views[view.id]?.length > 0 + return this.filterVisible(this.views[view.id] ?? []).length > 0 }, /** diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..e34d4bf0971 --- /dev/null +++ b/apps/files/src/components/FilesNavigationSearch.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnify, mdiSearchWeb } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { computed } from 'vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useSearchStore } from '../store/search.ts' +import { VIEW_ID } from '../views/search.ts' + +const { currentView } = useNavigation(true) +const searchStore = useSearchStore() + +/** + * When the route is changed from search view to something different + * we need to clear the search box. + */ +onBeforeNavigation((to, from, next) => { + if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) { + // we are leaving the search view so unset the query + searchStore.query = '' + searchStore.scope = 'filter' + } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) { + // fix the query if the user refreshed the view + if (searchStore.query && !to.query.query) { + // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3) + return next({ + ...to, + query: { + ...to.query, + query: searchStore.query, + }, + }) + } + } + next() +}) + +/** + * Are we currently on the search view. + * Needed to disable the action menu (we cannot change the search mode there) + */ +const isSearchView = computed(() => currentView.value.id === VIEW_ID) + +/** + * Different searchbox label depending if filtering or searching + */ +const searchLabel = computed(() => { + if (searchStore.scope === 'globally') { + return t('files', 'Search globally by filename …') + } + return t('files', 'Search here by filename …') +}) +</script> + +<template> + <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel"> + <template #actions> + <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView"> + <template #icon> + <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" /> + </template> + <NcActionButton close-after-click @click="searchStore.scope = 'filter'"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + {{ t('files', 'Filter and search from this location') }} + </NcActionButton> + <NcActionButton close-after-click @click="searchStore.scope = 'globally'"> + <template #icon> + <NcIconSvgWrapper :path="mdiSearchWeb" /> + </template> + {{ t('files', 'Search globally') }} + </NcActionButton> + </NcActions> + </template> + </NcAppNavigationSearch> +</template> diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index f1d2738e81d..fd10af1c495 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -58,7 +58,7 @@ export default { computed: { storageStatsTitle() { const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false) - const quotaByte = formatFileSize(this.storageStats?.quota, false, false) + const quotaByte = formatFileSize(this.storageStats?.total, false, false) // If no quota set if (this.storageStats?.quota < 0) { diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue index 76555db1536..ca10935940d 100644 --- a/apps/files/src/components/NewNodeDialog.vue +++ b/apps/files/src/components/NewNodeDialog.vue @@ -26,6 +26,11 @@ :helper-text="validity" :label="label" :value.sync="localDefaultName" /> + + <!-- Hidden file warning --> + <NcNoteCard v-if="isHiddenFileName" + type="warning" + :text="t('files', 'Files starting with a dot are hidden by default')" /> </form> </NcDialog> </template> @@ -35,12 +40,13 @@ import type { ComponentPublicInstance, PropType } from 'vue' import { getUniqueName } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { extname } from 'path' -import { nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue' import { getFilenameValidity } from '../utils/filenameValidity.ts' import NcButton from '@nextcloud/vue/components/NcButton' import NcDialog from '@nextcloud/vue/components/NcDialog' import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' const props = defineProps({ /** @@ -89,6 +95,11 @@ const nameInput = ref<ComponentPublicInstance>() const formElement = ref<HTMLFormElement>() const validity = ref('') +const isHiddenFileName = computed(() => { + // Check if the name starts with a dot, which indicates a hidden file + return localDefaultName.value.trim().startsWith('.') +}) + /** * Focus the filename input field */ diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index 5ae8220d594..4746fedf863 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -20,7 +20,18 @@ <slot name="header-overlay" /> </div> - <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }"> + <div v-if="dataSources.length === 0" + class="files-list__empty"> + <slot name="empty" /> + </div> + + <table :aria-hidden="dataSources.length === 0" + :inert="dataSources.length === 0" + class="files-list__table" + :class="{ + 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'], + 'files-list__table--hidden': dataSources.length === 0, + }"> <!-- Accessibility table caption for screen readers --> <caption v-if="caption" class="hidden-visually"> {{ caption }} @@ -146,7 +157,7 @@ export default defineComponent({ itemHeight() { // Align with css in FilesListVirtual // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) - return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55 + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 }, // Grid mode only @@ -309,7 +320,7 @@ export default defineComponent({ methods: { scrollTo(index: number) { - if (!this.$el) { + if (!this.$el || this.index === index) { return } |