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:
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user