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
+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();
});
},
/**