--- /dev/null
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcActions force-menu
+ :type="isActive ? 'secondary' : 'tertiary'"
+ :menu-name="filterName">
+ <template #icon>
+ <slot name="icon" />
+ </template>
+ <slot />
+
+ <template v-if="isActive">
+ <NcActionSeparator />
+ <NcActionButton class="files-list-filter__clear-button"
+ close-after-click
+ @click="$emit('reset-filter')">
+ {{ t('files', 'Clear filter') }}
+ </NcActionButton>
+ </template>
+ </NcActions>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
+
+defineProps<{
+ isActive: boolean
+ filterName: string
+}>()
+
+defineEmits<{
+ (event: 'reset-filter'): void
+}>()
+</script>
+
+<style scoped>
+.files-list-filter__clear-button :deep(.action-button__text) {
+ color: var(--color-error-text);
+}
+
+:deep(.button-vue) {
+ font-weight: normal !important;
+
+ * {
+ font-weight: normal !important;
+ }
+}
+</style>
--- /dev/null
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter :is-active="isActive"
+ :filter-name="t('files', 'Modified')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCalendarRange" />
+ </template>
+ <NcActionButton v-for="preset of timePresets"
+ :key="preset.id"
+ type="radio"
+ close-after-click
+ :model-value.sync="selectedOption"
+ :value="preset.id">
+ {{ preset.label }}
+ </NcActionButton>
+ <!-- TODO: Custom time range -->
+ </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITimePreset } from '../../filters/ModifiedFilter.ts'
+
+import { mdiCalendarRange } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+ components: {
+ FileListFilter,
+ NcActionButton,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ timePresets: {
+ type: Array as PropType<ITimePreset[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ // icons used in template
+ mdiCalendarRange,
+ }
+ },
+
+ data() {
+ return {
+ selectedOption: null as string | null,
+ timeRangeEnd: null as number | null,
+ timeRangeStart: null as number | null,
+ }
+ },
+
+ computed: {
+ /**
+ * Is the filter currently active
+ */
+ isActive() {
+ return this.selectedOption !== null
+ },
+
+ currentPreset() {
+ return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null
+ },
+ },
+
+ watch: {
+ selectedOption() {
+ if (this.selectedOption === null) {
+ this.$emit('update:preset')
+ } else {
+ const preset = this.currentPreset
+ this.$emit('update:preset', preset)
+ }
+ },
+ },
+
+ methods: {
+ t,
+
+ resetFilter() {
+ this.selectedOption = null
+ this.timeRangeEnd = null
+ this.timeRangeStart = null
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list-filter-time {
+ &__clear-button :deep(.action-button__text) {
+ color: var(--color-error-text);
+ }
+}
+</style>
--- /dev/null
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter class="file-list-filter-type"
+ :is-active="isActive"
+ :filter-name="t('files', 'Type')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiFile" />
+ </template>
+ <NcActionButton v-for="fileType of typePresets"
+ :key="fileType.id"
+ type="checkbox"
+ :model-value="selectedOptions.includes(fileType)"
+ @click="toggleOption(fileType)">
+ <template #icon>
+ <NcIconSvgWrapper :svg="fileType.icon" />
+ </template>
+ {{ fileType.label }}
+ </NcActionButton>
+ </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITypePreset } from '../../filters/TypeFilter.ts'
+
+import { mdiFile } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+ name: 'FileListFilterType',
+
+ components: {
+ FileListFilter,
+ NcActionButton,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ typePresets: {
+ type: Array as PropType<ITypePreset[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ mdiFile,
+ t,
+ }
+ },
+
+ data() {
+ return {
+ selectedOptions: [] as ITypePreset[],
+ }
+ },
+
+ computed: {
+ isActive() {
+ return this.selectedOptions.length > 0
+ },
+ },
+
+ watch: {
+ selectedOptions(newValue, oldValue) {
+ if (this.selectedOptions.length === 0) {
+ if (oldValue.length !== 0) {
+ this.$emit('update:preset')
+ }
+ } else {
+ this.$emit('update:preset', this.selectedOptions)
+ }
+ },
+ },
+
+ methods: {
+ resetFilter() {
+ this.selectedOptions = []
+ },
+
+ /**
+ * Toggle option from selected option
+ * @param option The option to toggle
+ */
+ toggleOption(option: ITypePreset) {
+ const idx = this.selectedOptions.indexOf(option)
+ if (idx !== -1) {
+ this.selectedOptions.splice(idx, 1)
+ } else {
+ this.selectedOptions.push(option)
+ }
+ },
+ },
+})
+</script>
+
+<style>
+.file-list-filter-type {
+ max-width: 220px;
+}
+</style>
--- /dev/null
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="file-list-filters">
+ <div class="file-list-filters__filter">
+ <span v-for="filter of visualFilters"
+ :key="filter.id"
+ ref="filterElements" />
+ </div>
+ <ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')">
+ <li v-for="(chip, index) of activeChips" :key="index">
+ <NcChip :icon-svg="chip.icon"
+ :text="chip.text"
+ @close="chip.onclick" />
+ </li>
+ </ul>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { computed, ref, watchEffect } from 'vue'
+import { useFiltersStore } from '../store/filters.ts'
+
+import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'
+
+const filterStore = useFiltersStore()
+const visualFilters = computed(() => filterStore.filtersWithUI)
+const activeChips = computed(() => filterStore.activeChips)
+
+const filterElements = ref<HTMLElement[]>([])
+watchEffect(() => {
+ filterElements.value
+ .forEach((el, index) => visualFilters.value[index].mount(el))
+})
+</script>
+
+<style scoped lang="scss">
+.file-list-filters {
+ display: flex;
+ flex-direction: column;
+ gap: var(--default-grid-baseline);
+ height: 100%;
+ width: 100%;
+
+ &__filter {
+ display: flex;
+ align-items: start;
+ justify-content: start;
+ gap: calc(var(--default-grid-baseline, 4px) * 2);
+
+ > * {
+ flex: 0 1 fit-content;
+ }
+ }
+
+ &__active {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--default-grid-baseline, 4px) * 2);
+ }
+}
+</style>
+++ /dev/null
-<template>
- <Fragment>
- </Fragment>
-</template>
-
-<script lang="ts">
-import type { View } from '@nextcloud/files'
-import type { PropType } from 'vue'
-
-import { Fragment } from 'vue-frag'
-import { defineComponent } from 'vue'
-
-export default defineComponent({
-
- props: {
- currentView: {
- type: Object as PropType<View>,
- required: true,
- },
- },
-
- watch: {
- currentView() {
- // Reset all filters on view change
- const components = this.$refs.filters as { resetFilter: () => void }[] | undefined
- components?.forEach((component) => component.resetFilter())
- },
- },
-})
-</script>
:scroll-to-index="scrollToIndex"
:caption="caption">
<template #filters>
- <FilesListFilters :current-view="currentView" />
+ <FileListFilters />
</template>
<template v-if="!isNoneSelected" #header-overlay>
import VirtualList from './VirtualList.vue'
import logger from '../logger.js'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
-import FilesListFilters from './FilesListFilter/FilesListFilters.vue'
+import FileListFilters from './FileListFilters.vue'
export default defineComponent({
name: 'FilesListVirtual',
components: {
- FilesListFilters,
+ FileListFilters,
FilesListHeader,
FilesListTableFooter,
FilesListTableHeader,
--clickable-area: var(--default-clickable-area);
--icon-preview-size: 32px;
+ --fixed-top-position: var(--default-clickable-area);
+
overflow: auto;
height: 100%;
will-change: scroll-position;
+ &:has(.file-list-filters__active) {
+ --fixed-top-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small));
+ }
+
& :deep() {
// Table head, body and footer
tbody {
}
.files-list__filters {
- display: flex;
- align-items: baseline;
- justify-content: start;
- gap: calc(var(--default-grid-baseline, 4px) * 2);
// Pinned on top when scrolling above table header
position: sticky;
top: 0;
// fix size and background
background-color: var(--color-main-background);
padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
- height: var(--row-height);
+ height: var(--fixed-top-position);
width: 100%;
-
- > * {
- flex: 0 1 fit-content;
- }
}
.files-list__thead-overlay {
// Pinned on top when scrolling
position: sticky;
- top: var(--row-height);
+ top: var(--fixed-top-position);
// Save space for a row checkbox
margin-left: var(--row-height);
// More than .files-list__thead
// Pinned on top when scrolling
position: sticky;
z-index: 10;
- top: var(--row-height);
+ top: var(--fixed-top-position);
}
// Table footer
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { registerFileListFilter, unregisterFileListFilter } from '@nextcloud/files'
+import { watchThrottled } from '@vueuse/core'
+import { onMounted, onUnmounted, ref } from 'vue'
+import { FilenameFilter } from '../filters/FilenameFilter'
+
+/**
+ * This is for the `Navigation` component to provide a filename filter
+ */
+export function useFilenameFilter() {
+ const searchQuery = ref('')
+ const filenameFilter = new FilenameFilter()
+
+ /**
+ * Updating the search query ref from the filter
+ * @param event The update:query event
+ */
+ function updateQuery(event: CustomEvent) {
+ if (event.type === 'update:query') {
+ searchQuery.value = event.detail
+ event.stopPropagation()
+ }
+ }
+
+ onMounted(() => {
+ filenameFilter.addEventListener('update:query', updateQuery)
+ registerFileListFilter(filenameFilter)
+ })
+ onUnmounted(() => {
+ filenameFilter.removeEventListener('update:query', updateQuery)
+ unregisterFileListFilter(filenameFilter.id)
+ })
+
+ // Update the query on the filter, but throttle to max. every 800ms
+ // This will debounce the filter refresh
+ watchThrottled(searchQuery, () => {
+ filenameFilter.updateQuery(searchQuery.value)
+ }, { throttle: 800 })
+
+ return {
+ searchQuery,
+ }
+}
+++ /dev/null
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import type { FilesFilter } from '../types'
-
-import { inject } from 'vue'
-
-/**
- * Provide functions for adding and deleting filters injected by the files list
- */
-export default function() {
- const addFilter = inject<(filter: FilesFilter) => void>('files:filter:add', () => {})
- const deleteFilter = inject<(id: string) => void>('files:filter:delete', () => {})
-
- return {
- addFilter,
- deleteFilter,
- }
-}
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { Node } from '@nextcloud/files'
+import type { IFileListFilter, Node } from '@nextcloud/files'
declare module '@nextcloud/event-bus' {
export interface NextcloudEvents {
// mapping of 'event name' => 'event type'
+ 'files:config:updated': { key: string, value: unknown }
+
'files:favorites:removed': Node
'files:favorites:added': Node
'files:node:renamed': Node
+
+ 'files:filter:added': IFileListFilter
+ 'files:filter:removed': string
}
}
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter } from '@nextcloud/files'
+
+/**
+ * Simple file list filter controlled by the Navigation search box
+ */
+export class FilenameFilter extends FileListFilter {
+
+ private searchQuery = ''
+
+ constructor() {
+ super('files:filename', 5)
+ subscribe('files:navigation:changed', () => this.updateQuery(''))
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ const queryParts = this.searchQuery.toLocaleLowerCase().split(' ').filter(Boolean)
+ return nodes.filter((node) => {
+ const displayname = node.displayname.toLocaleLowerCase()
+ return queryParts.every((part) => displayname.includes(part))
+ })
+ }
+
+ public updateQuery(query: string) {
+ query = (query || '').trim()
+
+ // Only if the query is different we update the filter to prevent re-computing all nodes
+ if (query !== this.searchQuery) {
+ this.searchQuery = query
+ this.filterUpdated()
+
+ const chips: IFileListFilterChip[] = []
+ if (query !== '') {
+ chips.push({
+ text: query,
+ onclick: () => {
+ this.updateQuery('')
+ },
+ })
+ }
+ this.updateChips(chips)
+ // Emit the new query as it might have come not from the Navigation
+ this.dispatchTypedEvent('update:query', new CustomEvent('update:query', { detail: query }))
+ }
+ }
+
+}
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { UserConfig } from '../types'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+
+class HiddenFilesFilter extends FileListFilter {
+
+ private showHidden?: boolean
+
+ constructor() {
+ super('files:hidden', 0)
+ this.showHidden = loadState<UserConfig>('files', 'config', { show_hidden: false }).show_hidden
+
+ subscribe('files:config:updated', ({ key, value }) => {
+ if (key === 'show_hidden') {
+ this.showHidden = Boolean(value)
+ this.filterUpdated()
+ }
+ })
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ if (this.showHidden) {
+ return nodes
+ }
+ return nodes.filter((node) => (node.attributes.hidden !== true && !node.basename.startsWith('.')))
+ }
+
+}
+
+/**
+ * Register a file list filter to only show hidden files if enabled by user config
+ */
+export function registerHiddenFilesFilter() {
+ registerFileListFilter(new HiddenFilesFilter())
+}
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+import FileListFilterModified from '../components/FileListFilter/FileListFilterModified.vue'
+
+import calendarSvg from '@mdi/svg/svg/calendar.svg?raw'
+
+export interface ITimePreset {
+ id: string,
+ label: string,
+ filter: (time: number) => boolean
+}
+
+const startOfToday = () => (new Date()).setHours(0, 0, 0, 0)
+
+/**
+ * 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 {
+
+ private currentInstance?: Vue
+ private currentPreset?: ITimePreset
+
+ constructor() {
+ super('files:modified', 50)
+ subscribe('files:navigation:changed', () => this.setPreset())
+ }
+
+ 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
+ }
+
+ return nodes.filter((node) => node.mtime === undefined || this.currentPreset!.filter(node.mtime.getTime()))
+ }
+
+ public setPreset(preset?: ITimePreset) {
+ this.currentPreset = preset
+ this.filterUpdated()
+
+ const chips: IFileListFilterChip[] = []
+ if (preset) {
+ chips.push({
+ icon: calendarSvg,
+ text: preset.label,
+ onclick: () => this.setPreset(),
+ })
+ } else {
+ (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter()
+ }
+ this.updateChips(chips)
+ }
+
+}
+
+/**
+ * Register the file list filter by modification date
+ */
+export function registerModifiedFilter() {
+ registerFileListFilter(new ModifiedFilter())
+}
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+import FileListFilterType from '../components/FileListFilter/FileListFilterType.vue'
+
+// TODO: Create a modern replacement for OC.MimeType...
+import svgDocument from '@mdi/svg/svg/file-document.svg?raw'
+import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw'
+import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw'
+import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw'
+import svgFolder from '@mdi/svg/svg/folder.svg?raw'
+import svgAudio from '@mdi/svg/svg/music.svg?raw'
+import svgImage from '@mdi/svg/svg/image.svg?raw'
+import svgMovie from '@mdi/svg/svg/movie.svg?raw'
+
+export interface ITypePreset {
+ id: string
+ label: string
+ icon: string
+ mime: string[]
+}
+
+const colorize = (svg: string, color: string) => {
+ return svg.replace('<path ', `<path fill="${color}" `)
+}
+
+/**
+ * Available presets
+ */
+const getTypePresets = async () => [
+ {
+ id: 'document',
+ label: t('files', 'Documents'),
+ icon: colorize(svgDocument, '#49abea'),
+ mime: ['x-office/document'],
+ },
+ {
+ id: 'spreadsheet',
+ label: t('files', 'Spreadsheets'),
+ icon: colorize(svgSpreadsheet, '#9abd4e'),
+ mime: ['x-office/spreadsheet'],
+ },
+ {
+ id: 'presentation',
+ label: t('files', 'Presentations'),
+ icon: colorize(svgPresentation, '#f0965f'),
+ mime: ['x-office/presentation'],
+ },
+ {
+ id: 'pdf',
+ label: t('files', 'PDFs'),
+ icon: colorize(svgPDF, '#dc5047'),
+ mime: ['application/pdf'],
+ },
+ {
+ id: 'folder',
+ label: t('files', 'Folders'),
+ icon: colorize(svgFolder, window.getComputedStyle(document.body).getPropertyValue('--color-primary-element')),
+ mime: ['httpd/unix-directory'],
+ },
+ {
+ id: 'audio',
+ label: t('files', 'Audio'),
+ icon: svgAudio,
+ mime: ['audio'],
+ },
+ {
+ id: 'image',
+ label: t('files', 'Pictures and images'),
+ icon: svgImage,
+ mime: ['image'],
+ },
+ {
+ id: 'video',
+ label: t('files', 'Videos'),
+ icon: svgMovie,
+ mime: ['video'],
+ },
+] as ITypePreset[]
+
+class TypeFilter extends FileListFilter {
+
+ private currentInstance?: Vue
+ private currentPresets?: ITypePreset[]
+ private allPresets?: ITypePreset[]
+
+ constructor() {
+ super('files:type', 10)
+ subscribe('files:navigation:changed', () => this.setPreset())
+ }
+
+ public async mount(el: HTMLElement) {
+ // We need to defer this as on init script this is not available:
+ if (this.allPresets === undefined) {
+ this.allPresets = await getTypePresets()
+ }
+
+ if (this.currentInstance) {
+ this.currentInstance.$destroy()
+ }
+
+ const View = Vue.extend(FileListFilterType as never)
+ this.currentInstance = new View({
+ propsData: {
+ typePresets: this.allPresets!,
+ },
+ el,
+ })
+ .$on('update:preset', this.setPreset.bind(this))
+ .$mount()
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ if (!this.currentPresets || this.currentPresets.length === 0) {
+ return nodes
+ }
+
+ const mimeList = this.currentPresets.reduce((previous: string[], current) => [...previous, ...current.mime], [] as string[])
+ return nodes.filter((node) => {
+ if (!node.mime) {
+ return false
+ }
+ const mime = node.mime.toLowerCase()
+
+ if (mimeList.includes(mime)) {
+ return true
+ } else if (mimeList.includes(window.OC.MimeTypeList.aliases[mime])) {
+ return true
+ } else if (mimeList.includes(mime.split('/')[0])) {
+ return true
+ }
+ return false
+ })
+ }
+
+ public setPreset(presets?: ITypePreset[]) {
+ this.currentPresets = presets
+ this.filterUpdated()
+
+ const chips: IFileListFilterChip[] = []
+ if (presets && presets.length > 0) {
+ for (const preset of presets) {
+ chips.push({
+ icon: preset.icon,
+ text: preset.label,
+ onclick: () => this.setPreset(presets.filter(({ id }) => id !== preset.id)),
+ })
+ }
+ } else {
+ (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter()
+ }
+ this.updateChips(chips)
+ }
+
+}
+
+/**
+ * Register the file list filter by file type
+ */
+export function registerTypeFilter() {
+ registerFileListFilter(new TypeFilter())
+}
import { action as renameAction } from './actions/renameAction'
import { action as sidebarAction } from './actions/sidebarAction'
import { action as viewInFolderAction } from './actions/viewInFolderAction'
+
+import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts'
+import { registerTypeFilter } from './filters/TypeFilter.ts'
+import { registerModifiedFilter } from './filters/ModifiedFilter.ts'
+
import { entry as newFolderEntry } from './newMenu/newFolder.ts'
import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts'
import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
registerRecentView()
registerPersonalFilesView()
+// Register file list filters
+registerHiddenFilesFilter()
+registerTypeFilter()
+registerModifiedFilter()
+
// Register preview service worker
registerPreviewServiceWorker()
Vue.use(PiniaVuePlugin)
// Init Navigation Service
-// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a oberserver
+// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a observer
const Navigation = Vue.observable(getNavigation())
Vue.prototype.$navigation = Navigation
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { getFileListFilters } from '@nextcloud/files'
+import { defineStore } from 'pinia'
+import logger from '../logger'
+
+export const useFiltersStore = defineStore('keyboard', {
+ state: () => ({
+ chips: {} as Record<string, IFileListFilterChip[]>,
+ filters: [] as IFileListFilter[],
+ filtersChanged: false,
+ }),
+
+ getters: {
+ /**
+ * Currently active filter chips
+ * @param state Internal state
+ */
+ activeChips(state): IFileListFilterChip[] {
+ return Object.values(state.chips).flat()
+ },
+
+ /**
+ * Filters sorted by order
+ * @param state Internal state
+ */
+ sortedFilters(state): IFileListFilter[] {
+ return state.filters.sort((a, b) => a.order - b.order)
+ },
+
+ /**
+ * All filters that provide a UI for visual controlling the filter state
+ */
+ filtersWithUI(): Required<IFileListFilter>[] {
+ return this.sortedFilters.filter((filter) => 'mount' in filter) as Required<IFileListFilter>[]
+ },
+ },
+
+ actions: {
+ addFilter(filter: IFileListFilter) {
+ filter.addEventListener('update:chips', this.onFilterUpdateChips)
+ filter.addEventListener('update:filter', this.onFilterUpdate)
+ this.filters.push(filter)
+ logger.debug('New file list filter registered', { id: filter.id })
+ },
+
+ removeFilter(filterId: string) {
+ const index = this.filters.findIndex(({ id }) => id === filterId)
+ if (index > -1) {
+ const [filter] = this.filters.splice(index, 1)
+ filter.removeEventListener('update:chips', this.onFilterUpdateChips)
+ filter.removeEventListener('update:filter', this.onFilterUpdate)
+ logger.debug('Files list filter unregistered', { id: filterId })
+ }
+ },
+
+ onFilterUpdate() {
+ this.filtersChanged = true
+ },
+
+ onFilterUpdateChips(event: FilterUpdateChipsEvent) {
+ const id = (event.target as IFileListFilter).id
+ this.chips = { ...this.chips, [id]: [...event.detail] }
+ },
+
+ init() {
+ subscribe('files:filter:added', this.addFilter)
+ subscribe('files:filter:removed', this.removeFilter)
+ for (const filter of getFileListFilters()) {
+ this.addFilter(filter)
+ }
+ },
+ },
+})
ratio?: number
templates?: Record<string, unknown>[]
}
-
-export interface FilesFilter {
- /**
- * ID of the filter
- */
- id: string
-
- /**
- * Filter function callback
- * @param node Node to check
- * @return True if keep the file false to remove from list
- */
- filter: (node: Node) => boolean
-}
</template>
<script lang="ts">
-import type { ContentsWithRoot } from '@nextcloud/files'
+import type { ContentsWithRoot, INode } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'
import type { CancelablePromise } from 'cancelable-promise'
import type { ComponentPublicInstance } from 'vue'
import type { Route } from 'vue-router'
-import type { FilesFilter, UserConfig } from '../types.ts'
+import type { UserConfig } from '../types.ts'
import { getCapabilities } from '@nextcloud/capabilities'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { useNavigation } from '../composables/useNavigation.ts'
import { useFilesStore } from '../store/files.ts'
+import { useFiltersStore } from '../store/filters.ts'
import { usePathsStore } from '../store/paths.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
filesSortingMixin,
],
- provide() {
- return {
- 'files:filter:add': (filter: FilesFilter) => {
- this.$set(this.filters, filter.id, filter)
- logger.debug('File list filter updated', { filters: [...Object.keys(this.filters)] })
- },
-
- 'files:filter:delete': (id: string) => {
- this.$delete(this.filters, id)
- logger.debug('File list filter removed', { filter: id, filters: [...Object.keys(this.filters)] })
- },
- }
- },
-
setup() {
const filesStore = useFilesStore()
+ const filtersStore = useFiltersStore()
const pathsStore = usePathsStore()
const selectionStore = useSelectionStore()
const uploaderStore = useUploaderStore()
t,
filesStore,
+ filtersStore,
pathsStore,
selectionStore,
uploaderStore,
data() {
return {
- filters: {} as Record<string, FilesFilter>, // TODO: With Vue3 use Map
loading: true,
promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,
+ dirContentsFiltered: [] as INode[],
+
unsubscribeStoreCallback: () => {},
}
},
return (this.currentFolder?._children || [])
.map(this.getNode)
.filter((node: Node) => !!node)
- .filter(this.filterHidden)
- },
-
- dirContentsFiltered(): Node[] {
- const filters = [...Object.values(this.filters)]
- return this.dirContents
- .filter((node: Node) => filters.every((filter: FilesFilter) => filter.filter(node)))
},
/**
return isSharingEnabled
&& this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
},
+
+ filtersChanged() {
+ return this.filtersStore.filtersChanged
+ },
},
watch: {
dirContents(contents) {
logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents })
emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents })
+ // Also refresh the filtered content
+ this.filterDirContent()
+ },
+
+ filtersChanged() {
+ if (this.filtersChanged) {
+ this.filterDirContent()
+ this.filtersStore.filtersChanged = false
+ }
},
},
mounted() {
+ this.filtersStore.init()
this.fetchContent()
subscribe('files:node:deleted', this.onNodeDeleted)
return this.filesStore.getNode(fileId)
},
- /**
- * Whether the node should be shown
- * @param node The node to check
- */
- filterHidden(node: Node) {
- const showHidden = this.userConfigStore?.userConfig.show_hidden
- return showHidden || (node.attributes.hidden !== true && !node.basename.startsWith('.'))
- },
-
/**
* Handle the node deleted event to reset open file
* @param node The deleted node
}
sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path)
},
+
toggleGridView() {
this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
},
+
+ filterDirContent() {
+ let nodes = this.dirContents
+ for (const filter of this.filtersStore.sortedFilters) {
+ nodes = filter.filter(nodes)
+ }
+ this.dirContentsFiltered = nodes
+ },
},
})
</script>
<template>
<NcAppNavigation data-cy-files-navigation
:aria-label="t('files', 'Files')">
+ <template #search>
+ <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter filenames…')" />
+ </template>
<template #list>
<NcAppNavigationItem v-for="view in parentViews"
:key="view.id"
import type { View } from '@nextcloud/files'
import { emit } from '@nextcloud/event-bus'
-import { translate as t } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import IconCog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
+import NcAppNavigationSearch from '@nextcloud/vue/dist/Components/NcAppNavigationSearch.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
import { useNavigation } from '../composables/useNavigation'
+import { useFilenameFilter } from '../composables/useFilenameFilter'
+import { useFiltersStore } from '../store/filters.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.js'
NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
+ NcAppNavigationSearch,
NcIconSvgWrapper,
SettingsModal,
},
setup() {
+ const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
const { currentView, views } = useNavigation()
+ const { searchQuery } = useFilenameFilter()
return {
currentView,
+ searchQuery,
+ t,
views,
+ filtersStore,
viewConfigStore,
}
},
},
methods: {
- t,
-
/**
* Only use exact route matching on routes with child views
* Because if a view does not have children (like the files view) then multiple routes might be matched for it
*/
supportsLocalSearch() {
// TODO: Make this an API
- const providerPaths = ['/settings/users', '/apps/files', '/apps/deck', '/settings/apps']
+ const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps']
return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
},
},
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/dialogs": "^5.3.5",
"@nextcloud/event-bus": "^3.3.1",
- "@nextcloud/files": "^3.6.0",
+ "@nextcloud/files": "^3.7.0",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/router": "^3.0.0",
"@nextcloud/sharing": "^0.2.2",
"@nextcloud/upload": "^1.4.1",
- "@nextcloud/vue": "^8.14.0",
+ "@nextcloud/vue": "^8.15.0",
"@simplewebauthn/browser": "^10.0.0",
"@skjnldsv/sanitize-svg": "^1.0.2",
"@vueuse/components": "^10.11.0",
}
},
"node_modules/@nextcloud/files": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.6.0.tgz",
- "integrity": "sha512-/3kzEJ1TsCgjkSVhjdI+FnF0c2rvYtiTAQPoNqkNQYFa7Vbor+XPuypBQIJZFMDMzEgUexAL4QuQT3YmeSfBAA==",
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.7.0.tgz",
+ "integrity": "sha512-u7Hwt7/13empViLvwHPQk1AnKjhDYf7tkXeCLaO6e03am2uqBlYwc3iUS4cZye5CuaEeJeW251jPUGTtRXjjWQ==",
"dependencies": {
"@nextcloud/auth": "^2.3.0",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",
- "@nextcloud/paths": "^2.1.0",
+ "@nextcloud/paths": "^2.2.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/sharing": "^0.2.2",
"cancelable-promise": "^4.3.1",
"is-svg": "^5.0.1",
+ "typedoc-plugin-missing-exports": "^3.0.0",
"typescript-event-target": "^1.1.1",
"webdav": "^5.6.0"
},
}
},
"node_modules/@nextcloud/paths": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-2.1.0.tgz",
- "integrity": "sha512-8wX0gqwez0bTuAS8A0OEiqbbp0ZsqLr07zSErmS6OYhh9KZcSt/kO6lQV5tnrFqIqJVsxwz4kHUjtZXh6DSf9Q==",
- "dependencies": {
- "core-js": "^3.6.4"
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-2.2.0.tgz",
+ "integrity": "sha512-G2KZmDgI3Lt3zFWz2EZ9opVda7nfQN7TU4LaplMRSSJEd8nILX5lYc8fl01oDf3DiUKAtLwXHuPdJhVuHoN+Tg==",
+ "engines": {
+ "node": "^20.0.0",
+ "npm": "^9.0.0 || ^10.0.0"
}
},
"node_modules/@nextcloud/router": {
}
},
"node_modules/@nextcloud/vue": {
- "version": "8.14.0",
- "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.14.0.tgz",
- "integrity": "sha512-hB3dG7tZWpItC74PfbTLW02754qYXFDH+h7Ksq6b7e8WlhnKLWrhNGKhSpNDt9/g+vb5bSIOxbiDZIJZ63hAuQ==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.15.0.tgz",
+ "integrity": "sha512-Yxf7bIzKV3vCDJDZo99dSLpfe9wMh0hTvmlov5B8V+ZX/foq+O/EcvPivbJmesjIi6LKg+z4K53d7tU2izAPSg==",
"dependencies": {
"@floating-ui/dom": "^1.1.0",
"@linusborg/vue-simple-portal": "^0.1.5",
"@nextcloud/l10n": "^3.0.1",
"@nextcloud/logger": "^3.0.1",
"@nextcloud/router": "^3.0.0",
+ "@nextcloud/sharing": "^0.2.2",
"@nextcloud/timezones": "^0.1.1",
"@nextcloud/vue-select": "^3.25.0",
"@vueuse/components": "^10.9.0",
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
"dev": true
},
+ "node_modules/@shikijs/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-Qsn8h15SWgv5TDRoDmiHNzdQO2BxDe86Yq6vIHf5T0cCvmfmccJKIzHtep8bQO9HMBZYCtCBzaXdd1MnxZBPSg==",
+ "peer": true,
+ "dependencies": {
+ "@types/hast": "^3.0.4"
+ }
+ },
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
"yallist": "^3.0.2"
}
},
+ "node_modules/lunr": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
+ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
+ "peer": true
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="
},
"node_modules/minimatch": {
- "version": "9.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
- "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
- "dev": true,
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"node": ">=6"
}
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/puppeteer": {
"version": "22.13.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.13.0.tgz",
"node": ">=4"
}
},
+ "node_modules/shiki": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.11.1.tgz",
+ "integrity": "sha512-VHD3Q0EBXaaa245jqayBe5zQyMQUdXBFjmGr9MpDaDpAKRMYn7Ff00DM5MLk26UyKjnml3yQ0O2HNX7PtYVNFQ==",
+ "peer": true,
+ "dependencies": {
+ "@shikijs/core": "1.11.1",
+ "@types/hast": "^3.0.4"
+ }
+ },
"node_modules/side-channel": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typedoc": {
+ "version": "0.26.5",
+ "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.5.tgz",
+ "integrity": "sha512-Vn9YKdjKtDZqSk+by7beZ+xzkkr8T8CYoiasqyt4TTRFy5+UHzL/mF/o4wGBjRF+rlWQHDb0t6xCpA3JNL5phg==",
+ "peer": true,
+ "dependencies": {
+ "lunr": "^2.3.9",
+ "markdown-it": "^14.1.0",
+ "minimatch": "^9.0.5",
+ "shiki": "^1.9.1",
+ "yaml": "^2.4.5"
+ },
+ "bin": {
+ "typedoc": "bin/typedoc"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x"
+ }
+ },
+ "node_modules/typedoc-plugin-missing-exports": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/typedoc-plugin-missing-exports/-/typedoc-plugin-missing-exports-3.0.0.tgz",
+ "integrity": "sha512-R7D8fYrK34mBFZSlF1EqJxfqiUSlQSmyrCiQgTQD52nNm6+kUtqwiaqaNkuJ2rA2wBgWFecUA8JzHT7x2r7ePg==",
+ "peerDependencies": {
+ "typedoc": "0.26.x"
+ }
+ },
+ "node_modules/typedoc/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "peer": true
+ },
+ "node_modules/typedoc/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "peer": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/typedoc/node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "peer": true,
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/typedoc/node_modules/markdown-it": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+ "peer": true,
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/typedoc/node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "peer": true
+ },
+ "node_modules/typedoc/node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "peer": true
+ },
"node_modules/typescript": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
- "devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
},
+ "node_modules/yaml": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
+ "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
+ "peer": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/dialogs": "^5.3.5",
"@nextcloud/event-bus": "^3.3.1",
- "@nextcloud/files": "^3.6.0",
+ "@nextcloud/files": "^3.7.0",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/router": "^3.0.0",
"@nextcloud/sharing": "^0.2.2",
"@nextcloud/upload": "^1.4.1",
- "@nextcloud/vue": "^8.14.0",
+ "@nextcloud/vue": "^8.15.0",
"@simplewebauthn/browser": "^10.0.0",
"@skjnldsv/sanitize-svg": "^1.0.2",
"@vueuse/components": "^10.11.0",