diff --git a/src/common/di.rs b/src/common/di.rs index 99febd7e..c89374b0 100755 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -24,7 +24,8 @@ use crate::common::config::AppConfig; use crate::common::errors::DomainError; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::{ - FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository, TrashDbRepository, + FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository, + TrashDbRepository, }; use crate::infrastructure::services::file_content_cache::{ FileContentCache, FileContentCacheConfig, diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index b7ecaa37..ce8525a2 100755 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -178,7 +178,7 @@ impl FileBlobReadRepository { 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 + WHERE fi.user_id = $1 AND NOT fi.is_trashed AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') AND ($2::bigint IS NULL @@ -198,7 +198,9 @@ impl FileBlobReadRepository { 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)?); + files.push(Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, uid, + )?); sort_dates.push(sd); } diff --git a/src/infrastructure/repositories/pg/file_metadata_repository.rs b/src/infrastructure/repositories/pg/file_metadata_repository.rs index 3b7b76fc..e7c6d305 100644 --- a/src/infrastructure/repositories/pg/file_metadata_repository.rs +++ b/src/infrastructure/repositories/pg/file_metadata_repository.rs @@ -100,7 +100,17 @@ impl FileMetadataRepository { })?; Ok(row.map( - |(file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height)| { + |( + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + )| { StoredMetadata { file_id, captured_at, @@ -152,7 +162,18 @@ impl FileMetadataRepository { })?; let mut map = HashMap::with_capacity(rows.len()); - for (file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height) in rows { + for ( + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + ) in rows + { map.insert( file_id.clone(), StoredMetadata { diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 0e24650c..baf9b598 100755 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -31,9 +31,9 @@ pub use contact_persistence_dto::*; pub use contact_pg_repository::ContactPgRepository; pub use device_code_pg_repository::DeviceCodePgRepository; pub use favorites_pg_repository::FavoritesPgRepository; -pub use file_metadata_repository::FileMetadataRepository; pub use file_blob_read_repository::FileBlobReadRepository; pub use file_blob_write_repository::FileBlobWriteRepository; +pub use file_metadata_repository::FileMetadataRepository; pub use folder_db_repository::FolderDbRepository; pub use nextcloud_object_id_repository::NextcloudObjectIdRepository; pub use recent_items_pg_repository::RecentItemsPgRepository; diff --git a/src/infrastructure/services/exif_service.rs b/src/infrastructure/services/exif_service.rs index ef912ed2..577948bc 100644 --- a/src/infrastructure/services/exif_service.rs +++ b/src/infrastructure/services/exif_service.rs @@ -61,13 +61,23 @@ impl ExifService { // ── Camera info ── if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) { - let val = field.display_value().to_string().trim_matches('"').trim().to_string(); + let val = field + .display_value() + .to_string() + .trim_matches('"') + .trim() + .to_string(); if !val.is_empty() { meta.camera_make = Some(val); } } if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) { - let val = field.display_value().to_string().trim_matches('"').trim().to_string(); + let val = field + .display_value() + .to_string() + .trim_matches('"') + .trim() + .to_string(); if !val.is_empty() { meta.camera_model = Some(val); } @@ -159,7 +169,7 @@ fn parse_u32_value(value: &exif::Value) -> Option { /// to match the intended orientation. pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::DynamicImage { match orientation { - 1 => img, // Normal + 1 => img, // Normal 2 => image::DynamicImage::from(image::imageops::flip_horizontal(&img)), // Mirror horizontal 3 => image::DynamicImage::from(image::imageops::rotate180(&img)), // Rotate 180° 4 => image::DynamicImage::from(image::imageops::flip_vertical(&img)), // Mirror vertical @@ -168,13 +178,13 @@ pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::D let flipped = image::imageops::flip_horizontal(&img); image::DynamicImage::from(image::imageops::rotate270(&flipped)) } - 6 => image::DynamicImage::from(image::imageops::rotate90(&img)), // Rotate 90° CW + 6 => image::DynamicImage::from(image::imageops::rotate90(&img)), // Rotate 90° CW 7 => { // Transverse: flip horizontal then rotate 90° let flipped = image::imageops::flip_horizontal(&img); image::DynamicImage::from(image::imageops::rotate90(&flipped)) } - 8 => image::DynamicImage::from(image::imageops::rotate270(&img)), // Rotate 270° CW + 8 => image::DynamicImage::from(image::imageops::rotate270(&img)), // Rotate 270° CW _ => img, } } diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index f75a7a95..2ae18fd4 100755 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -271,7 +271,9 @@ impl ThumbnailService { // Apply EXIF orientation so thumbnails display correctly let img = { - use crate::infrastructure::services::exif_service::{ExifService, apply_orientation}; + use crate::infrastructure::services::exif_service::{ + ExifService, apply_orientation, + }; let orientation = ExifService::extract(&data) .and_then(|m| m.orientation) .unwrap_or(1); @@ -360,7 +362,9 @@ impl ThumbnailService { // Apply EXIF orientation so thumbnails display correctly let img = { - use crate::infrastructure::services::exif_service::{ExifService, apply_orientation}; + use crate::infrastructure::services::exif_service::{ + ExifService, apply_orientation, + }; let orientation = ExifService::extract(&data) .and_then(|m| m.orientation) .unwrap_or(1); diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 66a69e7a..9f123a3b 100755 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -642,7 +642,11 @@ impl FileHandler { } } Err(e) => { - tracing::warn!("Failed to read file for EXIF extraction {}: {}", file_id, e); + tracing::warn!( + "Failed to read file for EXIF extraction {}: {}", + file_id, + e + ); } } } diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index 9f8304fd..e83965db 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -36,7 +36,10 @@ pub async fn list_photos( let file_read = &state.repositories.file_read_repository; - match file_read.list_media_files(user_id, params.before, limit).await { + 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()); @@ -54,10 +57,9 @@ pub async fn list_photos( // 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 + .headers_mut() + .insert("X-Next-Cursor", last_sd.to_string().parse().unwrap()); } response diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index d59d756d..15d1efe1 100755 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -183,11 +183,7 @@ async fn handle_webdav_methods( /// If `path` doesn't already start with the user's home folder name, prepend /// the home folder path so downstream services can find the resource in the DB. /// Returns `None` when the path already includes the prefix or resolution fails. -async fn resolve_webdav_path( - state: &Arc, - user_id: &str, - path: &str, -) -> Option { +async fn resolve_webdav_path(state: &Arc, user_id: &str, path: &str) -> Option { let folder_service = &state.applications.folder_service; let home_folders = folder_service .list_folders_for_owner(None, user_id) @@ -213,10 +209,7 @@ async fn handle_webdav_dispatch( // prefix when the path doesn't already include it. // Extract user_id before any async call to keep the future Send. let path = if !path.is_empty() && method.as_str() != "OPTIONS" { - let user_id = req - .extensions() - .get::() - .map(|u| u.id.clone()); + let user_id = req.extensions().get::().map(|u| u.id.clone()); if let Some(uid) = user_id { resolve_webdav_path(&state, &uid, &path) .await diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 0fb23bdf..106dbc75 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -265,6 +265,7 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes category: category_for(&fr.name, &fr.mime_type).to_string().into(), size_formatted: format_file_size(fr.size), owner_id: None, + sort_date: None, } } diff --git a/static/css/views/photos.css b/static/css/views/photos.css index e6476ba3..77cf26ad 100644 --- a/static/css/views/photos.css +++ b/static/css/views/photos.css @@ -8,18 +8,28 @@ display: block; } -/* Day group header */ +/* Toolbar with group mode toggle */ +.photos-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 8px 8px 4px; +} + +/* Toggle buttons — wider for text labels */ +.photos-toolbar .toggle-btn { + width: auto; + padding: 0 14px; + font-size: 13px; + font-weight: 500; +} + +/* Group header */ .photos-day-header { - position: sticky; - top: 0; - z-index: 10; - padding: 12px 4px 8px; + padding: 16px 8px 10px; 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 { @@ -29,12 +39,35 @@ margin-left: 8px; } -/* Photo grid */ +/* Photo grid — base (daily mode) */ .photos-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px; - padding: 0 4px 4px; + padding: 0 8px; + margin-bottom: 16px; +} + +/* Monthly mode — larger tiles, more breathing room */ +.photos-group-monthly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 6px; +} + +.photos-group-monthly .photos-day-header { + font-size: 17px; + padding: 20px 8px 12px; +} + +/* Yearly mode — smaller tiles, more items visible */ +.photos-group-yearly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 4px; +} + +.photos-group-yearly .photos-day-header { + font-size: 20px; + padding: 24px 8px 14px; } /* Individual photo tile */ @@ -222,7 +255,16 @@ .photos-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 2px; - padding: 0 2px 2px; + padding: 0 2px; + margin-bottom: 8px; + } + + .photos-group-monthly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + } + + .photos-group-yearly .photos-grid { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); } .photo-tile .photo-check { @@ -231,14 +273,17 @@ .photos-day-header { font-size: 14px; - padding: 10px 2px 6px; + padding: 10px 4px 6px; + } + + .photos-toolbar { + padding: 6px 4px 2px; } } /* Dark theme */ [data-theme="dark"] .photos-day-header { color: #e2e8f0; - background: rgba(15, 23, 42, 0.92); } [data-theme="dark"] .photo-tile { diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 278b525f..a5ea2e2c 100755 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -67,6 +67,7 @@ const _ICONS = { "globe": [512, "M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"], "hdd": [512, "M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 184.4c-17-15.2-39.4-24.4-64-24.4L64 256c-24.6 0-47 9.2-64 24.4L0 96zM64 288l384 0c35.3 0 64 28.7 64 64l0 64c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64zM320 416a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm128-32a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"], "id-card": [576, "M0 96l576 0c0-35.3-28.7-64-64-64L64 32C28.7 32 0 60.7 0 96zm0 32L0 416c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-288L0 128zM64 405.3c0-29.5 23.9-53.3 53.3-53.3l117.3 0c29.5 0 53.3 23.9 53.3 53.3c0 5.9-4.8 10.7-10.7 10.7L74.7 416c-5.9 0-10.7-4.8-10.7-10.7zM176 192a64 64 0 1 1 0 128 64 64 0 1 1 0-128zm176 16c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16z"], + "images": [576, "M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z"], "info-circle": [512, "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"], "key": [512, "M336 352c97.2 0 176-78.8 176-176S433.2 0 336 0S160 78.8 160 176c0 18.7 2.9 36.8 8.3 53.7L7 391c-4.5 4.5-7 10.6-7 17l0 80c0 13.3 10.7 24 24 24l80 0c13.3 0 24-10.7 24-24l0-40 40 0c13.3 0 24-10.7 24-24l0-40 40 0c6.4 0 12.5-2.5 17-7l33.3-33.3c16.9 5.4 35 8.3 53.7 8.3zM376 96a40 40 0 1 1 0 80 40 40 0 1 1 0-80z"], "keyboard": [576, "M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z"], @@ -74,6 +75,7 @@ const _ICONS = { "lock": [448, "M144 144l0 48 160 0 0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192l0-48C80 64.5 144.5 0 224 0s144 64.5 144 144l0 48 16 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0z"], "moon": [384, "M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"], "pen": [512, "M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"], + "play": [384, "M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"], "question-circle": [512, "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"], "save": [448, "M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm0 96c0-17.7 14.3-32 32-32l192 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32L96 224c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"], "search": [512, "M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"], diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index b896b408..6df216e0 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -1,6 +1,6 @@ /** * OxiCloud - Photos Timeline View - * Dense photo grid grouped by day, with infinite scroll and multi-select. + * Photo grid grouped by day/month/year, with infinite scroll and multi-select. */ const photosView = { @@ -20,6 +20,8 @@ const photosView = { _container: null, /** @type {boolean} */ _initialized: false, + /** @type {'daily'|'monthly'|'yearly'} */ + groupMode: 'monthly', PAGE_SIZE: 200, @@ -42,6 +44,7 @@ const photosView = { this._container = el; } if (!this._initialized) { + this.groupMode = localStorage.getItem('oxicloud-photos-group') || 'monthly'; this._initialized = true; } }, @@ -68,6 +71,14 @@ const photosView = { this._hideSelectionBar(); }, + /** Switch grouping mode */ + setGroupMode(mode) { + if (this.groupMode === mode) return; + this.groupMode = mode; + localStorage.setItem('oxicloud-photos-group', mode); + this._render(); + }, + /** Fetch a page of photos from the API */ async _loadPage() { if (this.loading || this.exhausted) return; @@ -93,7 +104,6 @@ const photosView = { this.exhausted = true; } else { this.items.push(...data); - // Read cursor from header const cursor = res.headers.get('X-Next-Cursor'); if (cursor && data.length >= this.PAGE_SIZE) { this.nextCursor = cursor; @@ -116,6 +126,10 @@ const photosView = { if (!this._container) return; this._destroyObserver(); + // Set group mode class on container + this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly'); + this._container.classList.add(`photos-group-${this.groupMode}`); + if (this.items.length === 0 && this.exhausted) { this._renderEmpty(); return; @@ -123,12 +137,12 @@ const photosView = { if (this.items.length === 0) return; - // Group by day - const groups = this._groupByDay(this.items); - let html = ''; + // Group by selected mode + const groups = this._groupItems(this.items); + let html = this._renderToolbar(); - for (const [dayLabel, files] of groups) { - html += `
${this._escHtml(dayLabel)}${files.length}
`; + for (const [label, files] of groups) { + html += `
${this._escHtml(label)}${files.length}
`; html += '
'; for (const file of files) { const isVideo = file.mime_type && file.mime_type.startsWith('video/'); @@ -165,6 +179,23 @@ const photosView = { } }, + /** Render the group mode toolbar */ + _renderToolbar() { + const t = (k, d) => window.i18n ? window.i18n.t(k) : d; + const modes = [ + ['daily', t('photos.view_daily', 'Day')], + ['monthly', t('photos.view_monthly', 'Month')], + ['yearly', t('photos.view_yearly', 'Year')] + ]; + let html = '
'; + for (const [mode, label] of modes) { + const active = this.groupMode === mode ? ' active' : ''; + html += ``; + } + html += '
'; + return html; + }, + /** Render empty state */ _renderEmpty() { const t = (k, d) => window.i18n ? window.i18n.t(k) : d; @@ -176,23 +207,37 @@ const photosView = {
`; }, - /** Group items by day using sort_date */ - _groupByDay(items) { + /** Group items by the current groupMode */ + _groupItems(items) { const map = new Map(); for (const item of items) { const ts = (item.sort_date || item.created_at) * 1000; const d = new Date(ts); - const key = d.toLocaleDateString(undefined, { - weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' - }); + let key; + if (this.groupMode === 'yearly') { + key = String(d.getFullYear()); + } else if (this.groupMode === 'monthly') { + key = d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' }); + } else { + key = d.toLocaleDateString(undefined, { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' + }); + } if (!map.has(key)) map.set(key, []); map.get(key).push(item); } return map; }, - /** Handle click on photo tile */ + /** Handle click on photo tile or toolbar */ _handleClick(e) { + // Handle group mode toggle + const modeBtn = e.target.closest('[data-group-mode]'); + if (modeBtn) { + this.setGroupMode(modeBtn.dataset.groupMode); + return; + } + const tile = e.target.closest('.photo-tile'); if (!tile) return; @@ -268,7 +313,6 @@ const photosView = { console.error('Delete failed:', fid, err); } } - // Remove from items and re-render this.items = this.items.filter(f => !this.selected.has(f.id)); this.selected.clear(); this._hideSelectionBar(); diff --git a/static/locales/de.json b/static/locales/de.json index 6bcb1ff5..9563059f 100755 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "Noch keine Fotos", "empty_hint": "Laden Sie Bilder oder Videos hoch, um sie hier zu sehen", - "items_selected": "ausgewählt" + "items_selected": "ausgewählt", + "view_daily": "Tag", + "view_monthly": "Monat", + "view_yearly": "Jahr" }, "actions": { "search": "Dateien suchen...", diff --git a/static/locales/en.json b/static/locales/en.json index 78637814..a4135be3 100755 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "No photos yet", "empty_hint": "Upload images or videos to see them here", - "items_selected": "selected" + "items_selected": "selected", + "view_daily": "Day", + "view_monthly": "Month", + "view_yearly": "Year" }, "actions": { "search": "Search files...", diff --git a/static/locales/es.json b/static/locales/es.json index 46759768..990da2b4 100755 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "Aún no hay fotos", "empty_hint": "Sube imágenes o videos para verlos aquí", - "items_selected": "seleccionados" + "items_selected": "seleccionados", + "view_daily": "Día", + "view_monthly": "Mes", + "view_yearly": "Año" }, "share": { "dialogTitle": "Compartir Enlace", diff --git a/static/locales/fa.json b/static/locales/fa.json index 3a146189..fba00459 100755 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "هنوز عکسی نیست", "empty_hint": "تصاویر یا ویدیوها را آپلود کنید تا اینجا نمایش داده شوند", - "items_selected": "انتخاب شده" + "items_selected": "انتخاب شده", + "view_daily": "روز", + "view_monthly": "ماه", + "view_yearly": "سال" }, "actions": { "search": "جست‌و‌جوی پرونده‌ها..", diff --git a/static/locales/fr.json b/static/locales/fr.json index 3bf5bac5..a4a176cf 100755 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "Pas encore de photos", "empty_hint": "Téléchargez des images ou des vidéos pour les voir ici", - "items_selected": "sélectionnés" + "items_selected": "sélectionnés", + "view_daily": "Jour", + "view_monthly": "Mois", + "view_yearly": "Année" }, "actions": { "search": "Rechercher des fichiers...", diff --git a/static/locales/it.json b/static/locales/it.json index 0d5d0b2f..dbb1230d 100755 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "Nessuna foto ancora", "empty_hint": "Carica immagini o video per vederli qui", - "items_selected": "selezionati" + "items_selected": "selezionati", + "view_daily": "Giorno", + "view_monthly": "Mese", + "view_yearly": "Anno" }, "actions": { "search": "Cerca file...", diff --git a/static/locales/nl.json b/static/locales/nl.json index 313eab6b..1f463083 100755 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "Nog geen foto's", "empty_hint": "Upload afbeeldingen of video's om ze hier te zien", - "items_selected": "geselecteerd" + "items_selected": "geselecteerd", + "view_daily": "Dag", + "view_monthly": "Maand", + "view_yearly": "Jaar" }, "actions": { "search": "Zoek bestanden...", diff --git a/static/locales/pt.json b/static/locales/pt.json index c89b587f..2dd7a438 100755 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "Nenhuma foto ainda", "empty_hint": "Envie imagens ou vídeos para vê-los aqui", - "items_selected": "selecionados" + "items_selected": "selecionados", + "view_daily": "Dia", + "view_monthly": "Mês", + "view_yearly": "Ano" }, "actions": { "search": "Pesquisar arquivos...", diff --git a/static/locales/zh.json b/static/locales/zh.json index ec37e573..b94f5156 100755 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -14,7 +14,10 @@ "photos": { "empty_state": "还没有照片", "empty_hint": "上传图片或视频即可在此查看", - "items_selected": "已选择" + "items_selected": "已选择", + "view_daily": "日", + "view_monthly": "月", + "view_yearly": "年" }, "actions": { "search": "搜索文件...",