Merge pull request #172 from jaredwolff/feat/exif-metadata

feat(photos): EXIF metadata extraction and Photos timeline view
This commit is contained in:
Dionisio Pozo
2026-03-05 23:42:10 +01:00
committed by GitHub
36 changed files with 1964 additions and 14 deletions
Generated
+16
View File
@@ -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",
+1
View File
@@ -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"] }
+22
View File
@@ -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`.
+7
View File
@@ -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
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, 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;
+188
View File
@@ -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
View File
@@ -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()
+66 -1
View File
@@ -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
// ═══════════════════════════════════════════════════════════════════════
+1
View File
@@ -11,6 +11,7 @@ pub mod favorites_handler;
pub mod file_handler;
pub mod folder_handler;
pub mod i18n_handler;
pub mod photos_handler;
pub mod recent_handler;
pub mod search_handler;
pub mod share_handler;
@@ -0,0 +1,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
+12
View File
@@ -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,
}
}
+303
View File
@@ -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;
}
+194
View File
@@ -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;
}
}
+8
View File
@@ -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>
+8 -2
View File
@@ -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
+30 -1
View File
@@ -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;
+1
View File
@@ -16,6 +16,7 @@ window.app = {
isSharedView: false,
isFavoritesView: false,
isRecentView: false,
isPhotosView: false,
currentSection: 'files',
isSearchMode: false,
shareDialogItem: null,
+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"],
+372
View File
@@ -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, '&quot;').replace(/</g, '&lt;');
}
};
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, '&quot;').replace(/</g, '&lt;');
}
};
window.photosLightbox = photosLightbox;
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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:",
+9
View File
@@ -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": "پوشهٔ جدید",
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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",
+9
View File
@@ -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": "新建文件夹",