fix(photos): fix SQL type mismatch, pagination panic, missing icons, and add day/month/year grouping

- Fix photos endpoint 500: remove ::uuid cast on user_id WHERE clause (VARCHAR column)
- Fix pagination underflow panic when total_pages is 0
- Add missing 'images' and 'play' icons to SVG icon registry
- Add day/month/year grouping toggle with localStorage persistence
- Improve grid spacing and group header styling per mode
- Add i18n translations for grouping labels (all 9 locales)
This commit is contained in:
Jared Wolff
2026-03-05 14:46:30 -05:00
parent 53e4f5afe6
commit 6a84a5c44e
22 changed files with 220 additions and 64 deletions
+2 -1
View File
@@ -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,
@@ -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);
}
@@ -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 {
+1 -1
View File
@@ -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;
+15 -5
View File
@@ -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<u32> {
/// 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,
}
}
@@ -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);
+5 -1
View File
@@ -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
);
}
}
}
@@ -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
@@ -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<AppState>,
user_id: &str,
path: &str,
) -> Option<String> {
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: &str, path: &str) -> Option<String> {
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::<CurrentUser>()
.map(|u| u.id.clone());
let user_id = req.extensions().get::<CurrentUser>().map(|u| u.id.clone());
if let Some(uid) = user_id {
resolve_webdav_path(&state, &uid, &path)
.await
@@ -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,
}
}
+58 -13
View File
@@ -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 {
+2
View File
@@ -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"],
+58 -14
View File
@@ -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 += `<div class="photos-day-header">${this._escHtml(dayLabel)}<span class="photos-day-count">${files.length}</span></div>`;
for (const [label, files] of groups) {
html += `<div class="photos-day-header">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>`;
html += '<div class="photos-grid">';
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 = '<div class="photos-toolbar"><div class="view-toggle">';
for (const [mode, label] of modes) {
const active = this.groupMode === mode ? ' active' : '';
html += `<button class="toggle-btn${active}" data-group-mode="${mode}">${this._escHtml(label)}</button>`;
}
html += '</div></div>';
return html;
},
/** Render empty state */
_renderEmpty() {
const t = (k, d) => window.i18n ? window.i18n.t(k) : d;
@@ -176,23 +207,37 @@ const photosView = {
</div>`;
},
/** 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();
+4 -1
View File
@@ -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...",
+4 -1
View File
@@ -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...",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -14,7 +14,10 @@
"photos": {
"empty_state": "هنوز عکسی نیست",
"empty_hint": "تصاویر یا ویدیوها را آپلود کنید تا اینجا نمایش داده شوند",
"items_selected": "انتخاب شده"
"items_selected": "انتخاب شده",
"view_daily": "روز",
"view_monthly": "ماه",
"view_yearly": "سال"
},
"actions": {
"search": "جست‌و‌جوی پرونده‌ها..",
+4 -1
View File
@@ -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...",
+4 -1
View File
@@ -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...",
+4 -1
View File
@@ -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...",
+4 -1
View File
@@ -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...",
+4 -1
View File
@@ -14,7 +14,10 @@
"photos": {
"empty_state": "还没有照片",
"empty_hint": "上传图片或视频即可在此查看",
"items_selected": "已选择"
"items_selected": "已选择",
"view_daily": "日",
"view_monthly": "月",
"view_yearly": "年"
},
"actions": {
"search": "搜索文件...",