feat: photo/video capture-date pipeline + premium UI/UX overhaul

Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists.

Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets.

Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-15 00:17:17 +02:00
parent dac299fea6
commit 81a93a489b
129 changed files with 8194 additions and 2473 deletions
+177
View File
@@ -0,0 +1,177 @@
// Global command palette (Cmd / Ctrl + K).
//
// Lists navigation targets and key actions; running one *clicks the existing
// control* (nav button, menu item, file input) so the palette can never drift
// from the real behaviour. Filtering is a simple case-insensitive substring
// match; keyboard: ↑/↓ to move, Enter to run, Esc to close.
import { i18n } from '../core/i18n.js';
/** @typedef {{ label: string, icon: string, run: () => void }} Command */
const state = {
/** @type {HTMLElement | null} */ overlay: null,
/** @type {HTMLInputElement | null} */ input: null,
/** @type {HTMLElement | null} */ list: null,
/** @type {Command[]} */ items: [],
/** @type {Command[]} */ filtered: [],
index: 0,
/** @type {HTMLElement | null} */ prevFocus: null
};
/** Click an element by selector (no-op if absent). */
function click(selector) {
/** @type {HTMLElement | null} */ (document.querySelector(selector))?.click();
}
/** Activate the sidebar nav button whose label key matches `nav.<section>`. */
function navTo(section) {
document.querySelectorAll('.nav-item').forEach((it) => {
const key = it.querySelector('span[data-i18n]')?.getAttribute('data-i18n');
if (key === `nav.${section}`) /** @type {HTMLElement} */ (it).click();
});
}
/** @returns {Command[]} */
function buildCommands() {
/** @type {Command[]} */
const cmds = [
{ label: i18n.t('nav.files'), icon: 'fa-folder', run: () => navTo('files') },
{ label: i18n.t('nav.shared'), icon: 'fa-oxiexport', run: () => navTo('shared') },
{ label: i18n.t('nav.sharedwithme'), icon: 'fa-oxiimport', run: () => navTo('sharedwithme') },
{ label: i18n.t('nav.recent'), icon: 'fa-clock', run: () => navTo('recent') },
{ label: i18n.t('nav.favorites'), icon: 'fa-star', run: () => navTo('favorites') },
{ label: i18n.t('nav.photos'), icon: 'fa-images', run: () => navTo('photos') },
{ label: i18n.t('nav.music'), icon: 'fa-music', run: () => navTo('music') },
{ label: i18n.t('nav.trash'), icon: 'fa-trash', run: () => navTo('trash') },
{ label: i18n.t('actions.upload_files'), icon: 'fa-cloud-upload-alt', run: () => click('#file-input') },
{ label: i18n.t('user_menu.profile'), icon: 'fa-user-circle', run: () => click('#user-menu-profile') },
{ label: i18n.t('user_menu.about'), icon: 'fa-info-circle', run: () => click('#user-menu-about') },
{ label: i18n.t('actions.logout'), icon: 'fa-sign-out-alt', run: () => click('#user-menu-logout') }
];
const adminBtn = document.getElementById('user-menu-admin');
if (adminBtn && !adminBtn.classList.contains('hidden')) {
cmds.splice(8, 0, {
label: i18n.t('user_menu.admin_panel'),
icon: 'fa-cogs',
run: () => click('#user-menu-admin')
});
}
return cmds;
}
function render() {
if (!state.list) return;
if (!state.filtered.length) {
state.list.innerHTML = `<li class="cmdk-empty">${i18n.t('cmdk.no_results', 'No matching commands')}</li>`;
return;
}
state.list.innerHTML = state.filtered
.map(
(cmd, i) => `
<li class="cmdk-item${i === state.index ? ' is-active' : ''}" role="option" aria-selected="${i === state.index}" data-i="${i}">
<i class="fas ${cmd.icon}" aria-hidden="true"></i>
<span>${cmd.label}</span>
</li>`
)
.join('');
}
function filter(query) {
const q = query.trim().toLowerCase();
state.filtered = q ? state.items.filter((c) => c.label.toLowerCase().includes(q)) : state.items.slice();
state.index = 0;
render();
}
function move(delta) {
if (!state.filtered.length) return;
state.index = (state.index + delta + state.filtered.length) % state.filtered.length;
render();
state.list?.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' });
}
function runActive() {
const cmd = state.filtered[state.index];
close();
cmd?.run();
}
/** @param {KeyboardEvent} e */
function onKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
move(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
move(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
runActive();
}
}
function ensureOverlay() {
if (state.overlay) return;
const overlay = document.createElement('div');
overlay.className = 'cmdk-overlay hidden';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', i18n.t('cmdk.title', 'Command palette'));
overlay.innerHTML = `
<div class="cmdk-panel">
<div class="cmdk-search">
<i class="fas fa-search" aria-hidden="true"></i>
<input type="text" class="cmdk-input" autocomplete="off" spellcheck="false" aria-label="${i18n.t('cmdk.title', 'Command palette')}">
</div>
<ul class="cmdk-list" role="listbox"></ul>
</div>`;
document.body.appendChild(overlay);
state.overlay = overlay;
state.input = /** @type {HTMLInputElement} */ (overlay.querySelector('.cmdk-input'));
state.list = /** @type {HTMLElement} */ (overlay.querySelector('.cmdk-list'));
state.input.placeholder = i18n.t('cmdk.placeholder', 'Type a command…');
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
overlay.addEventListener('keydown', onKeydown);
state.input.addEventListener('input', () => filter(state.input?.value ?? ''));
state.list.addEventListener('click', (e) => {
const li = /** @type {HTMLElement} */ (e.target).closest('.cmdk-item');
if (li instanceof HTMLElement) {
state.index = Number(li.dataset.i);
runActive();
}
});
}
function open() {
ensureOverlay();
state.prevFocus = /** @type {HTMLElement | null} */ (document.activeElement);
state.items = buildCommands();
if (state.input) state.input.value = '';
filter('');
state.overlay?.classList.remove('hidden');
requestAnimationFrame(() => state.overlay?.classList.add('active'));
state.input?.focus();
}
function close() {
if (!state.overlay) return;
state.overlay.classList.remove('active');
state.overlay.classList.add('hidden');
state.prevFocus?.focus?.();
state.prevFocus = null;
}
// Global shortcut: Cmd/Ctrl + K toggles the palette.
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
if (state.overlay && !state.overlay.classList.contains('hidden')) close();
else open();
}
});
+60 -9
View File
@@ -153,6 +153,21 @@ let _groupBy = '';
/** Whether the current sort order is reversed. */
let _reversed = false;
/**
* Flat column sort set by clicking a list-view header. When non-empty it
* overrides the group-by's `orderBy` and forces `_groupBy = ''`, so the list
* is a flat sort (no swimlanes) — the Drive/Finder column-sort behaviour.
* '' = follow the active group-by.
* @type {string}
*/
let _sortField = '';
/** The order field the server is currently sorting by (group-by or flat). */
function _currentOrderBy() {
if (_sortField) return _sortField;
return GROUP_BY_DEFS.find((d) => d.key === _groupBy)?.orderBy ?? 'name';
}
// ── Group-by controller (public API, consumed by navigation.js / main.js) ───
/**
@@ -177,8 +192,11 @@ const filesView = {
* @param {string} key '' | 'type' | 'modifiedAt' | 'createdAt' | 'size'
*/
setGroupBy(key) {
if (_groupBy === key) return;
// Re-pick the same group while a flat column-sort is active → still
// exit flat-sort and return to grouping, so don't early-return then.
if (_groupBy === key && !_sortField) return;
_groupBy = key;
_sortField = ''; // grouping takes over → follow the group's order field
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
_nextCursor = null;
_component?.clear();
@@ -197,6 +215,29 @@ const filesView = {
_nextCursor = null;
_component?.clear();
_loadPage({ isFirstPage: true });
},
/**
* Flat-sort the list by a column field (clicking a list-view header).
* Drops any grouping (no swimlanes); re-picking the active field flips the
* direction. Synchronous state update — read `currentSort` right after to
* refresh the header arrow, the async reload follows.
* @param {string} field 'name' | 'type' | 'size' | 'modified_at'
*/
setSortField(field) {
const sameField = _currentOrderBy() === field;
_reversed = sameField ? !_reversed : false;
_sortField = field;
_groupBy = '';
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
_nextCursor = null;
_component?.clear();
_loadPage({ isFirstPage: true });
},
/** Current effective sort `{ field, reversed }` — for the column-header arrows. */
get currentSort() {
return { field: _currentOrderBy(), reversed: _reversed };
}
};
@@ -315,7 +356,9 @@ async function _loadPage({ isFirstPage = false } = {}) {
try {
const def = GROUP_BY_DEFS.find((d) => d.key === _groupBy);
const orderBy = def?.orderBy ?? 'name';
// Flat column-sort (`_sortField`) overrides the group's order field. When
// it's set, `_groupBy` is '' so `def` has no keyFn → a flat list.
const orderBy = _currentOrderBy();
const { items, nextCursor } = await fetchResourcesPage(app.currentPath, {
cursor: _nextCursor,
@@ -334,6 +377,10 @@ async function _loadPage({ isFirstPage = false } = {}) {
if (isFirstPage) {
_component?.render(items, def?.keyFn, def?.labelFn);
// Keep the list-view column-header arrow in sync however the sort
// changed — header click, toolbar group-by, or direction toggle.
// No-op in grid view (no sortable headers present).
ui.updateListHeaderSort?.();
} else {
_component?.append(items, def?.keyFn, def?.labelFn);
}
@@ -399,14 +446,18 @@ async function loadFiles(options = { insertHistory: true }) {
_groupBy = _savedPrefs.groupBy;
_reversed = _savedPrefs.reversed;
// Delay spinner so fast loads avoid the flash
// Delay the loading skeleton so fast loads avoid a flash of placeholders.
// Skeleton cards/rows hold the layout (no empty-list flash) and mirror the
// current view; reduced-motion is honoured via the global guard.
const spinnerTimeout = setTimeout(() => {
ui.showError(`
<div class="files-loading-spinner">
<div class="spinner"></div>
<span>${i18n.t('files.loading')}</span>
</div>
`);
const isList = app.currentView === 'list';
const cell = isList
? '<div class="skeleton-row"><div class="skeleton skeleton-icon"></div><div class="skeleton skeleton-line skeleton-line--medium"></div><div class="skeleton skeleton-line skeleton-line--short"></div></div>'
: '<div class="skeleton-card"><div class="skeleton skeleton-thumb"></div><div class="skeleton skeleton-line skeleton-line--medium"></div><div class="skeleton skeleton-line skeleton-line--short"></div></div>';
const wrapper = isList ? 'files-skeleton' : 'files-grid-view files-skeleton';
ui.showError(
`<div class="${wrapper}" role="status" aria-busy="true" aria-label="${i18n.t('files.loading')}">${cell.repeat(8)}</div>`
);
}, 100);
// A temporary guard: _loadPage sets _loading itself, but we need to
+1 -1
View File
@@ -371,7 +371,7 @@ function setupActionsBarDelegation() {
if (recent) {
await recent.clearRecentFiles();
await recentView.init();
ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
ui.showNotification(i18n.t('notif.cleanupCompleted'), i18n.t('notif.cleanupCompletedBody'));
}
break;
default:
+34 -1
View File
@@ -73,16 +73,25 @@ function initSidebarToggle() {
if (!sidebarToggle || !sidebar || !sidebarOverlay) return;
/** @type {HTMLElement | null} */
let drawerPrevFocus = null;
function openSidebar() {
drawerPrevFocus = /** @type {HTMLElement | null} */ (document.activeElement);
sidebar?.classList.add('open');
sidebarOverlay?.classList.add('active');
document.body.style.overflow = 'hidden';
sidebarToggle?.setAttribute('aria-expanded', 'true');
/** @type {HTMLElement | null} */ (sidebar?.querySelector('.nav-item'))?.focus();
}
function closeSidebar() {
sidebar?.classList.remove('open');
sidebarOverlay?.classList.remove('active');
document.body.style.overflow = '';
sidebarToggle?.setAttribute('aria-expanded', 'false');
drawerPrevFocus?.focus?.();
drawerPrevFocus = null;
}
function toggleSidebar() {
@@ -99,6 +108,24 @@ function initSidebarToggle() {
// Close sidebar when clicking overlay
sidebarOverlay.addEventListener('click', closeSidebar);
// Trap Tab focus inside the open drawer (mobile).
sidebar.addEventListener('keydown', (e) => {
if (e.key !== 'Tab' || !sidebar.classList.contains('open')) return;
const f = /** @type {NodeListOf<HTMLElement>} */ (
sidebar.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')
);
if (!f.length) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
// Close sidebar on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sidebar.classList.contains('open')) {
@@ -155,7 +182,13 @@ function setCurrentSection(section) {
// Update nav item active classes by finding matching item from DOM
appElements.navItems?.forEach((item) => {
const itemSection = getSectionFromNavItem(item);
item.classList.toggle('active', itemSection === section);
const isActive = itemSection === section;
item.classList.toggle('active', isActive);
if (isActive) {
item.setAttribute('aria-current', 'page');
} else {
item.removeAttribute('aria-current');
}
});
// Update page title
+2 -1
View File
@@ -2,6 +2,7 @@
* Search view orchestration logic
*/
import { i18n } from '../core/i18n.js';
import { search } from '../features/files/search.js';
import { resolveHomeFolder } from './authSession.js';
import { app } from './state.js';
@@ -50,7 +51,7 @@ async function performSearch(query, sortBy) {
search.displaySearchResults(searchResults);
} catch (error) {
console.error('Search error:', error);
ui.showNotification('Error', 'Error performing search');
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.searchError'));
}
}
+62 -7
View File
@@ -16,7 +16,7 @@ import { wopiEditor } from '../features/files/wopiEditor.js';
import { recent } from '../features/library/recent.js';
import { buildBatchDownloadUrl } from '../utils/download.js';
import { positionMenu } from '../utils/menuPosition.js';
import { loadFiles } from './filesView.js';
import { loadFiles, filesView } from './filesView.js';
import { updateHistory } from './main.js';
import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js';
import { app } from './state.js';
@@ -497,10 +497,14 @@ const ui = {
separator.textContent = '>';
breadcrumb?.appendChild(separator);
// Segment item
// Segment item. The home folder's raw name ("My Folder - admin")
// is a technical default — show the friendly "Files" label for it
// (the real name stays as the tooltip).
const item = document.createElement('span');
item.className = 'breadcrumb-item';
item.textContent = segment.name;
const isHomeFolder = app.userHomeFolderId && segment.id === app.userHomeFolderId;
item.textContent = isHomeFolder ? i18n.t('nav.files') : segment.name;
if (isHomeFolder) item.title = segment.name;
item.dataset.folderId = segment.id;
if (!isLast) {
@@ -1007,29 +1011,80 @@ const ui = {
// Let ui.js delegation handle this container again
delete filesList.dataset.managedBy;
// A clickable, flat-sorting column header (Drive-style). The arrow is
// shown only on the active column; direction toggles on re-click.
const sortCol = (field, key, label) =>
`<button type="button" class="list-header-sort" data-sort-field="${field}">` +
`<span data-i18n="${key}">${label}</span>` +
`<i class="fas fa-arrow-up list-header-sort__arrow hidden" aria-hidden="true"></i></button>`;
filesList.innerHTML = `
<div class="list-header">
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
<div data-i18n="files.name">Name</div>
${sortCol('name', 'files.name', 'Name')}
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-i18n="files.owner">Owner</div>
<div data-i18n="files.type">Type</div>
<div data-i18n="files.size">Size</div>
<div data-i18n="files.modified">Modified</div>
${sortCol('type', 'files.type', 'Type')}
${sortCol('size', 'files.size', 'Size')}
${sortCol('modified_at', 'files.modified', 'Modified')}
<div></div><!-- actions -->
</div>`;
i18n.translateElement(filesList);
// Wire column-header sorting; _loadPage doesn't rebuild the header, so
// reflect the new sort in-place after each click.
filesList.querySelectorAll('.list-header-sort').forEach((el) => {
el.addEventListener('click', () => {
filesView.setSortField(/** @type {HTMLElement} */ (el).dataset.sortField || 'name');
this.updateListHeaderSort();
});
});
this.updateListHeaderSort();
filesList.classList.remove('hidden');
filesContainerError?.classList.add('hidden');
},
/**
* Reflect filesView's active sort on the list-view column headers: highlight
* the active column, point its arrow, and describe the state for AT.
* @returns {void}
*/
updateListHeaderSort() {
const { field, reversed } = filesView.currentSort;
document.querySelectorAll('#files-list .list-header-sort').forEach((el) => {
const btn = /** @type {HTMLElement} */ (el);
const active = btn.dataset.sortField === field;
btn.classList.toggle('is-active', active);
const colName = btn.querySelector('span')?.textContent || '';
btn.setAttribute(
'aria-label',
active
? `${colName}: ${reversed ? i18n.t('sort.desc', 'descending') : i18n.t('sort.asc', 'ascending')}`
: colName
);
const arrow = btn.querySelector('.list-header-sort__arrow');
if (arrow) {
arrow.classList.toggle('hidden', !active);
arrow.classList.toggle('fa-arrow-down', active && reversed);
arrow.classList.toggle('fa-arrow-up', !(active && reversed));
}
});
},
showEmptyList() {
this.showError(`
<i class="fas fa-folder-open empty-state-icon"></i>
<p data-i18n="files.no_files"></p>
<p data-i18n="files.empty_hint"></p>
<button type="button" class="btn btn-primary" id="empty-upload-btn">
<i class="fas fa-cloud-upload-alt" aria-hidden="true"></i>
<span data-i18n="actions.upload_files">Upload files</span>
</button>
`);
document.getElementById('empty-upload-btn')?.addEventListener('click', () => {
document.getElementById('file-input')?.click();
});
},
/**
+5
View File
@@ -4,6 +4,7 @@
*/
import { notifications } from '../core/notifications.js';
import { announce } from '../core/liveRegion.js';
const uiNotifications = {
/**
@@ -40,6 +41,10 @@ const uiNotifications = {
title: title || '',
text: message || ''
});
// Announce to screen readers — errors interrupt, everything else is polite.
const spoken = [title, message].filter(Boolean).join('. ');
announce(spoken, { assertive: iconClass === 'error' });
return;
}
};
+8 -2
View File
@@ -2,7 +2,7 @@
* User menu, profile modal and logout logic
*/
import { createUserVignette } from '../components/userVignette.js';
import { createUserVignette, disposeVignette } from '../components/userVignette.js';
import { getCsrfHeaders } from '../core/csrf.js';
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
@@ -183,11 +183,17 @@ function setupUserMenu() {
function _mountAvatarVignettes(userId) {
const avatarBtn = document.getElementById('user-avatar-btn');
if (avatarBtn) {
avatarBtn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
// Dispose the outgoing vignette's tooltip before replacing it, or its
// body-portalled popover orphans (the "email tooltip stays stuck" bug).
disposeVignette(/** @type {HTMLElement | null} */ (avatarBtn.firstElementChild));
// No hover-tooltip on your own avatar — the email is already in the open
// menu header, so it'd be redundant and overlap the notification bell.
avatarBtn.replaceChildren(createUserVignette(userId, 'menu', { showName: false, noTooltip: true }));
}
const menuHeader = document.querySelector('.user-menu-header');
if (menuHeader) {
disposeVignette(/** @type {HTMLElement | null} */ (menuHeader.firstElementChild));
menuHeader.replaceChildren(createUserVignette(userId, 'xl', { showName: true, showEmail: true }));
}
}