From 8d09589588926d97cadee150efe72ab9c2be6714 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:15:20 +0000 Subject: [PATCH] feat(photos): expose image width/height on the /api/photos timeline list_media_files now LEFT JOINs storage.file_metadata and returns each photo's pixel dimensions next to the sort date. The endpoint wraps FileDto in a flattened PhotoDto carrying width/height, so the gallery can lay tiles out at their true aspect ratio (justified layout) without a second per-file metadata round-trip and without layout shift. FileItem gains optional width/height. No change to FileDto or its other construction sites. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- .../pg/file_blob_read_repository.rs | 14 ++++++--- src/interfaces/api/handlers/photos_handler.rs | 31 +++++++++++++++---- static/js/core/types.js | 2 ++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index f2fefdfe..524f36d8 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -20,6 +20,8 @@ type MediaFileRow = ( String, // blob_hash Option, // user_id i64, // sort_date + Option, // width + Option, // height ); use bytes::Bytes; @@ -370,7 +372,7 @@ impl FileBlobReadRepository { owner_id: Uuid, before: Option, limit: i64, - ) -> Result<(Vec, Vec), DomainError> { + ) -> Result<(Vec, Vec, Vec<(Option, Option)>), DomainError> { let rows: Vec = sqlx::query_as( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, @@ -379,9 +381,11 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, fi.user_id, - EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date + EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date, + fm.width, fm.height FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id WHERE fi.user_id = $1 AND NOT fi.is_trashed AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') @@ -400,15 +404,17 @@ impl FileBlobReadRepository { let mut files = Vec::with_capacity(rows.len()); let mut sort_dates = Vec::with_capacity(rows.len()); + let mut dims = Vec::with_capacity(rows.len()); - for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows { + for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows { files.push(Self::row_to_file( id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, )?); sort_dates.push(sd); + dims.push((w, h)); } - Ok((files, sort_dates)) + Ok((files, sort_dates, dims)) } } diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index 1eb6c8c3..d1110f43 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -4,7 +4,7 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{error, info}; @@ -21,6 +21,20 @@ pub struct PhotosQueryParams { pub limit: Option, } +/// Photos-timeline item: a `FileDto` plus the image's original pixel +/// dimensions (from EXIF/metadata), flattened into the same JSON shape so +/// the gallery can lay tiles out at their true aspect ratio without a +/// second per-file metadata round-trip. +#[derive(Serialize)] +struct PhotoDto { + #[serde(flatten)] + file: FileDto, + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + height: Option, +} + /// Lists all image/video files for the authenticated user, sorted by /// capture date (EXIF DateTimeOriginal) falling back to upload date. /// @@ -55,17 +69,22 @@ pub async fn list_photos( .list_media_files(user_id, params.before, limit) .await { - Ok((files, sort_dates)) => { + Ok((files, sort_dates, dims)) => { info!("Photos: returned {} media files for user", files.len()); - // Convert to DTOs with sort_date populated - let dtos: Vec = files + // Convert to DTOs with sort_date + pixel dimensions populated. + let dtos: Vec = files .into_iter() .zip(sort_dates.iter()) - .map(|(file, &sd)| { + .zip(dims.iter()) + .map(|((file, &sd), &(w, h))| { let mut dto = FileDto::from(file); dto.sort_date = Some(sd as u64); - dto + PhotoDto { + file: dto, + width: w.map(|v| v.max(0) as u32), + height: h.map(|v| v.max(0) as u32), + } }) .collect(); diff --git a/static/js/core/types.js b/static/js/core/types.js index 0c12efb2..c8a5b2c8 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -45,6 +45,8 @@ * @property {number} size * @property {string} size_formatted * @property {number} sort_date + * @property {number} [width] original pixel width (photos timeline only) + * @property {number} [height] original pixel height (photos timeline only) * @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match * @property {string} content_hash raw BLAKE3 content hash, for dedup checks * @property {string} [snippet] plain-text fragment around a content match (search results only)