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.
This commit is contained in:
Jared Wolff
2026-03-05 13:40:02 -05:00
parent 69fe3a8b07
commit 53e4f5afe6
23 changed files with 1318 additions and 3 deletions
+1
View File
@@ -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;
@@ -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<i64>,
/// Max items to return (default 200, max 500).
pub limit: Option<i64>,
}
/// 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<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<PhotosQueryParams>,
) -> 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<FileDto> = 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()
}
}
}
+11
View File
@@ -309,6 +309,17 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.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");