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
+69
View File
@@ -8,6 +8,9 @@
//!
//! Performance: < 1µs for the `infer` check (reads only header bytes, no allocation).
use std::path::Path;
use tokio::io::AsyncReadExt;
/// Maximum bytes needed for magic-byte detection. Upload ingestion peeks
/// this many bytes off the stream before forwarding them unchanged.
pub const MAGIC_BYTES_LEN: usize = 8192;
@@ -53,6 +56,58 @@ pub fn refine_content_type(buf: &[u8], filename: &str, claimed: &str) -> String
claimed.to_string()
}
/// Detect the `Content-Type` to serve for an already-encoded thumbnail.
///
/// The slow encode path re-encodes to JPEG, but the fast path stores the
/// source image as-is (PNG / GIF / WebP), so the handler must not blindly
/// claim `image/jpeg`. Detects the real format from magic bytes, defaulting
/// to `image/jpeg` (the slow-path output) when detection is inconclusive.
pub fn thumbnail_content_type(data: &[u8]) -> &'static str {
infer::get(data)
.map(|kind| kind.mime_type())
.filter(|mime| mime.starts_with("image/"))
.unwrap_or("image/jpeg")
}
/// Async helper: reads the first bytes of a file on disk and refines the MIME type.
///
/// Designed for the upload path where the file has been spooled to a temp path.
pub async fn refine_content_type_from_file(
temp_path: &Path,
filename: &str,
claimed: &str,
) -> String {
// Fast path: if the client gave us a specific type, trust it
if !claimed.is_empty()
&& claimed != "application/octet-stream"
&& claimed != "binary/octet-stream"
{
return claimed.to_string();
}
// Read only the first bytes needed for magic detection (not the whole file).
match tokio::fs::File::open(temp_path).await {
Ok(mut file) => {
let mut buf = vec![0u8; MAGIC_BYTES_LEN];
let n = file.read(&mut buf).await.unwrap_or(0);
refine_content_type(&buf[..n], filename, claimed)
}
Err(e) => {
tracing::warn!(
"MIME detection: failed to read {} for magic bytes: {}",
temp_path.display(),
e
);
// Fall back to extension
let guess = mime_guess::from_path(filename);
if let Some(mime) = guess.first() {
return mime.to_string();
}
claimed.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -80,6 +135,20 @@ mod tests {
assert_eq!(result, "image/jpeg");
}
#[test]
fn thumbnail_content_type_detects_real_format() {
// Fast-path thumbnails keep the source format — serve it accurately.
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
assert_eq!(thumbnail_content_type(png), "image/png");
let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";
assert_eq!(thumbnail_content_type(jpeg), "image/jpeg");
// Inconclusive bytes default to JPEG (the slow-path encoder output).
assert_eq!(thumbnail_content_type(b"garbage"), "image/jpeg");
assert_eq!(thumbnail_content_type(b""), "image/jpeg");
}
#[test]
fn binary_octet_stream_also_triggers_detection() {
let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";