feat(photos): add Photos timeline view with lightbox and infinite scroll
Backend: new GET /api/photos endpoint with cursor-based pagination that queries image/video files sorted by EXIF captured_at (falling back to created_at), joining file_metadata for sort dates. Frontend: dense photo grid grouped by day with lazy-loaded thumbnails, IntersectionObserver infinite scroll, multi-select with batch download/delete, and a full-screen lightbox with prev/next navigation, EXIF metadata display, and download/favorite/delete toolbar. Includes navigation wiring, CSS (with dark theme), and i18n translations for all 9 locales.
This commit is contained in:
@@ -51,6 +51,11 @@ pub struct FileDto {
|
|||||||
/// Owner user ID (omitted from JSON when None)
|
/// Owner user ID (omitted from JSON when None)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub owner_id: Option<String>,
|
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 {
|
impl From<File> for FileDto {
|
||||||
@@ -73,6 +78,7 @@ impl From<File> for FileDto {
|
|||||||
category: Arc::from(category_for(name, mime)),
|
category: Arc::from(category_for(name, mime)),
|
||||||
size_formatted: format_file_size(size),
|
size_formatted: format_file_size(size),
|
||||||
owner_id: file.owner_id().map(String::from),
|
owner_id: file.owner_id().map(String::from),
|
||||||
|
sort_date: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +118,7 @@ impl FileDto {
|
|||||||
category: Arc::from("Document"),
|
category: Arc::from("Document"),
|
||||||
size_formatted: "0 Bytes".to_string(),
|
size_formatted: "0 Bytes".to_string(),
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
|
sort_date: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,66 @@ impl FileBlobReadRepository {
|
|||||||
self.hash_cache.insert(file_id.to_owned(), hash.clone());
|
self.hash_cache.insert(file_id.to_owned(), hash.clone());
|
||||||
Ok(hash)
|
Ok(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists all image/video files for a user, sorted by capture date (EXIF) or
|
||||||
|
/// creation date, with cursor-based pagination for the Photos timeline.
|
||||||
|
///
|
||||||
|
/// Returns `(Vec<File>, Vec<i64>)` where the second vec contains the
|
||||||
|
/// `sort_date` epoch for each file (used as pagination cursor).
|
||||||
|
pub async fn list_media_files(
|
||||||
|
&self,
|
||||||
|
owner_id: &str,
|
||||||
|
before: Option<i64>,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
|
||||||
|
let rows: Vec<(
|
||||||
|
String, // id
|
||||||
|
String, // name
|
||||||
|
Option<String>, // folder_id
|
||||||
|
Option<String>, // folder path
|
||||||
|
i64, // size
|
||||||
|
String, // mime_type
|
||||||
|
i64, // created_at
|
||||||
|
i64, // updated_at
|
||||||
|
Option<String>, // user_id
|
||||||
|
i64, // sort_date
|
||||||
|
)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
|
fi.size, fi.mime_type,
|
||||||
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||||
|
fi.user_id::text,
|
||||||
|
EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint AS sort_date
|
||||||
|
FROM storage.files fi
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
|
||||||
|
WHERE fi.user_id = $1::uuid
|
||||||
|
AND NOT fi.is_trashed
|
||||||
|
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
||||||
|
AND ($2::bigint IS NULL
|
||||||
|
OR EXTRACT(EPOCH FROM COALESCE(fm.captured_at, fi.created_at))::bigint < $2::bigint)
|
||||||
|
ORDER BY COALESCE(fm.captured_at, fi.created_at) DESC
|
||||||
|
LIMIT $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(before)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_media: {e}")))?;
|
||||||
|
|
||||||
|
let mut files = Vec::with_capacity(rows.len());
|
||||||
|
let mut sort_dates = Vec::with_capacity(rows.len());
|
||||||
|
|
||||||
|
for (id, name, fid, fpath, size, mime, ca, ma, uid, sd) in rows {
|
||||||
|
files.push(Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)?);
|
||||||
|
sort_dates.push(sd);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((files, sort_dates))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileReadPort for FileBlobReadRepository {
|
impl FileReadPort for FileBlobReadRepository {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ impl PathResolverService {
|
|||||||
category: Arc::from(category_for(&name, &mime)),
|
category: Arc::from(category_for(&name, &mime)),
|
||||||
size_formatted: format_file_size(sz),
|
size_formatted: format_file_size(sz),
|
||||||
owner_id: uid,
|
owner_id: uid,
|
||||||
|
sort_date: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod favorites_handler;
|
|||||||
pub mod file_handler;
|
pub mod file_handler;
|
||||||
pub mod folder_handler;
|
pub mod folder_handler;
|
||||||
pub mod i18n_handler;
|
pub mod i18n_handler;
|
||||||
|
pub mod photos_handler;
|
||||||
pub mod recent_handler;
|
pub mod recent_handler;
|
||||||
pub mod search_handler;
|
pub mod search_handler;
|
||||||
pub mod share_handler;
|
pub mod share_handler;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
|
/// Query parameters for the photos timeline endpoint.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PhotosQueryParams {
|
||||||
|
/// Cursor: only return items with sort_date < this value (epoch seconds).
|
||||||
|
pub before: Option<i64>,
|
||||||
|
/// Max items to return (default 200, max 500).
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists all image/video files for the authenticated user, sorted by
|
||||||
|
/// capture date (EXIF DateTimeOriginal) falling back to upload date.
|
||||||
|
///
|
||||||
|
/// Supports cursor-based pagination via the `before` parameter.
|
||||||
|
/// The `X-Next-Cursor` response header contains the cursor for the next page.
|
||||||
|
pub async fn list_photos(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Query(params): Query<PhotosQueryParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_id = &auth_user.id;
|
||||||
|
let limit = params.limit.unwrap_or(200).min(500).max(1);
|
||||||
|
|
||||||
|
let file_read = &state.repositories.file_read_repository;
|
||||||
|
|
||||||
|
match file_read.list_media_files(user_id, params.before, limit).await {
|
||||||
|
Ok((files, sort_dates)) => {
|
||||||
|
info!("Photos: returned {} media files for user", files.len());
|
||||||
|
|
||||||
|
// Convert to DTOs with sort_date populated
|
||||||
|
let dtos: Vec<FileDto> = files
|
||||||
|
.into_iter()
|
||||||
|
.zip(sort_dates.iter())
|
||||||
|
.map(|(file, &sd)| {
|
||||||
|
let mut dto = FileDto::from(file);
|
||||||
|
dto.sort_date = Some(sd as u64);
|
||||||
|
dto
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Set cursor header for next page
|
||||||
|
let mut response = Json(&dtos).into_response();
|
||||||
|
if let Some(&last_sd) = sort_dates.last() {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"X-Next-Cursor",
|
||||||
|
last_sd.to_string().parse().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error listing photos: {}", err);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("Failed to list photos: {}", err)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -309,6 +309,17 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
.nest("/favorites", favorites_router)
|
.nest("/favorites", favorites_router)
|
||||||
.nest("/recent", recent_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
|
// Re-enable trash routes to make the trash view work
|
||||||
if let Some(_trash_service_ref) = trash_service.clone() {
|
if let Some(_trash_service_ref) = trash_service.clone() {
|
||||||
tracing::info!("Setting up trash routes for trash view");
|
tracing::info!("Setting up trash routes for trash view");
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/* Photos timeline view */
|
||||||
|
.photos-container {
|
||||||
|
padding: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-container.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day group header */
|
||||||
|
.photos-day-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 12px 4px 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3748;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-day-header .photos-day-count {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo grid */
|
||||||
|
.photos-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tile .photo-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-day-header {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 2px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme */
|
||||||
|
[data-theme="dark"] .photos-day-header {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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/recent.css">
|
||||||
<link rel="stylesheet" href="/css/views/shared.css">
|
<link rel="stylesheet" href="/css/views/shared.css">
|
||||||
<link rel="stylesheet" href="/css/views/trash.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) -->
|
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
||||||
<script defer src="/js/core/i18n.js"></script>
|
<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/files/search.js"></script>
|
||||||
<script defer src="/js/features/library/favorites.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/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/features/sharing/fileSharing.js"></script>
|
||||||
<script defer src="/js/views/shared/sharedView.js"></script>
|
<script defer src="/js/views/shared/sharedView.js"></script>
|
||||||
<script defer src="/js/features/files/inlineViewer.js"></script>
|
<script defer src="/js/features/files/inlineViewer.js"></script>
|
||||||
@@ -81,6 +85,10 @@
|
|||||||
<i class="fas fa-star"></i>
|
<i class="fas fa-star"></i>
|
||||||
<span data-i18n="nav.favorites">Favorites</span>
|
<span data-i18n="nav.favorites">Favorites</span>
|
||||||
</div>
|
</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">
|
<div class="nav-item">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
<span data-i18n="nav.trash">Trash</span>
|
<span data-i18n="nav.trash">Trash</span>
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ function cacheElements() {
|
|||||||
elements.pageTitle = document.querySelector('.page-title');
|
elements.pageTitle = document.querySelector('.page-title');
|
||||||
elements.actionsBar = document.querySelector('.actions-bar');
|
elements.actionsBar = document.querySelector('.actions-bar');
|
||||||
elements.navItems = document.querySelectorAll('.nav-item');
|
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');
|
elements.searchInput = document.querySelector('.search-container input');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +437,12 @@ function setupEventListeners() {
|
|||||||
return;
|
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
|
// Check if this is the trash item
|
||||||
if (item === elements.trashBtn) {
|
if (item === elements.trashBtn) {
|
||||||
// Hide shared view if active
|
// Hide shared view if active
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ const VIEW_FLAGS = {
|
|||||||
'shared': 'isSharedView',
|
'shared': 'isSharedView',
|
||||||
'recent': 'isRecentView',
|
'recent': 'isRecentView',
|
||||||
'favorites': 'isFavoritesView',
|
'favorites': 'isFavoritesView',
|
||||||
'trash': 'isTrashView'
|
'trash': 'isTrashView',
|
||||||
|
'photos': 'isPhotosView'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +115,11 @@ function setCurrentSection(section) {
|
|||||||
if (section !== 'shared' && window.sharedView) {
|
if (section !== 'shared' && window.sharedView) {
|
||||||
window.sharedView.hide();
|
window.sharedView.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide photosView when switching to any other section
|
||||||
|
if (section !== 'photos' && window.photosView) {
|
||||||
|
window.photosView.hide();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToSharedView() {
|
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.switchToFilesView = switchToFilesView;
|
||||||
window.switchToSharedView = switchToSharedView;
|
window.switchToSharedView = switchToSharedView;
|
||||||
window.switchToFavoritesView = switchToFavoritesView;
|
window.switchToFavoritesView = switchToFavoritesView;
|
||||||
window.switchToRecentFilesView = switchToRecentFilesView;
|
window.switchToRecentFilesView = switchToRecentFilesView;
|
||||||
|
window.switchToPhotosView = switchToPhotosView;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ window.app = {
|
|||||||
isSharedView: false,
|
isSharedView: false,
|
||||||
isFavoritesView: false,
|
isFavoritesView: false,
|
||||||
isRecentView: false,
|
isRecentView: false,
|
||||||
|
isPhotosView: false,
|
||||||
currentSection: 'files',
|
currentSection: 'files',
|
||||||
isSearchMode: false,
|
isSearchMode: false,
|
||||||
shareDialogItem: null,
|
shareDialogItem: null,
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* OxiCloud - Photos Timeline View
|
||||||
|
* Dense photo grid grouped by day, 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,
|
||||||
|
|
||||||
|
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._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();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
// Read cursor from header
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (this.items.length === 0 && this.exhausted) {
|
||||||
|
this._renderEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.items.length === 0) return;
|
||||||
|
|
||||||
|
// Group by day
|
||||||
|
const groups = this._groupByDay(this.items);
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
for (const [dayLabel, files] of groups) {
|
||||||
|
html += `<div class="photos-day-header">${this._escHtml(dayLabel)}<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 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 day using sort_date */
|
||||||
|
_groupByDay(items) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const item of items) {
|
||||||
|
const ts = (item.sort_date || item.created_at) * 1000;
|
||||||
|
const d = new Date(ts);
|
||||||
|
const key = d.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||||
|
});
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key).push(item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Handle click on photo tile */
|
||||||
|
_handleClick(e) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove from items and re-render
|
||||||
|
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,14 @@
|
|||||||
"shared": "Geteilt",
|
"shared": "Geteilt",
|
||||||
"recent": "Zuletzt verwendet",
|
"recent": "Zuletzt verwendet",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
|
"photos": "Fotos",
|
||||||
"trash": "Papierkorb"
|
"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"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "Dateien suchen...",
|
"search": "Dateien suchen...",
|
||||||
"new_folder": "Neuer Ordner",
|
"new_folder": "Neuer Ordner",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "Shared",
|
"shared": "Shared",
|
||||||
"recent": "Recent",
|
"recent": "Recent",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
|
"photos": "Photos",
|
||||||
"trash": "Trash"
|
"trash": "Trash"
|
||||||
},
|
},
|
||||||
|
"photos": {
|
||||||
|
"empty_state": "No photos yet",
|
||||||
|
"empty_hint": "Upload images or videos to see them here",
|
||||||
|
"items_selected": "selected"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "Search files...",
|
"search": "Search files...",
|
||||||
"new_folder": "New folder",
|
"new_folder": "New folder",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "Compartidos",
|
"shared": "Compartidos",
|
||||||
"recent": "Recientes",
|
"recent": "Recientes",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
|
"photos": "Fotos",
|
||||||
"trash": "Papelera"
|
"trash": "Papelera"
|
||||||
},
|
},
|
||||||
|
"photos": {
|
||||||
|
"empty_state": "Aún no hay fotos",
|
||||||
|
"empty_hint": "Sube imágenes o videos para verlos aquí",
|
||||||
|
"items_selected": "seleccionados"
|
||||||
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"dialogTitle": "Compartir Enlace",
|
"dialogTitle": "Compartir Enlace",
|
||||||
"linkLabel": "Enlace compartido:",
|
"linkLabel": "Enlace compartido:",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "همرسانی شده",
|
"shared": "همرسانی شده",
|
||||||
"recent": "اخیر",
|
"recent": "اخیر",
|
||||||
"favorites": "موردعلاقهها",
|
"favorites": "موردعلاقهها",
|
||||||
|
"photos": "عکسها",
|
||||||
"trash": "سطل زباله"
|
"trash": "سطل زباله"
|
||||||
},
|
},
|
||||||
|
"photos": {
|
||||||
|
"empty_state": "هنوز عکسی نیست",
|
||||||
|
"empty_hint": "تصاویر یا ویدیوها را آپلود کنید تا اینجا نمایش داده شوند",
|
||||||
|
"items_selected": "انتخاب شده"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "جستوجوی پروندهها..",
|
"search": "جستوجوی پروندهها..",
|
||||||
"new_folder": "پوشهٔ جدید",
|
"new_folder": "پوشهٔ جدید",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "Partagés",
|
"shared": "Partagés",
|
||||||
"recent": "Récents",
|
"recent": "Récents",
|
||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
|
"photos": "Photos",
|
||||||
"trash": "Corbeille"
|
"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"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "Rechercher des fichiers...",
|
"search": "Rechercher des fichiers...",
|
||||||
"new_folder": "Nouveau dossier",
|
"new_folder": "Nouveau dossier",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "Condivisi",
|
"shared": "Condivisi",
|
||||||
"recent": "Recenti",
|
"recent": "Recenti",
|
||||||
"favorites": "Preferiti",
|
"favorites": "Preferiti",
|
||||||
|
"photos": "Foto",
|
||||||
"trash": "Cestino"
|
"trash": "Cestino"
|
||||||
},
|
},
|
||||||
|
"photos": {
|
||||||
|
"empty_state": "Nessuna foto ancora",
|
||||||
|
"empty_hint": "Carica immagini o video per vederli qui",
|
||||||
|
"items_selected": "selezionati"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "Cerca file...",
|
"search": "Cerca file...",
|
||||||
"new_folder": "Nuova cartella",
|
"new_folder": "Nuova cartella",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "Gedeeld",
|
"shared": "Gedeeld",
|
||||||
"recent": "Recente",
|
"recent": "Recente",
|
||||||
"favorites": "Favorieten",
|
"favorites": "Favorieten",
|
||||||
|
"photos": "Foto's",
|
||||||
"trash": "Prullenbak"
|
"trash": "Prullenbak"
|
||||||
},
|
},
|
||||||
|
"photos": {
|
||||||
|
"empty_state": "Nog geen foto's",
|
||||||
|
"empty_hint": "Upload afbeeldingen of video's om ze hier te zien",
|
||||||
|
"items_selected": "geselecteerd"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "Zoek bestanden...",
|
"search": "Zoek bestanden...",
|
||||||
"new_folder": "Nieuwe map",
|
"new_folder": "Nieuwe map",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "Compartilhados",
|
"shared": "Compartilhados",
|
||||||
"recent": "Recentes",
|
"recent": "Recentes",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
|
"photos": "Fotos",
|
||||||
"trash": "Lixeira"
|
"trash": "Lixeira"
|
||||||
},
|
},
|
||||||
|
"photos": {
|
||||||
|
"empty_state": "Nenhuma foto ainda",
|
||||||
|
"empty_hint": "Envie imagens ou vídeos para vê-los aqui",
|
||||||
|
"items_selected": "selecionados"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "Pesquisar arquivos...",
|
"search": "Pesquisar arquivos...",
|
||||||
"new_folder": "Nova pasta",
|
"new_folder": "Nova pasta",
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"shared": "共享",
|
"shared": "共享",
|
||||||
"recent": "最近",
|
"recent": "最近",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
|
"photos": "照片",
|
||||||
"trash": "回收站"
|
"trash": "回收站"
|
||||||
},
|
},
|
||||||
|
"photos": {
|
||||||
|
"empty_state": "还没有照片",
|
||||||
|
"empty_hint": "上传图片或视频即可在此查看",
|
||||||
|
"items_selected": "已选择"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"search": "搜索文件...",
|
"search": "搜索文件...",
|
||||||
"new_folder": "新建文件夹",
|
"new_folder": "新建文件夹",
|
||||||
|
|||||||
Reference in New Issue
Block a user