mirror of
https://github.com/nextcloud/server.git
synced 2026-05-22 10:06:37 -04:00
refactor(recent-files): drop frontend changes, move configs to instance level
Signed-off-by: Cristian Scheid <cristianscheid@gmail.com>
This commit is contained in:
parent
ba2676866c
commit
8f2cfe5f76
11 changed files with 52 additions and 554 deletions
|
|
@ -23,6 +23,9 @@ use OCP\Config\ValueType;
|
|||
class ConfigLexicon implements ILexicon {
|
||||
public const OVERWRITES_HOME_FOLDERS = 'overwrites_home_folders';
|
||||
public const RECENT_LIMIT = 'recent_limit';
|
||||
public const GROUP_RECENT_FILES = 'group_recent_files';
|
||||
public const RECENT_FILES_GROUP_MIME_TYPES = 'recent_files_group_mime_types';
|
||||
public const RECENT_FILES_GROUP_TIMESPAN_MINUTES = 'recent_files_group_timespan_minutes';
|
||||
|
||||
public function getStrictness(): Strictness {
|
||||
return Strictness::IGNORE;
|
||||
|
|
@ -45,6 +48,27 @@ class ConfigLexicon implements ILexicon {
|
|||
definition: 'Maximum number of files to display on recent files view',
|
||||
lazy: false,
|
||||
),
|
||||
new Entry(
|
||||
self::GROUP_RECENT_FILES,
|
||||
ValueType::BOOL,
|
||||
defaultRaw: false,
|
||||
definition: 'Whether to group recent files by MIME type or not',
|
||||
lazy: false,
|
||||
),
|
||||
new Entry(
|
||||
self::RECENT_FILES_GROUP_MIME_TYPES,
|
||||
ValueType::ARRAY,
|
||||
defaultRaw: [],
|
||||
definition: 'Which MIME types to group in the recent files list',
|
||||
lazy: false,
|
||||
),
|
||||
new Entry(
|
||||
self::RECENT_FILES_GROUP_TIMESPAN_MINUTES,
|
||||
ValueType::INT,
|
||||
defaultRaw: 2,
|
||||
definition: 'Time window in minutes to group files uploaded close together in the recent files list',
|
||||
lazy: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ class ViewController extends Controller {
|
|||
$this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
|
||||
$this->initialState->provideInitialState('viewConfigs', $this->viewConfig->getConfigs());
|
||||
$this->initialState->provideInitialState('recent_limit', $this->appConfig->getAppValueInt(ConfigLexicon::RECENT_LIMIT, 100));
|
||||
// Not yet consumed by the frontend, provided for future implementation
|
||||
$this->initialState->provideInitialState('group_recent_files', $this->appConfig->getAppValueBool(ConfigLexicon::GROUP_RECENT_FILES, false));
|
||||
$this->initialState->provideInitialState('recent_files_group_mime_types', $this->appConfig->getAppValueArray(ConfigLexicon::RECENT_FILES_GROUP_MIME_TYPES, []));
|
||||
$this->initialState->provideInitialState('recent_files_group_timespan_minutes', $this->appConfig->getAppValueInt(ConfigLexicon::RECENT_FILES_GROUP_TIMESPAN_MINUTES, 2));
|
||||
|
||||
// File sorting user config
|
||||
$filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true);
|
||||
|
|
|
|||
|
|
@ -79,33 +79,6 @@ class UserConfig {
|
|||
'default' => true,
|
||||
'allowed' => [true, false],
|
||||
],
|
||||
[
|
||||
// Whether to group images on recent files list or not
|
||||
'key' => 'group_recent_files_images',
|
||||
'default' => false,
|
||||
'allowed' => [true, false],
|
||||
],
|
||||
[
|
||||
// Which image mime types to group in the recent files list
|
||||
'key' => 'recent_files_group_mimetypes',
|
||||
'default' => '',
|
||||
'allowed' => [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
]
|
||||
],
|
||||
[
|
||||
// Time window in minutes to group files uploaded close together in the recent files list
|
||||
'key' => 'recent_files_group_timespan_minutes',
|
||||
'default' => 2,
|
||||
'min' => 1,
|
||||
'max' => 999,
|
||||
],
|
||||
];
|
||||
protected ?IUser $user = null;
|
||||
|
||||
|
|
@ -145,7 +118,7 @@ class UserConfig {
|
|||
* Get the default config value for a given key
|
||||
*
|
||||
* @param string $key a valid config key
|
||||
* @return string|bool|int
|
||||
* @return string|bool
|
||||
*/
|
||||
private function getDefaultConfigValue(string $key) {
|
||||
foreach (self::ALLOWED_CONFIGS as $config) {
|
||||
|
|
@ -173,25 +146,7 @@ class UserConfig {
|
|||
throw new \InvalidArgumentException('Unknown config key');
|
||||
}
|
||||
|
||||
if (is_string($value) && str_starts_with($value, '[') && str_ends_with($value, ']')) {
|
||||
$value = json_decode($value, true) ?? $value;
|
||||
}
|
||||
|
||||
$config = $this->getConfigDefinition($key);
|
||||
|
||||
if (isset($config['min'], $config['max'])) {
|
||||
if ((int)$value < $config['min'] || (int)$value > $config['max']) {
|
||||
throw new \InvalidArgumentException('Invalid config value');
|
||||
}
|
||||
} elseif (is_array($value)) {
|
||||
$allowedValues = $this->getAllowedConfigValues($key);
|
||||
foreach ($value as $v) {
|
||||
if (!in_array($v, $allowedValues)) {
|
||||
throw new \InvalidArgumentException('Invalid config value');
|
||||
}
|
||||
}
|
||||
$value = json_encode($value);
|
||||
} elseif (!in_array($value, $this->getAllowedConfigValues($key))) {
|
||||
if (!in_array($value, $this->getAllowedConfigValues($key))) {
|
||||
throw new \InvalidArgumentException('Invalid config value');
|
||||
}
|
||||
|
||||
|
|
@ -219,27 +174,9 @@ class UserConfig {
|
|||
if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) {
|
||||
return $value === '1';
|
||||
}
|
||||
if (is_string($value) && str_starts_with($value, '[') && str_ends_with($value, ']')) {
|
||||
$value = json_decode($value, true) ?? $value;
|
||||
}
|
||||
return $value;
|
||||
}, $this->getAllowedConfigKeys());
|
||||
|
||||
return array_combine($this->getAllowedConfigKeys(), $userConfigs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the config definition for a given key
|
||||
*
|
||||
* @param string $key
|
||||
* @return array
|
||||
*/
|
||||
private function getConfigDefinition(string $key): array {
|
||||
foreach (self::ALLOWED_CONFIGS as $config) {
|
||||
if ($config['key'] === $key) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<tr
|
||||
class="files-list__row files-list__row--image-group"
|
||||
:class="{
|
||||
'files-list__row--image-group-expanded': source.expanded,
|
||||
'files-list__row--active': isSelected,
|
||||
}">
|
||||
<td class="files-list__row-checkbox" @click.stop>
|
||||
<NcCheckboxRadioSwitch
|
||||
:aria-label="t('files', 'Toggle selection for image group')"
|
||||
:modelValue="isSelected"
|
||||
:indeterminate="isPartiallySelected"
|
||||
@update:modelValue="onSelectionChange" />
|
||||
</td>
|
||||
|
||||
<td class="files-list__row-name" @click="emit('toggle', source.source)">
|
||||
<span class="files-list__row-icon">
|
||||
<ImageMultipleIcon :size="20" />
|
||||
</span>
|
||||
|
||||
<span class="files-list__row-image-group-chevron">
|
||||
<NcIconSvgWrapper
|
||||
:path="mdiChevronDown"
|
||||
:size="20"
|
||||
:class="{ 'files-list__row-image-group-chevron--expanded': source.expanded }" />
|
||||
</span>
|
||||
|
||||
<span class="files-list__row-name-text">
|
||||
{{ n('files', '{count} image', '{count} images', source.images.length, { count: source.images.length }) }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td v-if="isMimeAvailable" class="files-list__row-mime" />
|
||||
<td v-if="isSizeAvailable" class="files-list__row-size" />
|
||||
<td v-if="isMtimeAvailable" class="files-list__row-mtime" />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ImageGroupNode } from '../composables/useImageGrouping.ts'
|
||||
|
||||
import { mdiChevronDown } from '@mdi/js'
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import ImageMultipleIcon from 'vue-material-design-icons/ImageMultiple.vue'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
source: ImageGroupNode
|
||||
isMimeAvailable?: boolean
|
||||
isSizeAvailable?: boolean
|
||||
isMtimeAvailable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle', key: string): void
|
||||
}>()
|
||||
|
||||
const selectionStore = useSelectionStore()
|
||||
|
||||
const childSources = computed(() => props.source.images.map((img) => img.source))
|
||||
|
||||
const isSelected = computed(() => childSources.value.every((src) => selectionStore.selected.includes(src)))
|
||||
|
||||
const isPartiallySelected = computed(() => !isSelected.value && childSources.value.some((src) => selectionStore.selected.includes(src)))
|
||||
|
||||
/**
|
||||
* Handle selection change for the image group
|
||||
*
|
||||
* @param selected - Whether the group should be selected or deselected
|
||||
*/
|
||||
async function onSelectionChange(selected: boolean) {
|
||||
const current = selectionStore.selected
|
||||
if (selected) {
|
||||
// select all children
|
||||
selectionStore.set([...new Set([...current, ...childSources.value])])
|
||||
} else {
|
||||
// unselect all children
|
||||
selectionStore.set(current.filter((src) => !childSources.value.includes(src)))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.files-list__row--image-group {
|
||||
.files-list__row-name {
|
||||
cursor: pointer;
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-image-group-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-maxcontrast);
|
||||
&--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-name-text {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<FileEntryImageGroup
|
||||
v-if="isGroup"
|
||||
:source="source"
|
||||
:isMimeAvailable="isMimeAvailable"
|
||||
:isSizeAvailable="isSizeAvailable"
|
||||
:isMtimeAvailable="isMtimeAvailable"
|
||||
@toggle="onToggleGroup?.($event)" />
|
||||
|
||||
<component
|
||||
:is="entryComponent"
|
||||
v-else
|
||||
:source="source"
|
||||
:class="{ 'files-list__row--group-child': isGroupChild }"
|
||||
v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { GroupedNode } from '../composables/useImageGrouping.ts'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import FileEntry from './FileEntry.vue'
|
||||
import FileEntryGrid from './FileEntryGrid.vue'
|
||||
import FileEntryImageGroup from './FileEntryImageGroup.vue'
|
||||
import { isImageGroup } from '../composables/useImageGrouping.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEntryWrapper',
|
||||
|
||||
components: {
|
||||
FileEntry,
|
||||
FileEntryGrid,
|
||||
FileEntryImageGroup,
|
||||
},
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
source: {
|
||||
type: Object as PropType<GroupedNode>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
gridMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isMimeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isSizeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isMtimeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
onToggleGroup: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['toggle-group'],
|
||||
|
||||
computed: {
|
||||
isGroup(): boolean {
|
||||
return isImageGroup(this.source)
|
||||
},
|
||||
|
||||
isGroupChild(): boolean {
|
||||
return '_isGroupChild' in this.source
|
||||
},
|
||||
|
||||
entryComponent() {
|
||||
return this.gridMode ? FileEntryGrid : FileEntry
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { NcFormBoxSwitch, NcInputField, NcSelect } from '@nextcloud/vue'
|
||||
import debounce from 'debounce'
|
||||
import { ref, watch } from 'vue'
|
||||
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
|
||||
import NcFormBox from '@nextcloud/vue/components/NcFormBox'
|
||||
import { useUserConfigStore } from '../../store/userconfig.ts'
|
||||
|
||||
const store = useUserConfigStore()
|
||||
|
||||
const availableMimetypes = [
|
||||
{ id: 'image/png', label: 'PNG' },
|
||||
{ id: 'image/jpeg', label: 'JPEG' },
|
||||
{ id: 'image/gif', label: 'GIF' },
|
||||
{ id: 'image/webp', label: 'WebP' },
|
||||
{ id: 'image/avif', label: 'AVIF' },
|
||||
{ id: 'image/heic', label: 'HEIC' },
|
||||
{ id: 'image/heif', label: 'HEIF' },
|
||||
]
|
||||
|
||||
const storedMimetypes = store.userConfig.recent_files_group_mimetypes
|
||||
const initialMimetypes = Array.isArray(storedMimetypes)
|
||||
? availableMimetypes.filter((m) => storedMimetypes.includes(m.id))
|
||||
: []
|
||||
|
||||
const selectedMimetypes = ref(initialMimetypes)
|
||||
|
||||
const debouncedUpdateMimetypes = debounce((value) => {
|
||||
store.update('recent_files_group_mimetypes', JSON.stringify(value.map((v) => v.id)))
|
||||
}, 500)
|
||||
|
||||
watch(selectedMimetypes, (value) => {
|
||||
debouncedUpdateMimetypes(value)
|
||||
})
|
||||
|
||||
const debouncedUpdateTimespan = debounce((value: number) => {
|
||||
store.update('recent_files_group_timespan_minutes', value)
|
||||
}, 500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSettingsSection id="recent" :name="t('files', 'Recent view')">
|
||||
<NcFormBox>
|
||||
<NcFormBoxSwitch
|
||||
v-model="store.userConfig.group_recent_files_images"
|
||||
:label="t('files', 'Group image files')"
|
||||
@update:modelValue="store.update('group_recent_files_images', $event)" />
|
||||
<label>{{ t('files', 'Group these image types together') }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedMimetypes"
|
||||
:options="availableMimetypes"
|
||||
labelOutside
|
||||
multiple />
|
||||
<NcInputField
|
||||
v-model="store.userConfig.recent_files_group_timespan_minutes"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:label="t('files', 'Time window in minutes to group files uploaded close together')"
|
||||
@update:modelValue="debouncedUpdateTimespan(Number($event))" />
|
||||
</NcFormBox>
|
||||
</NcAppSettingsSection>
|
||||
</template>
|
||||
|
|
@ -2,23 +2,20 @@
|
|||
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<VirtualList
|
||||
ref="table"
|
||||
:data-component="FileEntryWrapper"
|
||||
:data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
|
||||
data-key="source"
|
||||
:data-sources="groupedNodes"
|
||||
:gridMode="userConfig.grid_view"
|
||||
:extraProps="{
|
||||
gridMode: userConfig.grid_view,
|
||||
:data-sources="nodes"
|
||||
:grid-mode="userConfig.grid_view"
|
||||
:extra-props="{
|
||||
isMimeAvailable,
|
||||
isMtimeAvailable,
|
||||
isSizeAvailable,
|
||||
nodes,
|
||||
onToggleGroup: toggleGroup,
|
||||
}"
|
||||
:scrollToIndex="scrollToIndex"
|
||||
:scroll-to-index="scrollToIndex"
|
||||
:caption="caption">
|
||||
<!-- 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>
|
||||
|
|
@ -28,8 +25,8 @@
|
|||
{{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
|
||||
</span>
|
||||
<FilesListTableHeaderActions
|
||||
:currentView="currentView"
|
||||
:selectedNodes="selectedNodes" />
|
||||
:current-view="currentView"
|
||||
:selected-nodes="selectedNodes" />
|
||||
</template>
|
||||
|
||||
<template #before>
|
||||
|
|
@ -37,8 +34,8 @@
|
|||
<FilesListHeader
|
||||
v-for="header in headers"
|
||||
:key="header.id"
|
||||
:currentFolder="currentFolder"
|
||||
:currentView="currentView"
|
||||
:current-folder="currentFolder"
|
||||
:current-view="currentView"
|
||||
:header="header" />
|
||||
</template>
|
||||
|
||||
|
|
@ -47,9 +44,9 @@
|
|||
<!-- Table header and sort buttons -->
|
||||
<FilesListTableHeader
|
||||
ref="thead"
|
||||
:isMimeAvailable="isMimeAvailable"
|
||||
:isMtimeAvailable="isMtimeAvailable"
|
||||
:isSizeAvailable="isSizeAvailable"
|
||||
:is-mime-available="isMimeAvailable"
|
||||
:is-mtime-available="isMtimeAvailable"
|
||||
:is-size-available="isSizeAvailable"
|
||||
:nodes="nodes" />
|
||||
</template>
|
||||
|
||||
|
|
@ -61,10 +58,10 @@
|
|||
<!-- Tfoot-->
|
||||
<template #footer>
|
||||
<FilesListTableFooter
|
||||
:currentView="currentView"
|
||||
:isMimeAvailable="isMimeAvailable"
|
||||
:isMtimeAvailable="isMtimeAvailable"
|
||||
:isSizeAvailable="isSizeAvailable"
|
||||
:current-view="currentView"
|
||||
:is-mime-available="isMimeAvailable"
|
||||
:is-mtime-available="isMtimeAvailable"
|
||||
:is-size-available="isSizeAvailable"
|
||||
:nodes="nodes"
|
||||
:summary="summary" />
|
||||
</template>
|
||||
|
|
@ -74,17 +71,15 @@
|
|||
<script lang="ts">
|
||||
import type { INode } from '@nextcloud/files'
|
||||
import type { ComponentPublicInstance, PropType } from 'vue'
|
||||
import type { ImageGroupingConfig } from '../composables/useImageGrouping.ts'
|
||||
import type { UserConfig } from '../types.ts'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files'
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
|
||||
import { computed, defineComponent, shallowRef } from 'vue'
|
||||
import { computed, defineComponent } from 'vue'
|
||||
import FileEntry from './FileEntry.vue'
|
||||
import FileEntryGrid from './FileEntryGrid.vue'
|
||||
import FileEntryWrapper from './FileEntryWrapper.vue'
|
||||
import FileListFilterChips from './FileListFilter/FileListFilterChips.vue'
|
||||
import FileListFilterToSearch from './FileListFilter/FileListFilterToSearch.vue'
|
||||
import FilesListHeader from './FilesListHeader.vue'
|
||||
|
|
@ -95,7 +90,6 @@ import VirtualList from './VirtualList.vue'
|
|||
import { useEnabledFileActions } from '../composables/useFileActions.ts'
|
||||
import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useImageGrouping } from '../composables/useImageGrouping.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { useActiveStore } from '../store/active.ts'
|
||||
|
|
@ -173,42 +167,6 @@ export default defineComponent({
|
|||
return props.nodes.some((node: INode) => node.size !== undefined)
|
||||
})
|
||||
|
||||
const expandedGroups = shallowRef(new Set<string>())
|
||||
|
||||
const groupingConfig = computed<ImageGroupingConfig>(() => {
|
||||
const isRecentView = props.currentView.id === 'recent'
|
||||
const isGroupingEnabled = userConfigStore.userConfig.group_recent_files_images === true
|
||||
const configuredMimetypes = userConfigStore.userConfig.recent_files_group_mimetypes
|
||||
|
||||
const mimetypes = isRecentView && isGroupingEnabled && Array.isArray(configuredMimetypes)
|
||||
? configuredMimetypes
|
||||
: []
|
||||
|
||||
return {
|
||||
mimetypes,
|
||||
timespanMinutes: Number(userConfigStore.userConfig.recent_files_group_timespan_minutes) || 2,
|
||||
}
|
||||
})
|
||||
|
||||
const groupedNodes = useImageGrouping(
|
||||
computed(() => props.nodes),
|
||||
expandedGroups,
|
||||
groupingConfig,
|
||||
)
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupKey
|
||||
*/
|
||||
function toggleGroup(groupKey: string) {
|
||||
if (expandedGroups.value.has(groupKey)) {
|
||||
expandedGroups.value.delete(groupKey)
|
||||
} else {
|
||||
expandedGroups.value.add(groupKey)
|
||||
}
|
||||
expandedGroups.value = new Set(expandedGroups.value)
|
||||
}
|
||||
|
||||
return {
|
||||
fileId,
|
||||
headers: useFileListHeaders(),
|
||||
|
|
@ -225,10 +183,6 @@ export default defineComponent({
|
|||
|
||||
n,
|
||||
t,
|
||||
|
||||
groupedNodes,
|
||||
toggleGroup,
|
||||
FileEntryWrapper,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -583,10 +537,6 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
.files-list__row--group-child {
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
|
||||
// Before table and thead
|
||||
.files-list__before {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { INode } from '@nextcloud/files'
|
||||
|
||||
// eslint-disable-next-line perfectionist/sort-named-imports
|
||||
import { type Ref, computed } from 'vue'
|
||||
|
||||
export interface ImageGroupNode {
|
||||
_isImageGroup: true
|
||||
// Stable key for VirtualList recycled pool
|
||||
source: string
|
||||
images: INode[]
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
export type GroupedNode = INode | ImageGroupNode | (INode & { _isGroupChild: true })
|
||||
|
||||
/**
|
||||
*
|
||||
* @param node
|
||||
*/
|
||||
export function isImageGroup(node: GroupedNode): node is ImageGroupNode {
|
||||
return '_isImageGroup' in node && node._isImageGroup === true
|
||||
}
|
||||
|
||||
export interface ImageGroupingConfig {
|
||||
mimetypes: string[]
|
||||
timespanMinutes: number
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param node
|
||||
*/
|
||||
function getNodeTime(node: INode): number {
|
||||
const uploadTime = (node.attributes?.upload_time as number) ?? 0
|
||||
const crtime = (node.attributes?.crtime as number) ?? 0
|
||||
const mtime = node.mtime ? Math.floor(node.mtime / 1000) : 0
|
||||
return Math.max(uploadTime, crtime, mtime)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param nodes
|
||||
* @param expandedGroups
|
||||
* @param config
|
||||
*/
|
||||
export function useImageGrouping(
|
||||
nodes: Ref<INode[]>,
|
||||
expandedGroups: Ref<Set<string>>,
|
||||
config: Ref<ImageGroupingConfig>,
|
||||
) {
|
||||
return computed<GroupedNode[]>(() => {
|
||||
const result: GroupedNode[] = []
|
||||
let i = 0
|
||||
|
||||
const { mimetypes, timespanMinutes } = config.value
|
||||
const timespan = timespanMinutes * 60
|
||||
|
||||
if (mimetypes.length === 0) {
|
||||
return nodes.value
|
||||
}
|
||||
|
||||
const isGroupable = (node: INode) => node.mime !== undefined && mimetypes.includes(node.mime)
|
||||
|
||||
while (i < nodes.value.length) {
|
||||
const node = nodes.value[i]
|
||||
|
||||
if (!isGroupable(node)) {
|
||||
result.push(node)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const groupStartTime = getNodeTime(node)
|
||||
|
||||
// Look ahead: if any non-image falls within the timespan, don't group
|
||||
const isContaminated = groupStartTime && nodes.value.slice(i + 1).some((next) => {
|
||||
const nextTime = getNodeTime(next)
|
||||
if (!nextTime) {
|
||||
return false
|
||||
}
|
||||
if (Math.abs(nextTime - groupStartTime) > timespan) {
|
||||
return false
|
||||
}
|
||||
return !isGroupable(next)
|
||||
})
|
||||
|
||||
if (isContaminated) {
|
||||
result.push(node)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Start a new group from this image
|
||||
const images: INode[] = [node]
|
||||
i++
|
||||
|
||||
while (i < nodes.value.length) {
|
||||
const next = nodes.value[i]
|
||||
|
||||
if (!isGroupable(next)) {
|
||||
break
|
||||
}
|
||||
|
||||
const nextTime = getNodeTime(next)
|
||||
if (!groupStartTime || !nextTime || Math.abs(nextTime - groupStartTime) > timespan) {
|
||||
break
|
||||
}
|
||||
|
||||
images.push(next)
|
||||
i++
|
||||
}
|
||||
|
||||
if (images.length === 1) {
|
||||
result.push(images[0])
|
||||
continue
|
||||
}
|
||||
|
||||
const groupKey = `image-group-${images.map((n) => n.fileid).join('-')}`
|
||||
result.push({
|
||||
_isImageGroup: true,
|
||||
source: groupKey,
|
||||
images,
|
||||
expanded: expandedGroups.value.has(groupKey),
|
||||
})
|
||||
|
||||
if (expandedGroups.value.has(groupKey)) {
|
||||
result.push(...images.map((img) => Object.assign(Object.create(Object.getPrototypeOf(img)), img, { _isGroupChild: true })))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ export interface PathOptions {
|
|||
|
||||
// User config store
|
||||
export interface UserConfig {
|
||||
[key: string]: boolean | string | string[] | number | undefined
|
||||
[key: string]: boolean | string | undefined
|
||||
|
||||
crop_image_previews: boolean
|
||||
default_view: 'files' | 'personal'
|
||||
|
|
@ -59,9 +59,6 @@ export interface UserConfig {
|
|||
grid_view: boolean
|
||||
sort_favorites_first: boolean
|
||||
sort_folders_first: boolean
|
||||
group_recent_files_images: boolean
|
||||
recent_files_group_mimetypes: string[] | string
|
||||
recent_files_group_timespan_minutes: number
|
||||
|
||||
show_files_extensions: boolean
|
||||
show_hidden: boolean
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
|
|||
import FilesAppSettingsAppearance from '../components/FilesAppSettings/FilesAppSettingsAppearance.vue'
|
||||
import FilesAppSettingsGeneral from '../components/FilesAppSettings/FilesAppSettingsGeneral.vue'
|
||||
import FilesAppSettingsLegacyApi from '../components/FilesAppSettings/FilesAppSettingsLegacyApi.vue'
|
||||
import FilesAppSettingsRecent from '../components/FilesAppSettings/FilesAppSettingsRecent.vue'
|
||||
import FilesAppSettingsShortcuts from '../components/FilesAppSettings/FilesAppSettingsShortcuts.vue'
|
||||
import FilesAppSettingsWarnings from '../components/FilesAppSettings/FilesAppSettingsWarnings.vue'
|
||||
import FilesAppSettingsWebDav from '../components/FilesAppSettings/FilesAppSettingsWebDav.vue'
|
||||
|
|
@ -49,16 +48,15 @@ async function showKeyboardShortcuts() {
|
|||
<NcAppSettingsDialog
|
||||
:legacy="false"
|
||||
:name="t('files', 'Files settings')"
|
||||
noVersion
|
||||
no-version
|
||||
:open="open"
|
||||
showNavigation
|
||||
show-navigation
|
||||
@update:open="emit('close')">
|
||||
<FilesAppSettingsGeneral />
|
||||
<FilesAppSettingsAppearance />
|
||||
<FilesAppSettingsLegacyApi />
|
||||
<FilesAppSettingsWarnings />
|
||||
<FilesAppSettingsWebDav />
|
||||
<FilesAppSettingsRecent />
|
||||
<FilesAppSettingsShortcuts />
|
||||
</NcAppSettingsDialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -302,11 +302,11 @@ class ViewControllerTest extends TestCase {
|
|||
'backup_codes' => true,
|
||||
]);
|
||||
|
||||
$invokedCountProvideInitialState = $this->exactly(10);
|
||||
$invokedCountProvideInitialState = $this->exactly(13);
|
||||
$this->initialState->expects($invokedCountProvideInitialState)
|
||||
->method('provideInitialState')
|
||||
->willReturnCallback(function ($key, $data) use ($invokedCountProvideInitialState): void {
|
||||
if ($invokedCountProvideInitialState->numberOfInvocations() === 10) {
|
||||
if ($invokedCountProvideInitialState->numberOfInvocations() === 13) {
|
||||
$this->assertEquals('isTwoFactorEnabled', $key);
|
||||
$this->assertTrue($data);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue