feat(ui): improve groupby / sortby user experience + fix confusion in trash section

This commit is contained in:
Edouard Vanbelle
2026-05-30 11:15:46 +02:00
parent ae82f4b664
commit 19e8af84b7
10 changed files with 108 additions and 33 deletions
+15 -2
View File
@@ -33,8 +33,8 @@ import { uiNotifications } from './uiNotifications.js';
/** @import {FileItem, FolderItem} from '../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string,
* keyFn: (item: FileItem|FolderItem) => string|null,
* @typedef {{ key: string, label: string, icon?: string, orderBy: string,
* keyFn?: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string }} GroupByDef
*/
@@ -48,11 +48,21 @@ import { uiNotifications } from './uiNotifications.js';
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: '',
get label() {
return i18n.t('files.name', 'Name');
},
icon: 'fas fa-arrow-up-a-z',
orderBy: 'name'
// no keyFn → flat list.
},
{
key: 'type',
get label() {
return i18n.t('groupby.type', 'Type');
},
icon: 'fas fa-layer-group',
orderBy: 'type',
// Folders → 'Folder'; files → their pre-computed category string.
keyFn: (item) => ('mime_type' in item ? /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
@@ -82,6 +92,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.size', 'Size');
},
icon: 'fas fa-layer-group',
orderBy: 'size',
// sizeBucket(-1) → "Folders" sentinel; no labelFn needed.
keyFn: (item) => {
@@ -95,6 +106,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.modifiedAt', 'Modified date');
},
icon: 'fas fa-layer-group',
orderBy: 'modified_at',
// keyFn returns the human-readable bucket; the bucket IS the key.
keyFn: (item) => {
@@ -107,6 +119,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.createdAt', 'Created date');
},
icon: 'fas fa-layer-group',
orderBy: 'created_at',
keyFn: (item) => {
const r = /** @type {Record<string, number>} */ (/** @type {unknown} */ (item));
+15 -13
View File
@@ -86,7 +86,6 @@ const _toggleButtons = `
<div class="group-by-selector hidden" id="group-by-selector">
<button class="toggle-btn group-by-btn" id="group-by-btn"
title="Group by" data-i18n-title="groupby.title">
<i class="fas fa-layer-group"></i>
<span class="group-by-label"></span>
</button>
<button class="toggle-btn sort-dir-btn" id="sort-dir-btn"
@@ -168,7 +167,6 @@ const ACTIONS_BAR_TEMPLATES = {
<div class="group-by-selector hidden" id="group-by-selector">
<button class="toggle-btn group-by-btn" id="group-by-btn"
title="Group by" data-i18n-title="groupby.title">
<i class="fas fa-layer-group"></i>
<span class="group-by-label"></span>
</button>
<button class="toggle-btn sort-dir-btn" id="sort-dir-btn"
@@ -251,7 +249,7 @@ let _groupByDocumentClickHandler = null;
* Must be called AFTER `setActionsBarMode()` so the selector elements exist
* in the DOM.
*
* @param {Array<{key: string, label: string}>} [defs]
* @param {Array<{key: string, label: string, icon?: string}>} [defs]
*/
function syncGroupByMenu(defs = []) {
const selector = document.getElementById('group-by-selector');
@@ -274,15 +272,18 @@ function syncGroupByMenu(defs = []) {
return;
}
// Rebuild menu options — call i18n.t() directly so each label is resolved
// at call time (translations are loaded by the time any section switch runs).
// A def with key='' lets the section override the default "None" label.
const noneOverride = defs.find((d) => d.key === '');
const noneLabel = noneOverride ? noneOverride.label : i18n.t('groupby.none', 'None');
menu.innerHTML = `<button class="group-by-option active" data-group-by="">${escapeHtml(noneLabel)}</button>`;
// Rebuild menu options — call i18n.t() at call time so labels are
// localised. Each def supplies its own icon. A section opts into an
// ungrouped/sorted entry by including a def with key=''.
menu.innerHTML = '';
for (const def of defs) {
if (def.key === '') continue;
menu.insertAdjacentHTML('beforeend', `<button class="group-by-option" data-group-by="${escapeHtml(def.key)}">${escapeHtml(def.label)}</button>`);
const iconClass = def.icon ?? 'fas fa-layer-group';
menu.insertAdjacentHTML(
'beforeend',
`<button class="group-by-option" data-group-by="${escapeHtml(def.key)}">` +
`<i class="${escapeHtml(iconClass)}"></i> ${escapeHtml(def.label)}` +
`</button>`
);
}
// One stable document-level handler to close the menu on outside clicks.
@@ -314,9 +315,10 @@ function setupActionsBarDelegation() {
btn.classList.add('active');
document.getElementById('group-by-menu')?.classList.add('hidden');
const groupByBtn = document.getElementById('group-by-btn');
groupByBtn?.classList.toggle('active', key !== '');
// Always active — there's no neutral "None" state anymore.
groupByBtn?.classList.add('active');
const lbl = groupByBtn?.querySelector('.group-by-label');
if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : '';
if (lbl) lbl.innerHTML = btn.innerHTML; // clone icon + label markup
// Changing order-by dimension resets direction to ascending
_groupByView?.setDirection(false);
document.getElementById('sort-dir-btn')?.classList.remove('active');
+2
View File
@@ -417,6 +417,8 @@ function switchToTrashSection() {
setActionsBarMode('trash');
setGroupByView(trashView);
syncGroupByMenu(trashView.groupByDefs);
const trashPrefs = viewPrefs.load('trash');
applyGroupByMenuState(trashPrefs.groupBy || 'remainingDays', trashPrefs.reversed);
//reset files view + remove any error
ui.resetFilesList();
+6 -9
View File
@@ -22,7 +22,8 @@
*
* No-op when the menu elements are not in the DOM (e.g. before initApp).
*
* @param {string} groupBy Active group-by key, or `''` for "None".
* @param {string} groupBy Active group-by key, e.g. `''` for the "Name"
* / flat-sort option a section opts into.
* @param {boolean} reversed Whether sort direction is reversed.
*/
function applyGroupByMenuState(groupBy, reversed) {
@@ -32,18 +33,14 @@ function applyGroupByMenuState(groupBy, reversed) {
btn.classList.toggle('active', (btn.dataset.groupBy ?? '') === groupBy);
}
// Group-by button: active class + label text.
// Group-by button always reflects the active option (icon + label).
const groupByBtn = document.getElementById('group-by-btn');
groupByBtn?.classList.toggle('active', groupBy !== '');
groupByBtn?.classList.add('active');
const lbl = groupByBtn?.querySelector('.group-by-label');
if (lbl) {
if (groupBy === '') {
lbl.textContent = '';
} else {
const activeOpt = /** @type {HTMLElement|null} */ (document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`));
lbl.textContent = activeOpt?.textContent ?? '';
}
const activeOpt = /** @type {HTMLElement|null} */ (document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`));
lbl.innerHTML = activeOpt?.innerHTML ?? '';
}
// Sort-direction button: active = reversed.
+4
View File
@@ -37,6 +37,10 @@ const OxiIcons = {
512,
'M214.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 109.3 160 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-370.7 105.4 105.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z'
],
'arrow-up-a-z': [
576,
'M183.6 42.4C177.5 35.8 169 32 160 32s-17.5 3.8-23.6 10.4l-88 96c-11.9 13-11.1 33.3 2 45.2s33.3 11.1 45.2-2L128 146.3 128 448c0 17.7 14.3 32 32 32s32-14.3 32-32l0-301.7 32.4 35.4c11.9 13 32.2 13.9 45.2 2s13.9-32.2 2-45.2l-88-96zM320 320c0 17.7 14.3 32 32 32l50.7 0-73.4 73.4c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l128 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-50.7 0 73.4-73.4c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-128 0c-17.7 0-32 14.3-32 32zM416 32c-12.1 0-23.2 6.8-28.6 17.7l-64 128-16 32c-7.9 15.8-1.5 35 14.3 42.9s35 1.5 42.9-14.3l7.2-14.3 88.4 0 7.2 14.3c7.9 15.8 27.1 22.2 42.9 14.3s22.2-27.1 14.3-42.9l-16-32-64-128C439.2 38.8 428.1 32 416 32zM395.8 176L416 135.6 436.2 176l-40.4 0z'
],
'arrows-alt': [
512,
'M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 96-96 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6l0-32 96 0 0 96-32 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0 0-96 96 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 32-96 0 0-96 32 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64z'
+16 -2
View File
@@ -31,8 +31,8 @@ import { systemUsers } from '../../model/systemUsers.js';
/** @import {FavoritesResourceItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string,
* keyFn: (item: FileItem|FolderItem) => string|null,
* @typedef {{ key: string, label: string, icon?: string, orderBy: string,
* keyFn?: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string,
* headerNodeFn?: (key: string) => HTMLElement }} GroupByDef
*/
@@ -44,11 +44,21 @@ import { systemUsers } from '../../model/systemUsers.js';
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: '',
get label() {
return i18n.t('files.name', 'Name');
},
icon: 'fas fa-arrow-up-a-z',
orderBy: 'name'
// no keyFn → flat list.
},
{
key: 'owner',
get label() {
return i18n.t('groupby.owner', 'Owner');
},
icon: 'fas fa-layer-group',
orderBy: 'owner',
// keyFn groups by UUID — stable, avoids collisions on identical display names.
keyFn: (item) => {
@@ -63,6 +73,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.type', 'Type');
},
icon: 'fas fa-layer-group',
orderBy: 'type',
// keyFn: folders → 'Folder' swimlane; files → their `category` field.
keyFn: (item) => ('mime_type' in item ? /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
@@ -92,6 +103,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.size', 'Size');
},
icon: 'fas fa-layer-group',
orderBy: 'size',
// Folders have no size — sizeBucket(-1) returns the "Folders" label.
keyFn: (item) => {
@@ -105,6 +117,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.favoriteDate', 'Favorite date');
},
icon: 'fas fa-layer-group',
orderBy: 'favorited_at',
// sort_date is stored as unix seconds in _mapItems().
keyFn: (item) => {
@@ -117,6 +130,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.modifiedAt', 'Modified date');
},
icon: 'fas fa-layer-group',
orderBy: 'modified_at',
// modified_at is a unix seconds timestamp on FileItem/FolderItem.
keyFn: (item) => {
+3 -1
View File
@@ -22,7 +22,7 @@ import { grants } from '../../model/grants.js';
/** @import {FileItem, FolderItem} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string }} GroupByDef
* @typedef {{ key: string, label: string, icon?: string, orderBy: string }} GroupByDef
* @typedef {'items'|'sharedWith'} ViewMode
*/
@@ -42,6 +42,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.byFiles', 'By files');
},
icon: 'fas fa-layer-group',
orderBy: 'type'
},
{
@@ -49,6 +50,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.sharedWith', 'Shared with');
},
icon: 'fas fa-layer-group',
orderBy: 'subject'
}
];
+16 -2
View File
@@ -32,8 +32,8 @@ import { systemUsers } from '../../model/systemUsers.js';
/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string,
* keyFn: (item: FileItem|FolderItem) => string|null,
* @typedef {{ key: string, label: string, icon?: string, orderBy: string,
* keyFn?: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string,
* headerNodeFn?: (key: string) => HTMLElement }} GroupByDef
*/
@@ -54,11 +54,21 @@ import { systemUsers } from '../../model/systemUsers.js';
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: '',
get label() {
return i18n.t('files.name', 'Name');
},
icon: 'fas fa-arrow-up-a-z',
orderBy: 'name'
// no keyFn → flat list.
},
{
key: 'owner',
get label() {
return i18n.t('groupby.owner', 'Owner');
},
icon: 'fas fa-layer-group',
orderBy: 'owner',
keyFn: (item) => {
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
@@ -72,6 +82,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.type', 'Type');
},
icon: 'fas fa-layer-group',
orderBy: 'type',
keyFn: (item) => ('mime_type' in item ? /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
labelFn: (key) => {
@@ -100,6 +111,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.size', 'Size');
},
icon: 'fas fa-layer-group',
orderBy: 'size',
keyFn: (item) => {
if (!('mime_type' in item)) return sizeBucket(-1);
@@ -112,6 +124,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.accessedAt', 'Accessed date');
},
icon: 'fas fa-layer-group',
orderBy: 'accessed_at',
// sort_date is unix seconds set in _mapItems(); keyFn returns the bucket label.
keyFn: (item) => {
@@ -124,6 +137,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.modifiedAt', 'Modified date');
},
icon: 'fas fa-layer-group',
orderBy: 'modified_at',
keyFn: (item) => {
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
@@ -25,8 +25,8 @@ import { systemUsers } from '../../model/systemUsers.js';
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string,
* keyFn: (item: FileItem|FolderItem) => string|null,
* @typedef {{ key: string, label: string, icon?: string, orderBy: string,
* keyFn?: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string,
* headerNodeFn?: (key: string) => HTMLElement }} GroupByDef
*/
@@ -44,6 +44,15 @@ import { systemUsers } from '../../model/systemUsers.js';
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: '',
get label() {
return i18n.t('files.name', 'Name');
},
icon: 'fas fa-arrow-up-a-z',
orderBy: 'name'
// no keyFn → flat list.
},
{
key: 'owner',
// label is accessed via syncGroupByMenu → read at section-switch time,
@@ -51,6 +60,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.owner', 'Owner');
},
icon: 'fas fa-layer-group',
orderBy: 'granted_by',
// keyFn groups by UUID — stable and unique, avoids collisions between
// users with the same display name.
@@ -67,6 +77,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.type', 'Type');
},
icon: 'fas fa-layer-group',
orderBy: 'type',
// keyFn: folders get their own swimlane; files use the pre-computed
// `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …).
@@ -99,6 +110,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.size', 'Size');
},
icon: 'fas fa-layer-group',
orderBy: 'size',
// keyFn: the key IS the bucket label returned by sizeBucket(), so no
// separate labelFn is needed (same pattern as shareDate).
@@ -115,6 +127,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.shareDate', 'Share date');
},
icon: 'fas fa-layer-group',
orderBy: 'granted_at',
// keyFn returns the human-readable bucket label; the label IS the key
// because consecutive items with the same bucket should be in one group.
+16 -2
View File
@@ -28,8 +28,8 @@ import { fetchTrashPage } from '../../model/trashModel.js';
/** @import {FileItem, FolderItem, ResourceTypeEnum, TrashResourceItem} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string, reverseDefault?: boolean,
* keyFn: (item: FileItem|FolderItem) => string|null,
* @typedef {{ key: string, label: string, icon?: string, orderBy: string, reverseDefault?: boolean,
* keyFn?: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string,
* headerNodeFn?: (key: string) => HTMLElement }} GroupByDef
*/
@@ -49,11 +49,22 @@ import { fetchTrashPage } from '../../model/trashModel.js';
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: '',
get label() {
return i18n.t('files.name', 'Name');
},
icon: 'fas fa-arrow-up-a-z',
orderBy: 'name',
reverseDefault: false
// no keyFn → ResourceListComponent renders a flat list (server pins folders first).
},
{
key: 'remainingDays',
get label() {
return i18n.t('trash.groupby.remaining_days', 'Remaining days');
},
icon: 'fas fa-layer-group',
orderBy: 'deletion_date',
reverseDefault: false,
keyFn: (item) => {
@@ -66,6 +77,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.type', 'Type');
},
icon: 'fas fa-layer-group',
orderBy: 'type',
reverseDefault: false,
keyFn: (item) => ('mime_type' in item ? /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
@@ -95,6 +107,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('groupby.size', 'Size');
},
icon: 'fas fa-layer-group',
orderBy: 'size',
reverseDefault: false,
keyFn: (item) => {
@@ -108,6 +121,7 @@ const GROUP_BY_DEFS = [
get label() {
return i18n.t('trash.groupby.trashed_time', 'Trashed time');
},
icon: 'fas fa-layer-group',
orderBy: 'trashed_at',
reverseDefault: false,
keyFn: (item) => {