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
+125
View File
@@ -705,6 +705,9 @@ async function showInitialPanel() {
showPanel(loginPanel);
hidePanel(registerPanel);
hidePanel(adminSetupPanel);
// Cursor ready on the identifier field (panels start hidden, so a static
// `autofocus` attribute wouldn't fire — focus once the panel is shown).
/** @type {HTMLInputElement | null} */ (document.getElementById('login-username'))?.focus();
// Hide the admin setup link if admin already exists
const showAdminSetupLink = document.getElementById('show-admin-setup');
@@ -807,6 +810,7 @@ function initLoginElements() {
showPanel(loginPanel);
hidePanel(registerPanel);
hidePanel(adminSetupPanel);
/** @type {HTMLInputElement | null} */ (document.getElementById('login-username'))?.focus();
});
document.getElementById('show-admin-setup').addEventListener('click', () => {
@@ -821,9 +825,119 @@ function initLoginElements() {
hidePanel(adminSetupPanel);
});
// UX affordances: password reveal, magic-link disclosure, live match feedback,
// Caps-Lock warning on password fields.
initPasswordToggles();
initMagicLinkDisclosure();
initPasswordMatch('register-password', 'register-password-confirm', 'register-match');
initPasswordMatch('admin-password', 'admin-password-confirm', 'admin-match');
['login-password', 'register-password', 'admin-password'].forEach((id) =>
initCapsLockWarning(/** @type {HTMLInputElement | null} */ (document.getElementById(id)))
);
return true;
}
/**
* Wire every password show/hide toggle. Each button sits next to the
* <input> it controls inside an `.auth-input-wrap`.
*/
function initPasswordToggles() {
document.querySelectorAll('[data-pw-toggle]').forEach((btn) => {
const wrap = btn.closest('.auth-input-wrap');
const input = /** @type {HTMLInputElement | null} */ (wrap?.querySelector('input') ?? null);
if (!input) return;
const sync = () => {
const shown = input.type === 'text';
btn.setAttribute('aria-pressed', String(shown));
btn.setAttribute(
'aria-label',
shown ? i18n.t('auth.hidePassword', 'Hide password') : i18n.t('auth.showPassword', 'Show password')
);
};
sync();
btn.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
sync();
input.focus();
});
});
}
/**
* Show a "Caps Lock is on" hint under a password field while the key is active
* and the field is focused — saves a failed-login round-trip.
* @param {HTMLInputElement | null} input
*/
function initCapsLockWarning(input) {
if (!input) return;
const warn = document.createElement('div');
warn.className = 'auth-caps-warning hidden';
warn.setAttribute('role', 'status');
const icon = document.createElement('i');
icon.className = 'fas fa-arrow-up';
icon.setAttribute('aria-hidden', 'true');
const text = document.createElement('span');
text.textContent = i18n.t('auth.capsLock', 'Caps Lock is on');
warn.append(icon, text);
input.closest('.auth-input-group')?.appendChild(warn);
/** @param {KeyboardEvent} e */
const sync = (e) => {
const on = typeof e.getModifierState === 'function' && e.getModifierState('CapsLock');
warn.classList.toggle('hidden', !(on && document.activeElement === input));
};
input.addEventListener('keydown', sync);
input.addEventListener('keyup', sync);
input.addEventListener('blur', () => warn.classList.add('hidden'));
}
/**
* Progressive disclosure: keep the magic-link form collapsed behind a quiet
* toggle so the login screen leads with a single primary path.
*/
function initMagicLinkDisclosure() {
const toggle = document.getElementById('magic-link-toggle');
const reveal = document.getElementById('magic-link-reveal');
if (!toggle || !reveal) return;
toggle.addEventListener('click', () => {
// classList.toggle returns true when the class is now PRESENT (collapsed).
const open = reveal.classList.toggle('hidden') === false;
toggle.setAttribute('aria-expanded', String(open));
if (open) {
const email = /** @type {HTMLInputElement | null} */ (document.getElementById('magic-link-email'));
email?.focus();
}
});
}
/**
* Live "passwords match" feedback rendered under a confirm field.
* @param {string} passId
* @param {string} confirmId
* @param {string} indicatorId
*/
function initPasswordMatch(passId, confirmId, indicatorId) {
const pass = /** @type {HTMLInputElement | null} */ (document.getElementById(passId));
const confirmEl = /** @type {HTMLInputElement | null} */ (document.getElementById(confirmId));
const out = document.getElementById(indicatorId);
if (!pass || !confirmEl || !out) return;
const update = () => {
if (!confirmEl.value) {
out.className = 'auth-match';
out.textContent = '';
return;
}
const ok = pass.value === confirmEl.value;
out.className = `auth-match show ${ok ? 'auth-match--ok' : 'auth-match--bad'}`;
out.textContent = ok
? i18n.t('auth.passwordsMatch', 'Passwords match')
: i18n.t('auth.passwords_mismatch', "Passwords don't match");
};
pass.addEventListener('input', update);
confirmEl.addEventListener('input', update);
}
// Initialize login elements if on login page
const isLoginPage = initLoginElements();
@@ -993,6 +1107,13 @@ if (isLoginPage && loginForm) {
const username = inputVal('login-username');
const password = inputVal('login-password');
const loginSubmit = /** @type {HTMLButtonElement | null} */ (
document.getElementById('login-submit')
);
loginSubmit?.classList.add('is-loading');
loginSubmit?.setAttribute('aria-busy', 'true');
if (loginSubmit) loginSubmit.disabled = true;
try {
const data = await login(username, password);
@@ -1030,6 +1151,10 @@ if (isLoginPage && loginForm) {
} catch (error) {
loginError.textContent = errMessage(error) || 'Error logging in';
loginError.style.display = 'block';
} finally {
loginSubmit?.classList.remove('is-loading');
loginSubmit?.removeAttribute('aria-busy');
if (loginSubmit) loginSubmit.disabled = false;
}
});
}