Merge pull request #172 from jaredwolff/feat/exif-metadata
feat(photos): EXIF metadata extraction and Photos timeline view
This commit is contained in:
Generated
+16
@@ -1477,6 +1477,15 @@ dependencies = [
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kamadak-exif"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077"
|
||||
dependencies = [
|
||||
"mutate_once",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1718,6 +1727,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mutate_once"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1820,6 +1835,7 @@ dependencies = [
|
||||
"image",
|
||||
"infer",
|
||||
"jsonwebtoken",
|
||||
"kamadak-exif",
|
||||
"md-5",
|
||||
"mimalloc",
|
||||
"mime_guess",
|
||||
|
||||
@@ -37,6 +37,7 @@ dotenvy = "0.15.7"
|
||||
moka = { version = "0.12", features = ["future", "sync"] }
|
||||
http-range-header = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
kamadak-exif = "0.5"
|
||||
md-5 = "0.10"
|
||||
sha2 = "0.10.9"
|
||||
blake3 = { version = "1.8.3", features = ["rayon"] }
|
||||
|
||||
@@ -646,6 +646,28 @@ CREATE INDEX IF NOT EXISTS idx_shares_created_by ON storage.shares(created_by);
|
||||
|
||||
COMMENT ON TABLE storage.shares IS 'Shared links for files and folders with token-based access';
|
||||
|
||||
-- ── EXIF / media metadata for image and video files ─────────────────────
|
||||
-- Separate table keeps storage.files lean (most files aren't images).
|
||||
-- Populated at upload time by the ExifService.
|
||||
CREATE TABLE IF NOT EXISTS storage.file_metadata (
|
||||
file_id UUID PRIMARY KEY REFERENCES storage.files(id) ON DELETE CASCADE,
|
||||
captured_at TIMESTAMP WITH TIME ZONE, -- EXIF DateTimeOriginal
|
||||
latitude DOUBLE PRECISION, -- GPS latitude (decimal degrees)
|
||||
longitude DOUBLE PRECISION, -- GPS longitude (decimal degrees)
|
||||
camera_make TEXT, -- EXIF Make
|
||||
camera_model TEXT, -- EXIF Model
|
||||
orientation SMALLINT, -- EXIF Orientation (1-8)
|
||||
width INTEGER, -- Original pixel width
|
||||
height INTEGER, -- Original pixel height
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- For the Photos timeline: ORDER BY captured_at DESC with cursor pagination
|
||||
CREATE INDEX IF NOT EXISTS idx_file_metadata_captured
|
||||
ON storage.file_metadata(captured_at DESC) WHERE captured_at IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE storage.file_metadata IS 'EXIF and media metadata extracted at upload time';
|
||||
|
||||
-- ── Atomic recursive folder copy (WebDAV COPY Depth: infinity) ──────────
|
||||
--
|
||||
-- Copies the entire subtree rooted at `p_source_id` under `p_target_parent_id`.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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, FolderDbRepository, TrashDbRepository,
|
||||
FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository,
|
||||
TrashDbRepository,
|
||||
};
|
||||
use crate::infrastructure::services::file_content_cache::{
|
||||
FileContentCache, FileContentCacheConfig,
|
||||
@@ -214,6 +215,9 @@ impl AppServiceFactory {
|
||||
None
|
||||
};
|
||||
|
||||
// File metadata repository — EXIF/media metadata for images
|
||||
let file_metadata_repository = Arc::new(FileMetadataRepository::new(db_pool.clone()));
|
||||
|
||||
tracing::info!(
|
||||
"Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)"
|
||||
);
|
||||
@@ -223,6 +227,7 @@ impl AppServiceFactory {
|
||||
folder_repo_concrete,
|
||||
file_read_repository,
|
||||
file_write_repository,
|
||||
file_metadata_repository,
|
||||
i18n_repository,
|
||||
trash_repository,
|
||||
}
|
||||
@@ -815,6 +820,7 @@ pub struct RepositoryServices {
|
||||
pub folder_repo_concrete: Arc<FolderDbRepository>,
|
||||
pub file_read_repository: Arc<FileBlobReadRepository>,
|
||||
pub file_write_repository: Arc<FileBlobWriteRepository>,
|
||||
pub file_metadata_repository: Arc<FileMetadataRepository>,
|
||||
pub i18n_repository: Arc<FileSystemI18nService>,
|
||||
pub trash_repository: Option<Arc<TrashDbRepository>>,
|
||||
}
|
||||
|
||||
@@ -144,6 +144,68 @@ 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
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! PostgreSQL repository for image/video EXIF metadata.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::services::exif_service::ExifMetadata;
|
||||
|
||||
/// Metadata as stored/retrieved from the database.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StoredMetadata {
|
||||
pub file_id: String,
|
||||
pub captured_at: Option<DateTime<Utc>>,
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub camera_make: Option<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub orientation: Option<i16>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
/// Repository for `storage.file_metadata` table operations.
|
||||
pub struct FileMetadataRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl FileMetadataRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Insert or update EXIF metadata for a file.
|
||||
pub async fn upsert(&self, file_id: &str, meta: &ExifMetadata) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO storage.file_metadata
|
||||
(file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (file_id) DO UPDATE SET
|
||||
captured_at = EXCLUDED.captured_at,
|
||||
latitude = EXCLUDED.latitude,
|
||||
longitude = EXCLUDED.longitude,
|
||||
camera_make = EXCLUDED.camera_make,
|
||||
camera_model = EXCLUDED.camera_model,
|
||||
orientation = EXCLUDED.orientation,
|
||||
width = EXCLUDED.width,
|
||||
height = EXCLUDED.height
|
||||
"#,
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(meta.captured_at)
|
||||
.bind(meta.latitude)
|
||||
.bind(meta.longitude)
|
||||
.bind(&meta.camera_make)
|
||||
.bind(&meta.camera_model)
|
||||
.bind(meta.orientation.map(|o| o as i16))
|
||||
.bind(meta.width.map(|w| w as i32))
|
||||
.bind(meta.height.map(|h| h as i32))
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to upsert file metadata: {}", e);
|
||||
DomainError::internal_error("FileMetadata", format!("upsert: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get metadata for a single file.
|
||||
pub async fn get(&self, file_id: &str) -> Result<Option<StoredMetadata>, DomainError> {
|
||||
let row: Option<(
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i16>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT file_id::text, captured_at, latitude, longitude,
|
||||
camera_make, camera_model, orientation, width, height
|
||||
FROM storage.file_metadata
|
||||
WHERE file_id = $1::uuid
|
||||
"#,
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to get file metadata: {}", e);
|
||||
DomainError::internal_error("FileMetadata", format!("get: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(row.map(
|
||||
|(
|
||||
file_id,
|
||||
captured_at,
|
||||
latitude,
|
||||
longitude,
|
||||
camera_make,
|
||||
camera_model,
|
||||
orientation,
|
||||
width,
|
||||
height,
|
||||
)| {
|
||||
StoredMetadata {
|
||||
file_id,
|
||||
captured_at,
|
||||
latitude,
|
||||
longitude,
|
||||
camera_make,
|
||||
camera_model,
|
||||
orientation,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Get metadata for multiple files in a single query.
|
||||
pub async fn get_batch(
|
||||
&self,
|
||||
file_ids: &[String],
|
||||
) -> Result<HashMap<String, StoredMetadata>, DomainError> {
|
||||
if file_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows: Vec<(
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i16>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT file_id::text, captured_at, latitude, longitude,
|
||||
camera_make, camera_model, orientation, width, height
|
||||
FROM storage.file_metadata
|
||||
WHERE file_id = ANY($1::uuid[])
|
||||
"#,
|
||||
)
|
||||
.bind(file_ids)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to batch get file metadata: {}", e);
|
||||
DomainError::internal_error("FileMetadata", format!("get_batch: {e}"))
|
||||
})?;
|
||||
|
||||
let mut map = HashMap::with_capacity(rows.len());
|
||||
for (
|
||||
file_id,
|
||||
captured_at,
|
||||
latitude,
|
||||
longitude,
|
||||
camera_make,
|
||||
camera_model,
|
||||
orientation,
|
||||
width,
|
||||
height,
|
||||
) in rows
|
||||
{
|
||||
map.insert(
|
||||
file_id.clone(),
|
||||
StoredMetadata {
|
||||
file_id,
|
||||
captured_at,
|
||||
latitude,
|
||||
longitude,
|
||||
camera_make,
|
||||
camera_model,
|
||||
orientation,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod contact_persistence_dto;
|
||||
mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
mod nextcloud_object_id_repository;
|
||||
mod recent_items_pg_repository;
|
||||
mod session_pg_repository;
|
||||
@@ -32,6 +33,7 @@ pub use device_code_pg_repository::DeviceCodePgRepository;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! EXIF metadata extraction from image files.
|
||||
//!
|
||||
//! Uses `kamadak-exif` to parse EXIF headers from JPEG/TIFF/HEIF images.
|
||||
//! Extraction is cheap — only the header bytes are read, not the full image.
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use exif::{In, Reader, Tag};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Extracted EXIF metadata fields.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExifMetadata {
|
||||
/// Photo capture time (EXIF DateTimeOriginal)
|
||||
pub captured_at: Option<DateTime<Utc>>,
|
||||
/// GPS latitude in decimal degrees (positive = North)
|
||||
pub latitude: Option<f64>,
|
||||
/// GPS longitude in decimal degrees (positive = East)
|
||||
pub longitude: Option<f64>,
|
||||
/// Camera manufacturer (EXIF Make)
|
||||
pub camera_make: Option<String>,
|
||||
/// Camera model (EXIF Model)
|
||||
pub camera_model: Option<String>,
|
||||
/// EXIF Orientation tag (1-8)
|
||||
pub orientation: Option<u16>,
|
||||
/// Original image width in pixels
|
||||
pub width: Option<u32>,
|
||||
/// Original image height in pixels
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
/// Stateless service for extracting EXIF metadata from image bytes.
|
||||
pub struct ExifService;
|
||||
|
||||
impl ExifService {
|
||||
/// Extract EXIF metadata from raw image bytes.
|
||||
///
|
||||
/// Returns `None` if the file has no EXIF data (e.g. PNG, GIF, WebP)
|
||||
/// or if parsing fails entirely. Individual fields may be `None` even
|
||||
/// when the EXIF block exists (not all cameras populate every tag).
|
||||
pub fn extract(data: &[u8]) -> Option<ExifMetadata> {
|
||||
let exif = Reader::new()
|
||||
.read_from_container(&mut Cursor::new(data))
|
||||
.ok()?;
|
||||
|
||||
let mut meta = ExifMetadata::default();
|
||||
|
||||
// ── Capture date ──
|
||||
if let Some(field) = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) {
|
||||
meta.captured_at = parse_exif_datetime(&field.display_value().to_string());
|
||||
}
|
||||
// Fallback to DateTimeDigitized if DateTimeOriginal is missing
|
||||
if meta.captured_at.is_none()
|
||||
&& let Some(field) = exif.get_field(Tag::DateTimeDigitized, In::PRIMARY)
|
||||
{
|
||||
meta.captured_at = parse_exif_datetime(&field.display_value().to_string());
|
||||
}
|
||||
|
||||
// ── GPS coordinates ──
|
||||
meta.latitude = parse_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
|
||||
meta.longitude = parse_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
|
||||
|
||||
// ── 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();
|
||||
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();
|
||||
if !val.is_empty() {
|
||||
meta.camera_model = Some(val);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Orientation ──
|
||||
if let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY)
|
||||
&& let exif::Value::Short(ref v) = field.value
|
||||
&& let Some(&o) = v.first()
|
||||
&& (1..=8).contains(&o)
|
||||
{
|
||||
meta.orientation = Some(o);
|
||||
}
|
||||
|
||||
// ── Dimensions ──
|
||||
if let Some(field) = exif.get_field(Tag::PixelXDimension, In::PRIMARY) {
|
||||
meta.width = parse_u32_value(&field.value);
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::PixelYDimension, In::PRIMARY) {
|
||||
meta.height = parse_u32_value(&field.value);
|
||||
}
|
||||
// Fallback to ImageWidth/ImageLength if PixelXDimension is missing
|
||||
if meta.width.is_none()
|
||||
&& let Some(field) = exif.get_field(Tag::ImageWidth, In::PRIMARY)
|
||||
{
|
||||
meta.width = parse_u32_value(&field.value);
|
||||
}
|
||||
if meta.height.is_none()
|
||||
&& let Some(field) = exif.get_field(Tag::ImageLength, In::PRIMARY)
|
||||
{
|
||||
meta.height = parse_u32_value(&field.value);
|
||||
}
|
||||
|
||||
Some(meta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse EXIF datetime string "YYYY:MM:DD HH:MM:SS" into DateTime<Utc>.
|
||||
fn parse_exif_datetime(s: &str) -> Option<DateTime<Utc>> {
|
||||
// EXIF dates use ":" as separator for date parts
|
||||
let s = s.trim().trim_matches('"');
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S"))
|
||||
.ok()
|
||||
.map(|ndt| ndt.and_utc())
|
||||
}
|
||||
|
||||
/// Parse GPS coordinate from EXIF rational values + reference (N/S or E/W).
|
||||
fn parse_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
|
||||
let field = exif.get_field(coord_tag, In::PRIMARY)?;
|
||||
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
|
||||
|
||||
let rationals = match &field.value {
|
||||
exif::Value::Rational(v) if v.len() >= 3 => v,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let degrees = rationals[0].to_f64();
|
||||
let minutes = rationals[1].to_f64();
|
||||
let seconds = rationals[2].to_f64();
|
||||
|
||||
let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0;
|
||||
|
||||
// Apply hemisphere sign
|
||||
let reference = ref_field.display_value().to_string();
|
||||
let reference = reference.trim().trim_matches('"');
|
||||
if reference == "S" || reference == "W" {
|
||||
decimal = -decimal;
|
||||
}
|
||||
|
||||
Some(decimal)
|
||||
}
|
||||
|
||||
/// Extract a u32 from various EXIF value types (Short, Long).
|
||||
fn parse_u32_value(value: &exif::Value) -> Option<u32> {
|
||||
match value {
|
||||
exif::Value::Short(v) => v.first().map(|&x| x as u32),
|
||||
exif::Value::Long(v) => v.first().copied(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply EXIF orientation to a `DynamicImage`.
|
||||
///
|
||||
/// EXIF orientation values 1-8 describe how the stored pixels relate to
|
||||
/// the intended display orientation. This function transforms the image
|
||||
/// to match the intended orientation.
|
||||
pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::DynamicImage {
|
||||
match orientation {
|
||||
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
|
||||
5 => {
|
||||
// Transpose: flip horizontal then rotate 270° (= rotate 90° CW then flip horizontal)
|
||||
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
|
||||
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
|
||||
_ => img,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod chunked_upload_service;
|
||||
pub mod compression_service;
|
||||
pub mod dedup_service;
|
||||
pub mod exif_service;
|
||||
pub mod file_content_cache;
|
||||
pub mod file_system_i18n_service;
|
||||
pub mod image_transcode_service;
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,17 @@ impl ThumbnailService {
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||
|
||||
// Apply EXIF orientation so thumbnails display correctly
|
||||
let img = {
|
||||
use crate::infrastructure::services::exif_service::{
|
||||
ExifService, apply_orientation,
|
||||
};
|
||||
let orientation = ExifService::extract(&data)
|
||||
.and_then(|m| m.orientation)
|
||||
.unwrap_or(1);
|
||||
apply_orientation(img, orientation)
|
||||
};
|
||||
|
||||
// Calculate new dimensions preserving aspect ratio
|
||||
let (orig_width, orig_height) = (img.width(), img.height());
|
||||
let (new_width, new_height) = if orig_width > orig_height {
|
||||
@@ -349,6 +360,17 @@ impl ThumbnailService {
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||
|
||||
// Apply EXIF orientation so thumbnails display correctly
|
||||
let img = {
|
||||
use crate::infrastructure::services::exif_service::{
|
||||
ExifService, apply_orientation,
|
||||
};
|
||||
let orientation = ExifService::extract(&data)
|
||||
.and_then(|m| m.orientation)
|
||||
.unwrap_or(1);
|
||||
apply_orientation(img, orientation)
|
||||
};
|
||||
|
||||
let (orig_w, orig_h) = (img.width(), img.height());
|
||||
|
||||
ThumbnailSize::all()
|
||||
|
||||
@@ -605,7 +605,7 @@ impl FileHandler {
|
||||
Err(response) => return response.into_response(),
|
||||
};
|
||||
|
||||
// Generate thumbnails for supported images in background
|
||||
// Generate thumbnails and extract EXIF metadata for supported images in background
|
||||
if state
|
||||
.core
|
||||
.thumbnail_service
|
||||
@@ -615,6 +615,7 @@ impl FileHandler {
|
||||
let thumbnail_service = state.core.thumbnail_service.clone();
|
||||
let dedup_service = state.core.dedup_service.clone();
|
||||
let file_read = state.repositories.file_read_repository.clone();
|
||||
let metadata_repo = state.repositories.file_metadata_repository.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Resolve the actual blob path on disk (not the logical file path,
|
||||
@@ -627,6 +628,29 @@ impl FileHandler {
|
||||
}
|
||||
};
|
||||
let file_path = dedup_service.blob_path(&blob_hash);
|
||||
|
||||
// Extract EXIF metadata (reads only header bytes, very fast).
|
||||
// Runs before thumbnail generation so the OS page cache is primed.
|
||||
{
|
||||
use crate::infrastructure::services::exif_service::ExifService;
|
||||
match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => {
|
||||
if let Some(meta) = ExifService::extract(&data)
|
||||
&& let Err(e) = metadata_repo.upsert(&file_id, &meta).await
|
||||
{
|
||||
tracing::warn!("Failed to store EXIF for {}: {}", file_id, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to read file for EXIF extraction {}: {}",
|
||||
file_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
|
||||
thumbnail_service.generate_all_sizes_background(file_id, file_path);
|
||||
});
|
||||
@@ -635,6 +659,47 @@ impl FileHandler {
|
||||
Self::created_json_response(&file).into_response()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// METADATA
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Returns EXIF/media metadata for a file.
|
||||
///
|
||||
/// Used by the Photos lightbox and for testing EXIF extraction.
|
||||
pub async fn get_file_metadata(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
Path(file_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Verify ownership
|
||||
let file_read = &state.repositories.file_read_repository;
|
||||
if let Err(e) = file_read.verify_file_owner(&file_id, &auth_user.id).await {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let metadata_repo = &state.repositories.file_metadata_repository;
|
||||
match metadata_repo.get(&file_id).await {
|
||||
Ok(Some(meta)) => (StatusCode::OK, Json(meta)).into_response(),
|
||||
Ok(None) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"file_id": file_id,
|
||||
"message": "No EXIF metadata available"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// DELETE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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,78 @@
|
||||
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).clamp(1, 500);
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -146,6 +146,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.route("/upload", post(FileHandler::upload_file_with_thumbnails))
|
||||
.route("/{id}", get(FileHandler::download_file))
|
||||
.route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail))
|
||||
.route("/{id}/metadata", get(FileHandler::get_file_metadata))
|
||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)) // 10 GB for file uploads
|
||||
.with_state(app_state.clone());
|
||||
|
||||
@@ -308,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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/* Photos timeline view */
|
||||
.photos-container {
|
||||
padding: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photos-container.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
padding: 16px 8px 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.photos-day-header .photos-day-count {
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Photo grid — base (daily mode) */
|
||||
.photos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 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 */
|
||||
.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;
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photos-day-header {
|
||||
font-size: 14px;
|
||||
padding: 10px 4px 6px;
|
||||
}
|
||||
|
||||
.photos-toolbar {
|
||||
padding: 6px 4px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] .photos-day-header {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
<link rel="stylesheet" href="/css/views/recent.css">
|
||||
<link rel="stylesheet" href="/css/views/shared.css">
|
||||
<link rel="stylesheet" href="/css/views/trash.css">
|
||||
<link rel="stylesheet" href="/css/views/photos.css">
|
||||
<link rel="stylesheet" href="/css/views/photosLightbox.css">
|
||||
|
||||
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
||||
<script defer src="/js/core/i18n.js"></script>
|
||||
@@ -32,6 +34,8 @@
|
||||
<script defer src="/js/features/files/search.js"></script>
|
||||
<script defer src="/js/features/library/favorites.js"></script>
|
||||
<script defer src="/js/features/library/recent.js"></script>
|
||||
<script defer src="/js/features/library/photos.js"></script>
|
||||
<script defer src="/js/features/library/photosLightbox.js"></script>
|
||||
<script defer src="/js/features/sharing/fileSharing.js"></script>
|
||||
<script defer src="/js/views/shared/sharedView.js"></script>
|
||||
<script defer src="/js/features/files/inlineViewer.js"></script>
|
||||
@@ -81,6 +85,10 @@
|
||||
<i class="fas fa-star"></i>
|
||||
<span data-i18n="nav.favorites">Favorites</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-photos">
|
||||
<i class="fas fa-images"></i>
|
||||
<span data-i18n="nav.photos">Photos</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span data-i18n="nav.trash">Trash</span>
|
||||
|
||||
@@ -271,7 +271,7 @@ function cacheElements() {
|
||||
elements.pageTitle = document.querySelector('.page-title');
|
||||
elements.actionsBar = document.querySelector('.actions-bar');
|
||||
elements.navItems = document.querySelectorAll('.nav-item');
|
||||
elements.trashBtn = document.querySelector('.nav-item:nth-child(5)'); // The trash nav item
|
||||
elements.trashBtn = document.querySelector('.nav-item:nth-child(6)'); // The trash nav item (after Photos)
|
||||
elements.searchInput = document.querySelector('.search-container input');
|
||||
}
|
||||
|
||||
@@ -436,7 +436,13 @@ function setupEventListeners() {
|
||||
switchToRecentFilesView();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if this is the photos item
|
||||
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.photos') {
|
||||
switchToPhotosView();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is the trash item
|
||||
if (item === elements.trashBtn) {
|
||||
// Hide shared view if active
|
||||
|
||||
@@ -73,7 +73,8 @@ const VIEW_FLAGS = {
|
||||
'shared': 'isSharedView',
|
||||
'recent': 'isRecentView',
|
||||
'favorites': 'isFavoritesView',
|
||||
'trash': 'isTrashView'
|
||||
'trash': 'isTrashView',
|
||||
'photos': 'isPhotosView'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,6 +115,11 @@ function setCurrentSection(section) {
|
||||
if (section !== 'shared' && window.sharedView) {
|
||||
window.sharedView.hide();
|
||||
}
|
||||
|
||||
// Hide photosView when switching to any other section
|
||||
if (section !== 'photos' && window.photosView) {
|
||||
window.photosView.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function switchToSharedView() {
|
||||
@@ -223,7 +229,30 @@ function switchToRecentFilesView() {
|
||||
}
|
||||
}
|
||||
|
||||
function switchToPhotosView() {
|
||||
setCurrentSection('photos');
|
||||
|
||||
// Hide breadcrumb
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
if (breadcrumb) breadcrumb.style.display = 'none';
|
||||
|
||||
// Hide actions-bar (photos has its own upload via selection bar)
|
||||
window.setActionsBarMode('hidden');
|
||||
|
||||
// Hide file containers
|
||||
const filesGrid = document.getElementById('files-grid');
|
||||
const filesListView = document.getElementById('files-list-view');
|
||||
if (filesGrid) filesGrid.style.display = 'none';
|
||||
if (filesListView) filesListView.style.display = 'none';
|
||||
|
||||
// Show photos view
|
||||
if (window.photosView) {
|
||||
window.photosView.show();
|
||||
}
|
||||
}
|
||||
|
||||
window.switchToFilesView = switchToFilesView;
|
||||
window.switchToSharedView = switchToSharedView;
|
||||
window.switchToFavoritesView = switchToFavoritesView;
|
||||
window.switchToRecentFilesView = switchToRecentFilesView;
|
||||
window.switchToPhotosView = switchToPhotosView;
|
||||
|
||||
@@ -16,6 +16,7 @@ window.app = {
|
||||
isSharedView: false,
|
||||
isFavoritesView: false,
|
||||
isRecentView: false,
|
||||
isPhotosView: false,
|
||||
currentSection: 'files',
|
||||
isSearchMode: false,
|
||||
shareDialogItem: null,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* OxiCloud - Photos Timeline View
|
||||
* Photo grid grouped by day/month/year, with infinite scroll and multi-select.
|
||||
*/
|
||||
|
||||
const photosView = {
|
||||
/** @type {Array} All loaded photo items */
|
||||
items: [],
|
||||
/** @type {string|null} Cursor for next page */
|
||||
nextCursor: null,
|
||||
/** @type {boolean} Currently fetching */
|
||||
loading: false,
|
||||
/** @type {boolean} All items loaded */
|
||||
exhausted: false,
|
||||
/** @type {Set<string>} Selected item IDs */
|
||||
selected: new Set(),
|
||||
/** @type {IntersectionObserver|null} */
|
||||
_observer: null,
|
||||
/** @type {HTMLElement|null} */
|
||||
_container: null,
|
||||
/** @type {boolean} */
|
||||
_initialized: false,
|
||||
/** @type {'daily'|'monthly'|'yearly'} */
|
||||
groupMode: 'monthly',
|
||||
|
||||
PAGE_SIZE: 200,
|
||||
|
||||
/** Auth headers (HttpOnly cookies) */
|
||||
_headers(json = false) {
|
||||
const h = typeof getCsrfHeaders === 'function' ? { ...getCsrfHeaders() } : {};
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
return h;
|
||||
},
|
||||
|
||||
/** Initialize / re-initialize the photos view */
|
||||
init() {
|
||||
if (!this._container) {
|
||||
const contentArea = document.querySelector('.content-area');
|
||||
if (!contentArea) return;
|
||||
const el = document.createElement('div');
|
||||
el.id = 'photos-container';
|
||||
el.className = 'photos-container';
|
||||
contentArea.appendChild(el);
|
||||
this._container = el;
|
||||
}
|
||||
if (!this._initialized) {
|
||||
this.groupMode = localStorage.getItem('oxicloud-photos-group') || 'monthly';
|
||||
this._initialized = true;
|
||||
}
|
||||
},
|
||||
|
||||
/** Show the photos view and load data */
|
||||
show() {
|
||||
this.init();
|
||||
if (!this._container) return;
|
||||
this._container.classList.add('active');
|
||||
this.items = [];
|
||||
this.nextCursor = null;
|
||||
this.exhausted = false;
|
||||
this.selected.clear();
|
||||
this._render();
|
||||
this._loadPage();
|
||||
},
|
||||
|
||||
/** Hide the photos view */
|
||||
hide() {
|
||||
if (this._container) {
|
||||
this._container.classList.remove('active');
|
||||
}
|
||||
this._destroyObserver();
|
||||
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;
|
||||
this.loading = true;
|
||||
this._showLoading(true);
|
||||
|
||||
try {
|
||||
let url = `/api/photos?limit=${this.PAGE_SIZE}`;
|
||||
if (this.nextCursor) {
|
||||
url += `&before=${this.nextCursor}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: this._headers()
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
this.exhausted = true;
|
||||
} else {
|
||||
this.items.push(...data);
|
||||
const cursor = res.headers.get('X-Next-Cursor');
|
||||
if (cursor && data.length >= this.PAGE_SIZE) {
|
||||
this.nextCursor = cursor;
|
||||
} else {
|
||||
this.exhausted = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading photos:', err);
|
||||
this.exhausted = true;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this._showLoading(false);
|
||||
this._render();
|
||||
}
|
||||
},
|
||||
|
||||
/** Render the full timeline from this.items */
|
||||
_render() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (this.items.length === 0) return;
|
||||
|
||||
// Group by selected mode
|
||||
const groups = this._groupItems(this.items);
|
||||
let html = this._renderToolbar();
|
||||
|
||||
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/');
|
||||
const selected = this.selected.has(file.id) ? ' selected' : '';
|
||||
const thumbUrl = `/api/files/${file.id}/thumbnail/preview`;
|
||||
html += `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}">`;
|
||||
html += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
|
||||
html += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
|
||||
if (isVideo) {
|
||||
html += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Sentinel for infinite scroll
|
||||
html += '<div class="photos-sentinel"></div>';
|
||||
|
||||
this._container.innerHTML = html;
|
||||
|
||||
// Attach click handlers via delegation
|
||||
this._container.onclick = (e) => this._handleClick(e);
|
||||
|
||||
// Observe sentinel for infinite scroll
|
||||
const sentinel = this._container.querySelector('.photos-sentinel');
|
||||
if (sentinel && !this.exhausted) {
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
this._loadPage();
|
||||
}
|
||||
}, { rootMargin: '400px' });
|
||||
this._observer.observe(sentinel);
|
||||
}
|
||||
},
|
||||
|
||||
/** 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;
|
||||
this._container.innerHTML = `
|
||||
<div class="photos-empty">
|
||||
<i class="fas fa-images"></i>
|
||||
<p class="photos-empty-title">${t('photos.empty_state', 'No photos yet')}</p>
|
||||
<p>${t('photos.empty_hint', 'Upload images or videos to see them here')}</p>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/** 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);
|
||||
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 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;
|
||||
|
||||
const id = tile.dataset.id;
|
||||
const check = e.target.closest('.photo-check');
|
||||
|
||||
// If clicking checkbox or in selection mode, toggle select
|
||||
if (check || this.selected.size > 0) {
|
||||
this._toggleSelect(id, tile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise open lightbox
|
||||
const idx = this.items.findIndex(f => f.id === id);
|
||||
if (idx >= 0 && window.photosLightbox) {
|
||||
window.photosLightbox.open(this.items, idx);
|
||||
}
|
||||
},
|
||||
|
||||
/** Toggle selection of an item */
|
||||
_toggleSelect(id, tile) {
|
||||
if (this.selected.has(id)) {
|
||||
this.selected.delete(id);
|
||||
tile.classList.remove('selected');
|
||||
} else {
|
||||
this.selected.add(id);
|
||||
tile.classList.add('selected');
|
||||
}
|
||||
this._updateSelectionBar();
|
||||
},
|
||||
|
||||
/** Show/update selection bar */
|
||||
_updateSelectionBar() {
|
||||
let bar = document.getElementById('photos-selection-bar');
|
||||
|
||||
if (this.selected.size === 0) {
|
||||
this._hideSelectionBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'photos-selection-bar';
|
||||
bar.className = 'photos-selection-bar';
|
||||
document.body.appendChild(bar);
|
||||
}
|
||||
|
||||
const t = (k, d) => window.i18n ? window.i18n.t(k) : d;
|
||||
const count = this.selected.size;
|
||||
bar.innerHTML = `
|
||||
<span class="selection-count">${count} ${t('photos.items_selected', 'selected')}</span>
|
||||
<button id="photos-sel-download" title="Download"><i class="fas fa-download"></i></button>
|
||||
<button id="photos-sel-delete" title="Delete"><i class="fas fa-trash"></i></button>
|
||||
<button id="photos-sel-clear" title="Clear"><i class="fas fa-times"></i></button>
|
||||
`;
|
||||
|
||||
bar.querySelector('#photos-sel-clear').onclick = () => {
|
||||
this.selected.clear();
|
||||
this._container.querySelectorAll('.photo-tile.selected').forEach(t => t.classList.remove('selected'));
|
||||
this._hideSelectionBar();
|
||||
};
|
||||
|
||||
bar.querySelector('#photos-sel-delete').onclick = async () => {
|
||||
if (!confirm('Delete selected items?')) return;
|
||||
for (const fid of this.selected) {
|
||||
try {
|
||||
await fetch(`/api/files/${fid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: this._headers()
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', fid, err);
|
||||
}
|
||||
}
|
||||
this.items = this.items.filter(f => !this.selected.has(f.id));
|
||||
this.selected.clear();
|
||||
this._hideSelectionBar();
|
||||
this._render();
|
||||
};
|
||||
|
||||
bar.querySelector('#photos-sel-download').onclick = async () => {
|
||||
for (const fid of this.selected) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/files/${fid}`;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
};
|
||||
|
||||
bar.style.display = 'flex';
|
||||
},
|
||||
|
||||
_hideSelectionBar() {
|
||||
const bar = document.getElementById('photos-selection-bar');
|
||||
if (bar) bar.style.display = 'none';
|
||||
},
|
||||
|
||||
_showLoading(show) {
|
||||
if (!this._container) return;
|
||||
let loader = this._container.querySelector('.photos-loading');
|
||||
if (show && !loader) {
|
||||
loader = document.createElement('div');
|
||||
loader.className = 'photos-loading';
|
||||
loader.innerHTML = '<i class="fas fa-spinner"></i> Loading...';
|
||||
this._container.appendChild(loader);
|
||||
} else if (!show && loader) {
|
||||
loader.remove();
|
||||
}
|
||||
},
|
||||
|
||||
_destroyObserver() {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
this._observer = null;
|
||||
}
|
||||
},
|
||||
|
||||
_escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
},
|
||||
|
||||
_escAttr(s) {
|
||||
return String(s || '').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
};
|
||||
|
||||
window.photosView = photosView;
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* OxiCloud - Photos Lightbox
|
||||
* Full-screen image/video viewer with prev/next navigation.
|
||||
*/
|
||||
|
||||
const photosLightbox = {
|
||||
/** @type {Array} Items array reference */
|
||||
items: [],
|
||||
/** @type {number} Current index */
|
||||
index: -1,
|
||||
/** @type {HTMLElement|null} */
|
||||
_overlay: null,
|
||||
/** @type {string|null} Current blob URL to revoke */
|
||||
_blobUrl: null,
|
||||
/** @type {Function|null} */
|
||||
_keyHandler: null,
|
||||
|
||||
/** Auth headers */
|
||||
_headers() {
|
||||
return typeof getCsrfHeaders === 'function' ? { ...getCsrfHeaders() } : {};
|
||||
},
|
||||
|
||||
/** Open lightbox at given index */
|
||||
open(items, index) {
|
||||
this.items = items;
|
||||
this.index = index;
|
||||
this._createOverlay();
|
||||
this._show();
|
||||
this._bindKeys();
|
||||
},
|
||||
|
||||
/** Close lightbox */
|
||||
close() {
|
||||
if (this._overlay) {
|
||||
this._overlay.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
if (this._overlay) {
|
||||
this._overlay.remove();
|
||||
this._overlay = null;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
this._revokeBlob();
|
||||
this._unbindKeys();
|
||||
},
|
||||
|
||||
/** Navigate to previous */
|
||||
prev() {
|
||||
if (this.index > 0) {
|
||||
this.index--;
|
||||
this._show();
|
||||
}
|
||||
},
|
||||
|
||||
/** Navigate to next */
|
||||
next() {
|
||||
if (this.index < this.items.length - 1) {
|
||||
this.index++;
|
||||
this._show();
|
||||
}
|
||||
},
|
||||
|
||||
/** Create the overlay DOM structure */
|
||||
_createOverlay() {
|
||||
if (this._overlay) this._overlay.remove();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'photos-lightbox';
|
||||
el.innerHTML = `
|
||||
<div class="lightbox-info">
|
||||
<div class="lightbox-filename"></div>
|
||||
<div class="lightbox-meta"></div>
|
||||
</div>
|
||||
<button class="lightbox-close"><i class="fas fa-times"></i></button>
|
||||
<button class="lightbox-nav lightbox-prev"><i class="fas fa-chevron-left"></i></button>
|
||||
<div class="lightbox-content"></div>
|
||||
<button class="lightbox-nav lightbox-next"><i class="fas fa-chevron-right"></i></button>
|
||||
<div class="lightbox-toolbar">
|
||||
<button class="lb-download" title="Download"><i class="fas fa-download"></i></button>
|
||||
<button class="lb-favorite" title="Favorite"><i class="far fa-star"></i></button>
|
||||
<button class="lb-delete" title="Delete"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="lightbox-counter"></div>
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
this._overlay = el;
|
||||
|
||||
// Event listeners
|
||||
el.querySelector('.lightbox-close').onclick = () => this.close();
|
||||
el.querySelector('.lightbox-prev').onclick = () => this.prev();
|
||||
el.querySelector('.lightbox-next').onclick = () => this.next();
|
||||
|
||||
// Click backdrop to close
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === el || e.target.classList.contains('lightbox-content')) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar actions
|
||||
el.querySelector('.lb-download').onclick = () => this._download();
|
||||
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite();
|
||||
el.querySelector('.lb-delete').onclick = () => this._delete();
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => el.classList.add('active'));
|
||||
},
|
||||
|
||||
/** Display the current item */
|
||||
async _show() {
|
||||
if (!this._overlay || this.index < 0) return;
|
||||
|
||||
const item = this.items[this.index];
|
||||
const content = this._overlay.querySelector('.lightbox-content');
|
||||
const filename = this._overlay.querySelector('.lightbox-filename');
|
||||
const meta = this._overlay.querySelector('.lightbox-meta');
|
||||
const counter = this._overlay.querySelector('.lightbox-counter');
|
||||
|
||||
filename.textContent = item.name;
|
||||
counter.textContent = `${this.index + 1} / ${this.items.length}`;
|
||||
|
||||
// Format date
|
||||
const ts = (item.sort_date || item.created_at) * 1000;
|
||||
const dateStr = new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
|
||||
|
||||
// Update nav button visibility
|
||||
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden';
|
||||
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden';
|
||||
|
||||
// Load content
|
||||
this._revokeBlob();
|
||||
content.innerHTML = '<div class="photos-loading"><i class="fas fa-spinner"></i></div>';
|
||||
|
||||
try {
|
||||
const isVideo = item.mime_type && item.mime_type.startsWith('video/');
|
||||
const res = await fetch(`/api/files/${item.id}`, {
|
||||
credentials: 'include',
|
||||
headers: this._headers()
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
this._blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (isVideo) {
|
||||
content.innerHTML = `<video src="${this._blobUrl}" controls autoplay></video>`;
|
||||
} else {
|
||||
content.innerHTML = `<img src="${this._blobUrl}" alt="${this._escAttr(item.name)}">`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Lightbox load error:', err);
|
||||
content.innerHTML = '<div class="photos-loading">Failed to load</div>';
|
||||
}
|
||||
|
||||
// Load EXIF metadata
|
||||
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
|
||||
},
|
||||
|
||||
/** Load EXIF metadata for info bar */
|
||||
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
|
||||
try {
|
||||
const res = await fetch(`/api/files/${fileId}/metadata`, {
|
||||
credentials: 'include',
|
||||
headers: this._headers()
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
let parts = [dateStr];
|
||||
if (sizeStr) parts.push(sizeStr);
|
||||
if (data.camera_make || data.camera_model) {
|
||||
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' '));
|
||||
}
|
||||
if (data.width && data.height) {
|
||||
parts.push(`${data.width}×${data.height}`);
|
||||
}
|
||||
metaEl.textContent = parts.join(' · ');
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-critical, keep existing meta
|
||||
}
|
||||
},
|
||||
|
||||
/** Download current item */
|
||||
_download() {
|
||||
const item = this.items[this.index];
|
||||
if (!item) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/files/${item.id}`;
|
||||
a.download = item.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
},
|
||||
|
||||
/** Toggle favorite on current item */
|
||||
async _toggleFavorite() {
|
||||
const item = this.items[this.index];
|
||||
if (!item || !window.favorites) return;
|
||||
try {
|
||||
await fetch(`/api/favorites/file/${item.id}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this._headers(true)
|
||||
});
|
||||
const btn = this._overlay.querySelector('.lb-favorite');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active');
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = btn.classList.contains('active') ? 'fas fa-star' : 'far fa-star';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Favorite toggle failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/** Delete current item */
|
||||
async _delete() {
|
||||
const item = this.items[this.index];
|
||||
if (!item) return;
|
||||
if (!confirm(`Delete ${item.name}?`)) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/files/${item.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: this._headers()
|
||||
});
|
||||
// Remove from photosView items too
|
||||
if (window.photosView) {
|
||||
window.photosView.items = window.photosView.items.filter(f => f.id !== item.id);
|
||||
}
|
||||
this.items.splice(this.index, 1);
|
||||
if (this.items.length === 0) {
|
||||
this.close();
|
||||
if (window.photosView) window.photosView._render();
|
||||
} else {
|
||||
if (this.index >= this.items.length) this.index = this.items.length - 1;
|
||||
this._show();
|
||||
if (window.photosView) window.photosView._render();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/** Keyboard navigation */
|
||||
_bindKeys() {
|
||||
this._keyHandler = (e) => {
|
||||
if (e.key === 'Escape') this.close();
|
||||
else if (e.key === 'ArrowLeft') this.prev();
|
||||
else if (e.key === 'ArrowRight') this.next();
|
||||
};
|
||||
document.addEventListener('keydown', this._keyHandler);
|
||||
},
|
||||
|
||||
_unbindKeys() {
|
||||
if (this._keyHandler) {
|
||||
document.removeEventListener('keydown', this._keyHandler);
|
||||
this._keyHandler = null;
|
||||
}
|
||||
},
|
||||
|
||||
_revokeBlob() {
|
||||
if (this._blobUrl) {
|
||||
URL.revokeObjectURL(this._blobUrl);
|
||||
this._blobUrl = null;
|
||||
}
|
||||
},
|
||||
|
||||
_escAttr(s) {
|
||||
return String(s || '').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
};
|
||||
|
||||
window.photosLightbox = photosLightbox;
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "Geteilt",
|
||||
"recent": "Zuletzt verwendet",
|
||||
"favorites": "Favoriten",
|
||||
"photos": "Fotos",
|
||||
"trash": "Papierkorb"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Noch keine Fotos",
|
||||
"empty_hint": "Laden Sie Bilder oder Videos hoch, um sie hier zu sehen",
|
||||
"items_selected": "ausgewählt",
|
||||
"view_daily": "Tag",
|
||||
"view_monthly": "Monat",
|
||||
"view_yearly": "Jahr"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Dateien suchen...",
|
||||
"new_folder": "Neuer Ordner",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "Shared",
|
||||
"recent": "Recent",
|
||||
"favorites": "Favorites",
|
||||
"photos": "Photos",
|
||||
"trash": "Trash"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "No photos yet",
|
||||
"empty_hint": "Upload images or videos to see them here",
|
||||
"items_selected": "selected",
|
||||
"view_daily": "Day",
|
||||
"view_monthly": "Month",
|
||||
"view_yearly": "Year"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search files...",
|
||||
"new_folder": "New folder",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "Compartidos",
|
||||
"recent": "Recientes",
|
||||
"favorites": "Favoritos",
|
||||
"photos": "Fotos",
|
||||
"trash": "Papelera"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Aún no hay fotos",
|
||||
"empty_hint": "Sube imágenes o videos para verlos aquí",
|
||||
"items_selected": "seleccionados",
|
||||
"view_daily": "Día",
|
||||
"view_monthly": "Mes",
|
||||
"view_yearly": "Año"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Compartir Enlace",
|
||||
"linkLabel": "Enlace compartido:",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "همرسانی شده",
|
||||
"recent": "اخیر",
|
||||
"favorites": "موردعلاقهها",
|
||||
"photos": "عکسها",
|
||||
"trash": "سطل زباله"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "هنوز عکسی نیست",
|
||||
"empty_hint": "تصاویر یا ویدیوها را آپلود کنید تا اینجا نمایش داده شوند",
|
||||
"items_selected": "انتخاب شده",
|
||||
"view_daily": "روز",
|
||||
"view_monthly": "ماه",
|
||||
"view_yearly": "سال"
|
||||
},
|
||||
"actions": {
|
||||
"search": "جستوجوی پروندهها..",
|
||||
"new_folder": "پوشهٔ جدید",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "Partagés",
|
||||
"recent": "Récents",
|
||||
"favorites": "Favoris",
|
||||
"photos": "Photos",
|
||||
"trash": "Corbeille"
|
||||
},
|
||||
"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",
|
||||
"view_daily": "Jour",
|
||||
"view_monthly": "Mois",
|
||||
"view_yearly": "Année"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Rechercher des fichiers...",
|
||||
"new_folder": "Nouveau dossier",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "Condivisi",
|
||||
"recent": "Recenti",
|
||||
"favorites": "Preferiti",
|
||||
"photos": "Foto",
|
||||
"trash": "Cestino"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nessuna foto ancora",
|
||||
"empty_hint": "Carica immagini o video per vederli qui",
|
||||
"items_selected": "selezionati",
|
||||
"view_daily": "Giorno",
|
||||
"view_monthly": "Mese",
|
||||
"view_yearly": "Anno"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Cerca file...",
|
||||
"new_folder": "Nuova cartella",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "Gedeeld",
|
||||
"recent": "Recente",
|
||||
"favorites": "Favorieten",
|
||||
"photos": "Foto's",
|
||||
"trash": "Prullenbak"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nog geen foto's",
|
||||
"empty_hint": "Upload afbeeldingen of video's om ze hier te zien",
|
||||
"items_selected": "geselecteerd",
|
||||
"view_daily": "Dag",
|
||||
"view_monthly": "Maand",
|
||||
"view_yearly": "Jaar"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Zoek bestanden...",
|
||||
"new_folder": "Nieuwe map",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "Compartilhados",
|
||||
"recent": "Recentes",
|
||||
"favorites": "Favoritos",
|
||||
"photos": "Fotos",
|
||||
"trash": "Lixeira"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nenhuma foto ainda",
|
||||
"empty_hint": "Envie imagens ou vídeos para vê-los aqui",
|
||||
"items_selected": "selecionados",
|
||||
"view_daily": "Dia",
|
||||
"view_monthly": "Mês",
|
||||
"view_yearly": "Ano"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Pesquisar arquivos...",
|
||||
"new_folder": "Nova pasta",
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
"shared": "共享",
|
||||
"recent": "最近",
|
||||
"favorites": "收藏",
|
||||
"photos": "照片",
|
||||
"trash": "回收站"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "还没有照片",
|
||||
"empty_hint": "上传图片或视频即可在此查看",
|
||||
"items_selected": "已选择",
|
||||
"view_daily": "日",
|
||||
"view_monthly": "月",
|
||||
"view_yearly": "年"
|
||||
},
|
||||
"actions": {
|
||||
"search": "搜索文件...",
|
||||
"new_folder": "新建文件夹",
|
||||
|
||||
Reference in New Issue
Block a user