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',
Comment on lines +93 to +99
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about tif or other image formats?
We could just always (if grouping is enabled) group by mime group, e.g. image/* and / or video/*

]
],
[
// 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 [];
}
}
113 changes: 113 additions & 0 deletions apps/files/src/components/FileEntryImageGroup.vue
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO it makes more sense to make this more generic, meaning just make this a group based on mime type. Of cause for now you can use image formats but then we can adjust this if needed anytime and dont create special cases :)

The reason we have comparatively few issues in the files app nowadays is the results of the quite flexible way we changed if when migrated from jQuery.

Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<!--
- 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)">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This must be changed this is not accessible.
You cannot put the event listener on the td element as this is not interactive for keyboard users.

You will have to put in either a proper a or button element with the proper semantics to make this accessible.

<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>
95 changes: 95 additions & 0 deletions apps/files/src/components/FileEntryWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!--
- 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"
:isMimeAvailable="isMimeAvailable"
:isSizeAvailable="isSizeAvailable"
:isMtimeAvailable="isMtimeAvailable"
:class="{ 'files-list__row--group-child': isGroupChild }"
v-bind="$attrs" />
</template>

<script lang="ts">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please directly use script setup syntax for all new components, helping to not create new backlog and simplify boilerplate

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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!--
- 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>
Loading
Loading