Skip to content
Open
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
67 changes: 65 additions & 2 deletions apps/files/lib/Service/UserConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ 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;

Expand Down Expand Up @@ -118,7 +145,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 +173,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 +219,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 [];
}
}
145 changes: 145 additions & 0 deletions apps/files/src/components/FileEntryImageGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!--
- 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">
<ChevronRightIcon v-if="!source.expanded" :size="20" />
<ChevronDownIcon v-else :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) {
// select all children
this.selectionStore.set([...new Set([...current, ...this.childSources])])
} else {
// unselect 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 {
.files-list__row-name {
cursor: pointer;
* {
cursor: pointer;
}
}

.files-list__row-image-group-chevron {
display: flex;
align-items: center;
color: var(--color-text-maxcontrast);
}

.files-list__row-name-text {
color: var(--color-main-text);
}
}
</style>
92 changes: 92 additions & 0 deletions apps/files/src/components/FileEntryWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!--
- 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>
Loading
Loading