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:
@@ -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<String>,
|
||||
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
impl From<File> for FileDto {
|
||||
@@ -73,6 +78,7 @@ impl From<File> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<File>, Vec<i64>)` 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<i64>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
|
||||
let rows: Vec<(
|
||||
String, // id
|
||||
String, // name
|
||||
Option<String>, // folder_id
|
||||
Option<String>, // folder path
|
||||
i64, // size
|
||||
String, // mime_type
|
||||
i64, // created_at
|
||||
i64, // updated_at
|
||||
Option<String>, // 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 {
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user