Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions apps/files/lib/Service/UserConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ class UserConfig {
'default' => true,
'allowed' => [true, false],
],
[
// Which mime types to group together 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;

Expand Down Expand Up @@ -118,7 +139,7 @@ private function getAllowedConfigValues(string $key): array {
* Get the default config value for a given key
*
* @param string $key a valid config key
* @return string|bool
* @return string|bool|int
*/
private function getDefaultConfigValue(string $key) {
foreach (self::ALLOWED_CONFIGS as $config) {
Expand Down Expand Up @@ -146,7 +167,25 @@ public function setConfig(string $key, $value): void {
throw new \InvalidArgumentException('Unknown config key');
}

if (!in_array($value, $this->getAllowedConfigValues($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))) {
throw new \InvalidArgumentException('Invalid config value');
}

Expand Down Expand Up @@ -174,9 +213,27 @@ public function getConfigs(): array {
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 [];
}
}
143 changes: 143 additions & 0 deletions apps/files/src/components/FileEntryImageGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<!--
- 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')"
:model-value="isSelected"
@update:modelValue="onSelectionChange" />
</td>

<td class="files-list__row-name" @click="onRowClick">
<span class="files-list__row-image-group-chevron" @click.stop="$emit('toggle', source.source)">
<ChevronRightIcon v-if="!source.expanded" :size="20" />
<ChevronDownIcon v-else :size="20" />
</span>

<span class="files-list__row-icon">
<ImageMultipleIcon :size="20" />
</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">
import type { PropType } from 'vue'
import type { ImageGroupNode } from '../composables/useImageGrouping.ts'

import { n, t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue'
import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue'
import ImageMultipleIcon from 'vue-material-design-icons/ImageMultiple.vue'
import { useSelectionStore } from '../store/selection.ts'

export default defineComponent({
name: 'FileEntryImageGroup',

components: {
ChevronDownIcon,
ChevronRightIcon,
ImageMultipleIcon,
NcCheckboxRadioSwitch,
},

props: {
source: {
type: Object as PropType<ImageGroupNode>,
required: true,
},
isMimeAvailable: {
type: Boolean,
default: false,
},
isSizeAvailable: {
type: Boolean,
default: false,
},
isMtimeAvailable: {
type: Boolean,
default: false,
},
},

emits: ['toggle'],

setup() {
const selectionStore = useSelectionStore()
return { selectionStore, n, t }
},

computed: {
childSources() {
return this.source.images.map(img => img.source)
},

isSelected() {
return this.childSources.every(src => this.selectionStore.selected.includes(src))
},

isPartiallySelected() {
return !this.isSelected && this.childSources.some(src => this.selectionStore.selected.includes(src))
},
},

methods: {
onSelectionChange(selected: boolean) {
const current = this.selectionStore.selected
if (selected) {
// add all children
this.selectionStore.set([...new Set([...current, ...this.childSources])])
} else {
// remove all children
this.selectionStore.set(current.filter(src => !this.childSources.includes(src)))
}
},

onRowClick() {
this.onSelectionChange(!this.isSelected)
},
},
})
</script>

<style scoped lang="scss">
.files-list__row--image-group {
cursor: pointer;

.files-list__row-image-group-chevron {
display: flex;
align-items: center;
flex: 0 0 24px;
color: var(--color-text-maxcontrast);
cursor: pointer;

&:hover {
color: var(--color-main-text);
}
}

.files-list__row-name-text {
color: var(--color-main-text);
font-weight: bold;
}
}
</style>
81 changes: 81 additions & 0 deletions apps/files/src/components/FileEntryWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<FileEntryImageGroup
v-if="isGroup"
:source="source"
:is-mime-available="isMimeAvailable"
:is-size-available="isSizeAvailable"
:is-mtime-available="isMtimeAvailable"
@toggle="onToggleGroup?.($event)" />

<component
:is="entryComponent"
v-else
:source="source"
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)
},

entryComponent() {
return this.gridMode ? FileEntryGrid : FileEntry
},
},
})
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script lang="ts" setup>
import { ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import { NcInputField, NcSelect } from '@nextcloud/vue'
import debounce from 'debounce'
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>
<label>{{ t('files', 'Group these file types together') }}</label>
<NcSelect
v-model="selectedMimetypes"
:options="availableMimetypes"
label-outside
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:model-value="debouncedUpdateTimespan(Number($event))" />
</NcFormBox>
</NcAppSettingsSection>
</template>
Loading
Loading