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
+39
View File
@@ -48,6 +48,10 @@ const Modal = {
/** @private */
_savedBodyHTML: '',
// Element focused before the modal opened — focus returns here on close.
/** @private @type {HTMLElement|null} */
_previousFocus: null,
/**
* Initialize modal system
*/
@@ -80,6 +84,11 @@ const Modal = {
}
});
// Trap Tab focus within the dialog while it is open.
this.overlay.addEventListener('keydown', (e) => {
if (e.key === 'Tab') this._trapFocus(e);
});
// Clear inline error as soon as the user starts typing
this.input?.addEventListener('input', () => this.clearError());
@@ -227,12 +236,38 @@ const Modal = {
});
},
/**
* Keep Tab focus cycling within the dialog container (focus trap).
* @private
* @param {KeyboardEvent} e
*/
_trapFocus(e) {
const container = this.overlay?.querySelector('.modal-container');
if (!container) return;
const focusables = /** @type {NodeListOf<HTMLElement>} */ (
container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
},
/**
* Open the modal
*/
open() {
if (!this.overlay) return;
this._previousFocus = /** @type {HTMLElement|null} */ (document.activeElement);
this.confirmBtn.disabled = false;
// Show overlay
@@ -295,6 +330,10 @@ const Modal = {
this._panelMode = false;
this._savedBodyHTML = '';
}
// Return focus to whatever was focused before the modal opened.
this._previousFocus?.focus?.();
this._previousFocus = null;
}, 200);
},
+45 -5
View File
@@ -18,7 +18,7 @@
// @ts-check
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatRelativeTime } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { systemUsers } from '../model/systemUsers.js';
import { buildResourceIcon } from './resourceIcon.js';
@@ -37,6 +37,23 @@ import { createUserVignette } from './userVignette.js';
*/
const JUST_ADDED_KEY = '__oxicloud_just_added__';
/**
* Recency label for a grid card's metadata line: a relative "time ago"
* ("hace 3 días") within the last ~30 days — the dominant retrieval cue under
* a thumbnail — collapsing to a compact absolute date ("13 jun 2026") beyond
* that, so stale files don't read as a vague "hace 8 meses".
* @param {number|string|Date|null|undefined} value Unix seconds, ISO string or Date.
* @returns {string}
*/
function gridMetaDate(value) {
if (!value) return '';
const date =
value instanceof Date ? value : new Date(typeof value === 'number' && value < 1e12 ? value * 1000 : value);
if (Number.isNaN(date.getTime())) return '';
const ageDays = (Date.now() - date.getTime()) / 86_400_000;
return ageDays > 30 ? formatDateShort(value) : formatRelativeTime(value);
}
/**
* @typedef {Object} CustomAction
* @property {string} iconHtml - Inner HTML for the button icon (e.g. `<i class="fas fa-undo"></i>`).
@@ -681,16 +698,24 @@ export class ResourceListComponent {
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false;
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at;
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal));
// Pass the raw value (Unix seconds) straight to formatDateTime — it does
// the seconds→ms conversion. Wrapping it in new Date() first treated the
// seconds value as milliseconds → every date showed as Jan 1970.
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(dateVal);
const relDate = gridMetaDate(dateVal);
el.innerHTML = `
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
<div class="name-cell">
<div class="resource-icon-slot"></div>
<span>${escapeHtml(folder.name)}</span>
<span title="${escapeHtml(folder.name)}">${escapeHtml(folder.name)}</span>
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
</div>
<div class="grid-meta" title="${escapeHtml(formattedDate)}">
<span class="grid-meta__date">${escapeHtml(relDate)}</span>
${isShared && folder.owner_id ? `<span class="grid-meta__owner-slot" data-owner-id="${escapeHtml(folder.owner_id)}"></span>` : ''}
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(folder.path || '')}">${escapeHtml(folder.path || '')}</div>` : ''}
${cfg.showType ? `<div class="type-cell">${labels.folderTypeLabel}</div>` : ''}
@@ -704,6 +729,9 @@ export class ResourceListComponent {
`;
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
if (isShared && folder.owner_id) {
el.querySelector('.grid-meta__owner-slot')?.replaceWith(createUserVignette(folder.owner_id, 'xs', { showName: false }));
}
return el;
}
@@ -718,7 +746,11 @@ export class ResourceListComponent {
const typeLabel = labels.fileTypeLabel(file.category || '');
const fileSize = file.size_formatted || formatFileSize(file.size);
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (file))[cfg.dateField] ?? file.modified_at;
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal));
// Pass the raw value (Unix seconds) straight to formatDateTime — it does
// the seconds→ms conversion. Wrapping it in new Date() first treated the
// seconds value as milliseconds → every date showed as Jan 1970.
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(dateVal);
const relDate = gridMetaDate(dateVal);
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
@@ -736,11 +768,16 @@ export class ResourceListComponent {
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
<div class="name-cell">
<div class="resource-icon-slot"></div>
<span>${escapeHtml(file.name)}</span>
<span title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span>
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
${file.snippet ? `<span class="file-item__snippet" title="${escapeHtml(file.snippet)}">${escapeHtml(file.snippet)}</span>` : ''}
</div>
<div class="grid-meta" title="${escapeHtml(formattedDate)}">
<span class="grid-meta__date">${escapeHtml(relDate)}</span>
<span class="grid-meta__size">${escapeHtml(fileSize)}</span>
${isShared && file.owner_id ? `<span class="grid-meta__owner-slot" data-owner-id="${escapeHtml(file.owner_id)}"></span>` : ''}
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(file.path || '')}">${escapeHtml(file.path || '')}</div>` : ''}
${cfg.showType ? `<div class="type-cell">${typeLabel}</div>` : ''}
@@ -754,6 +791,9 @@ export class ResourceListComponent {
`;
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
if (isShared && file.owner_id) {
el.querySelector('.grid-meta__owner-slot')?.replaceWith(createUserVignette(file.owner_id, 'xs', { showName: false }));
}
return el;
}
+29 -3
View File
@@ -91,6 +91,10 @@ function _applyPhoto(avatar, photoUrl, name) {
* When true (and showName is true), the primary email address is shown below
* the name in a lighter style. Name and email are wrapped in a
* `.user-vignette__info` column. Has no effect when showName is false.
* @property {boolean} [noTooltip=false]
* When true, skip the email hover-tooltip. Use for the user's own avatar in
* the toolbar — the email is already shown in the open menu header, so the
* tooltip is redundant (and would overlap the bell).
* @property {boolean} [showOrigin=true]
* When true (the default), an `is_external` badge overlays the
* bottom-right of the avatar for external users only — internal
@@ -108,7 +112,14 @@ function _applyPhoto(avatar, photoUrl, name) {
* @param {VignetteOptions} [options]
* @returns {HTMLElement}
*/
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false, showOrigin = true } = {}) {
// Tracks the hover-tooltip cleanup for each vignette so a caller that
// re-renders the vignette (e.g. replaceChildren on a storage update) can
// dispose the previous tooltip — otherwise its body-portalled popover orphans
// and, if it was visible at re-render time, stays stuck on screen.
/** @type {WeakMap<HTMLElement, () => void>} */
const _tooltipCleanups = new WeakMap();
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false, showOrigin = true, noTooltip = false } = {}) {
const colorIdx = _colorIndex(userId);
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
@@ -182,9 +193,9 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
// than the native `title` attribute's ~500–1500 ms wait).
// `aria-label` is set in parallel so screen readers still get
// the email — popover content is mouse/keyboard-hover only.
if (email && !showEmail) {
if (email && !showEmail && !noTooltip) {
wrapper.setAttribute('aria-label', email);
attachTooltip(wrapper, email);
_tooltipCleanups.set(wrapper, attachTooltip(wrapper, email));
}
if (photo) {
_applyPhoto(avatar, photo, name);
@@ -214,3 +225,18 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
return wrapper;
}
/**
* Dispose a vignette before discarding it: tears down its hover-tooltip and
* removes the body-portalled popover so nothing orphans on re-render.
* Safe to call on any element (no-op if it has no tracked tooltip).
* @param {HTMLElement | null} el
*/
export function disposeVignette(el) {
if (!el) return;
const cleanup = _tooltipCleanups.get(el);
if (cleanup) {
cleanup();
_tooltipCleanups.delete(el);
}
}