mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 01:30:50 -04:00
feat(files): render filters in top bar
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
871f037dda
commit
985b66c64f
17 changed files with 681 additions and 651 deletions
|
|
@ -1,55 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcActions
|
||||
force-menu
|
||||
:variant="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 NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
|
||||
|
||||
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-text-error, var(--color-error-text));
|
||||
}
|
||||
|
||||
:deep(.button-vue) {
|
||||
font-weight: normal !important;
|
||||
|
||||
* {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
|
||||
import NcChip from '@nextcloud/vue/components/NcChip'
|
||||
import { useFiltersStore } from '../../store/filters.ts'
|
||||
|
||||
const filterStore = useFiltersStore()
|
||||
const activeChips = computed(() => filterStore.activeChips)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
v-if="activeChips.length > 0"
|
||||
:class="$style.fileListFilterChips"
|
||||
:aria-label="t('files', 'Active filters')">
|
||||
<li v-for="(chip, index) of activeChips" :key="index">
|
||||
<NcChip
|
||||
:aria-label-close="t('files', 'Remove filter')"
|
||||
:icon-svg="chip.icon"
|
||||
:text="chip.text"
|
||||
@close="chip.onclick">
|
||||
<template v-if="chip.user" #icon>
|
||||
<NcAvatar
|
||||
disable-menu
|
||||
hide-status
|
||||
:size="24"
|
||||
:user="chip.user" />
|
||||
</template>
|
||||
</NcChip>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.fileListFilterChips {
|
||||
display: flex;
|
||||
gap: var(--default-grid-baseline);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,100 +3,122 @@
|
|||
- 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="mdiCalendarRangeOutline" />
|
||||
</template>
|
||||
<NcActionButton
|
||||
<div>
|
||||
<NcButton
|
||||
v-for="preset of timePresets"
|
||||
:key="preset.id"
|
||||
type="radio"
|
||||
close-after-click
|
||||
:model-value.sync="selectedOption"
|
||||
:value="preset.id">
|
||||
alignment="start"
|
||||
:pressed="preset === selectedOption"
|
||||
variant="tertiary"
|
||||
wide
|
||||
@update:pressed="$event ? (selectedOption = preset) : onReset()">
|
||||
{{ preset.label }}
|
||||
</NcActionButton>
|
||||
<!-- TODO: Custom time range -->
|
||||
</FileListFilter>
|
||||
</NcButton>
|
||||
<NcDateTimePicker
|
||||
v-if="selectedOption?.id === 'custom'"
|
||||
v-model="timeRange"
|
||||
append-to-body
|
||||
:aria-label="t('files', 'Custom date range')"
|
||||
type="date-range" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { ITimePreset } from '../../filters/ModifiedFilter.ts'
|
||||
<script setup lang="ts">
|
||||
import type { ITimePreset, ModifiedFilter } from '../../filters/ModifiedFilter.ts'
|
||||
|
||||
import { mdiCalendarRangeOutline } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import FileListFilter from './FileListFilter.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { NcDateTimePicker } from '@nextcloud/vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
FileListFilter,
|
||||
NcActionButton,
|
||||
NcIconSvgWrapper,
|
||||
},
|
||||
const props = defineProps<{
|
||||
filter: ModifiedFilter
|
||||
}>()
|
||||
|
||||
props: {
|
||||
timePresets: {
|
||||
type: Array as PropType<ITimePreset[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
// icons used in template
|
||||
mdiCalendarRangeOutline,
|
||||
const selectedOption = ref<typeof timePresets[number]>()
|
||||
watch(selectedOption, (preset) => {
|
||||
if (selectedOption.value) {
|
||||
if (selectedOption.value.id === 'custom' && !timeRange.value) {
|
||||
timeRange.value = [new Date(startOfLastWeek()), new Date(startOfToday())]
|
||||
selectedOption.value.timeRange = [...timeRange.value]
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
props.filter.setPreset(selectedOption.value)
|
||||
} else {
|
||||
props.filter.setPreset()
|
||||
}
|
||||
})
|
||||
|
||||
const timeRange = ref<[Date, Date]>()
|
||||
watch(timeRange, () => {
|
||||
if (timeRange.value) {
|
||||
selectedOption.value!.timeRange = [...timeRange.value]
|
||||
props.filter.setPreset(selectedOption.value)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
selectedOption.value = props.filter.preset && timePresets.find((f) => f.id === props.filter.preset!.id)
|
||||
props.filter.addEventListener('reset', onReset)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
props.filter.removeEventListener('reset', onReset)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handler for resetting the filter
|
||||
*/
|
||||
function onReset() {
|
||||
selectedOption.value = undefined
|
||||
timeRange.value = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const startOfToday = () => (new Date()).setHours(0, 0, 0, 0)
|
||||
const startOfLastWeek = () => startOfToday() - (7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
/**
|
||||
* Available presets
|
||||
*/
|
||||
const timePresets = [
|
||||
{
|
||||
id: 'today',
|
||||
label: t('files', 'Today'),
|
||||
filter: (time: number) => time > startOfToday(),
|
||||
} satisfies ITimePreset,
|
||||
{
|
||||
id: 'last-7',
|
||||
label: t('files', 'Last 7 days'),
|
||||
filter: (time: number) => time > startOfLastWeek(),
|
||||
} satisfies ITimePreset,
|
||||
{
|
||||
id: 'last-30',
|
||||
label: t('files', 'Last 30 days'),
|
||||
filter: (time: number) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)),
|
||||
} satisfies ITimePreset,
|
||||
{
|
||||
id: 'this-year',
|
||||
label: t('files', 'This year ({year})', { year: (new Date()).getFullYear() }),
|
||||
filter: (time: number) => time > (new Date(startOfToday())).setMonth(0, 1),
|
||||
} satisfies ITimePreset,
|
||||
{
|
||||
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)),
|
||||
} satisfies ITimePreset,
|
||||
{
|
||||
id: 'custom',
|
||||
label: t('files', 'Custom range'),
|
||||
timeRange: [new Date(startOfLastWeek()), new Date(startOfToday())],
|
||||
filter(time: number) {
|
||||
if (!this.timeRange) {
|
||||
return true
|
||||
}
|
||||
const timeValue = new Date(time).getTime()
|
||||
return timeValue >= this.timeRange[0].getTime() && timeValue <= this.timeRange[1].getTime()
|
||||
},
|
||||
} satisfies ITimePreset & Record<string, unknown>,
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
|
|
|||
|
|
@ -4,44 +4,26 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<NcButton v-show="isVisible" @click="onClick">
|
||||
<NcButton v-if="isVisible" size="small" @click="onClick">
|
||||
{{ t('files', 'Search everywhere') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { ref } from 'vue'
|
||||
import { computed } 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)
|
||||
const searchStore = useSearchStore(getPinia())
|
||||
|
||||
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
|
||||
}
|
||||
const isVisible = computed(() => searchStore.query.length >= 3 && searchStore.scope === 'filter')
|
||||
|
||||
/**
|
||||
* Button click handler to make the filtering a global search.
|
||||
*/
|
||||
function onClick() {
|
||||
const searchStore = useSearchStore(getPinia())
|
||||
searchStore.scope = 'globally'
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,124 +3,164 @@
|
|||
- 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="mdiFileOutline" />
|
||||
</template>
|
||||
<NcActionButton
|
||||
<div :class="$style.fileListFilterType">
|
||||
<NcButton
|
||||
v-for="fileType of typePresets"
|
||||
:key="fileType.id"
|
||||
type="checkbox"
|
||||
:model-value="selectedOptions.includes(fileType)"
|
||||
@click="toggleOption(fileType)">
|
||||
:pressed="selectedOptions.includes(fileType)"
|
||||
variant="tertiary"
|
||||
alignment="start"
|
||||
wide
|
||||
@update:pressed="toggleOption(fileType, $event)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :svg="fileType.icon" />
|
||||
</template>
|
||||
{{ fileType.label }}
|
||||
</NcActionButton>
|
||||
</FileListFilter>
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { ITypePreset } from '../../filters/TypeFilter.ts'
|
||||
<script setup lang="ts">
|
||||
import type { ITypePreset, TypeFilter } from '../../filters/TypeFilter.ts'
|
||||
|
||||
import { mdiFileOutline } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
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 { t } from '@nextcloud/l10n'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import FileListFilter from './FileListFilter.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileListFilterType',
|
||||
const props = defineProps<{
|
||||
filter: TypeFilter
|
||||
}>()
|
||||
|
||||
components: {
|
||||
FileListFilter,
|
||||
NcActionButton,
|
||||
NcIconSvgWrapper,
|
||||
},
|
||||
|
||||
props: {
|
||||
presets: {
|
||||
type: Array as PropType<ITypePreset[]>,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
typePresets: {
|
||||
type: Array as PropType<ITypePreset[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
mdiFileOutline,
|
||||
t,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedOptions: [] as ITypePreset[],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isActive() {
|
||||
return this.selectedOptions.length > 0
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
/** Reset selected options if property is changed */
|
||||
presets() {
|
||||
this.selectedOptions = this.presets ?? []
|
||||
},
|
||||
|
||||
selectedOptions(newValue, oldValue) {
|
||||
if (this.selectedOptions.length === 0) {
|
||||
if (oldValue.length !== 0) {
|
||||
this.$emit('update:presets')
|
||||
}
|
||||
} else {
|
||||
this.$emit('update:presets', this.selectedOptions)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.selectedOptions = this.presets ?? []
|
||||
},
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
},
|
||||
const selectedOptions = ref<ITypePreset[]>([])
|
||||
watch(selectedOptions, () => {
|
||||
props.filter.setPresets([...selectedOptions.value])
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
props.filter.addEventListener('reset', resetFilter)
|
||||
props.filter.addEventListener('deselect', onDeselect)
|
||||
selectedOptions.value = typePresets.filter(({ id }) => props.filter.presets.some((preset) => preset.id === id))
|
||||
})
|
||||
onUnmounted(() => {
|
||||
props.filter.removeEventListener('reset', resetFilter)
|
||||
props.filter.removeEventListener('deselect', onDeselect)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handler for reset event from filter
|
||||
*/
|
||||
function resetFilter() {
|
||||
selectedOptions.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deselect event from filter
|
||||
*
|
||||
* @param event - The custom event
|
||||
*/
|
||||
function onDeselect(event: CustomEvent<string>) {
|
||||
const option = typePresets.find((preset) => preset.id === event.detail)
|
||||
if (option) {
|
||||
toggleOption(option, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle option from selected option
|
||||
*
|
||||
* @param option The option to toggle
|
||||
* @param selected Whether the option is selected or not
|
||||
*/
|
||||
function toggleOption(option: ITypePreset, selected: boolean) {
|
||||
selectedOptions.value = selectedOptions.value.filter((o) => o.id !== option.id)
|
||||
|
||||
if (selected) {
|
||||
selectedOptions.value.push(option)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.file-list-filter-type {
|
||||
max-width: 220px;
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Available presets
|
||||
*/
|
||||
const typePresets = [
|
||||
{
|
||||
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',
|
||||
// TRANSLATORS: This is for filtering files, e.g. PNG or JPEG, so photos, drawings, or images in general
|
||||
label: t('files', 'Images'),
|
||||
icon: svgImage,
|
||||
mime: ['image'],
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
label: t('files', 'Videos'),
|
||||
icon: svgMovie,
|
||||
mime: ['video'],
|
||||
},
|
||||
] as ITypePreset[]
|
||||
|
||||
/**
|
||||
* Helper to colorize an svg icon
|
||||
*
|
||||
* @param svg - the svg content
|
||||
* @param color - the color to apply
|
||||
*/
|
||||
function colorize(svg: string, color: string) {
|
||||
return svg.replace('<path ', `<path fill="${color}" `)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.fileListFilterType {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-grid-baseline);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
139
apps/files/src/components/FileListFilter/FileListFilters.vue
Normal file
139
apps/files/src/components/FileListFilter/FileListFilters.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IFileListFilterWithUi } from '@nextcloud/files'
|
||||
|
||||
import { mdiArrowLeft, mdiFilterVariant } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, ref } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcPopover from '@nextcloud/vue/components/NcPopover'
|
||||
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
|
||||
import { useFiltersStore } from '../../store/filters.ts'
|
||||
|
||||
const filterStore = useFiltersStore()
|
||||
const visualFilters = computed(() => filterStore.filtersWithUI)
|
||||
const hasActiveFilters = computed(() => filterStore.activeChips.length > 0)
|
||||
|
||||
const selectedFilter = ref<IFileListFilterWithUi>()
|
||||
|
||||
const { isWide } = useFileListWidth()
|
||||
const menuTriggerId = 'file-list-filters-menu-trigger'
|
||||
|
||||
const boundary = document.getElementById('app-content-vue')!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.fileListFilters" data-test-id="files-list-filters">
|
||||
<template v-if="isWide">
|
||||
<NcPopover v-for="filter of visualFilters" :key="filter.id" :boundary="boundary">
|
||||
<template #trigger>
|
||||
<NcButton variant="tertiary">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :svg="filter.iconSvgInline" />
|
||||
</template>
|
||||
{{ filter.displayName }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<div :class="$style.fileListFilters__popoverContainer">
|
||||
<component :is="filter.tagName" :filter.prop="filter" />
|
||||
</div>
|
||||
</template>
|
||||
</NcPopover>
|
||||
</template>
|
||||
|
||||
<NcPopover
|
||||
v-else
|
||||
:boundary="boundary"
|
||||
:popup-role="selectedFilter ? 'dialog' : 'menu'"
|
||||
@update:shown="selectedFilter = undefined">
|
||||
<template #trigger>
|
||||
<NcButton
|
||||
:id="menuTriggerId"
|
||||
:aria-label="t('files', 'Filters')"
|
||||
:pressed="hasActiveFilters"
|
||||
variant="tertiary">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFilterVariant" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="selectedFilter" :class="$style.fileListFilters__popoverFilterView">
|
||||
<NcButton wide variant="tertiary" @click="selectedFilter = undefined">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper directional :path="mdiArrowLeft" />
|
||||
</template>
|
||||
{{ t('files', 'Back to filters') }}
|
||||
</NcButton>
|
||||
<component :is="selectedFilter.tagName" :filter.prop="selectedFilter" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<ul :class="$style.fileListFilters__popoverContainer" :aria-labelledby="menuTriggerId" role="menu">
|
||||
<li v-for="filter of visualFilters" :key="filter.id" role="presentation">
|
||||
<NcButton
|
||||
role="menuitem"
|
||||
alignment="start"
|
||||
variant="tertiary"
|
||||
wide
|
||||
@click="selectedFilter = filter">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :svg="filter.iconSvgInline" />
|
||||
</template>
|
||||
{{ filter.displayName }}
|
||||
</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</NcPopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.fileListFilters {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--default-grid-baseline);
|
||||
margin-inline-end: var(--default-grid-baseline);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fileListFilters__popoverFilterView {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(2 * var(--default-grid-baseline));
|
||||
padding: calc(var(--default-grid-baseline) / 2);
|
||||
min-width: calc(7 * var(--default-clickable-area));
|
||||
}
|
||||
|
||||
.fileListFilters__popoverContainer {
|
||||
box-sizing: border-box;
|
||||
padding: calc(var(--default-grid-baseline) / 2);
|
||||
min-width: calc(7 * var(--default-clickable-area));
|
||||
}
|
||||
|
||||
.fileListFilters__filter {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
gap: calc(var(--default-grid-baseline, 4px) * 2);
|
||||
|
||||
> * {
|
||||
flex: 0 1 fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.fileListFilters__active {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(var(--default-grid-baseline, 4px) * 2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<!--
|
||||
- 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" data-cy-files-filters>
|
||||
<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
|
||||
:aria-label-close="t('files', 'Remove filter')"
|
||||
:icon-svg="chip.icon"
|
||||
:text="chip.text"
|
||||
@close="chip.onclick">
|
||||
<template v-if="chip.user" #icon>
|
||||
<NcAvatar
|
||||
disable-menu
|
||||
hide-status
|
||||
:size="24"
|
||||
:user="chip.user" />
|
||||
</template>
|
||||
</NcChip>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
|
||||
import NcChip from '@nextcloud/vue/components/NcChip'
|
||||
import { useFiltersStore } from '../store/filters.ts'
|
||||
|
||||
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>
|
||||
|
|
@ -17,9 +17,8 @@
|
|||
}"
|
||||
:scroll-to-index="scrollToIndex"
|
||||
:caption="caption">
|
||||
<template #filters>
|
||||
<FileListFilters />
|
||||
</template>
|
||||
<!-- eslint-disable-next-line vue/singleline-html-element-content-newline -- no space allowed as otherwise `:empty` css selector does not trigger! -->
|
||||
<template #filters><FileListFilterToSearch /><FileListFilterChips /></template>
|
||||
|
||||
<template v-if="!isNoneSelected" #header-overlay>
|
||||
<span class="files-list__selected">
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof FileListFilterToSearch>
|
||||
|
||||
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<typeof FileListFilterToSearch>
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
@ -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('<path ', `<path fill="${color}" `)
|
||||
}
|
||||
const tagName = 'files-file-list-filter-type'
|
||||
|
||||
/**
|
||||
* Available presets
|
||||
*/
|
||||
async function getTypePresets() {
|
||||
return [
|
||||
{
|
||||
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',
|
||||
// TRANSLATORS: This is for filtering files, e.g. PNG or JPEG, so photos, drawings, or images in general
|
||||
label: t('files', 'Images'),
|
||||
icon: svgImage,
|
||||
mime: ['image'],
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
label: t('files', 'Videos'),
|
||||
icon: svgMovie,
|
||||
mime: ['video'],
|
||||
},
|
||||
] as ITypePreset[]
|
||||
}
|
||||
|
||||
class TypeFilter extends FileListFilter {
|
||||
class TypeFilter extends FileListFilter implements IFileListFilterWithUi {
|
||||
private currentInstance?: Vue
|
||||
private currentPresets: ITypePreset[]
|
||||
private allPresets?: ITypePreset[]
|
||||
|
||||
public readonly displayName = t('files', 'Type')
|
||||
public readonly iconSvgInline = svgFileOutline
|
||||
public readonly tagName = tagName
|
||||
|
||||
constructor() {
|
||||
super('files:type', 10)
|
||||
this.currentPresets = []
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Already mounted
|
||||
if (this.currentInstance) {
|
||||
this.currentInstance.$destroy()
|
||||
delete this.currentInstance
|
||||
}
|
||||
|
||||
const View = Vue.extend(FileListFilterType as never)
|
||||
this.currentInstance = new View({
|
||||
propsData: {
|
||||
presets: this.currentPresets,
|
||||
typePresets: this.allPresets!,
|
||||
},
|
||||
el,
|
||||
})
|
||||
.$on('update:presets', this.setPresets.bind(this))
|
||||
.$mount()
|
||||
}
|
||||
|
||||
public filter(nodes: INode[]): INode[] {
|
||||
if (!this.currentPresets || this.currentPresets.length === 0) {
|
||||
return nodes
|
||||
|
|
@ -149,10 +59,17 @@ class TypeFilter extends FileListFilter {
|
|||
}
|
||||
|
||||
public reset(): void {
|
||||
this.setPresets()
|
||||
// to be listener by the component
|
||||
this.dispatchEvent(new CustomEvent('reset'))
|
||||
}
|
||||
|
||||
public get presets(): ITypePreset[] {
|
||||
return this.currentPresets
|
||||
}
|
||||
|
||||
public setPresets(presets?: ITypePreset[]) {
|
||||
logger.debug('TypeFilter: setting presets', { presets })
|
||||
|
||||
this.currentPresets = presets ?? []
|
||||
if (this.currentInstance !== undefined) {
|
||||
// could be called before the instance was created
|
||||
|
|
@ -185,13 +102,31 @@ class TypeFilter extends FileListFilter {
|
|||
*/
|
||||
private removeFilterPreset(presetId: string) {
|
||||
const filtered = this.currentPresets.filter(({ id }) => 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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
9
apps/files/src/shims.d.ts
vendored
Normal file
9
apps/files/src/shims.d.ts
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<IFileListFilter> {
|
||||
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<Required<IFileListFilter>[]>(() => sortedFilters.value.filter(isFileListFilterWithUi))
|
||||
const filtersWithUI = computed<IFileListFilterWithUi[]>(() => sortedFilters.value.filter(isFileListFilterWithUi))
|
||||
|
||||
/**
|
||||
* Register a new filter on the store.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@
|
|||
</NcActionButton>
|
||||
</NcActions>
|
||||
|
||||
<FileListFilters />
|
||||
|
||||
<NcButton
|
||||
v-if="enableGridView"
|
||||
:aria-label="gridViewButtonLabel"
|
||||
|
|
@ -194,6 +196,7 @@ import IconReload from 'vue-material-design-icons/Reload.vue'
|
|||
import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue'
|
||||
import BreadCrumbs from '../components/BreadCrumbs.vue'
|
||||
import DragAndDropNotice from '../components/DragAndDropNotice.vue'
|
||||
import FileListFilters from '../components/FileListFilter/FileListFilters.vue'
|
||||
import FilesListVirtual from '../components/FilesListVirtual.vue'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
|
|
@ -220,6 +223,7 @@ export default defineComponent({
|
|||
components: {
|
||||
BreadCrumbs,
|
||||
DragAndDropNotice,
|
||||
FileListFilters,
|
||||
FilesListVirtual,
|
||||
LinkIcon,
|
||||
ListViewIcon,
|
||||
|
|
|
|||
|
|
@ -3,53 +3,45 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<FileListFilter
|
||||
class="file-list-filter-accounts"
|
||||
:is-active="selectedAccounts.length > 0"
|
||||
:filter-name="t('files_sharing', 'People')"
|
||||
@reset-filter="resetFilter">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiAccountMultipleOutline" />
|
||||
</template>
|
||||
<NcActionInput
|
||||
<div :class="$style.fileListFilterAccount">
|
||||
<NcTextField
|
||||
v-if="availableAccounts.length > 1"
|
||||
v-model="accountFilter"
|
||||
:label="t('files_sharing', 'Filter accounts')"
|
||||
:label-outside="false"
|
||||
:show-trailing-button="false"
|
||||
type="search" />
|
||||
<NcActionButton
|
||||
type="search"
|
||||
:label="t('files_sharing', 'Filter accounts')" />
|
||||
<NcButton
|
||||
v-for="account of shownAccounts"
|
||||
:key="account.id"
|
||||
class="file-list-filter-accounts__item"
|
||||
type="radio"
|
||||
:model-value="selectedAccounts.includes(account)"
|
||||
:value="account.id"
|
||||
@click="toggleAccount(account.id)">
|
||||
alignment="start"
|
||||
:pressed="selectedAccounts.includes(account)"
|
||||
variant="tertiary"
|
||||
wide
|
||||
@update:pressed="toggleAccount(account.id, $event)">
|
||||
<template #icon>
|
||||
<NcAvatar
|
||||
class="file-list-filter-accounts__avatar"
|
||||
:class="$style.fileListFilterAccount__avatar"
|
||||
v-bind="account"
|
||||
:size="24"
|
||||
disable-menu
|
||||
hide-status />
|
||||
</template>
|
||||
{{ account.displayName }}
|
||||
</NcActionButton>
|
||||
</FileListFilter>
|
||||
<span v-if="account.id === currentUserId" :class="$style.fileListFilterAccount__currentUser">
|
||||
({{ t('files', 'you') }})
|
||||
</span>
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAccountData } from '../files_filters/AccountFilter.ts'
|
||||
import type { AccountFilter, IAccountData } from '../files_filters/AccountFilter.ts'
|
||||
|
||||
import { mdiAccountMultipleOutline } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import FileListFilter from '../../../files/src/components/FileListFilter/FileListFilter.vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import { getCurrentUser } from '../../../../core/src/OC/currentuser.js'
|
||||
|
||||
interface IUserSelectData {
|
||||
id: string
|
||||
|
|
@ -57,48 +49,88 @@ interface IUserSelectData {
|
|||
displayName: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:accounts', value: IAccountData[]): void
|
||||
const props = defineProps<{
|
||||
filter: AccountFilter
|
||||
}>()
|
||||
|
||||
const currentUserId = getCurrentUser()!.uid
|
||||
|
||||
const accountFilter = ref('')
|
||||
const availableAccounts = ref<IUserSelectData[]>([])
|
||||
const selectedAccounts = ref<IUserSelectData[]>([])
|
||||
watch(selectedAccounts, () => {
|
||||
const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName }))
|
||||
props.filter.setAccounts(accounts.length > 0 ? accounts : undefined)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setAvailableAccounts(props.filter.availableAccounts)
|
||||
selectedAccounts.value = availableAccounts.value.filter(({ id }) => props.filter.filterAccounts?.some(({ uid }) => uid === id)) ?? []
|
||||
props.filter.addEventListener('accounts-updated', setAvailableAccounts)
|
||||
props.filter.addEventListener('reset', resetFilter)
|
||||
props.filter.addEventListener('deselect', deselect)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
props.filter.removeEventListener('accounts-updated', setAvailableAccounts)
|
||||
props.filter.removeEventListener('reset', resetFilter)
|
||||
props.filter.removeEventListener('deselect', deselect)
|
||||
})
|
||||
|
||||
/**
|
||||
* Currently shown accounts (filtered)
|
||||
*/
|
||||
const shownAccounts = computed(() => {
|
||||
if (!accountFilter.value) {
|
||||
return availableAccounts.value
|
||||
return [...availableAccounts.value].sort(sortAccounts)
|
||||
}
|
||||
|
||||
const queryParts = accountFilter.value.toLocaleLowerCase().trim().split(' ')
|
||||
return availableAccounts.value.filter((account) => queryParts.every((part) => account.user.toLocaleLowerCase().includes(part)
|
||||
const accounts = availableAccounts.value.filter((account) => queryParts.every((part) => account.user.toLocaleLowerCase().includes(part)
|
||||
|| account.displayName.toLocaleLowerCase().includes(part)))
|
||||
return accounts.sort(sortAccounts)
|
||||
})
|
||||
|
||||
/**
|
||||
* Sort accounts, putting the current user at the begin
|
||||
*
|
||||
* @param a - First account
|
||||
* @param b - Second account
|
||||
*/
|
||||
function sortAccounts(a: IUserSelectData, b: IUserSelectData) {
|
||||
if (a.id === currentUserId) {
|
||||
return -1
|
||||
}
|
||||
if (b.id === currentUserId) {
|
||||
return 1
|
||||
}
|
||||
return a.displayName.localeCompare(b.displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an account as selected
|
||||
*
|
||||
* @param accountId The account to toggle
|
||||
* @param selected Whether to select or deselect the account
|
||||
*/
|
||||
function toggleAccount(accountId: string) {
|
||||
const account = availableAccounts.value.find(({ id }) => id === accountId)
|
||||
if (account && selectedAccounts.value.includes(account)) {
|
||||
selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
|
||||
} else {
|
||||
function toggleAccount(accountId: string, selected: boolean) {
|
||||
selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
|
||||
if (selected) {
|
||||
const account = availableAccounts.value.find(({ id }) => id === accountId)
|
||||
if (account) {
|
||||
selectedAccounts.value = [...selectedAccounts.value, account]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch selected account, on change we emit the new account data to the filter instance
|
||||
watch(selectedAccounts, () => {
|
||||
// Emit selected accounts as account data
|
||||
const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName }))
|
||||
emit('update:accounts', accounts)
|
||||
})
|
||||
/**
|
||||
* Deselect an account
|
||||
*
|
||||
* @param event - The custom event
|
||||
*/
|
||||
function deselect(event: CustomEvent) {
|
||||
const accountId = event.detail as string
|
||||
selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset this filter
|
||||
|
|
@ -113,26 +145,27 @@ function resetFilter() {
|
|||
*
|
||||
* @param accounts - Accounts to use
|
||||
*/
|
||||
function setAvailableAccounts(accounts: IAccountData[]): void {
|
||||
function setAvailableAccounts(accounts: IAccountData[] | CustomEvent): void {
|
||||
if (accounts instanceof CustomEvent) {
|
||||
accounts = accounts.detail as IAccountData[]
|
||||
}
|
||||
availableAccounts.value = accounts.map(({ uid, displayName }) => ({ displayName, id: uid, user: uid }))
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetFilter,
|
||||
setAvailableAccounts,
|
||||
toggleAccount,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-list-filter-accounts {
|
||||
&__item {
|
||||
min-width: 250px;
|
||||
}
|
||||
<style module>
|
||||
.fileListFilterAccount {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
// 24px is the avatar size
|
||||
margin: calc((var(--default-clickable-area) - 24px) / 2)
|
||||
}
|
||||
.fileListFilterAccount__avatar {
|
||||
/* 24px is the avatar size */
|
||||
margin: calc((var(--default-clickable-area) - 24px) / 2);
|
||||
}
|
||||
|
||||
.fileListFilterAccount__currentUser {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue