diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue deleted file mode 100644 index 879a95da0ba..00000000000 --- a/apps/files/src/components/FileListFilter/FileListFilter.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - {{ t('files', 'Clear filter') }} - - - - - - - - diff --git a/apps/files/src/components/FileListFilter/FileListFilterChips.vue b/apps/files/src/components/FileListFilter/FileListFilterChips.vue new file mode 100644 index 00000000000..5ddbbf9866e --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterChips.vue @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue index 2066a0912a9..e7d5b6cda13 100644 --- a/apps/files/src/components/FileListFilter/FileListFilterModified.vue +++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue @@ -3,100 +3,122 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> - - - - - + + alignment="start" + :pressed="preset === selectedOption" + variant="tertiary" + wide + @update:pressed="$event ? (selectedOption = preset) : onReset()"> {{ preset.label }} - - - + + + - + + diff --git a/apps/files/src/components/FileListFilter/FileListFilters.vue b/apps/files/src/components/FileListFilter/FileListFilters.vue new file mode 100644 index 00000000000..82b42a19f93 --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilters.vue @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + {{ filter.displayName }} + + + + + + + + + + + + + + + + + + + + + + + + + {{ t('files', 'Back to filters') }} + + + + + + + + + + + {{ filter.displayName }} + + + + + + + + + + diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue deleted file mode 100644 index 0fc6b29d140..00000000000 --- a/apps/files/src/components/FileListFilters.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 8f9d1d09012..f642836a2b7 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -17,9 +17,8 @@ }" :scroll-to-index="scrollToIndex" :caption="caption"> - - - + + @@ -81,7 +80,8 @@ import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { computed, defineComponent } from 'vue' import FileEntry from './FileEntry.vue' import FileEntryGrid from './FileEntryGrid.vue' -import FileListFilters from './FileListFilters.vue' +import FileListFilterChips from './FileListFilter/FileListFilterChips.vue' +import FileListFilterToSearch from './FileListFilter/FileListFilterToSearch.vue' import FilesListHeader from './FilesListHeader.vue' import FilesListTableFooter from './FilesListTableFooter.vue' import FilesListTableHeader from './FilesListTableHeader.vue' @@ -99,7 +99,8 @@ export default defineComponent({ name: 'FilesListVirtual', components: { - FileListFilters, + FileListFilterChips, + FileListFilterToSearch, FilesListHeader, FilesListTableFooter, FilesListTableHeader, @@ -511,15 +512,15 @@ export default defineComponent({ --clickable-area: var(--default-clickable-area); --icon-preview-size: 24px; - --fixed-block-start-position: var(--default-clickable-area); + --fixed-block-start-position: calc(var(--clickable-area-small) + var(--default-grid-baseline, 4px)); display: flex; flex-direction: column; overflow: auto; height: 100%; will-change: scroll-position; - &:has(.file-list-filters__active) { - --fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small)); + &:has(&__filters:empty) { + --fixed-block-start-position: 0px; } & :deep() { @@ -572,6 +573,10 @@ export default defineComponent({ } .files-list__filters { + display: flex; + gap: var(--default-grid-baseline); + box-sizing: border-box; + // Pinned on top when scrolling above table header position: sticky; top: 0; @@ -582,6 +587,10 @@ export default defineComponent({ padding-inline: var(--row-height) var(--default-grid-baseline, 4px); height: var(--fixed-block-start-position); width: 100%; + + &:not(:empty) { + padding-block: calc(var(--default-grid-baseline, 4px) / 2); + } } .files-list__thead-overlay { diff --git a/apps/files/src/filters/ModifiedFilter.ts b/apps/files/src/filters/ModifiedFilter.ts index e877ccc5be3..e78dacbd37d 100644 --- a/apps/files/src/filters/ModifiedFilter.ts +++ b/apps/files/src/filters/ModifiedFilter.ts @@ -2,11 +2,13 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileListFilterChip, INode } from '@nextcloud/files' -import calendarSvg from '@mdi/svg/svg/calendar.svg?raw' +import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files' + +import svgCalendarRangeOutline from '@mdi/svg/svg/calendar-range-outline.svg?raw' import { FileListFilter, registerFileListFilter } from '@nextcloud/files' import { t } from '@nextcloud/l10n' +import wrap from '@vue/web-component-wrapper' import Vue from 'vue' import FileListFilterModified from '../components/FileListFilter/FileListFilterModified.vue' @@ -16,63 +18,20 @@ export interface ITimePreset { filter: (time: number) => boolean } -const startOfToday = () => (new Date()).setHours(0, 0, 0, 0) +const tagName = 'files-file-list-filter-modified' -/** - * Available presets - */ -const timePresets: ITimePreset[] = [ - { - id: 'today', - label: t('files', 'Today'), - filter: (time: number) => time > startOfToday(), - }, - { - id: 'last-7', - label: t('files', 'Last 7 days'), - filter: (time: number) => time > (startOfToday() - (7 * 24 * 60 * 60 * 1000)), - }, - { - id: 'last-30', - label: t('files', 'Last 30 days'), - filter: (time: number) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)), - }, - { - id: 'this-year', - label: t('files', 'This year ({year})', { year: (new Date()).getFullYear() }), - filter: (time: number) => time > (new Date(startOfToday())).setMonth(0, 1), - }, - { - id: 'last-year', - label: t('files', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }), - filter: (time: number) => (time > (new Date(startOfToday())).setFullYear((new Date()).getFullYear() - 1, 0, 1)) && (time < (new Date(startOfToday())).setMonth(0, 1)), - }, -] as const - -class ModifiedFilter extends FileListFilter { +class ModifiedFilter extends FileListFilter implements IFileListFilterWithUi { private currentInstance?: Vue private currentPreset?: ITimePreset + public readonly displayName = t('files', 'Modified') + public readonly iconSvgInline = svgCalendarRangeOutline + public readonly tagName = tagName + constructor() { super('files:modified', 50) } - public mount(el: HTMLElement) { - if (this.currentInstance) { - this.currentInstance.$destroy() - } - - const View = Vue.extend(FileListFilterModified as never) - this.currentInstance = new View({ - propsData: { - timePresets, - }, - el, - }) - .$on('update:preset', this.setPreset.bind(this)) - .$mount() - } - public filter(nodes: INode[]): INode[] { if (!this.currentPreset) { return nodes @@ -82,7 +41,11 @@ class ModifiedFilter extends FileListFilter { } public reset(): void { - this.setPreset() + this.dispatchEvent(new CustomEvent('reset')) + } + + public get preset() { + return this.currentPreset } public setPreset(preset?: ITimePreset) { @@ -92,9 +55,9 @@ class ModifiedFilter extends FileListFilter { const chips: IFileListFilterChip[] = [] if (preset) { chips.push({ - icon: calendarSvg, + icon: svgCalendarRangeOutline, text: preset.label, - onclick: () => this.setPreset(), + onclick: () => this.reset(), }) } else { (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter() @@ -103,9 +66,26 @@ class ModifiedFilter extends FileListFilter { } } +export type { ModifiedFilter } + /** * Register the file list filter by modification date */ export function registerModifiedFilter() { + const WrappedComponent = wrap(Vue, FileListFilterModified) + // In Vue 2, wrap doesn't support disabling shadow :( + // Disable with a hack + Object.defineProperty(WrappedComponent.prototype, 'attachShadow', { + value() { + return this + }, + }) + Object.defineProperty(WrappedComponent.prototype, 'shadowRoot', { + get() { + return this + }, + }) + + customElements.define(tagName, WrappedComponent) registerFileListFilter(new ModifiedFilter()) } diff --git a/apps/files/src/filters/SearchFilter.ts b/apps/files/src/filters/SearchFilter.ts deleted file mode 100644 index e4891c5dfe7..00000000000 --- a/apps/files/src/filters/SearchFilter.ts +++ /dev/null @@ -1,47 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { INode } from '@nextcloud/files' -import type { ComponentPublicInstance } from 'vue' - -import { subscribe } from '@nextcloud/event-bus' -import { FileListFilter, registerFileListFilter } from '@nextcloud/files' -import Vue from 'vue' -import FileListFilterToSearch from '../components/FileListFilter/FileListFilterToSearch.vue' - -class SearchFilter extends FileListFilter { - private currentInstance?: ComponentPublicInstance - - constructor() { - super('files:filter-to-search', 999) - subscribe('files:search:updated', ({ query, scope }) => { - if (query && scope === 'filter') { - this.currentInstance?.showButton() - } else { - this.currentInstance?.hideButton() - } - }) - } - - public mount(el: HTMLElement) { - if (this.currentInstance) { - this.currentInstance.$destroy() - } - - const View = Vue.extend(FileListFilterToSearch) - this.currentInstance = new View().$mount(el) as unknown as ComponentPublicInstance - } - - public filter(nodes: INode[]): INode[] { - return nodes - } -} - -/** - * Register a file list filter to only show hidden files if enabled by user config - */ -export function registerFilterToSearchToggle() { - registerFileListFilter(new SearchFilter()) -} diff --git a/apps/files/src/filters/TypeFilter.ts b/apps/files/src/filters/TypeFilter.ts index e06b4d150a3..c258b023b9d 100644 --- a/apps/files/src/filters/TypeFilter.ts +++ b/apps/files/src/filters/TypeFilter.ts @@ -2,21 +2,16 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileListFilterChip, INode } from '@nextcloud/files' -// TODO: Create a modern replacement for OC.MimeType... -import svgDocument from '@mdi/svg/svg/file-document.svg?raw' -import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw' -import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw' -import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw' -import svgFolder from '@mdi/svg/svg/folder.svg?raw' -import svgImage from '@mdi/svg/svg/image.svg?raw' -import svgMovie from '@mdi/svg/svg/movie.svg?raw' -import svgAudio from '@mdi/svg/svg/music.svg?raw' +import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files' + +import svgFileOutline from '@mdi/svg/svg/file-outline.svg?raw' import { FileListFilter, registerFileListFilter } from '@nextcloud/files' import { t } from '@nextcloud/l10n' +import wrap from '@vue/web-component-wrapper' import Vue from 'vue' import FileListFilterType from '../components/FileListFilter/FileListFilterType.vue' +import logger from '../logger.ts' export interface ITypePreset { id: string @@ -25,106 +20,21 @@ export interface ITypePreset { mime: string[] } -/** - * - * @param svg - * @param color - */ -function colorize(svg: string, color: string) { - return svg.replace(' id !== presetId) + this.dispatchEvent(new CustomEvent('deselect', { detail: presetId })) this.setPresets(filtered) } } +export type { TypeFilter } + /** * Register the file list filter by file type */ export function registerTypeFilter() { + const WrappedComponent = wrap(Vue, FileListFilterType) + // In Vue 2, wrap doesn't support disabling shadow :( + // Disable with a hack + Object.defineProperty(WrappedComponent.prototype, 'attachShadow', { + value() { + return this + }, + }) + Object.defineProperty(WrappedComponent.prototype, 'shadowRoot', { + get() { + return this + }, + }) + + window.customElements.define(tagName, WrappedComponent) registerFileListFilter(new TypeFilter()) } diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 42876b5c6b2..243fbcf0cb0 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -21,7 +21,6 @@ import { action as viewInFolderAction } from './actions/viewInFolderAction.ts' import { registerFilenameFilter } from './filters/FilenameFilter.ts' import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts' import { registerModifiedFilter } from './filters/ModifiedFilter.ts' -import { registerFilterToSearchToggle } from './filters/SearchFilter.ts' import { registerTypeFilter } from './filters/TypeFilter.ts' import { entry as newFolderEntry } from './newMenu/newFolder.ts' import { registerTemplateEntries } from './newMenu/newFromTemplate.ts' @@ -68,7 +67,6 @@ registerHiddenFilesFilter() registerTypeFilter() registerModifiedFilter() registerFilenameFilter() -registerFilterToSearchToggle() // Register sidebar action registerSidebarFavoriteAction() diff --git a/apps/files/src/shims.d.ts b/apps/files/src/shims.d.ts new file mode 100644 index 00000000000..acd053e7af9 --- /dev/null +++ b/apps/files/src/shims.d.ts @@ -0,0 +1,9 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare module '*.svg?raw' { + const content: string + export default content +} diff --git a/apps/files/src/store/filters.ts b/apps/files/src/store/filters.ts index b3b9d270482..57bc2740d4c 100644 --- a/apps/files/src/store/filters.ts +++ b/apps/files/src/store/filters.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files' +import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip, IFileListFilterWithUi } from '@nextcloud/files' import { emit, subscribe } from '@nextcloud/event-bus' import { getFileListFilters } from '@nextcloud/files' @@ -16,8 +16,8 @@ import logger from '../logger.ts' * * @param value The filter to check */ -function isFileListFilterWithUi(value: IFileListFilter): value is Required { - return 'mount' in value +function isFileListFilterWithUi(value: IFileListFilter): value is IFileListFilterWithUi { + return 'tagName' in value } export const useFiltersStore = defineStore('filters', () => { @@ -37,7 +37,7 @@ export const useFiltersStore = defineStore('filters', () => { /** * All filters that provide a UI for visual controlling the filter state */ - const filtersWithUI = computed[]>(() => sortedFilters.value.filter(isFileListFilterWithUi)) + const filtersWithUI = computed(() => sortedFilters.value.filter(isFileListFilterWithUi)) /** * Register a new filter on the store. diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 5c720fe9a8a..94a381d82a6 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -63,6 +63,8 @@ + + - - - - - + - + + alignment="start" + :pressed="selectedAccounts.includes(account)" + variant="tertiary" + wide + @update:pressed="toggleAccount(account.id, $event)"> {{ account.displayName }} - - + + ({{ t('files', 'you') }}) + + + - diff --git a/apps/files_sharing/src/files_filters/AccountFilter.ts b/apps/files_sharing/src/files_filters/AccountFilter.ts index 01a48de4b83..c728c29f0e7 100644 --- a/apps/files_sharing/src/files_filters/AccountFilter.ts +++ b/apps/files_sharing/src/files_filters/AccountFilter.ts @@ -3,12 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileListFilterChip, INode } from '@nextcloud/files' +import type { IFileListFilterChip, IFileListFilterWithUi, INode } from '@nextcloud/files' +import svgAccountMultipleOutline from '@mdi/svg/svg/account-multiple-outline.svg?raw' import { subscribe } from '@nextcloud/event-bus' import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' import { ShareType } from '@nextcloud/sharing' import { isPublicShare } from '@nextcloud/sharing/public' +import wrap from '@vue/web-component-wrapper' import Vue from 'vue' import FileListFilterAccount from '../components/FileListFilterAccount.vue' @@ -21,48 +24,42 @@ export interface IAccountData { displayName: string } -type CurrentInstance = Vue & { - resetFilter: () => void - setAvailableAccounts: (accounts: IAccountData[]) => void - toggleAccount: (account: string) => void -} +const tagName = 'files_sharing-file-list-filter-account' /** * File list filter to filter by owner / sharee */ -class AccountFilter extends FileListFilter { - private availableAccounts: IAccountData[] - private currentInstance?: CurrentInstance - private filterAccounts?: IAccountData[] +class AccountFilter extends FileListFilter implements IFileListFilterWithUi { + #availableAccounts: IAccountData[] + #filterAccounts?: IAccountData[] + + public readonly displayName = t('files_sharing', 'People') + public readonly iconSvgInline = svgAccountMultipleOutline + public readonly tagName = tagName constructor() { super('files_sharing:account', 100) - this.availableAccounts = [] + this.#availableAccounts = [] subscribe('files:list:updated', ({ contents }) => { this.updateAvailableAccounts(contents) }) } - public mount(el: HTMLElement) { - if (this.currentInstance) { - this.currentInstance.$destroy() - } + public get availableAccounts() { + return this.#availableAccounts + } - const View = Vue.extend(FileListFilterAccount as never) - this.currentInstance = new View({ el }) - .$on('update:accounts', (accounts?: IAccountData[]) => this.setAccounts(accounts)) - .$mount() as CurrentInstance - this.currentInstance - .setAvailableAccounts(this.availableAccounts) + public get filterAccounts() { + return this.#filterAccounts } public filter(nodes: INode[]): INode[] { - if (!this.filterAccounts || this.filterAccounts.length === 0) { + if (!this.#filterAccounts || this.#filterAccounts.length === 0) { return nodes } - const userIds = this.filterAccounts.map(({ uid }) => uid) + const userIds = this.#filterAccounts.map(({ uid }) => uid) // Filter if the owner of the node is in the list of filtered accounts return nodes.filter((node) => { if (window.OCP.Files.Router.params.view === TRASHBIN_VIEW_ID) { @@ -95,7 +92,7 @@ class AccountFilter extends FileListFilter { } public reset(): void { - this.currentInstance?.resetFilter() + this.dispatchEvent(new CustomEvent('reset')) } /** @@ -104,13 +101,13 @@ class AccountFilter extends FileListFilter { * @param accounts - Account to filter or undefined if inactive. */ public setAccounts(accounts?: IAccountData[]) { - this.filterAccounts = accounts + this.#filterAccounts = accounts let chips: IFileListFilterChip[] = [] - if (this.filterAccounts && this.filterAccounts.length > 0) { - chips = this.filterAccounts.map(({ displayName, uid }) => ({ + if (this.#filterAccounts && this.#filterAccounts.length > 0) { + chips = this.#filterAccounts.map(({ displayName, uid }) => ({ text: displayName, user: uid, - onclick: () => this.currentInstance?.toggleAccount(uid), + onclick: () => this.dispatchEvent(new CustomEvent('deselect', { detail: uid })), })) } @@ -164,13 +161,13 @@ class AccountFilter extends FileListFilter { } } - this.availableAccounts = [...available.values()] - if (this.currentInstance) { - this.currentInstance.setAvailableAccounts(this.availableAccounts) - } + this.#availableAccounts = [...available.values()] + this.dispatchEvent(new CustomEvent('accounts-updated')) } } +export type { AccountFilter } + /** * Register the file list filter by owner or sharees */ @@ -180,5 +177,20 @@ export function registerAccountFilter() { return } + const WrappedComponent = wrap(Vue, FileListFilterAccount) + // In Vue 2, wrap doesn't support disabling shadow :( + // Disable with a hack + Object.defineProperty(WrappedComponent.prototype, 'attachShadow', { + value() { + return this + }, + }) + Object.defineProperty(WrappedComponent.prototype, 'shadowRoot', { + get() { + return this + }, + }) + + customElements.define(tagName, WrappedComponent) registerFileListFilter(new AccountFilter()) }