From 53e4f5afe60488810d8b545d48b33c1f28ef1c13 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Thu, 5 Mar 2026 13:40:02 -0500 Subject: [PATCH] feat(photos): add Photos timeline view with lightbox and infinite scroll Backend: new GET /api/photos endpoint with cursor-based pagination that queries image/video files sorted by EXIF captured_at (falling back to created_at), joining file_metadata for sort dates. Frontend: dense photo grid grouped by day with lazy-loaded thumbnails, IntersectionObserver infinite scroll, multi-select with batch download/delete, and a full-screen lightbox with prev/next navigation, EXIF metadata display, and download/favorite/delete toolbar. Includes navigation wiring, CSS (with dark theme), and i18n translations for all 9 locales. --- src/application/dtos/file_dto.rs | 7 + .../pg/file_blob_read_repository.rs | 60 ++++ .../services/path_resolver_service.rs | 1 + src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/photos_handler.rs | 76 ++++ src/interfaces/api/routes.rs | 11 + static/css/views/photos.css | 258 ++++++++++++++ static/css/views/photosLightbox.css | 194 +++++++++++ static/index.html | 8 + static/js/app/main.js | 10 +- static/js/app/navigation.js | 31 +- static/js/app/state.js | 1 + static/js/features/library/photos.js | 328 ++++++++++++++++++ static/js/features/library/photosLightbox.js | 281 +++++++++++++++ static/locales/de.json | 6 + static/locales/en.json | 6 + static/locales/es.json | 6 + static/locales/fa.json | 6 + static/locales/fr.json | 6 + static/locales/it.json | 6 + static/locales/nl.json | 6 + static/locales/pt.json | 6 + static/locales/zh.json | 6 + 23 files changed, 1318 insertions(+), 3 deletions(-) create mode 100644 src/interfaces/api/handlers/photos_handler.rs create mode 100644 static/css/views/photos.css create mode 100644 static/css/views/photosLightbox.css create mode 100644 static/js/features/library/photos.js create mode 100644 static/js/features/library/photosLightbox.js diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 18cc815a..1fba6812 100755 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -51,6 +51,11 @@ pub struct FileDto { /// Owner user ID (omitted from JSON when None) #[serde(skip_serializing_if = "Option::is_none")] pub owner_id: Option, + + /// Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at). + /// Only populated by the /api/photos endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_date: Option, } impl From for FileDto { @@ -73,6 +78,7 @@ impl From for FileDto { category: Arc::from(category_for(name, mime)), size_formatted: format_file_size(size), owner_id: file.owner_id().map(String::from), + sort_date: None, } } } @@ -112,6 +118,7 @@ impl FileDto { category: Arc::from("Document"), size_formatted: "0 Bytes".to_string(), owner_id: None, + sort_date: None, } } } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 6207d6fb..b7ecaa37 100755 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -144,6 +144,66 @@ impl FileBlobReadRepository { self.hash_cache.insert(file_id.to_owned(), hash.clone()); Ok(hash) } + + /// Lists all image/video files for a user, sorted by capture date (EXIF) or + /// creation date, with cursor-based pagination for the Photos timeline. + /// + /// Returns `(Vec, Vec)` where the second vec contains the + /// `sort_date` epoch for each file (used as pagination cursor). + pub async fn list_media_files( + &self, + owner_id: &str, + before: Option, + limit: i64, + ) -> Result<(Vec, Vec), DomainError> { + let rows: Vec<( + String, // id + String, // name + Option, // folder_id + Option, // folder path + i64, // size + String, // mime_type + i64, // created_at + i64, // updated_at + Option, // user_id + i64, // sort_date + )> = sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text, + EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint AS sort_date + 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::uuid + AND NOT fi.is_trashed + AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') + AND ($2::bigint IS NULL + OR EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint < $2::bigint) + ORDER BY COALESCE(fm.captured_at, fi.created_at) DESC + LIMIT $3 + "#, + ) + .bind(owner_id) + .bind(before) + .bind(limit) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_media: {e}")))?; + + let mut files = Vec::with_capacity(rows.len()); + let mut sort_dates = Vec::with_capacity(rows.len()); + + for (id, name, fid, fpath, size, mime, ca, ma, uid, sd) in rows { + files.push(Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)?); + sort_dates.push(sd); + } + + Ok((files, sort_dates)) + } } impl FileReadPort for FileBlobReadRepository { diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index f237afe6..60a0b391 100755 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -173,6 +173,7 @@ impl PathResolverService { category: Arc::from(category_for(&name, &mime)), size_formatted: format_file_size(sz), owner_id: uid, + sort_date: None, })) } } diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 5dc06917..0e7048e2 100755 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -11,6 +11,7 @@ pub mod favorites_handler; pub mod file_handler; pub mod folder_handler; pub mod i18n_handler; +pub mod photos_handler; pub mod recent_handler; pub mod search_handler; pub mod share_handler; diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs new file mode 100644 index 00000000..9f8304fd --- /dev/null +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -0,0 +1,76 @@ +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; +use std::sync::Arc; +use tracing::{error, info}; + +use crate::application::dtos::file_dto::FileDto; +use crate::common::di::AppState; +use crate::interfaces::middleware::auth::AuthUser; + +/// Query parameters for the photos timeline endpoint. +#[derive(Deserialize)] +pub struct PhotosQueryParams { + /// Cursor: only return items with sort_date < this value (epoch seconds). + pub before: Option, + /// Max items to return (default 200, max 500). + pub limit: Option, +} + +/// Lists all image/video files for the authenticated user, sorted by +/// capture date (EXIF DateTimeOriginal) falling back to upload date. +/// +/// Supports cursor-based pagination via the `before` parameter. +/// The `X-Next-Cursor` response header contains the cursor for the next page. +pub async fn list_photos( + State(state): State>, + auth_user: AuthUser, + Query(params): Query, +) -> impl IntoResponse { + let user_id = &auth_user.id; + let limit = params.limit.unwrap_or(200).min(500).max(1); + + let file_read = &state.repositories.file_read_repository; + + match file_read.list_media_files(user_id, params.before, limit).await { + Ok((files, sort_dates)) => { + info!("Photos: returned {} media files for user", files.len()); + + // Convert to DTOs with sort_date populated + let dtos: Vec = files + .into_iter() + .zip(sort_dates.iter()) + .map(|(file, &sd)| { + let mut dto = FileDto::from(file); + dto.sort_date = Some(sd as u64); + dto + }) + .collect(); + + // Set cursor header for next page + let mut response = Json(&dtos).into_response(); + if let Some(&last_sd) = sort_dates.last() { + response.headers_mut().insert( + "X-Next-Cursor", + last_sd.to_string().parse().unwrap(), + ); + } + + response + } + Err(err) => { + error!("Error listing photos: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to list photos: {}", err) + })), + ) + .into_response() + } + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 266d0715..1062df7b 100755 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -309,6 +309,17 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .nest("/favorites", favorites_router) .nest("/recent", recent_router); + // Photos timeline endpoint — lists all image/video files sorted by capture date + { + use crate::interfaces::api::handlers::photos_handler; + + let photos_router = Router::new() + .route("/", get(photos_handler::list_photos)) + .with_state(app_state.clone()); + + router = router.nest("/photos", photos_router); + } + // Re-enable trash routes to make the trash view work if let Some(_trash_service_ref) = trash_service.clone() { tracing::info!("Setting up trash routes for trash view"); diff --git a/static/css/views/photos.css b/static/css/views/photos.css new file mode 100644 index 00000000..e6476ba3 --- /dev/null +++ b/static/css/views/photos.css @@ -0,0 +1,258 @@ +/* Photos timeline view */ +.photos-container { + padding: 0; + display: none; +} + +.photos-container.active { + display: block; +} + +/* Day group header */ +.photos-day-header { + position: sticky; + top: 0; + z-index: 10; + padding: 12px 4px 8px; + font-size: 15px; + font-weight: 600; + color: #2d3748; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.photos-day-header .photos-day-count { + font-weight: 400; + color: #94a3b8; + font-size: 13px; + margin-left: 8px; +} + +/* Photo grid */ +.photos-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 4px; + padding: 0 4px 4px; +} + +/* Individual photo tile */ +.photo-tile { + position: relative; + aspect-ratio: 1; + overflow: hidden; + border-radius: 4px; + cursor: pointer; + background: #e2e8f0; +} + +.photo-tile img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.2s ease; +} + +.photo-tile:hover img { + transform: scale(1.05); +} + +/* Selection checkbox */ +.photo-tile .photo-check { + position: absolute; + top: 6px; + left: 6px; + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.8); + background: rgba(0, 0, 0, 0.25); + opacity: 0; + transition: opacity 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 11px; + z-index: 2; +} + +.photo-tile:hover .photo-check, +.photo-tile.selected .photo-check { + opacity: 1; +} + +.photo-tile.selected .photo-check { + background: #ff5e3a; + border-color: #ff5e3a; +} + +.photo-tile.selected { + outline: 3px solid #ff5e3a; + outline-offset: -3px; +} + +.photo-tile.selected img { + transform: scale(0.92); +} + +/* Video badge */ +.photo-tile .video-badge { + position: absolute; + bottom: 6px; + right: 6px; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 12px; + z-index: 2; +} + +/* Duration badge for videos */ +.photo-tile .video-duration { + position: absolute; + bottom: 6px; + left: 6px; + font-size: 11px; + color: #fff; + background: rgba(0, 0, 0, 0.55); + padding: 2px 6px; + border-radius: 4px; + z-index: 2; +} + +/* Empty state */ +.photos-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + text-align: center; + color: #94a3b8; +} + +.photos-empty i { + font-size: 56px; + margin-bottom: 16px; + color: #cbd5e1; +} + +.photos-empty p { + margin: 4px 0; + font-size: 15px; +} + +.photos-empty .photos-empty-title { + font-size: 18px; + font-weight: 600; + color: #64748b; +} + +/* Infinite scroll sentinel */ +.photos-sentinel { + height: 1px; + width: 100%; +} + +/* Loading spinner */ +.photos-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: #94a3b8; + font-size: 14px; + gap: 8px; +} + +.photos-loading i { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Selection bar */ +.photos-selection-bar { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: #1e293b; + color: #fff; + padding: 10px 20px; + border-radius: 12px; + display: flex; + align-items: center; + gap: 16px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); + z-index: 1000; + font-size: 14px; +} + +.photos-selection-bar button { + background: none; + border: none; + color: #fff; + cursor: pointer; + padding: 6px 10px; + border-radius: 6px; + font-size: 14px; + transition: background 0.15s; +} + +.photos-selection-bar button:hover { + background: rgba(255, 255, 255, 0.15); +} + +.photos-selection-bar .selection-count { + font-weight: 600; +} + +/* Responsive */ +@media (max-width: 768px) { + .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 2px; + padding: 0 2px 2px; + } + + .photo-tile .photo-check { + opacity: 1; + } + + .photos-day-header { + font-size: 14px; + padding: 10px 2px 6px; + } +} + +/* Dark theme */ +[data-theme="dark"] .photos-day-header { + color: #e2e8f0; + background: rgba(15, 23, 42, 0.92); +} + +[data-theme="dark"] .photo-tile { + background: #334155; +} + +[data-theme="dark"] .photos-empty i { + color: #475569; +} + +[data-theme="dark"] .photos-empty .photos-empty-title { + color: #94a3b8; +} + +[data-theme="dark"] .photos-empty p { + color: #64748b; +} diff --git a/static/css/views/photosLightbox.css b/static/css/views/photosLightbox.css new file mode 100644 index 00000000..47eaee58 --- /dev/null +++ b/static/css/views/photosLightbox.css @@ -0,0 +1,194 @@ +/* Photos lightbox overlay */ +.photos-lightbox { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.92); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.photos-lightbox.active { + opacity: 1; + pointer-events: auto; +} + +/* Main content area */ +.lightbox-content { + position: relative; + max-width: 90vw; + max-height: 85vh; + display: flex; + align-items: center; + justify-content: center; +} + +.lightbox-content img, +.lightbox-content video { + max-width: 90vw; + max-height: 85vh; + object-fit: contain; + border-radius: 4px; + user-select: none; +} + +/* Navigation arrows */ +.lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font-size: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + z-index: 10001; +} + +.lightbox-nav:hover { + background: rgba(255, 255, 255, 0.25); +} + +.lightbox-prev { + left: 20px; +} + +.lightbox-next { + right: 20px; +} + +/* Close button */ +.lightbox-close { + position: absolute; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + z-index: 10001; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* Top info bar */ +.lightbox-info { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 16px 70px 16px 20px; + background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); + color: #fff; + z-index: 10001; +} + +.lightbox-filename { + font-size: 15px; + font-weight: 600; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lightbox-meta { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +/* Bottom toolbar */ +.lightbox-toolbar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 16px 20px; + background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + z-index: 10001; +} + +.lightbox-toolbar button { + background: rgba(255, 255, 255, 0.12); + border: none; + color: #fff; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: background 0.15s; +} + +.lightbox-toolbar button:hover { + background: rgba(255, 255, 255, 0.25); +} + +.lightbox-toolbar button.active { + color: #ff5e3a; +} + +/* Counter */ +.lightbox-counter { + position: absolute; + bottom: 16px; + left: 20px; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + z-index: 10001; +} + +/* Responsive */ +@media (max-width: 768px) { + .lightbox-nav { + width: 36px; + height: 36px; + font-size: 16px; + } + + .lightbox-prev { + left: 8px; + } + + .lightbox-next { + right: 8px; + } + + .lightbox-content img, + .lightbox-content video { + max-width: 100vw; + max-height: 80vh; + } +} diff --git a/static/index.html b/static/index.html index cf098ace..b9734f55 100755 --- a/static/index.html +++ b/static/index.html @@ -14,6 +14,8 @@ + + @@ -32,6 +34,8 @@ + + @@ -81,6 +85,10 @@ Favorites +