From 5790a1459f35bad79d079504a4e4d86e5eb8bb40 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 00:44:20 +0200 Subject: [PATCH 1/9] feat(folders): add curser and the normalized way to get foler's item list. add reverse order --- src/application/dtos/folder_dto.rs | 142 +++++++- src/application/dtos/grant_dto.rs | 5 + src/application/ports/authorization_ports.rs | 1 + src/application/services/folder_service.rs | 110 ++++++- src/domain/services/authorization.rs | 4 + .../repositories/pg/folder_db_repository.rs | 236 ++++++++++++++ src/infrastructure/services/pg_acl_engine.rs | 204 ++++++++---- src/interfaces/api/handlers/folder_handler.rs | 116 ++++++- src/interfaces/api/handlers/grant_handler.rs | 10 +- src/interfaces/api/routes.rs | 7 +- static/css/components/buttons.css | 11 + static/js/app/filesView.js | 308 ++++++++++++++++-- static/js/app/main.js | 19 +- static/js/app/navigation.js | 10 +- static/js/core/icons.js | 17 + static/js/model/filesModel.js | 70 +++- static/js/model/grants.js | 4 +- .../js/views/sharedWithMe/sharedWithMeView.js | 20 +- 18 files changed, 1183 insertions(+), 111 deletions(-) diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 098f1739..e411fab6 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -1,8 +1,13 @@ use std::sync::Arc; +use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor}; +use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::domain::entities::folder::Folder; +use crate::domain::services::authorization::ResourceKind; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; /// DTO for folder creation requests #[derive(Debug, Deserialize, ToSchema)] @@ -145,3 +150,138 @@ impl Default for FolderDto { Self::empty() } } + +// ════════════════════════════════════════════════════════════════════════════ +// Cursor-paginated folder resources (GET /api/folders/{id}/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// Raw row returned by the UNION ALL query that combines `storage.folders` and +/// `storage.files` for a given parent folder. Used internally between the +/// repository and service/handler layers — never serialised directly. +pub struct FolderResourceRow { + pub resource_type: String, // "folder" | "file" + pub id: Uuid, + pub name: String, + /// Parent folder UUID (for both resource types). + pub parent_id: Option, + /// `None` for folders. + pub mime_type: Option, + /// `-1` sentinel for folders (no physical size). + pub size: i64, + pub created_at: DateTime, + pub modified_at: DateTime, + pub owner_id: Uuid, + // Pre-computed sort fields — returned by the SQL for cursor construction. + /// `LOWER(name)` used by `name`/`type` sorts. + pub sort_str: String, + /// `category_order` for files, `0` for folders. + pub type_order: i64, + /// `0` for folders, `1` for files (used by `name` sort to keep folders first). + pub folder_first: i32, +} + +/// Opaque keyset-pagination cursor for `/api/folders/{id}/resources`. +/// +/// Encoded as base64url-JSON (same scheme as [`GrantCursor`]). +/// Fields are sparse: only the sort-relevant ones are serialised. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FolderResourceCursor { + /// Sort dimension active when this cursor was produced. + #[serde(default = "FolderResourceCursor::default_order")] + pub order_by: String, + /// UUID of the last item on the previous page (tie-breaker). + pub resource_id: Uuid, + /// `LOWER(name)` for `name`/`type` sorts. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_str: Option, + /// Multipurpose integer sort key: + /// - `name`: `folder_first` (0 = folder, 1 = file) + /// - `type`: `category_order` (0 = Folder, 100 = Image …) + /// - `size`: file size in bytes, -1 for folders + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_int: Option, + /// Timestamp for `modified_at` / `created_at` sorts. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_ts: Option>, + /// Whether the result set was reversed when this cursor was produced. + /// Must be passed unchanged on subsequent page requests. + #[serde(default)] + pub reverse: bool, +} + +impl FolderResourceCursor { + fn default_order() -> String { + "name".to_owned() + } +} + +impl PageCursor for FolderResourceCursor {} + +/// Query parameters for `GET /api/folders/{id}/resources`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct FolderResourcesQuery { + /// Maximum items per page (1–200, default 50). + #[serde(default = "CursorQuery::default_limit")] + pub limit: u32, + /// Opaque cursor from a previous response. Omit to start from the top. + pub cursor: Option, + /// Sort / group-by dimension. Supported: `"name"` (default), `"type"`, + /// `"modified_at"`, `"created_at"`, `"size"`. + pub order_by: Option, + /// Comma-separated resource types to include, e.g. `"file,folder"`. + /// Omit to include both. + pub resource_types: Option, + /// Reverse the sort order. Default `false` (normal order). + /// Must be the same on all pages of the same result set — the cursor + /// carries this flag so the server can validate consistency. + #[serde(default)] + pub reverse: bool, +} + +impl FolderResourcesQuery { + /// Returns `limit` clamped to `[1, 200]`. + pub fn limit_clamped(&self) -> usize { + self.limit.clamp(1, 200) as usize + } + + /// Decode the optional cursor string. Invalid cursor → start from top. + pub fn decode_cursor(&self) -> Option { + self.cursor + .as_deref() + .and_then(FolderResourceCursor::decode) + } + + /// Parse `resource_types` into a `Vec`. + /// Returns `None` when the field is absent (= include all types). + pub fn resource_kinds(&self) -> Option> { + self.resource_types.as_deref().map(|s| { + s.split(',') + .filter_map(|t| ResourceKind::parse(t.trim())) + .collect() + }) + } +} + +/// Options for [`FolderService::list_resources_paged_with_perms`]. +/// +/// Groups the optional parameters so the function stays within clippy's +/// `too_many_arguments` limit while remaining easy to extend. +pub struct ListResourcesOptions<'a> { + pub limit: usize, + pub cursor: Option, + pub order_by: &'a str, + pub kinds: Option<&'a [ResourceKind]>, + pub reverse: bool, +} + +/// One item in a `/resources` page — a file or folder with a `resource_type` tag. +/// Re-uses [`ResourceContentDto`] so the shape is identical to `SharedWithMeItemDto.resource`. +#[derive(Debug, Serialize, ToSchema)] +pub struct FolderResourceItemDto { + pub resource_type: ResourceTypeDto, + /// Full resource details. Shape is determined by `resource_type`. + pub resource: ResourceContentDto, +} + +/// Response envelope for `GET /api/folders/{id}/resources`. +pub type FolderResourcesDto = CursorListResponse; diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index d6b1030d..9725a25a 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -252,6 +252,11 @@ pub struct SharedWithMeQuery { /// Comma-separated resource types to include, e.g. `file,folder`. /// Omit to return all known types. pub resource_types: Option, + /// Reverse the sort order. Default `false` (normal order). + /// Must be the same on all pages of the same result set — the cursor + /// carries this flag so the server can validate consistency. + #[serde(default)] + pub reverse: bool, } impl SharedWithMeQuery { diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 6e752f33..6fa74909 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -86,6 +86,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static { limit: u32, cursor: Option, sort_by: &str, + reverse: bool, ) -> Result<(Vec, Option), DomainError>; /// All grants on a specific resource (for "Manage sharing" UI). Caller diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index f962edcf..9e39e7e0 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -1,5 +1,7 @@ +use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::folder_dto::{ - CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, + CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions, + MoveFolderDto, RenameFolderDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::folder_ports::FolderUseCase; @@ -582,3 +584,109 @@ impl FolderUseCase for FolderService { }) } } + +// ── FolderService — cursor-paginated resource listing ──────────────────────── + +impl FolderService { + /// Cursor-paginated listing of sub-folders **and** files inside `parent_id`. + /// + /// Enforces `Permission::Read` on the parent folder before querying. + /// `order_by` controls both the SQL `ORDER BY` and the cursor encoding. + /// `kinds` filters the result to only the specified resource types. + pub async fn list_resources_paged_with_perms( + &self, + parent_id: &str, + caller_id: Uuid, + opts: ListResourcesOptions<'_>, + ) -> Result<(Vec, Option), DomainError> { + // 1. AuthZ — same check as list_folders_with_perms + self.authz + .require( + Subject::User(caller_id), + Permission::Read, + Self::folder_resource(parent_id)?, + ) + .await?; + + let pid = + Uuid::parse_str(parent_id).map_err(|_| DomainError::not_found("Folder", parent_id))?; + + let ListResourcesOptions { + limit, + cursor, + order_by, + kinds, + reverse, + } = opts; + + // 2. Fetch limit+1 rows so we can detect has_next + let mut rows = self + .folder_storage + .list_resources_paged(pid, limit + 1, cursor.as_ref(), order_by, kinds, reverse) + .await?; + + // 3. Detect has_next, build encoded next cursor + let next_cursor = if rows.len() > limit { + let last = &rows[limit - 1]; + let c = build_folder_resource_cursor(last, order_by, reverse); + rows.truncate(limit); + Some(c.encode()) + } else { + None + }; + + Ok((rows, next_cursor)) + } +} + +/// Build the next-page cursor from the last row of the current page. +/// `reverse` is stored in the cursor so subsequent pages use the same order. +fn build_folder_resource_cursor( + row: &FolderResourceRow, + order_by: &str, + reverse: bool, +) -> FolderResourceCursor { + match order_by { + "type" => FolderResourceCursor { + order_by: "type".to_owned(), + resource_id: row.id, + sort_str: Some(row.sort_str.clone()), + sort_int: Some(row.type_order), + sort_ts: None, + reverse, + }, + "modified_at" => FolderResourceCursor { + order_by: "modified_at".to_owned(), + resource_id: row.id, + sort_str: None, + sort_int: None, + sort_ts: Some(row.modified_at), + reverse, + }, + "created_at" => FolderResourceCursor { + order_by: "created_at".to_owned(), + resource_id: row.id, + sort_str: None, + sort_int: None, + sort_ts: Some(row.created_at), + reverse, + }, + "size" => FolderResourceCursor { + order_by: "size".to_owned(), + resource_id: row.id, + sort_str: None, + sort_int: Some(row.size), + sort_ts: None, + reverse, + }, + _ => FolderResourceCursor { + // "name" (default): sort_int = folder_first (0 or 1) + order_by: "name".to_owned(), + resource_id: row.id, + sort_str: Some(row.sort_str.clone()), + sort_int: Some(i64::from(row.folder_first)), + sort_ts: None, + reverse, + }, + } +} diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index c062c273..64fe3e80 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -285,6 +285,10 @@ pub struct GrantCursor { /// - `"size"` — file size in bytes (-1 = Folder sentinel) #[serde(skip_serializing_if = "Option::is_none")] pub sort_int: Option, + /// Whether the result set was reversed when this cursor was produced. + /// Must be passed unchanged on subsequent page requests. + #[serde(default)] + pub reverse: bool, } impl GrantCursor { diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 8001204b..508370c6 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -12,9 +12,11 @@ use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; +use crate::application::dtos::folder_dto::{FolderResourceCursor, FolderResourceRow}; use crate::common::errors::DomainError; use crate::domain::entities::folder::Folder; use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::authorization::ResourceKind; use crate::domain::services::path_service::StoragePath; /// Type alias for folder metadata rows from SQL queries. @@ -1060,4 +1062,238 @@ impl FolderDbRepository { } Ok(()) } + + /// Cursor-paginated combined listing of sub-folders and files inside + /// `parent_id`, sorted by `order_by`. + /// + /// **Authorization must be verified by the caller** before invoking this + /// method — no ownership filter is applied here. + /// + /// Fetches `limit` rows (caller should pass `desired_page_size + 1` to + /// detect the existence of a next page). Returns raw [`FolderResourceRow`] + /// values; the handler / service layer converts them to DTOs. + pub async fn list_resources_paged( + &self, + parent_id: Uuid, + limit: usize, + cursor: Option<&FolderResourceCursor>, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result, DomainError> { + let include_folders = kinds.is_none_or(|k| k.contains(&ResourceKind::Folder)); + let include_files = kinds.is_none_or(|k| k.contains(&ResourceKind::File)); + + if !include_folders && !include_files { + return Ok(Vec::new()); + } + + // ── CTE branches ──────────────────────────────────────────────────── + let folder_branch = r#" + SELECT + 'folder'::text AS resource_type, + f.id, + f.name, + f.parent_id AS folder_id, + NULL::text AS mime_type, + -1::bigint AS size, + f.created_at, + f.updated_at AS modified_at, + f.user_id, + LOWER(f.name) AS sort_str, + 0::bigint AS type_order, + 0::int AS folder_first + FROM storage.folders f + WHERE f.parent_id = $1::uuid AND NOT f.is_trashed + "#; + + let file_branch = r#" + SELECT + 'file'::text AS resource_type, + fm.id, + fm.name, + fm.folder_id, + fm.mime_type, + fm.size::bigint, + fm.created_at, + fm.updated_at AS modified_at, + fm.user_id, + LOWER(fm.name) AS sort_str, + fm.category_order::bigint AS type_order, + 1::int AS folder_first + FROM storage.files fm + WHERE fm.folder_id = $1::uuid AND NOT fm.is_trashed + "#; + + let cte_inner = match (include_folders, include_files) { + (true, true) => format!("{folder_branch} UNION ALL {file_branch}"), + (true, false) => folder_branch.to_owned(), + (false, true) => file_branch.to_owned(), + (false, false) => unreachable!(), + }; + + // ── Cursor binds ───────────────────────────────────────────────────── + // $1 = parent_id $2 = cursor_str $3 = cursor_int + // $4 = cursor_ts $5 = cursor_id $6 = limit + let cursor_str = cursor.and_then(|c| c.sort_str.clone()); + let cursor_int = cursor.and_then(|c| c.sort_int); + let cursor_ts = cursor.and_then(|c| c.sort_ts); + let cursor_id = cursor.map(|c| c.resource_id); + + // ── Sort-specific WHERE + ORDER BY ─────────────────────────────────── + // Each arm produces two variants based on `reverse`. + // For "name": folder_first stays ASC in both directions (folders always + // precede files); only the alpha order within each group flips. + let (where_clause, order_clause) = match order_by { + "type" => { + if reverse { + ( + r#"WHERE ($3::bigint IS NULL) + OR (type_order < $3) + OR (type_order = $3 AND sort_str < $2) + OR (type_order = $3 AND sort_str = $2 AND id < $5::uuid)"#, + "ORDER BY type_order DESC, sort_str DESC, id DESC", + ) + } else { + ( + r#"WHERE ($3::bigint IS NULL) + OR (type_order > $3) + OR (type_order = $3 AND sort_str > $2) + OR (type_order = $3 AND sort_str = $2 AND id > $5::uuid)"#, + "ORDER BY type_order ASC, sort_str ASC, id ASC", + ) + } + } + "modified_at" => { + if reverse { + ( + r#"WHERE ($4::timestamptz IS NULL) + OR (modified_at > $4) + OR (modified_at = $4 AND id > $5::uuid)"#, + "ORDER BY modified_at ASC, id ASC", + ) + } else { + ( + r#"WHERE ($4::timestamptz IS NULL) + OR (modified_at < $4) + OR (modified_at = $4 AND id < $5::uuid)"#, + "ORDER BY modified_at DESC, id DESC", + ) + } + } + "created_at" => { + if reverse { + ( + r#"WHERE ($4::timestamptz IS NULL) + OR (created_at > $4) + OR (created_at = $4 AND id > $5::uuid)"#, + "ORDER BY created_at ASC, id ASC", + ) + } else { + ( + r#"WHERE ($4::timestamptz IS NULL) + OR (created_at < $4) + OR (created_at = $4 AND id < $5::uuid)"#, + "ORDER BY created_at DESC, id DESC", + ) + } + } + "size" => { + if reverse { + ( + r#"WHERE ($3::bigint IS NULL) + OR (size < $3) + OR (size = $3 AND id < $5::uuid)"#, + "ORDER BY size DESC, id DESC", + ) + } else { + ( + r#"WHERE ($3::bigint IS NULL) + OR (size > $3) + OR (size = $3 AND id > $5::uuid)"#, + "ORDER BY size ASC, id ASC", + ) + } + } + _ => { + // "name" (default): folder_first stays ASC so folders always precede + // files; only the alpha order within each group flips when reversed. + if reverse { + ( + r#"WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str < $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND id < $5::uuid)"#, + "ORDER BY folder_first ASC, sort_str DESC, id DESC", + ) + } else { + ( + r#"WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str > $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND id > $5::uuid)"#, + "ORDER BY folder_first ASC, sort_str ASC, id ASC", + ) + } + } + }; + + let sql = format!( + "WITH resources AS ({cte_inner}) \ + SELECT resource_type, id, name, folder_id, mime_type, size, \ + created_at, modified_at, user_id, sort_str, type_order, folder_first \ + FROM resources \ + {where_clause} \ + {order_clause} \ + LIMIT $6" + ); + + // Row: (resource_type, id, name, folder_id, mime_type, size, + // created_at, modified_at, user_id, sort_str, type_order, folder_first) + type Row = ( + String, + Uuid, + String, + Option, + Option, + i64, + chrono::DateTime, + chrono::DateTime, + Uuid, + String, + i64, + i32, + ); + + let rows = sqlx::query_as::<_, Row>(&sql) + .bind(parent_id) + .bind(cursor_str) + .bind(cursor_int) + .bind(cursor_ts) + .bind(cursor_id) + .bind(limit as i64) + .fetch_all(self.pool()) + .await + .map_err(|e| { + DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}")) + })?; + + Ok(rows + .into_iter() + .map(|r| FolderResourceRow { + resource_type: r.0, + id: r.1, + name: r.2, + parent_id: r.3, + mime_type: r.4, + size: r.5, + created_at: r.6, + modified_at: r.7, + owner_id: r.8, + sort_str: r.9, + type_order: r.10, + folder_first: r.11, + }) + .collect()) + } } diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 54df9614..4542f167 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -300,6 +300,7 @@ impl AuthorizationEngine for PgAclEngine { limit: u32, cursor: Option, sort_by: &str, + reverse: bool, ) -> Result<(Vec, Option), DomainError> { // ── Common setup ────────────────────────────────────────────────────── let kind_strs: Option> = if kinds.is_empty() { @@ -358,6 +359,7 @@ impl AuthorizationEngine for PgAclEngine { // ── Build sort-specific SQL fragments ───────────────────────────────── // "name" and "type" share the same LEFT JOINs; only sort_int_expr, // the cursor WHERE condition, and ORDER BY differ. + // Each branch emits two variants selected by `reverse`. let sql = match sort_by { "name" | "type" => { let sort_int_expr = if sort_by == "type" { @@ -365,20 +367,39 @@ impl AuthorizationEngine for PgAclEngine { } else { "NULL::bigint" }; - let where_clause = if sort_by == "type" { - r#"( $5::integer IS NULL - OR sort_int > $5 - OR (sort_int = $5 AND LOWER(sort_str) > $4) - OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"# + // Normal vs reversed keyset + ORDER BY. + let (where_clause, order_clause) = if sort_by == "type" { + if reverse { + ( + r#"( $5::integer IS NULL + OR sort_int < $5 + OR (sort_int = $5 AND LOWER(sort_str) < $4) + OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id < $7::uuid))"#, + "sort_int DESC, LOWER(sort_str) DESC, resource_id DESC", + ) + } else { + ( + r#"( $5::integer IS NULL + OR sort_int > $5 + OR (sort_int = $5 AND LOWER(sort_str) > $4) + OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#, + "sort_int ASC, LOWER(sort_str) ASC, resource_id ASC", + ) + } + } else if reverse { + ( + r#"( $4::text IS NULL + OR LOWER(sort_str) < $4 + OR (LOWER(sort_str) = $4 AND resource_id < $7::uuid))"#, + "LOWER(sort_str) DESC, resource_id DESC", + ) } else { - r#"( $4::text IS NULL - OR LOWER(sort_str) > $4 - OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"# - }; - let order_clause = if sort_by == "type" { - "sort_int ASC, LOWER(sort_str) ASC, resource_id ASC" - } else { - "LOWER(sort_str) ASC, resource_id ASC" + ( + r#"( $4::text IS NULL + OR LOWER(sort_str) > $4 + OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#, + "LOWER(sort_str) ASC, resource_id ASC", + ) }; format!( r#"WITH {AGG}, @@ -400,64 +421,112 @@ impl AuthorizationEngine for PgAclEngine { LIMIT $8"# ) } - "granted_by" => format!( + "granted_by" => { // Joins auth.users to sort alphabetically by username. // Cursor encodes (owner_name=$4, granted_at=$6, resource_id=$7). - r#"WITH {AGG}, - owner_named AS ( - SELECT agg.*, - LOWER(u.username) AS sort_str, - NULL::bigint AS sort_int - FROM agg - LEFT JOIN auth.users u ON u.id = agg.granted_by + let (where_clause, order_clause) = if reverse { + ( + r#"( $4::text IS NULL + OR sort_str < $4 + OR (sort_str = $4 AND ( + $6::timestamptz IS NULL + OR granted_at > $6 + OR (granted_at = $6 AND resource_id > $7::uuid))))"#, + "sort_str DESC, granted_at ASC, resource_id ASC", + ) + } else { + ( + r#"( $4::text IS NULL + OR sort_str > $4 + OR (sort_str = $4 AND ( + $6::timestamptz IS NULL + OR granted_at < $6 + OR (granted_at = $6 AND resource_id < $7::uuid))))"#, + "sort_str ASC, granted_at DESC, resource_id DESC", + ) + }; + format!( + r#"WITH {AGG}, + owner_named AS ( + SELECT agg.*, + LOWER(u.username) AS sort_str, + NULL::bigint AS sort_int + FROM agg + LEFT JOIN auth.users u ON u.id = agg.granted_by + ) + SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int + FROM owner_named + WHERE {where_clause} + ORDER BY {order_clause} + LIMIT $8"# ) - SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int - FROM owner_named - WHERE ( $4::text IS NULL - OR sort_str > $4 - OR (sort_str = $4 AND ( - $6::timestamptz IS NULL - OR granted_at < $6 - OR (granted_at = $6 AND resource_id < $7::uuid)))) - ORDER BY sort_str ASC, granted_at DESC, resource_id DESC - LIMIT $8"# - ), - "size" => format!( - // Folders have no size — they sort first with a sentinel of -1. - // Files sort by size ASC; resource_id breaks ties. + } + "size" => { + // Folders have no size — sentinel -1 (sorts first ASC, last DESC). // Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused. - r#"WITH {AGG}, - sized AS ( - SELECT agg.*, - NULL::text AS sort_str, - CASE WHEN agg.resource_type = 'folder' THEN -1 - ELSE fi.size - END AS sort_int - FROM agg - LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file' + let (where_clause, order_clause) = if reverse { + ( + r#"( $5::bigint IS NULL + OR sort_int < $5 + OR (sort_int = $5 AND resource_id < $7::uuid))"#, + "sort_int DESC, resource_id DESC", + ) + } else { + ( + r#"( $5::bigint IS NULL + OR sort_int > $5 + OR (sort_int = $5 AND resource_id > $7::uuid))"#, + "sort_int ASC, resource_id ASC", + ) + }; + format!( + r#"WITH {AGG}, + sized AS ( + SELECT agg.*, + NULL::text AS sort_str, + CASE WHEN agg.resource_type = 'folder' THEN -1 + ELSE fi.size + END AS sort_int + FROM agg + LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file' + ) + SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int + FROM sized + WHERE {where_clause} + ORDER BY {order_clause} + LIMIT $8"# ) - SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int - FROM sized - WHERE ( $5::bigint IS NULL - OR sort_int > $5 - OR (sort_int = $5 AND resource_id > $7::uuid)) - ORDER BY sort_int ASC, resource_id ASC - LIMIT $8"# - ), - _ => format!( - // Default: sort by grant date DESC (newest first). + } + _ => { + // Default: sort by grant date. + // Normal = DESC (newest first); reversed = ASC (oldest first). // Cursor encodes (granted_at=$6, resource_id=$7); $4/$5 unused. - r#"WITH {AGG} - SELECT resource_type, resource_id, permissions, granted_at, granted_by, - NULL::text AS sort_str, - NULL::bigint AS sort_int - FROM agg - WHERE ( $6::timestamptz IS NULL - OR granted_at < $6 - OR (granted_at = $6 AND resource_id < $7::uuid)) - ORDER BY granted_at DESC, resource_id DESC - LIMIT $8"# - ), + let (where_clause, order_clause) = if reverse { + ( + r#"( $6::timestamptz IS NULL + OR granted_at > $6 + OR (granted_at = $6 AND resource_id > $7::uuid))"#, + "granted_at ASC, resource_id ASC", + ) + } else { + ( + r#"( $6::timestamptz IS NULL + OR granted_at < $6 + OR (granted_at = $6 AND resource_id < $7::uuid))"#, + "granted_at DESC, resource_id DESC", + ) + }; + format!( + r#"WITH {AGG} + SELECT resource_type, resource_id, permissions, granted_at, granted_by, + NULL::text AS sort_str, + NULL::bigint AS sort_int + FROM agg + WHERE {where_clause} + ORDER BY {order_clause} + LIMIT $8"# + ) + } }; // ── Execute — uniform 8 binds for every sort mode ───────────────────── @@ -493,6 +562,7 @@ impl AuthorizationEngine for PgAclEngine { resource_id: r.1, resource_name: sort_str_lc, sort_int: None, + reverse, }, "type" => GrantCursor { sort_by: "type".to_owned(), @@ -500,6 +570,7 @@ impl AuthorizationEngine for PgAclEngine { resource_id: r.1, resource_name: sort_str_lc, sort_int: r.6, + reverse, }, "granted_by" => GrantCursor { sort_by: "granted_by".to_owned(), @@ -507,6 +578,7 @@ impl AuthorizationEngine for PgAclEngine { resource_id: r.1, resource_name: r.5.clone(), // already lowercased by SQL sort_int: None, + reverse, }, "size" => GrantCursor { sort_by: "size".to_owned(), @@ -514,6 +586,7 @@ impl AuthorizationEngine for PgAclEngine { resource_id: r.1, resource_name: None, sort_int: r.6, + reverse, }, _ => GrantCursor { sort_by: "granted_at".to_owned(), @@ -521,6 +594,7 @@ impl AuthorizationEngine for PgAclEngine { resource_id: r.1, resource_name: None, sort_int: None, + reverse, }, } }) diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 90f77bd5..8c8b19c5 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -10,10 +10,16 @@ use std::hash::{Hash, Hasher}; use std::sync::Arc; use tokio_util::io::ReaderStream; +use crate::application::dtos::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; +use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::{ - CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, + CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery, + ListResourcesOptions, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::folder_listing_dto::FolderListingDto; +use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::folder_ports::FolderUseCase; @@ -466,6 +472,7 @@ pub async fn list_root_folders( FolderHandler::list_root_folders_impl(state, auth_user).await } +#[deprecated = "Use /api/folders/{id}/resources instead"] #[utoipa::path( get, path = "/api/folders/{id}/contents", @@ -477,6 +484,7 @@ pub async fn list_root_folders( security(("bearerAuth" = [])), tag = "folders" )] +#[allow(deprecated)] pub async fn list_folder_contents( state: State, auth_user: AuthUser, @@ -503,6 +511,7 @@ pub async fn list_root_folders_paginated( FolderHandler::list_root_folders_paginated_impl(state, auth_user, pagination).await } +#[deprecated = "Use /api/folders/{id}/resources instead"] #[utoipa::path( get, path = "/api/folders/{id}/contents/paginated", @@ -517,6 +526,7 @@ pub async fn list_root_folders_paginated( security(("bearerAuth" = [])), tag = "folders" )] +#[allow(deprecated)] pub async fn list_folder_contents_paginated( state: State, auth_user: AuthUser, @@ -526,6 +536,7 @@ pub async fn list_folder_contents_paginated( FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await } +#[deprecated = "Use /api/folders/{id}/resources instead"] #[utoipa::path( get, path = "/api/folders/{id}/listing", @@ -538,6 +549,7 @@ pub async fn list_folder_contents_paginated( security(("bearerAuth" = [])), tag = "folders" )] +#[allow(deprecated)] pub async fn list_folder_listing( state: State>, auth_user: AuthUser, @@ -628,3 +640,105 @@ pub async fn download_folder_zip( ) -> impl IntoResponse { FolderHandler::download_folder_zip_impl(state, auth_user, path, query).await } + +// ── GET /api/folders/{id}/resources ───────────────────────────────────────── + +#[utoipa::path( + get, + path = "/api/folders/{id}/resources", + params( + ("id" = String, Path, description = "Folder ID"), + FolderResourcesQuery, + ), + responses( + (status = 200, + description = "Cursor-paginated files and folders inside the requested folder. \ + Items arrive in `order_by` order (folders first when order_by=name). \ + `next_cursor` is absent on the last page.", + body = FolderResourcesDto), + (status = 404, description = "Folder not found or access denied"), + ), + tag = "folders" +)] +pub async fn list_folder_resources( + State(service): State, + auth_user: AuthUser, + Path(id): Path, + Query(q): Query, +) -> impl IntoResponse { + let order_by = q.order_by.clone().unwrap_or_else(|| "name".to_owned()); + let kinds = q.resource_kinds(); + let opts = ListResourcesOptions { + limit: q.limit_clamped(), + cursor: q.decode_cursor(), + order_by: &order_by, + kinds: kinds.as_deref(), + reverse: q.reverse, + }; + + match service + .list_resources_paged_with_perms(&id, auth_user.id, opts) + .await + { + Ok((rows, next_cursor)) => { + let items: Vec = rows + .into_iter() + .map(|row| { + if row.resource_type == "folder" { + let dto = FolderDto { + id: row.id.to_string(), + name: row.name.clone(), + path: String::new(), // cleared — share recipients must not see hierarchy + parent_id: row.parent_id.map(|u| u.to_string()), + owner_id: Some(row.owner_id.to_string()), + created_at: row.created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + is_root: false, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + }; + FolderResourceItemDto { + resource_type: ResourceTypeDto::Folder, + resource: ResourceContentDto::Folder(dto), + } + } else { + let mime = row + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let size_bytes = row.size.max(0) as u64; + let dto = FileDto { + id: row.id.to_string(), + name: row.name.clone(), + path: String::new(), + size: size_bytes, + mime_type: Arc::from(mime), + folder_id: row.parent_id.map(|u| u.to_string()), + created_at: row.created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + icon_class: Arc::from(icon_class_for(&row.name, mime)), + icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)), + category: Arc::from(category_for(&row.name, mime)), + size_formatted: format_file_size(size_bytes), + owner_id: Some(row.owner_id.to_string()), + sort_date: None, + etag: String::new(), + }; + FolderResourceItemDto { + resource_type: ResourceTypeDto::File, + resource: ResourceContentDto::File(dto), + } + } + }) + .collect(); + + ( + StatusCode::OK, + Json(FolderResourcesDto::with_cursor(items, next_cursor)), + ) + .into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index dcaa48f6..260e77ab 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -341,16 +341,18 @@ pub async fn list_shared_with_me( .into_response(); } - // Decode cursor — discard it when the sort dimension changed to avoid - // keyset confusion across sort modes. + let reverse = q.reverse; + + // Decode cursor — discard it when the sort dimension or direction changed + // to avoid keyset confusion across sort modes. let cursor = q .decode_cursor::() - .filter(|c| c.sort_by == sort_by); + .filter(|c| c.sort_by == sort_by && c.reverse == reverse); // Fetch paged summaries from the ACL engine. let (summaries, next_cursor) = match state .authorization - .list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by) + .list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by, reverse) .await { Ok(r) => r, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index ebf65ec5..c58f6d9d 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -58,9 +58,10 @@ use crate::interfaces::api::handlers::file_handler::{ delete_file, download_file, get_file_metadata, get_thumbnail, list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail, }; +#[allow(deprecated)] use crate::interfaces::api::handlers::folder_handler::{ create_folder, delete_folder_with_trash, download_folder_zip, get_folder, list_folder_contents, - list_folder_contents_paginated, list_folder_listing, list_root_folders, + list_folder_contents_paginated, list_folder_listing, list_folder_resources, list_root_folders, list_root_folders_paginated, move_folder, rename_folder, }; use crate::interfaces::api::handlers::i18n_handler::{ @@ -155,6 +156,9 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router) -> Router> { // Extract services from the pre-built AppState let folder_service = app_state.applications.folder_service_concrete.clone(); @@ -195,6 +199,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { "/{id}/contents/paginated", get(list_folder_contents_paginated), ) + .route("/{id}/resources", get(list_folder_resources)) .route("/{id}/rename", put(rename_folder)) .route("/{id}/move", put(move_folder)) .with_state(folder_service.clone()); diff --git a/static/css/components/buttons.css b/static/css/components/buttons.css index 82a690e2..8779d784 100644 --- a/static/css/components/buttons.css +++ b/static/css/components/buttons.css @@ -121,6 +121,8 @@ } .group-by-selector { + display: flex; + align-items: center; position: relative; } @@ -132,6 +134,15 @@ color: var(--color-accent); } +/* Sort direction button — rotate the SVG icon when order is reversed */ +.sort-dir-btn .oxi-icon { + transition: transform 0.2s ease; +} + +.sort-dir-btn.active .oxi-icon { + transform: rotate(180deg); +} + /* Active label shown inline next to the icon */ .group-by-label { display: none; diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 747879c6..31b71614 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -4,21 +4,23 @@ * OxiCloud – Files section view. * * Orchestrates the main Files section: - * - Data fetching via `filesModel` - * - Rendering via a `ResourceListComponent` instance + * - Data fetching via `filesModel` (cursor-paginated `/api/folders/{id}/resources`) + * - Rendering via a `ResourceListComponent` instance with optional swimlane grouping * - Drag-and-drop initialisation (delegated to `ui.initDragDrop`) * - * Exports `loadFiles` (navigation & deep-link entry-point) and `addItem` - * (post-upload / post-create optimistic UI updates used by fileOperations - * and search). + * Exports: + * - `loadFiles` – navigation & deep-link entry-point + * - `addItem` – post-upload / post-create optimistic UI updates + * - `filesView` – group-by controller consumed by `navigation.js` / `main.js` */ import { ResourceListComponent } from '../components/resourceList.js'; +import { normalizeDateBucket, sizeBucket } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; import { favorites } from '../features/library/favorites.js'; -import { fetchListing, rebuildBreadCrumb } from '../model/filesModel.js'; +import { fetchResourcesPage, rebuildBreadCrumb } from '../model/filesModel.js'; import { grants } from '../model/grants.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; @@ -28,12 +30,160 @@ import { uiNotifications } from './uiNotifications.js'; /** @import {FileItem, FolderItem} from '../core/types.js' */ +/** + * @typedef {{ key: string, label: string, orderBy: string, + * keyFn: (item: FileItem|FolderItem) => string|null, + * labelFn?: (key: string) => string }} GroupByDef + */ + +// ── Group-by dimension definitions ─────────────────────────────────────────── + +/** + * Group-by dimension definitions for the Files section. + * Mirrors the same shape used by `sharedWithMeView.groupByDefs` so `main.js` + * can drive the group-by dropdown generically. + * + * @type {GroupByDef[]} + */ +const GROUP_BY_DEFS = [ + { + key: 'type', + get label() { + return i18n.t('groupby.type', 'Type'); + }, + orderBy: 'type', + // Folders → 'Folder'; files → their pre-computed category string. + keyFn: (item) => ('mime_type' in item ? /** @type {Record} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'), + labelFn: (key) => { + // biome-ignore format: keep indentation + /** @type {Record} */ + const labels = { + Folder: i18n.t('groupby.type.folders', 'Folders'), + Image: i18n.t('category.images', 'Images'), + Video: i18n.t('category.videos', 'Videos'), + Audio: i18n.t('category.audio', 'Audio'), + PDF: 'PDF', + Document: i18n.t('category.documents', 'Documents'), + Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'), + Presentation: i18n.t('category.presentations', 'Presentations'), + Archive: i18n.t('category.archives', 'Archives'), + Code: i18n.t('category.code', 'Code'), + Markdown: i18n.t('category.markdown', 'Markdown'), + Text: i18n.t('category.text', 'Text'), + Installer: i18n.t('category.installers', 'Installers') + }; + return labels[key] ?? key; + } + }, + { + key: 'modifiedAt', + get label() { + return i18n.t('groupby.modifiedAt', 'Modified date'); + }, + orderBy: 'modified_at', + // keyFn returns the human-readable bucket; the bucket IS the key. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.modified_at ? normalizeDateBucket(r.modified_at) : null; + } + }, + { + key: 'createdAt', + get label() { + return i18n.t('groupby.createdAt', 'Created date'); + }, + orderBy: 'created_at', + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.created_at ? normalizeDateBucket(r.created_at) : null; + } + }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // sizeBucket(-1) → "Folders" sentinel; no labelFn needed. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + } +]; + +// ── Module-level state ──────────────────────────────────────────────────────── + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'files-load-more-wrapper'; + /** @type {ResourceListComponent|null} */ let _component = null; -/** Guard against concurrent `loadFiles` calls. */ +/** Guard against concurrent `_loadPage` calls. */ let _loading = false; +/** Opaque cursor for the next page; `null` on first page or when exhausted. */ +let _nextCursor = /** @type {string|null} */ (null); + +/** + * Active group-by key: '' = no grouping (name order), or one of the keys + * from GROUP_BY_DEFS. + * @type {string} + */ +let _groupBy = ''; + +/** Whether the current sort order is reversed. */ +let _reversed = false; + +// ── Group-by controller (public API, consumed by navigation.js / main.js) ─── + +/** + * Controller object registered with `setGroupByView()` by navigation.js when + * the Files section is active. Exposes the same interface as + * `sharedWithMeView` so the generic group-by infrastructure in `main.js` + * drives both sections identically. + */ +const filesView = { + /** + * The group-by dimension definitions for this section. + * `main.js` reads this to populate the Group-by dropdown dynamically. + * @returns {GroupByDef[]} + */ + get groupByDefs() { + return GROUP_BY_DEFS; + }, + + /** + * Change the active group-by dimension and reload from page 1. + * Calling with the current key is a no-op. + * @param {string} key '' | 'type' | 'modifiedAt' | 'createdAt' | 'size' + */ + setGroupBy(key) { + if (_groupBy === key) return; + _groupBy = key; + _nextCursor = null; + _component?.clear(); + _loadPage({ isFirstPage: true }); + }, + + /** + * Flip the sort direction and reload from page 1. + * Calling with the current value is a no-op. + * @param {boolean} reversed + */ + setDirection(reversed) { + if (_reversed === reversed) return; + _reversed = reversed; + _nextCursor = null; + _component?.clear(); + _loadPage({ isFirstPage: true }); + } +}; + +// ── Component factory ───────────────────────────────────────────────────────── + /** * Return (creating on first call) the `ResourceListComponent` bound to * `#files-list`. The element must already be in the DOM. @@ -85,9 +235,99 @@ function _ensureComponent() { ui.initDragDrop(/** @type {HTMLElement} */ (filesList)); } + _ensureLoadMoreButton(); + return _component; } +// ── "Load more" button ──────────────────────────────────────────────────────── + +/** + * Create the "Load more" wrapper once and attach it below `.files-container`. + * Subsequent calls are no-ops. + */ +function _ensureLoadMoreButton() { + if (document.getElementById(LOAD_MORE_ID)) return; + + const filesContainer = document.querySelector('.files-container'); + if (!filesContainer) return; + + const wrapper = document.createElement('div'); + wrapper.id = LOAD_MORE_ID; + wrapper.className = 'swm-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'files-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('files.loadMore', 'Load more'); + btn.addEventListener('click', () => { + _loadPage({ isFirstPage: false }); + }); + + wrapper.appendChild(btn); + filesContainer.after(wrapper); +} + +/** + * @param {boolean} visible + */ +function _setLoadMoreVisible(visible) { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.toggle('hidden', !visible); +} + +// ── Page loader ─────────────────────────────────────────────────────────────── + +/** + * Fetch one cursor page and render it. + * @param {{ isFirstPage?: boolean }} [opts] + * @returns {Promise} + */ +async function _loadPage({ isFirstPage = false } = {}) { + if (_loading) return; + _loading = true; + + try { + const def = GROUP_BY_DEFS.find((d) => d.key === _groupBy); + const orderBy = def?.orderBy ?? 'name'; + + const { items, nextCursor } = await fetchResourcesPage(app.currentPath, { + cursor: _nextCursor, + orderBy, + limit: 50, + reverse: _reversed + }); + + _nextCursor = nextCursor; + + if (items.length === 0 && isFirstPage) { + ui.showEmptyList(); + _setLoadMoreVisible(false); + return; + } + + if (isFirstPage) { + _component?.render(items, def?.keyFn, def?.labelFn); + } else { + _component?.append(items, def?.keyFn, def?.labelFn); + } + + await _component?.resolveOwnerCells(); + _setLoadMoreVisible(!!nextCursor); + } catch (/** @type {any} */ err) { + if (err?.status === 403) { + ui.showError(`

${i18n.t('errors.forbidden', 'Could not load files')}

`); + } else { + console.error('filesView: load error', err); + uiNotifications.show('Error', 'Could not load files and folders'); + } + } finally { + _loading = false; + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + /** * Append a single item to the current view (post-upload / post-create * optimistic update). No-op when the Files section is not active or the @@ -111,14 +351,18 @@ function addItem(item) { * * @param {Object} [options] * @param {boolean} [options.insertHistory=true] - * @param {boolean} [options.forceRefresh=false] + * @param {boolean} [options.forceRefresh=false] (legacy — kept for callers; ignored internally) */ async function loadFiles(options = { insertHistory: true }) { if (_loading) { console.log('A file load is already in progress, ignoring request'); return; } - _loading = true; + + // Reset cursor, groupBy, and direction on navigation to a different folder. + _nextCursor = null; + _groupBy = ''; + _reversed = false; // Delay spinner so fast loads avoid the flash const spinnerTimeout = setTimeout(() => { @@ -130,6 +374,10 @@ async function loadFiles(options = { insertHistory: true }) { `); }, 100); + // A temporary guard: _loadPage sets _loading itself, but we need to + // block re-entrant loadFiles() calls during the setup below. + _loading = true; + try { if (!app.userHomeFolderId) await resolveHomeFolder(); @@ -148,10 +396,6 @@ async function loadFiles(options = { insertHistory: true }) { ui.updateBreadcrumb(); updateHistory(options.insertHistory ?? true); - const { folders, files } = await fetchListing(app.currentPath, { - forceRefresh: options.forceRefresh ?? false - }); - clearTimeout(spinnerTimeout); // Prepare the container (shows #files-list, hides error panel) @@ -164,23 +408,31 @@ async function loadFiles(options = { insertHistory: true }) { batchToolbar.init(); batchToolbar.setActiveComponent(component); - if (folders.length === 0 && files.length === 0) { - ui.showEmptyList(); - } else { - component.render([...folders, ...files]); - await component.resolveOwnerCells(); - } + // Hand off to _loadPage (re-use cursor/groupBy state just reset above). + _loading = false; // _loadPage sets its own guard + await _loadPage({ isFirstPage: true }); - console.log(`Loaded ${folders.length} folders and ${files.length} files`); - - // Deep-link: open a specific file if requested via app.viewFile + // Deep-link: open a specific file if requested via app.viewFile. + // We don't have a flat file list anymore (cursor pages), so only try + // to open it if it was already rendered (first page). if (app.viewFile) { - const fileFound = files.find((f) => f.id === app.viewFile) ?? null; - if (fileFound) { - console.log(`file ${app.viewFile} found, calling viewer`); - await inlineViewer.openFile(fileFound); + // Find the item among all rendered cards via the DOM attribute. + const rendered = document.querySelector(`[data-id="${app.viewFile}"][data-type="file"]`); + if (rendered) { + // The component's item list may be sparse; ask for a fresh fetch. + const fileRes = await fetch(`/api/files/${app.viewFile}`, { + credentials: 'same-origin', + cache: 'no-store' + }); + if (fileRes.ok) { + const fileFound = /** @type {FileItem} */ (await fileRes.json()); + await inlineViewer.openFile(fileFound); + } else { + app.viewFile = null; + updateHistory(false); + } } else { - console.log(`file ${app.viewFile} not found`); + console.log(`file ${app.viewFile} not in first page — skipping auto-open`); app.viewFile = null; updateHistory(false); } @@ -198,4 +450,4 @@ async function loadFiles(options = { insertHistory: true }) { } } -export { addItem, loadFiles }; +export { addItem, filesView, loadFiles }; diff --git a/static/js/app/main.js b/static/js/app/main.js index 6ca2fe07..a90b7e4d 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -89,6 +89,10 @@ const _toggleButtons = ` + @@ -206,14 +210,14 @@ function setActionsBarMode(mode, force = false) { /** * The view that currently owns the group-by selector, or `null` when no * section supports grouping. Set by `setGroupByView()` from navigation.js. - * @type {{ setGroupBy: (key: string) => void } | null} + * @type {{ setGroupBy: (key: string) => void, setDirection: (reversed: boolean) => void } | null} */ let _groupByView = null; /** * Update the reference to the view that handles group-by changes. * Called by navigation.js when the active section changes. - * @param {{ setGroupBy: (key: string) => void } | null} view + * @param {{ setGroupBy: (key: string) => void, setDirection: (reversed: boolean) => void } | null} view */ function setGroupByView(view) { _groupByView = view; @@ -247,6 +251,8 @@ function syncGroupByMenu(defs = []) { btn?.classList.remove('active'); const lbl = btn?.querySelector('.group-by-label'); if (lbl) lbl.textContent = ''; + // Reset direction button to ascending (↑) + document.getElementById('sort-dir-btn')?.classList.remove('active'); return; } @@ -289,10 +295,19 @@ function setupActionsBarDelegation() { groupByBtn?.classList.toggle('active', key !== ''); const lbl = groupByBtn?.querySelector('.group-by-label'); if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : ''; + // Changing order-by dimension resets direction to ascending + _groupByView?.setDirection(false); + document.getElementById('sort-dir-btn')?.classList.remove('active'); return; } switch (btn.id) { + case 'sort-dir-btn': { + const nowReversed = !btn.classList.contains('active'); + _groupByView?.setDirection(nowReversed); + btn.classList.toggle('active', nowReversed); + return; + } case 'group-by-btn': document.getElementById('group-by-menu')?.classList.toggle('hidden'); return; diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 4f590fa2..3ad7162b 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -11,7 +11,7 @@ import { photosView } from '../features/library/photos.js'; import { recent } from '../features/library/recent.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; -import { loadFiles } from './filesView.js'; +import { filesView, loadFiles } from './filesView.js'; import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js'; import { app, appElements } from './state.js'; import { loadTrashItems } from './trashView.js'; @@ -237,8 +237,8 @@ function switchToFilesSection() { // Set actions bar mode setActionsBarMode('files', true); - setGroupByView(null); - syncGroupByMenu([]); + setGroupByView(filesView); + syncGroupByMenu(filesView.groupByDefs); // Show owner column in the Files section ui.setOwnerColumnVisible(true); @@ -432,8 +432,8 @@ function switchToMusicSection() { function activateFilesUI() { setCurrentSection('files'); setActionsBarMode('files', true); - setGroupByView(null); - syncGroupByMenu([]); + setGroupByView(filesView); + syncGroupByMenu(filesView.groupByDefs); const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.remove('hidden'); toggleFileContainer(true); diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 01a1d8c4..28ff4464 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -25,6 +25,23 @@ const OxiIcons = { 512, 'M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 96-96 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6l0-32 96 0 0 96-32 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0 0-96 96 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 32-96 0 0-96 32 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64z' ], + 'arrow-up': [ + 512, + 'M214.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 109.3 160 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-370.7 105.4 105.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z' + ], + 'arrow-down': [ + 512, + 'M169.4 502.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 402.7 224 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 370.7-105.4-105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z' + ], + 'arrow-down-short-wide': [ + 576, + 'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z' + ], + 'arrow-down-wide-short': [ + 576, + 'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L320 96z' + ], + ban: [ 512, 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z' diff --git a/static/js/model/filesModel.js b/static/js/model/filesModel.js index a4833ca6..f483b5e9 100644 --- a/static/js/model/filesModel.js +++ b/static/js/model/filesModel.js @@ -107,4 +107,72 @@ async function fetchListing(folderId, options = {}) { }; } -export { fetchListing, getFolder, rebuildBreadCrumb }; +/** + * Map one tagged resource item from `/api/folders/{id}/resources` into the + * canonical `FileItem` / `FolderItem` shape used by `ResourceListComponent`. + * + * @param {{ resource_type: string, resource: Record }} tagged + * @returns {FileItem|FolderItem} + */ +function _mapResourceItem(tagged) { + const r = tagged.resource; + if (tagged.resource_type === 'folder') { + return /** @type {FolderItem} */ ({ + id: String(r.id ?? ''), + name: String(r.name ?? ''), + path: String(r.path ?? ''), + parent_id: r.parent_id != null ? String(r.parent_id) : '', + owner_id: r.owner_id != null ? String(r.owner_id) : '', + created_at: /** @type {number} */ (r.created_at), + modified_at: /** @type {number} */ (r.modified_at), + is_root: Boolean(r.is_root), + icon_class: String(r.icon_class ?? 'fas fa-folder'), + icon_special_class: String(r.icon_special_class ?? 'folder-icon'), + category: String(r.category ?? 'Folder') + }); + } + return /** @type {FileItem} */ ({ + id: String(r.id ?? ''), + name: String(r.name ?? ''), + path: String(r.path ?? ''), + folder_id: r.folder_id != null ? String(r.folder_id) : '', + owner_id: r.owner_id != null ? String(r.owner_id) : '', + mime_type: String(r.mime_type ?? ''), + size: /** @type {number} */ (r.size), + size_formatted: String(r.size_formatted ?? ''), + created_at: /** @type {number} */ (r.created_at), + modified_at: /** @type {number} */ (r.modified_at), + icon_class: String(r.icon_class ?? ''), + icon_special_class: String(r.icon_special_class ?? ''), + category: String(r.category ?? '') + }); +} + +/** + * Fetch one cursor page from `GET /api/folders/{id}/resources`. + * + * @param {string} folderId + * @param {{ cursor?: string|null, orderBy?: string, limit?: number, reverse?: boolean }} [opts] + * @returns {Promise<{ items: Array, nextCursor: string|null }>} + */ +async function fetchResourcesPage(folderId, { cursor = null, orderBy = 'name', limit = 50, reverse = false } = {}) { + const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + if (reverse) params.set('reverse', 'true'); + + const res = await fetch(`/api/folders/${folderId}/resources?${params}`, NO_CACHE); + if (!res.ok) { + const err = /** @type {any} */ (new Error(`fetchResourcesPage: ${res.status}`)); + err.status = res.status; + throw err; + } + + const data = await res.json(); + const items = /** @type {Array<{ resource_type: string, resource: Record }>} */ (Array.isArray(data.items) ? data.items : []).map( + _mapResourceItem + ); + + return { items, nextCursor: data.next_cursor ?? null }; +} + +export { fetchListing, fetchResourcesPage, getFolder, rebuildBreadCrumb }; diff --git a/static/js/model/grants.js b/static/js/model/grants.js index e385a74d..4eddcbe7 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -89,15 +89,17 @@ const grants = { * @param {number} [opts.limit] - Max items per page (1–200, default 50). * @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page. * @param {string} [opts.orderBy] - Sort dimension: 'granted_at' | 'granted_by' (default: 'granted_at'). + * @param {boolean} [opts.reverse] - Reverse the sort order (default: false). * @returns {Promise} */ - async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) { + async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy, reverse = false } = {}) { const params = new URLSearchParams({ limit: String(limit), resource_types: resourceTypes.join(',') }); if (cursor) params.set('cursor', cursor); if (orderBy) params.set('sort_by', orderBy); + if (reverse) params.set('reverse', 'true'); const response = await fetch(`/api/grants/incoming/resources?${params}`); diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 35961d6b..cb498bff 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -143,6 +143,9 @@ const sharedWithMeView = { */ _groupBy: '', + /** Whether the current sort order is reversed. */ + _reversed: false, + // ── Public API ──────────────────────────────────────────────────────────── /** @@ -167,6 +170,19 @@ const sharedWithMeView = { this._loadPage(); }, + /** + * Flip the sort direction and reload from page 1. + * Calling with the current value is a no-op. + * @param {boolean} reversed + */ + setDirection(reversed) { + if (this._reversed === reversed) return; + this._reversed = reversed; + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + /** * (Re-)load from page 1 and render into the existing files container. * Called every time the user switches to this section. @@ -175,6 +191,7 @@ const sharedWithMeView = { this._nextCursor = null; this._loading = false; this._groupBy = ''; + this._reversed = false; this._ensureLoadMoreButton(); @@ -275,7 +292,8 @@ const sharedWithMeView = { resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']), limit: 50, cursor: this._nextCursor ?? undefined, - orderBy + orderBy, + reverse: this._reversed }); this._nextCursor = data.next_cursor ?? null; From 607ae5e6df01528580a10f3ccba3987d1af53612 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 10:08:38 +0200 Subject: [PATCH 2/9] feat(resources): add cursor, group by on /api/favorites/resources --- src/application/dtos/favorites_dto.rs | 120 ++++- src/application/ports/favorites_ports.rs | 18 +- src/application/services/favorites_service.rs | 107 ++++- .../pg/favorites_pg_repository.rs | 309 ++++++++++++- .../api/handlers/favorites_handler.rs | 138 +++++- src/interfaces/api/routes.rs | 8 +- static/js/app/filesView.js | 2 +- static/js/app/navigation.js | 32 +- static/js/core/types.js | 16 + static/js/features/files/batchToolbar.js | 8 +- static/js/features/files/contextMenus.js | 4 +- static/js/features/library/favorites.js | 237 +++------- static/js/model/favoritesModel.js | 51 +++ static/js/views/favorites/favoritesView.js | 422 ++++++++++++++++++ .../js/views/sharedWithMe/sharedWithMeView.js | 2 +- 15 files changed, 1265 insertions(+), 209 deletions(-) create mode 100644 static/js/model/favoritesModel.js create mode 100644 static/js/views/favorites/favoritesView.js diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 9a6f4234..19d09cef 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -1,10 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; +use super::cursor::{CursorListResponse, CursorQuery, PageCursor}; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, }; +use super::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::domain::services::authorization::ResourceKind; /// DTO for favorites item, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. @@ -104,6 +108,120 @@ pub struct BatchFavoritesResult { pub favorites: Vec, } +// ════════════════════════════════════════════════════════════════════════════ +// Cursor-paginated favorites resources (GET /api/favorites/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// Raw row returned by the UNION ALL query that joins `auth.user_favorites` +/// with `storage.files` / `storage.folders`. Never serialised directly. +pub struct FavoriteResourceRow { + pub resource_type: String, // "file" | "folder" + pub resource_id: Uuid, + pub name: String, + pub parent_id: Option, + pub mime_type: Option, + /// `-1` for folders, actual byte-count for files. + pub size: i64, + pub resource_created_at: DateTime, + pub modified_at: DateTime, + pub owner_id: Uuid, + /// `true` when `owner_id == requesting user_id`. + pub is_owner: bool, + pub favorited_at: DateTime, + /// Human-readable path (e.g. `Documents/Work` for a folder, + /// `Documents/Work/report.pdf` for a file). Always populated; the + /// handler clears it to `""` when `is_owner` is false. + pub path: Option, + // Pre-computed sort fields for cursor construction. + pub sort_str: Option, + pub sort_int: Option, + pub sort_ts: Option>, +} + +/// Opaque keyset-pagination cursor for `GET /api/favorites/resources`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FavoritesCursor { + /// Sort dimension active when this cursor was produced. + /// Values: `"name"` (default), `"type"`, `"favorited_at"`, `"modified_at"`, + /// `"size"`, `"owner"`. + #[serde(default = "FavoritesCursor::default_order")] + pub order_by: String, + /// UUID of the last item on the previous page (tie-breaker). + pub resource_id: Uuid, + /// `LOWER(name)` for `name`/`type` sorts; `LOWER(username)` for `owner`. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_str: Option, + /// Multipurpose integer: `folder_first` for `name`, `type_order` for `type`, + /// size in bytes for `size`. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_int: Option, + /// Timestamp for `favorited_at` and `modified_at` sorts. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_ts: Option>, + /// Whether the result set was reversed — must match on every page. + #[serde(default)] + pub reverse: bool, +} + +impl FavoritesCursor { + fn default_order() -> String { + "name".to_owned() + } +} + +impl PageCursor for FavoritesCursor {} + +/// Query parameters for `GET /api/favorites/resources`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct FavoritesResourcesQuery { + /// Maximum items per page (1–200, default 50). + #[serde(default = "CursorQuery::default_limit")] + pub limit: u32, + /// Opaque cursor from a previous response. Omit to start from the first page. + pub cursor: Option, + /// Sort / group-by dimension. Supported: `"name"` (default), `"type"`, + /// `"favorited_at"`, `"modified_at"`, `"size"`, `"owner"`. + pub order_by: Option, + /// Comma-separated resource types to include, e.g. `"file,folder"`. + /// Omit to include both. + pub resource_types: Option, + /// Reverse the sort order. Default `false`. + #[serde(default)] + pub reverse: bool, +} + +impl FavoritesResourcesQuery { + pub fn limit_clamped(&self) -> usize { + self.limit.clamp(1, 200) as usize + } + + pub fn decode_cursor(&self) -> Option { + self.cursor.as_deref().and_then(FavoritesCursor::decode) + } + + /// Returns `None` when `resource_types` is absent (= include all). + pub fn resource_kinds(&self) -> Option> { + self.resource_types.as_deref().map(|s| { + s.split(',') + .filter_map(|t| ResourceKind::parse(t.trim())) + .collect() + }) + } +} + +/// One item in a `GET /api/favorites/resources` page. +#[derive(Debug, Serialize, ToSchema)] +pub struct FavoritesResourceItemDto { + pub resource_type: ResourceTypeDto, + /// When the resource was added to the user's favorites. + pub favorited_at: DateTime, + /// Full resource details — shape determined by `resource_type`. + pub resource: ResourceContentDto, +} + +/// Response envelope for `GET /api/favorites/resources`. +pub type FavoritesResourcesDto = CursorListResponse; + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BatchFavoritesStats { /// How many items were requested diff --git a/src/application/ports/favorites_ports.rs b/src/application/ports/favorites_ports.rs index f3b2bcc1..55f01884 100644 --- a/src/application/ports/favorites_ports.rs +++ b/src/application/ports/favorites_ports.rs @@ -2,8 +2,11 @@ use std::collections::HashSet; use uuid::Uuid; -use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto}; +use crate::application::dtos::favorites_dto::{ + BatchFavoritesResult, FavoriteItemDto, FavoriteResourceRow, FavoritesCursor, +}; use crate::common::errors::Result; +use crate::domain::services::authorization::ResourceKind; /// Defines operations for managing user favorites pub trait FavoritesUseCase: Send + Sync { @@ -74,4 +77,17 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static { user_id: Uuid, item_ids: &[(&str, &str)], // (item_id, item_type) pairs ) -> Result>; + + /// Cursor-paginated list of a user's favorited resources. + /// Items that no longer exist (deleted/trashed) are silently excluded. + /// `kinds = None` → both files and folders. + async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option<&FavoritesCursor>, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result>; } diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index e1da74d2..6ca17c54 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -4,11 +4,14 @@ use std::sync::Arc; use tracing::info; use uuid::Uuid; +use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::favorites_dto::{ - BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, + BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow, + FavoritesCursor, }; use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::domain::services::authorization::ResourceKind; use crate::infrastructure::repositories::pg::FavoritesPgRepository; /// Implementation of the FavoritesUseCase for managing user favorites. @@ -155,3 +158,105 @@ impl FavoritesUseCase for FavoritesService { self.repo.batch_check_favorites(user_id, item_ids).await } } + +impl FavoritesService { + /// Cursor-paginated list of the user's favorited resources. + /// + /// No authz needed — favorites are strictly user-scoped; the repository + /// enforces `WHERE user_id = $1` so users can only see their own entries. + /// + /// Returns `(rows, next_cursor_encoded)`. + pub async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result<(Vec, Option)> { + // Fetch one extra row to detect whether a next page exists. + let mut rows = self + .repo + .list_resources_paged( + user_id, + limit + 1, + cursor.as_ref(), + order_by, + kinds, + reverse, + ) + .await?; + + let next_cursor = if rows.len() > limit { + let last = &rows[limit - 1]; + let c = build_favorites_cursor(last, order_by, reverse); + rows.truncate(limit); + Some(c.encode()) + } else { + None + }; + + Ok((rows, next_cursor)) + } +} + +/// Build the next-page cursor from the last row of the current page. +/// `reverse` is stored in the cursor so subsequent pages use the same direction. +fn build_favorites_cursor( + row: &FavoriteResourceRow, + order_by: &str, + reverse: bool, +) -> FavoritesCursor { + match order_by { + "type" => FavoritesCursor { + order_by: "type".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(name) + sort_int: row.sort_int, // type_order + sort_ts: None, + reverse, + }, + "favorited_at" => FavoritesCursor { + order_by: "favorited_at".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: None, + sort_ts: row.sort_ts, // favorited_at timestamp + reverse, + }, + "modified_at" => FavoritesCursor { + order_by: "modified_at".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: None, + sort_ts: row.sort_ts, // modified_at timestamp + reverse, + }, + "size" => FavoritesCursor { + order_by: "size".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: row.sort_int, // file size in bytes + sort_ts: None, + reverse, + }, + "owner" => FavoritesCursor { + order_by: "owner".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(username) + sort_int: None, + sort_ts: row.sort_ts, // favorited_at (secondary sort) + reverse, + }, + _ => FavoritesCursor { + // "name" (default): sort_str = LOWER(name), sort_int = folder_first (0 = folder, 1 = file) + order_by: "name".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), + sort_int: row.sort_int, // folder_first + sort_ts: None, + reverse, + }, + } +} diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index 220c9fdd..03d00a9e 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -4,9 +4,12 @@ use std::sync::Arc; use tracing::error; use uuid::Uuid; -use crate::application::dtos::favorites_dto::FavoriteItemDto; +use crate::application::dtos::favorites_dto::{ + FavoriteItemDto, FavoriteResourceRow, FavoritesCursor, +}; use crate::application::ports::favorites_ports::FavoritesRepositoryPort; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::domain::services::authorization::ResourceKind; /// PostgreSQL implementation of the favorites persistence port. pub struct FavoritesPgRepository { @@ -279,4 +282,308 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { Ok(rows.iter().map(|r| r.get::("item_id")).collect()) } + + async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option<&FavoritesCursor>, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result> { + let include_folders = + kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder))); + let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File))); + + // ── Build the UNION ALL CTE ───────────────────────────────────────── + let mut cte_branches: Vec<&str> = Vec::new(); + + let folder_branch = r#" + SELECT + 'folder'::text AS resource_type, + fld.id AS resource_id, + fld.name, + fld.parent_id, + NULL::text AS mime_type, + -1::bigint AS size, + fld.created_at AS resource_created_at, + fld.updated_at AS modified_at, + fld.user_id AS owner_id, + (fld.user_id = $1::uuid) AS is_owner, + uf.created_at AS favorited_at, + fld.path::text AS resource_path, + LOWER(fld.name) AS sort_str, + 0::bigint AS type_order, + 0::int AS folder_first + FROM auth.user_favorites uf + INNER JOIN storage.folders fld + ON fld.id = uf.item_id::UUID AND NOT fld.is_trashed + WHERE uf.user_id = $1::uuid AND uf.item_type = 'folder'"#; + + let file_branch = r#" + SELECT + 'file'::text AS resource_type, + f.id AS resource_id, + f.name, + f.folder_id AS parent_id, + f.mime_type, + f.size::bigint, + f.created_at AS resource_created_at, + f.updated_at AS modified_at, + f.user_id AS owner_id, + (f.user_id = $1::uuid) AS is_owner, + uf.created_at AS favorited_at, + COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, + LOWER(f.name) AS sort_str, + f.category_order::bigint AS type_order, + 1::int AS folder_first + FROM auth.user_favorites uf + INNER JOIN storage.files f + ON f.id = uf.item_id::UUID AND NOT f.is_trashed + LEFT JOIN storage.folders pfld + ON pfld.id = f.folder_id + WHERE uf.user_id = $1::uuid AND uf.item_type = 'file'"#; + + if include_folders { + cte_branches.push(folder_branch); + } + if include_files { + cte_branches.push(file_branch); + } + + if cte_branches.is_empty() { + return Ok(Vec::new()); + } + + let union_sql = cte_branches.join("\n UNION ALL\n"); + let cte = format!("WITH resources AS ({union_sql}\n)"); + + // ── Cursor values ─────────────────────────────────────────────────── + let cur_str: Option<&str> = cursor.and_then(|c| c.sort_str.as_deref()); + let cur_int: Option = cursor.and_then(|c| c.sort_int); + let cur_ts: Option> = cursor.and_then(|c| c.sort_ts); + let cur_id: Option = cursor.map(|c| c.resource_id); + + // ── Per-dimension keyset WHERE + ORDER BY ─────────────────────────── + // Binds: $1=user_id (in CTE), $2=cur_str, $3=cur_int, $4=cur_ts, + // $5=cur_id, $6=limit (for "owner" sort: JOIN uses no extra binds) + let (keyset, order_by_clause, need_user_join) = match (order_by, reverse) { + // ── name ──────────────────────────────────────────────────────── + ("name", false) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str > $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)", + "ORDER BY folder_first ASC, sort_str ASC, resource_id ASC", + false, + ), + ("name", true) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str < $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)", + "ORDER BY folder_first ASC, sort_str DESC, resource_id DESC", + false, + ), + // ── type ──────────────────────────────────────────────────────── + ("type", false) => ( + "WHERE ($3::bigint IS NULL) + OR (type_order > $3) + OR (type_order = $3 AND sort_str > $2) + OR (type_order = $3 AND sort_str = $2 AND resource_id > $5::uuid)", + "ORDER BY type_order ASC, sort_str ASC, resource_id ASC", + false, + ), + ("type", true) => ( + "WHERE ($3::bigint IS NULL) + OR (type_order < $3) + OR (type_order = $3 AND sort_str < $2) + OR (type_order = $3 AND sort_str = $2 AND resource_id < $5::uuid)", + "ORDER BY type_order DESC, sort_str DESC, resource_id DESC", + false, + ), + // ── favorited_at ───────────────────────────────────────────────── + ("favorited_at", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (favorited_at < $4) + OR (favorited_at = $4 AND resource_id < $5::uuid)", + "ORDER BY favorited_at DESC, resource_id DESC", + false, + ), + ("favorited_at", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (favorited_at > $4) + OR (favorited_at = $4 AND resource_id > $5::uuid)", + "ORDER BY favorited_at ASC, resource_id ASC", + false, + ), + // ── modified_at ────────────────────────────────────────────────── + ("modified_at", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (modified_at < $4) + OR (modified_at = $4 AND resource_id < $5::uuid)", + "ORDER BY modified_at DESC, resource_id DESC", + false, + ), + ("modified_at", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (modified_at > $4) + OR (modified_at = $4 AND resource_id > $5::uuid)", + "ORDER BY modified_at ASC, resource_id ASC", + false, + ), + // ── size ───────────────────────────────────────────────────────── + ("size", false) => ( + "WHERE ($3::bigint IS NULL) + OR (size > $3) + OR (size = $3 AND resource_id > $5::uuid)", + "ORDER BY size ASC, resource_id ASC", + false, + ), + ("size", true) => ( + "WHERE ($3::bigint IS NULL) + OR (size < $3) + OR (size = $3 AND resource_id < $5::uuid)", + "ORDER BY size DESC, resource_id DESC", + false, + ), + // ── owner ──────────────────────────────────────────────────────── + ("owner", false) => ( + "WHERE ($2::text IS NULL) + OR (LOWER(u.username) > $2) + OR (LOWER(u.username) = $2 AND favorited_at < $4) + OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id < $5::uuid)", + "ORDER BY LOWER(u.username) ASC, favorited_at DESC, resource_id DESC", + true, + ), + ("owner", true) => ( + "WHERE ($2::text IS NULL) + OR (LOWER(u.username) < $2) + OR (LOWER(u.username) = $2 AND favorited_at > $4) + OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id > $5::uuid)", + "ORDER BY LOWER(u.username) DESC, favorited_at ASC, resource_id ASC", + true, + ), + // ── default: same as name, ascending ───────────────────────────── + (_, false) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str > $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)", + "ORDER BY folder_first ASC, sort_str ASC, resource_id ASC", + false, + ), + (_, true) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str < $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)", + "ORDER BY folder_first ASC, sort_str DESC, resource_id DESC", + false, + ), + }; + + let user_join = if need_user_join { + "LEFT JOIN auth.users u ON u.id = r.owner_id" + } else { + "" + }; + // For "owner" sort the JOIN makes LOWER(u.username) available; add it to SELECT + // so the cursor can carry the correct sort key. + let username_col = if need_user_join { + ",\n LOWER(u.username) AS username_lower" + } else { + "" + }; + + let sql = format!( + "{cte} +SELECT + r.resource_type, r.resource_id, r.name, r.parent_id, + r.mime_type, r.size, r.resource_created_at, r.modified_at, + r.owner_id, r.is_owner, r.favorited_at, r.resource_path, + r.sort_str, r.type_order, r.folder_first{username_col} +FROM resources r +{user_join} +{keyset} +{order_by_clause} +LIMIT $6" + ); + + let rows = sqlx::query(&sql) + .bind(user_id) // $1 (in CTE + outer) + .bind(cur_str) // $2 + .bind(cur_int) // $3 + .bind(cur_ts) // $4 + .bind(cur_id) // $5 + .bind(limit as i64) // $6 + .fetch_all(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error listing favorite resources: {e}"); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to list favorite resources: {e}"), + ) + })?; + + let result = rows + .iter() + .map(|row| { + let resource_type: String = row.get("resource_type"); + let sort_str_val: Option = row.try_get("sort_str").ok(); + let type_order: i64 = row.try_get("type_order").unwrap_or(0); + let folder_first: i32 = row.try_get("folder_first").unwrap_or(0); + let size: i64 = row.get("size"); + + // Pre-compute the cursor sort fields based on order_by + let (c_sort_str, c_sort_int, c_sort_ts) = match order_by { + "name" => (sort_str_val, Some(folder_first as i64), None), + "type" => (sort_str_val, Some(type_order), None), + "size" => (None, Some(size), None), + "favorited_at" => { + let ts: Option> = + row.try_get("favorited_at").ok(); + (None, None, ts) + } + "modified_at" => { + let ts: Option> = + row.try_get("modified_at").ok(); + (None, None, ts) + } + "owner" => { + // For "owner" sort the JOIN added LOWER(u.username) AS username_lower. + // The cursor's sort_str must carry the username (not the file name). + let username: Option = row.try_get("username_lower").ok(); + let ts: Option> = + row.try_get("favorited_at").ok(); + (username, None, ts) + } + _ => (sort_str_val, Some(folder_first as i64), None), + }; + + FavoriteResourceRow { + resource_type, + resource_id: row.get("resource_id"), + name: row.get("name"), + parent_id: row.try_get("parent_id").ok(), + mime_type: row.try_get("mime_type").ok(), + size, + resource_created_at: row.get("resource_created_at"), + modified_at: row.get("modified_at"), + owner_id: row.get("owner_id"), + is_owner: row.try_get("is_owner").unwrap_or(false), + favorited_at: row.get("favorited_at"), + path: row.try_get("resource_path").ok(), + sort_str: c_sort_str, + sort_int: c_sort_int, + sort_ts: c_sort_ts, + } + }) + .collect(); + + Ok(result) + } } diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 25ff376d..a1ee2703 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, }; @@ -9,8 +9,18 @@ use std::sync::Arc; use tracing::{error, info}; use utoipa::ToSchema; +use crate::application::dtos::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; +use crate::application::dtos::favorites_dto::{ + FavoritesResourceItemDto, FavoritesResourcesDto, FavoritesResourcesQuery, +}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::services::favorites_service::FavoritesService; +use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; /// Single item in a batch-add-favorites request. @@ -27,11 +37,16 @@ pub struct BatchFavoritesRequest { } /// Handler for favorite-related API endpoints +/// +/// # Deprecated +/// Use `GET /api/favorites/resources` instead. This endpoint is kept for +/// backwards compatibility but will be removed in a future release. +#[deprecated = "Use GET /api/favorites/resources instead"] #[utoipa::path( get, path = "/api/favorites", responses( - (status = 200, description = "List of favorites", body = Vec) + (status = 200, description = "List of favorites (deprecated — use /api/favorites/resources)", body = Vec) ), security(("bearerAuth" = [])), tag = "favorites" @@ -178,6 +193,125 @@ pub async fn remove_favorite( } } +/// Cursor-paginated list of a user's favorited resources. +/// +/// Supports sorting by `name`, `type`, `favorited_at`, `modified_at`, `size`, or `owner`. +/// Items that have been deleted/trashed are silently excluded. +/// `path` is cleared when the resource is not owned by the requesting user. +#[utoipa::path( + get, + path = "/api/favorites/resources", + params(FavoritesResourcesQuery), + responses( + (status = 200, description = "Paginated list of favorited resources", + body = crate::application::dtos::favorites_dto::FavoritesResourcesDto), + (status = 400, description = "Invalid cursor or query parameters"), + ), + security(("bearerAuth" = [])), + tag = "favorites" +)] +pub async fn list_favorites_resources( + State(favorites_service): State>, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let user_id = auth_user.id; + + let order_by = q.order_by.as_deref().unwrap_or("name").to_owned(); + + // If a cursor exists, validate that it matches the requested sort/direction. + let cursor = q + .decode_cursor() + .filter(|c| c.order_by == order_by && c.reverse == q.reverse); + + let kinds = q.resource_kinds(); + + match favorites_service + .list_resources_paged( + user_id, + q.limit_clamped(), + cursor, + &order_by, + kinds.as_deref(), + q.reverse, + ) + .await + { + Ok((rows, next_cursor)) => { + let items: Vec = rows + .into_iter() + .map(|row| { + // Path is only shown to the owner; non-owners see "" + // to avoid leaking another user's folder hierarchy. + let path = if row.is_owner { + row.path.clone().unwrap_or_default() + } else { + String::new() + }; + + if row.resource_type == "folder" { + let dto = FolderDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + parent_id: row.parent_id.map(|u| u.to_string()), + owner_id: Some(row.owner_id.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + is_root: false, + icon_class: std::sync::Arc::from("fas fa-folder"), + icon_special_class: std::sync::Arc::from("folder-icon"), + category: std::sync::Arc::from("Folder"), + }; + FavoritesResourceItemDto { + resource_type: ResourceTypeDto::Folder, + favorited_at: row.favorited_at, + resource: ResourceContentDto::Folder(dto), + } + } else { + let mime = row + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let size_bytes = row.size.max(0) as u64; + let dto = FileDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + size: size_bytes, + mime_type: std::sync::Arc::from(mime), + folder_id: row.parent_id.map(|u| u.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)), + icon_special_class: std::sync::Arc::from(icon_special_class_for( + &row.name, mime, + )), + category: std::sync::Arc::from(category_for(&row.name, mime)), + size_formatted: format_file_size(size_bytes), + owner_id: Some(row.owner_id.to_string()), + sort_date: None, + etag: String::new(), + }; + FavoritesResourceItemDto { + resource_type: ResourceTypeDto::File, + favorited_at: row.favorited_at, + resource: ResourceContentDto::File(dto), + } + } + }) + .collect(); + + ( + StatusCode::OK, + Json(FavoritesResourcesDto::with_cursor(items, next_cursor)), + ) + .into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} + /// Add multiple items to favourites in a single transaction. /// POST /api/favorites/batch #[utoipa::path( diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index c58f6d9d..4d1f5355 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -331,10 +331,14 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // Create a router without the i18n routes // Create routes for favorites if the service is available let favorites_router = if let Some(favorites_service) = favorites_service.clone() { - use crate::interfaces::api::handlers::favorites_handler; + #[allow(deprecated)] + use crate::interfaces::api::handlers::favorites_handler::{ + self, get_favorites, list_favorites_resources, + }; Router::new() - .route("/", get(favorites_handler::get_favorites)) + .route("/", get(get_favorites)) // deprecated, kept for compat + .route("/resources", get(list_favorites_resources)) // new cursor-paginated endpoint .route("/batch", post(favorites_handler::batch_add_favorites)) .route( "/{item_type}/{item_id}", diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 31b71614..4ea1749a 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -208,7 +208,7 @@ function _ensureComponent() { const isFile = 'mime_type' in item; const type = isFile ? 'file' : 'folder'; if (favorites.isFavorite(item.id, type)) { - await favorites.removeFromFavorites(item.id, type); + await favorites.removeFromFavorites(item.id, type, item.name); _component?.setFavoriteVisualState(item.id, type, false); } else { await favorites.addToFavorites(item.id, item.name, type, null); diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 3ad7162b..f26fd185 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -9,6 +9,7 @@ import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; import { recent } from '../features/library/recent.js'; +import { favoritesView } from '../views/favorites/favoritesView.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; import { filesView, loadFiles } from './filesView.js'; @@ -164,6 +165,11 @@ function setCurrentSection(section) { sharedWithMeView.hide(); } + // Hide favoritesView "Load more" button when leaving the favorites section + if (section !== 'favorites' && favoritesView) { + favoritesView.hide(); + } + // Reset owner column — sections that need it re-enable it explicitly below. ui.setOwnerColumnVisible(false); @@ -273,8 +279,8 @@ function switchToFavoritesSection() { // Set actions bar mode setActionsBarMode('favorites'); - setGroupByView(null); - syncGroupByMenu([]); + setGroupByView(favoritesView); + syncGroupByMenu(favoritesView.groupByDefs); // Show the Owner column — names are resolved async after render. ui.setOwnerColumnVisible(true); @@ -289,23 +295,13 @@ function switchToFavoritesSection() { // ensure correct view syncViewContainers(); - //reset files view + remove any error - ui.resetFilesList(); - - if (favorites) { - // temp solution - sharedView.loadItems().then(() => { - favorites.displayFavorites(); - }); - } else { - console.error('Favorites module not loaded or initialized'); - ui.showError(` - -

Error loading the favorites module

- `); - } - if (batchToolbar) batchToolbar.clear(); + + // Prefetch isFavorite cache in background (non-blocking) + favorites.init(); + + // Load and render via the cursor-paginated view + favoritesView.init(); } function switchToRecentFilesSection() { diff --git a/static/js/core/types.js b/static/js/core/types.js index 343e8091..c30b6b44 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -333,6 +333,22 @@ * @property {string|undefined} [next_cursor] - Absent when the last page is reached. */ +/** + * One item returned by `GET /api/favorites/resources`. + * `resource_type` discriminates the shape of `resource`. + * @typedef {Object} FavoritesResourceItem + * @property {ResourceTypeEnum} resource_type - 'file' | 'folder' + * @property {string} favorited_at - ISO-8601 timestamp when the item was starred. + * @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type. + */ + +/** + * Response for `GET /api/favorites/resources`. + * @typedef {Object} FavoritesResourcesResponse + * @property {FavoritesResourceItem[]} items + * @property {string|undefined} [next_cursor] - Absent when the last page is reached. + */ + /** * @typedef {Object} ContactEmail * @property {string} email diff --git a/static/js/features/files/batchToolbar.js b/static/js/features/files/batchToolbar.js index 55bd16a3..99aadc1a 100644 --- a/static/js/features/files/batchToolbar.js +++ b/static/js/features/files/batchToolbar.js @@ -508,12 +508,8 @@ const batchToolbar = { const data = await response.json(); const inserted = data.stats?.inserted || 0; - // Replace cache directly from response (no extra GET) - if (data.favorites && favorites._replaceCacheFromResponse) { - favorites._replaceCacheFromResponse(data.favorites); - } else { - await favorites._fetchFromServer(); - } + // Re-fetch the isFavorite cache from the server. + await favorites._fetchFromServer(); this.clear(); loadFiles(); diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index f95f4ff0..c0520e40 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -122,7 +122,7 @@ const contextMenus = { // Check if folder is already in favorites to toggle if (favorites?.isFavorite(folder.id, 'folder')) { // Remove from favorites - const ok = await favorites.removeFromFavorites(folder.id, 'folder'); + const ok = await favorites.removeFromFavorites(folder.id, 'folder', folder.name); if (ok && ui && typeof ui.setFavoriteVisualState === 'function') { ui.setFavoriteVisualState(folder.id, 'folder', false); } @@ -244,7 +244,7 @@ const contextMenus = { // Check if file is already in favorites to toggle if (favorites?.isFavorite(file.id, 'file')) { // Remove from favorites - const ok = await favorites.removeFromFavorites(file.id, 'file'); + const ok = await favorites.removeFromFavorites(file.id, 'file', file.name); if (ok && ui && typeof ui.setFavoriteVisualState === 'function') { ui.setFavoriteVisualState(file.id, 'file', false); } diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index fc2629ff..f5065ef2 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -1,30 +1,28 @@ /** * OxiCloud - Favorites Module (server-authoritative) * - * Source of truth: GET /api/favorites (enriched with name/size/mime via SQL JOIN). - * Local in-memory cache (`_cache`) keeps `isFavorite()` synchronous for the - * rendering path so star icons can be painted without a round-trip. + * Source of truth: GET /api/favorites/resources (cursor-paginated). + * The in-memory cache (`_cache`) is a Set of "type:id" keys that keeps + * `isFavorite()` synchronous so star icons are painted without a round-trip. + * + * Display is handled by `views/favorites/favoritesView.js`. */ import { ui } from '../../app/ui.js'; -import { ResourceListComponent } from '../../components/resourceList.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; -import { batchToolbar } from '../files/batchToolbar.js'; -import * as pathTooltip from '../pathTooltip.js'; - -/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */ +import { fetchFavoritesPage } from '../../model/favoritesModel.js'; const favorites = { - /** @type {Map} key = "file:" | "folder:" */ - _cache: new Map(), + /** + * Set of "type:id" cache keys. A Set is enough — we only need O(1) lookups. + * @type {Set} + */ + _cache: new Set(), - /** Whether the initial fetch from the server has completed */ + /** Whether the initial fetch from the server has completed. */ _ready: false, - /** @type {ResourceListComponent|null} */ - _component: null, - // ───────────────────── helpers ───────────────────── _authHeaders() { @@ -34,56 +32,43 @@ const favorites = { /** * @param {string} id * @param {string} type + * @returns {string} */ _cacheKey(id, type) { return `${type}:${id}`; }, - /** - * Replace the entire in-memory cache from an array of FavoriteItemDto - * objects (as returned by the batch endpoint). Avoids an extra - * GET /api/favorites round-trip. - * @param {any[]} items - */ - _replaceCacheFromResponse(items) { - this._cache.clear(); - for (const item of items) { - this._cache.set(this._cacheKey(item.item_id, item.item_type), item); - } - this._ready = true; - console.log(`Favorites cache replaced from response: ${this._cache.size} items`); - }, - // ───────────────────── lifecycle ───────────────────── /** - * Initialise the module: fetch the full list from the server and populate - * the in-memory cache. Called once from app.js on startup. + * Initialise the module: fetch the full favorites list from the server and + * populate the in-memory cache. Called from navigation.js every time the + * Favorites section is entered (non-blocking — the view loads in parallel). */ async init() { - console.log('Initializing favorites module (server-authoritative)'); await this._fetchFromServer(); }, /** - * Fetch favourites from the backend and rebuild the cache. + * Fetch all favorited resource IDs from the server and rebuild the cache. + * Paginates through `GET /api/favorites/resources` until exhausted. */ async _fetchFromServer() { try { - const response = await fetch('/api/favorites', { - headers: this._authHeaders() - }); - - if (!response.ok) { - console.warn(`Favorites API returned ${response.status}`); - return; - } - - /** @type {FavoriteItem[]} */ - const items = await response.json(); this._cache.clear(); - for (const item of items) { - this._cache.set(this._cacheKey(item.item_id, item.item_type), item); + + let cursor = /** @type {string|undefined} */ (undefined); + + // Paginate with the max page size so most users need only one request. + while (true) { + const data = await fetchFavoritesPage({ limit: 200, cursor, orderBy: 'name' }); + for (const item of data.items) { + // `item.resource.id` works for both FileItem (id) and FolderItem (id). + const r = /** @type {Record} */ (/** @type {unknown} */ (item.resource)); + this._cache.add(this._cacheKey(r.id, item.resource_type)); + } + if (!data.next_cursor) break; + cursor = data.next_cursor; } this._ready = true; @@ -96,20 +81,22 @@ const favorites = { // ───────────────────── public API ───────────────────── /** - * Synchronous check used by ui.js to paint star icons. + * Synchronous check used by the rendering layer to paint star icons. * @param {string} id * @param {string} type + * @returns {boolean} */ isFavorite(id, type) { return this._cache.has(this._cacheKey(id, type)); }, /** - * Add an item to favourites (server-first). + * Add an item to favourites (server-first, then update local cache). * @param {string} id * @param {string} name * @param {string} type - * @param {string | null} _parentId + * @param {string | null} _parentId - unused, kept for call-site compatibility + * @returns {Promise} */ async addToFavorites(id, name, type, _parentId) { try { @@ -122,10 +109,9 @@ const favorites = { throw new Error(`Server returned ${response.status}`); } - // Refresh cache from server to get enriched data - await this._fetchFromServer(); + // Optimistically update local cache without a full re-fetch. + this._cache.add(this._cacheKey(id, type)); - // Notify user if (ui?.showNotification) { ui.showNotification(i18n.t('favorites.added_title'), `"${name}" ${i18n.t('favorites.added_msg')}`); } @@ -138,16 +124,14 @@ const favorites = { }, /** - * Remove an item from favourites (server-first). + * Remove an item from favourites (server-first, then update local cache). * @param {string} id * @param {string} type + * @param {string} [name] - Display name for the notification; falls back to `id`. + * @returns {Promise} */ - async removeFromFavorites(id, type) { + async removeFromFavorites(id, type, name = id) { try { - // Remember name for notification before removing from cache - const cached = this._cache.get(this._cacheKey(id, type)); - const itemName = cached?.item_name || id; - const response = await fetch(`/api/favorites/${type}/${id}`, { method: 'DELETE', headers: this._authHeaders() @@ -157,11 +141,10 @@ const favorites = { throw new Error(`Server returned ${response.status}`); } - // Remove from local cache this._cache.delete(this._cacheKey(id, type)); if (ui?.showNotification) { - ui.showNotification(i18n.t('favorites.removed_title'), `"${itemName}" ${i18n.t('favorites.removed_msg')}`); + ui.showNotification(i18n.t('favorites.removed_title'), `"${name}" ${i18n.t('favorites.removed_msg')}`); } return true; @@ -171,124 +154,32 @@ const favorites = { } }, - // ───────────────────── display ───────────────────── - /** - * Render the favourites view. All data comes from the in-memory cache - * (which was populated from the enriched backend response — zero extra - * fetches). + * Batch-add multiple items to favourites in a single server call. + * Re-fetches the cache after success to stay consistent. + * + * @param {Array<{item_id: string, item_type: string}>} items + * @returns {Promise} */ - async displayFavorites() { + async batchAdd(items) { try { + const response = await fetch('/api/favorites/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this._authHeaders() }, + body: JSON.stringify({ items }) + }); + + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + + // Re-fetch the full cache so the Set reflects the latest server state. await this._fetchFromServer(); - ui.resetFilesList(); - batchToolbar.init(); - ui.updateBreadcrumb(); - - if (this._cache.size === 0) { - ui.showError(` - -

${i18n.t('favorites.empty_state')}

-

${i18n.t('favorites.empty_hint')}

- `); - return; - } - - /** @type {Array} */ - const items = []; - - for (const item of this._cache.values()) { - // owner_id comes from the backend JOIN (actual file/folder owner) - if (item.item_type === 'folder') { - items.push( - /** @type {FolderItem} */ ({ - id: item.item_id, - name: item.item_name || item.item_id, - parent_id: item.parent_id || '', - modified_at: item.modified_at || item.created_at, - path: item.item_path || '', - category: 'folder', - created_at: item.created_at, - icon_class: item.icon_class, - icon_special_class: item.icon_special_class, - owner_id: item.owner_id ?? '', - is_root: false - }) - ); - } else { - items.push( - /** @type {FileItem} */ ({ - id: item.item_id, - name: item.item_name || item.item_id, - folder_id: item.parent_id || '', - mime_type: item.item_mime_type, - icon_class: item.icon_class, - icon_special_class: item.icon_special_class, - category: item.category, - size: item.item_size || 0, - size_formatted: item.size_formatted, - modified_at: item.modified_at || item.created_at, - path: item.item_path || '', - owner_id: item.owner_id ?? '', - created_at: item.created_at, - sort_date: item.created_at - }) - ); - } - } - - const filesList = document.getElementById('files-list'); - if (filesList) { - if (!this._component) { - this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { - selectable: true, - showFavorite: true, - showOwner: true, - showShareBadge: true, - draggable: false, - showContextMenu: true, - itemModifierClass: 'favorite-item', - isFavorite: (id, type) => this.isFavorite(id, type), - onOpen: (item) => ui.openItem(item), - onFavoriteToggle: async (item) => { - const isFile = 'mime_type' in item; - const type = isFile ? 'file' : 'folder'; - if (this.isFavorite(item.id, type)) { - await this.removeFromFavorites(item.id, type); - this._component?.setFavoriteVisualState(item.id, type, false); - } else { - await this.addToFavorites(item.id, item.name, type, null); - this._component?.setFavoriteVisualState(item.id, type, true); - } - }, - onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), - onSelectionChange: (selectedItems) => { - batchToolbar._selected.clear(); - for (const sel of selectedItems) { - const isFile = 'mime_type' in sel; - batchToolbar._selected.set(sel.id, { - id: sel.id, - name: sel.name, - type: isFile ? 'file' : 'folder', - parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' - }); - } - batchToolbar._syncUI(); - } - }); - } - batchToolbar.setActiveComponent(this._component); - this._component.render(items); - pathTooltip.init(filesList); - } - - await this._component?.resolveOwnerCells(); - } catch (error) { - console.error('Error displaying favorites:', error); - if (ui?.showNotification) { - ui.showNotification('Error', 'Error loading favorite items'); - } + return true; + } catch (err) { + console.error('Error in batchAdd:', err); + return false; } } }; diff --git a/static/js/model/favoritesModel.js b/static/js/model/favoritesModel.js new file mode 100644 index 00000000..5f8e4c10 --- /dev/null +++ b/static/js/model/favoritesModel.js @@ -0,0 +1,51 @@ +/** + * OxiCloud – Favorites resource model. + * + * Thin fetch wrapper for `GET /api/favorites/resources` (cursor-paginated). + * The old `GET /api/favorites` endpoint is kept for the isFavorite cache in + * `features/library/favorites.js` — this module only handles the new endpoint. + */ + +/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../core/types.js' */ + +/** + * @typedef {Object} FavoritesResourceItem + * @property {ResourceTypeEnum} resource_type - 'file' | 'folder' + * @property {string} favorited_at - ISO-8601 timestamp + * @property {FileItem|FolderItem} resource - Full resource details + */ + +/** + * @typedef {Object} FavoritesResourcesResponse + * @property {FavoritesResourceItem[]} items + * @property {string|undefined} [next_cursor] + */ + +/** + * Fetch one page of the current user's favorited resources. + * + * @param {{ + * cursor?: string, + * orderBy?: string, + * limit?: number, + * reverse?: boolean, + * resourceTypes?: ResourceTypeEnum[], + * }} [opts] + * @returns {Promise} + */ +async function fetchFavoritesPage({ cursor, orderBy = 'name', limit = 50, reverse = false, resourceTypes } = {}) { + const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + if (reverse) params.set('reverse', 'true'); + if (resourceTypes?.length) params.set('resource_types', resourceTypes.join(',')); + + const res = await fetch(`/api/favorites/resources?${params}`); + if (!res.ok) { + const err = new Error(`Failed to fetch favorites: HTTP ${res.status}`); + /** @type {any} */ (err).status = res.status; + throw err; + } + return res.json(); +} + +export { fetchFavoritesPage }; diff --git a/static/js/views/favorites/favoritesView.js b/static/js/views/favorites/favoritesView.js new file mode 100644 index 00000000..b7dc9de0 --- /dev/null +++ b/static/js/views/favorites/favoritesView.js @@ -0,0 +1,422 @@ +/** + * OxiCloud – Favorites view. + * + * Renders files and folders the current user has starred, using the + * cursor-paginated `GET /api/favorites/resources` endpoint. + * + * Uses `ResourceListComponent` so the grid ↔ list toggle and all card + * components work out of the box. A "Load more" button is injected below + * the files container for cursor-based pagination. + * + * Public API mirrors `sharedWithMeView`: + * - `groupByDefs` — array of group-by dimension definitions + * - `setGroupBy(key)` — change active dimension + reload from page 1 + * - `setDirection(reversed)` — flip sort direction + reload from page 1 + * - `init()` — (re-)enter the section; resets state + loads page 1 + * - `hide()` — called when leaving this section + */ + +import { ui } from '../../app/ui.js'; +import { ResourceListComponent } from '../../components/resourceList.js'; +import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; +import { i18n } from '../../core/i18n.js'; +import { batchToolbar } from '../../features/files/batchToolbar.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; +import { favorites } from '../../features/library/favorites.js'; +import { fetchFavoritesPage } from '../../model/favoritesModel.js'; +import { systemUsers } from '../../model/systemUsers.js'; + +/** @import {FavoritesResourceItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ + +/** + * @typedef {{ key: string, label: string, orderBy: string, + * keyFn: (item: FileItem|FolderItem) => string|null, + * labelFn?: (key: string) => string }} GroupByDef + */ + +/** + * Group-by dimension definitions for the Favorites section. + * The empty-key entry (no grouping) is the default; it sorts by `name`. + * + * @type {GroupByDef[]} + */ +const GROUP_BY_DEFS = [ + { + key: 'type', + get label() { + return i18n.t('groupby.type', 'Type'); + }, + orderBy: 'type', + // keyFn: folders → 'Folder' swimlane; files → their `category` field. + keyFn: (item) => ('mime_type' in item ? /** @type {Record} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'), + labelFn: (key) => { + // biome-ignore format: keep indentation + /** @type {Record} */ + const labels = { + Folder: i18n.t('groupby.type.folders', 'Folders'), + Image: i18n.t('category.images', 'Images'), + Video: i18n.t('category.videos', 'Videos'), + Audio: i18n.t('category.audio', 'Audio'), + PDF: 'PDF', + Document: i18n.t('category.documents', 'Documents'), + Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'), + Presentation: i18n.t('category.presentations', 'Presentations'), + Archive: i18n.t('category.archives', 'Archives'), + Code: i18n.t('category.code', 'Code'), + Markdown: i18n.t('category.markdown', 'Markdown'), + Text: i18n.t('category.text', 'Text'), + Installer: i18n.t('category.installers', 'Installers') + }; + return labels[key] ?? key; + } + }, + { + key: 'favoriteDate', + get label() { + return i18n.t('groupby.favoriteDate', 'Favorite date'); + }, + orderBy: 'favorited_at', + // sort_date is stored as unix seconds in _mapItems(). + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.sort_date ? normalizeDateBucket(r.sort_date) : null; + } + }, + { + key: 'modifiedAt', + get label() { + return i18n.t('groupby.modifiedAt', 'Modified date'); + }, + orderBy: 'modified_at', + // modified_at is a unix seconds timestamp on FileItem/FolderItem. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.modified_at ? normalizeDateBucket(r.modified_at) : null; + } + }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // Folders have no size — sizeBucket(-1) returns the "Folders" label. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, + { + key: 'owner', + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'owner', + // keyFn groups by UUID — stable, avoids collisions on identical display names. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + labelFn: (id) => systemUsers.getDisplayNameSync(id) + } +]; + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'fav-load-more-wrapper'; + +const favoritesView = { + // ── State ───────────────────────────────────────────────────────────────── + + /** @type {string|null} */ + _nextCursor: null, + + _loading: false, + + /** @type {ResourceListComponent|null} */ + _component: null, + + /** + * Active group-by key. '' = no grouping (sorted by name). + * @type {string} + */ + _groupBy: '', + + /** Whether the current sort order is reversed. */ + _reversed: false, + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * The group-by dimension definitions for this section. + * `main.js` reads this to populate the Group-by dropdown. + * @returns {GroupByDef[]} + */ + get groupByDefs() { + return GROUP_BY_DEFS; + }, + + /** + * Change the active group-by dimension and reload from page 1. + * Calling with the current key is a no-op. + * @param {string} key + */ + setGroupBy(key) { + if (this._groupBy === key) return; + this._groupBy = key; + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * Flip the sort direction and reload from page 1. + * Calling with the current value is a no-op. + * @param {boolean} reversed + */ + setDirection(reversed) { + if (this._reversed === reversed) return; + this._reversed = reversed; + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * (Re-)enter the Favorites section: reset state, create / reuse the + * component, and load page 1. + */ + async init() { + this._nextCursor = null; + this._loading = false; + this._groupBy = ''; + this._reversed = false; + + this._ensureLoadMoreButton(); + + // Prefetch system users so owner tooltips resolve without delay. + systemUsers.prefetch(); + + ui.resetFilesList(); + batchToolbar.init(); + ui.updateBreadcrumb(); + + const filesList = document.getElementById('files-list'); + if (filesList) { + if (!this._component) { + this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: true, + showFavorite: true, + showOwner: true, + showShareBadge: false, + draggable: false, + showContextMenu: true, + isFavorite: (id, type) => favorites.isFavorite(id, type), + isShared: () => false, + onOpen: (item) => ui.openItem(item), + onFavoriteToggle: async (item) => { + const isFile = 'mime_type' in item; + const type = isFile ? 'file' : 'folder'; + if (favorites.isFavorite(item.id, type)) { + await favorites.removeFromFavorites(item.id, type, item.name); + this._component?.setFavoriteVisualState(item.id, type, false); + } else { + await favorites.addToFavorites(item.id, item.name, type, null); + this._component?.setFavoriteVisualState(item.id, type, true); + } + }, + onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), + onSelectionChange: (selectedItems) => { + batchToolbar._selected.clear(); + for (const sel of selectedItems) { + const isFile = 'mime_type' in sel; + batchToolbar._selected.set(sel.id, { + id: sel.id, + name: sel.name, + type: isFile ? 'file' : 'folder', + parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' + }); + } + batchToolbar._syncUI(); + } + }); + } + batchToolbar.setActiveComponent(this._component); + } + + await this._loadPage(); + }, + + /** + * Hide the "Load more" button when leaving this section. + * The files container itself is managed by navigation.js. + */ + hide() { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.add('hidden'); + + batchToolbar.setActiveComponent(null); + + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.destroy(filesList); + }, + + // ── Internal helpers ────────────────────────────────────────────────────── + + /** + * Fetch one page, map items → FileItem / FolderItem, render them. + * @returns {Promise} + */ + async _loadPage() { + if (this._loading) return; + this._loading = true; + + const isFirstPage = this._nextCursor === null; + + try { + const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy); + const orderBy = def?.orderBy ?? 'name'; + + const data = await fetchFavoritesPage({ + resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']), + limit: 50, + cursor: this._nextCursor ?? undefined, + orderBy, + reverse: this._reversed + }); + + this._nextCursor = data.next_cursor ?? null; + + if (data.items.length === 0 && isFirstPage) { + ui.showError(` + +

${i18n.t('favorites.empty_state', 'No favorites yet')}

+

${i18n.t('favorites.empty_hint', 'Star files and folders to find them here quickly')}

+ `); + this._setLoadMoreVisible(false); + return; + } + + const items = this._mapItems(data.items); + + if (isFirstPage) { + this._component?.render(items, def?.keyFn, def?.labelFn); + } else { + this._component?.append(items, def?.keyFn, def?.labelFn); + } + + // Wire unified item tooltip (owner + path) after items are in the DOM + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.init(filesList); + + await this._component?.resolveOwnerCells(); + + this._setLoadMoreVisible(!!this._nextCursor); + } catch (err) { + ui.showError(` + +

${i18n.t('errors_loadFailed', 'Failed to load items')}

+ `); + console.error('favoritesView: load error', err); + } finally { + this._loading = false; + } + }, + + /** + * Map `FavoritesResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving + * server order. Sets `sort_date` (unix seconds) to the favorite date so the + * `favoriteDate` keyFn can bucket by when the item was starred. + * + * @param {FavoritesResourceItem[]} items + * @returns {Array} + */ + _mapItems(items) { + /** @type {Array} */ + const result = []; + + /** @param {string} iso @returns {number} unix seconds */ + const toSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000); + + for (const item of items) { + if (item.resource_type === 'folder') { + const f = /** @type {FolderItem} */ (item.resource); + result.push( + /** @type {FolderItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + parent_id: f.parent_id ?? '', + owner_id: f.owner_id ?? '', + is_root: f.is_root ?? false, + created_at: f.created_at, + modified_at: f.modified_at, + // sort_date = favorited_at (unix seconds) for the favoriteDate keyFn + sort_date: toSecs(item.favorited_at), + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: 'Folder' + }) + ); + } else if (item.resource_type === 'file') { + const f = /** @type {FileItem} */ (item.resource); + result.push( + /** @type {FileItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + folder_id: f.folder_id ?? '', + owner_id: f.owner_id ?? '', + mime_type: f.mime_type, + size: f.size, + size_formatted: f.size_formatted, + created_at: f.created_at, + modified_at: f.modified_at, + sort_date: toSecs(item.favorited_at), + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: f.category + }) + ); + } + } + + return result; + }, + + // ── "Load more" button ──────────────────────────────────────────────────── + + /** + * Create the "Load more" wrapper once and attach it below `.files-container`. + * Subsequent calls are no-ops. + */ + _ensureLoadMoreButton() { + if (document.getElementById(LOAD_MORE_ID)) return; + + const filesContainer = document.querySelector('.files-container'); + if (!filesContainer) return; + + const wrapper = document.createElement('div'); + wrapper.id = LOAD_MORE_ID; + wrapper.className = 'swm-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'fav-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('favorites.loadMore', 'Load more'); + btn.addEventListener('click', () => this._loadPage()); + + wrapper.appendChild(btn); + filesContainer.after(wrapper); + }, + + /** + * @param {boolean} visible + */ + _setLoadMoreVisible(visible) { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.toggle('hidden', !visible); + } +}; + +export { favoritesView }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index cb498bff..1ddd6324 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -222,7 +222,7 @@ const sharedWithMeView = { const isFile = 'mime_type' in item; const type = isFile ? 'file' : 'folder'; if (favorites.isFavorite(item.id, type)) { - await favorites.removeFromFavorites(item.id, type); + await favorites.removeFromFavorites(item.id, type, item.name); this._component?.setFavoriteVisualState(item.id, type, false); } else { await favorites.addToFavorites(item.id, item.name, type, null); From 7f4b32971840dbe3c7f11dedd88d69b343432195 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 10:51:50 +0200 Subject: [PATCH 3/9] feat(itemToolltip): fusion pathTooltip + ownerTooltip into itemToolltip, move it in the header for more visibility --- static/css/components/itemTooltip.css | 115 +++++++++++ static/css/components/pathTooltip.css | 25 --- static/css/main.css | 2 +- static/js/app/trashView.js | 6 +- static/js/components/resourceList.js | 2 + static/js/core/icons.js | 4 + static/js/features/itemTooltip.js | 181 ++++++++++++++++++ static/js/features/library/recent.js | 4 +- static/js/features/ownerTooltip.js | 121 ------------ static/js/features/pathTooltip.js | 89 --------- .../js/views/sharedWithMe/sharedWithMeView.js | 6 +- 11 files changed, 311 insertions(+), 244 deletions(-) create mode 100644 static/css/components/itemTooltip.css delete mode 100644 static/css/components/pathTooltip.css create mode 100644 static/js/features/itemTooltip.js delete mode 100644 static/js/features/ownerTooltip.js delete mode 100644 static/js/features/pathTooltip.js diff --git a/static/css/components/itemTooltip.css b/static/css/components/itemTooltip.css new file mode 100644 index 00000000..0c4a0efc --- /dev/null +++ b/static/css/components/itemTooltip.css @@ -0,0 +1,115 @@ +/* ── Item tooltip — "technical sheet" ─────────────────────────────────────── */ +/* */ +/* Overlays the search bar area in the top-bar while hovering a file item. */ +/* Spans from the sidebar right-edge to just before .user-controls. */ +/* */ +/* 3-column CSS grid: [icon] [label] [value] */ +/* All values start at the same x position regardless of label width. */ + +.path-tooltip { + position: fixed; + + /* Fill most of the top-bar (topbar height 70px, 8px margin top/bottom) */ + top: 8px; + + /* Desktop: starts at sidebar right edge + top-bar padding */ + left: calc(var(--sidebar-width) + 30px); + + /* + * Right edge stops just before .user-controls: + * 30px (top-bar right padding) + ~90px (notif bell + gap + avatar btn) + 4px gap + * Using left + right instead of width so the browser computes it dynamically. + */ + right: 124px; + + /* High enough to sit above the top-bar content */ + z-index: 200; + + display: grid; + grid-template-columns: 1em max-content 1fr; + column-gap: 0.6em; + row-gap: 6px; + align-items: center; + + /* Fill the top-bar height minus an 8px margin on each side */ + height: 54px; + padding: 0 16px; + border-radius: 10px; + border: 2px solid var(--color-border-medium); + + background-color: var(--color-bg-input); + color: var(--color-text-muted); + font-size: 0.75rem; + + pointer-events: none; + + /* avoid blinking when pointer moves between items */ + transition: display 0.2s allow-discrete; +} + +/* Icon column */ +.path-tooltip__icon { + color: var(--color-text-faint); + font-size: 0.7rem; + text-align: center; + justify-self: center; +} + +/* Label column */ +.path-tooltip__label { + font-weight: 600; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.path-tooltip__label::after { + content: ':'; +} + +/* Value column */ +.path-tooltip__value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* Path value uses monospace to read folder separators clearly */ +.path-tooltip__value--path { + font-family: monospace; +} + +/* "?" placeholder when data is absent */ +.path-tooltip__value--unknown { + color: var(--color-text-faint); + font-style: italic; +} + +/* ── Narrow screens (sidebar hidden) ────────────────────────────────────── */ +/* On mobile the sidebar slides off-screen; align after the #sidebar-toggle */ +/* button (≈44px) that takes its place in the top-bar. */ + +@media (max-width: 768px) { + .path-tooltip { + /* 16px topbar padding + ~44px sidebar-toggle + 8px gap */ + left: 68px; + + /* + * Right edge stops before search-toggle-btn + user-controls: + * ~40px (search-toggle) + 12px (gap) + ~90px (user-controls) + 16px (padding) + */ + right: 158px; + } +} + +/* ── Vignette inside tooltip ─────────────────────────────────────────────── */ +/* The vignette name hard-codes 14px; scale it down to match the tooltip. */ + +.path-tooltip .user-vignette { + overflow: hidden; + min-width: 0; +} + +.path-tooltip .user-vignette__name { + font-size: 0.75rem; +} diff --git a/static/css/components/pathTooltip.css b/static/css/components/pathTooltip.css deleted file mode 100644 index 575c0ff8..00000000 --- a/static/css/components/pathTooltip.css +++ /dev/null @@ -1,25 +0,0 @@ -.path-tooltip { - position: fixed; - bottom: 8px; - left: calc(var(--sidebar-width) + 8px); - z-index: 5; - - max-width: 120ch; - padding: 4px 10px; - border-radius: 3px; - - background-color: var(--color-bg-subtle); - border: 1px solid var(--color-border-faint); - color: var(--color-text-muted); - font-size: 0.75rem; - font-family: monospace; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - pointer-events: none; - - /* avoid blinking when pointer change items */ - transition: display 0.2s allow-discrete; -} diff --git a/static/css/main.css b/static/css/main.css index 83aa67a1..158ed1ee 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -30,7 +30,7 @@ @import url("./components/search.css"); @import url("./components/icons.css"); @import url("./components/csp-utilities.css"); -@import url("./components/pathTooltip.css"); +@import url("./components/itemTooltip.css"); /* Theme */ @import url("./themes/dark.css"); diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index 2b4ceb1b..91414e31 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -6,7 +6,7 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; import { fileOps } from '../features/files/fileOperations.js'; -import * as pathTooltip from '../features/pathTooltip.js'; +import * as itemTooltip from '../features/itemTooltip.js'; import { appElements } from './state.js'; import { ui } from './ui.js'; @@ -23,7 +23,7 @@ async function loadTrashItems() { try { if (batchToolbar) batchToolbar.clear(); - pathTooltip.destroy(elements.filesList); + itemTooltip.destroy(elements.filesList); ui.resetFilesList(); // ensure also list visible & error hidden elements.filesList.innerHTML = `
@@ -50,7 +50,7 @@ async function loadTrashItems() { trashItems.forEach((item) => { addTrashItemToView(item); }); - pathTooltip.init(elements.filesList); + itemTooltip.init(elements.filesList); } catch (error) { console.error('Error loading trash items:', error); ui.showNotification('Error', 'Error loading trash items'); diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 168d07fe..d1393997 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -407,6 +407,7 @@ export class ResourceListComponent { el.dataset.folderName = folder.name; el.dataset.parentId = folder.parent_id || ''; if (folder.path) el.dataset.path = folder.path; + if (folder.owner_id) el.dataset.ownerId = folder.owner_id; if (cfg.draggable) el.setAttribute('draggable', 'true'); const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false; @@ -463,6 +464,7 @@ export class ResourceListComponent { el.dataset.fileName = file.name; el.dataset.folderId = file.folder_id || ''; if (file.path) el.dataset.path = file.path; + if (file.owner_id) el.dataset.ownerId = file.owner_id; if (cfg.draggable) el.setAttribute('draggable', 'true'); el.innerHTML = ` diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 28ff4464..125d945f 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -263,6 +263,10 @@ const OxiIcons = { 512, 'M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z' ], + 'location-crosshairs': [ + 576, + 'M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM128 256a160 160 0 1 0 320 0 160 160 0 1 0 -320 0zm160-96a96 96 0 1 1 0 192 96 96 0 1 1 0-192z' + ], 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' diff --git a/static/js/features/itemTooltip.js b/static/js/features/itemTooltip.js new file mode 100644 index 00000000..3848b55a --- /dev/null +++ b/static/js/features/itemTooltip.js @@ -0,0 +1,181 @@ +// @ts-check + +/** + * Item tooltip — unified hover tooltip showing a stable "technical sheet" for + * a hovered `.file-item`. + * + * Both rows are always rendered so the layout never shifts between items. + * A "?" placeholder is shown when data is absent for a given row. + * + * data-owner-id → 👤 Owner [userVignette] (avatar + name, async) + * data-path → ⊕ Path Documents/Work (monospace) + * + * The tooltip is shown only when at least one of the two attributes is present. + * Lines are laid out in a 3-column CSS grid (icon | label | value) so values + * are always left-aligned at the same x position. + * + * Replaces the former `pathTooltip` and `ownerTooltip` modules. + * + * Usage: + * import * as itemTooltip from '../features/itemTooltip.js'; + * itemTooltip.init(containerEl) — call after rendering items + * itemTooltip.destroy(containerEl) — call when leaving the section + */ + +import { createUserVignette } from '../components/userVignette.js'; +import { i18n } from '../core/i18n.js'; +import { systemUsers } from '../model/systemUsers.js'; + +// ── Tooltip DOM ─────────────────────────────────────────────────────────────── + +/** @returns {HTMLElement} */ +function _getOrCreateTooltip() { + let el = document.getElementById('path-tooltip'); + if (!el) { + el = document.createElement('div'); + el.id = 'path-tooltip'; + el.className = 'path-tooltip hidden'; + document.body.appendChild(el); + } + return el; +} + +function _hide() { + const el = document.getElementById('path-tooltip'); + if (el) el.classList.add('hidden'); +} + +// ── Row builder ─────────────────────────────────────────────────────────────── + +/** + * Append one grid row (icon | label | value) to the tooltip container. + * The three cells are direct children of the CSS grid — column assignment + * is automatic. + * + * @param {HTMLElement} tooltip + * @param {string} iconClass FontAwesome class string, e.g. `"fas fa-user"` + * @param {string} labelText + * @param {(el: HTMLElement) => void} populate Fills the value cell. + * @returns {HTMLElement} The value cell. + */ +function _addRow(tooltip, iconClass, labelText, populate) { + const icon = document.createElement('i'); + icon.className = `${iconClass} path-tooltip__icon`; + tooltip.appendChild(icon); + + const label = document.createElement('span'); + label.className = 'path-tooltip__label'; + label.textContent = labelText; + tooltip.appendChild(label); + + const value = document.createElement('span'); + value.className = 'path-tooltip__value'; + populate(value); + tooltip.appendChild(value); + + return value; +} + +/** + * Append a "?" placeholder cell (used when data is unavailable). + * @param {HTMLElement} el + */ +function _setUnknown(el) { + el.classList.add('path-tooltip__value--unknown'); + el.textContent = '?'; +} + +// ── Event handler ───────────────────────────────────────────────────────────── + +/** + * @param {MouseEvent} e + */ +function _onEnter(e) { + const item = /** @type {HTMLElement} */ (e.currentTarget); + const ownerId = item.dataset.ownerId; + const path = item.dataset.path; + + // Nothing to show — don't display an all-? tooltip. + if (!ownerId && !path) return; + + const tooltip = _getOrCreateTooltip(); + + // Clear previous content. + while (tooltip.firstChild) tooltip.removeChild(tooltip.firstChild); + + // ── Owner row (always rendered) ─────────────────────────────────────────── + _addRow(tooltip, 'fas fa-user', i18n.t('files.owner', 'Owner'), (el) => { + if (ownerId && systemUsers.isAvailable()) { + el.appendChild(createUserVignette(ownerId, 'xs')); + } else { + _setUnknown(el); + } + }); + + // ── Path row (always rendered) ──────────────────────────────────────────── + _addRow(tooltip, 'fas fa-location-crosshairs', i18n.t('tooltip.path', 'Path'), (el) => { + if (path) { + el.classList.add('path-tooltip__value--path'); + el.textContent = path; + } else { + _setUnknown(el); + } + }); + + tooltip.classList.remove('hidden'); +} + +function _onLeave() { + _hide(); +} + +// ── Listener registry (WeakMap for leak-free cleanup) ──────────────────────── + +/** + * @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers + */ + +/** @type {WeakMap} */ +const _registry = new WeakMap(); + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Attach tooltip listeners to every `.file-item` inside `container`. + * Items with neither `data-owner-id` nor `data-path` will not trigger the + * tooltip. Safe to call repeatedly — already-wired elements are skipped. + * @param {HTMLElement} container + */ +function init(container) { + for (const item of container.querySelectorAll('.file-item')) { + const el = /** @type {HTMLElement} */ (item); + if (_registry.has(el)) continue; // already wired + + const enter = (/** @type {MouseEvent} */ ev) => _onEnter(ev); + const leave = () => _onLeave(); + + el.addEventListener('mouseenter', enter); + el.addEventListener('mouseleave', leave); + _registry.set(el, { enter, leave }); + } +} + +/** + * Remove tooltip listeners from all `.file-item` elements inside `container` + * and hide any visible tooltip. + * @param {HTMLElement} container + */ +function destroy(container) { + for (const item of container.querySelectorAll('.file-item')) { + const el = /** @type {HTMLElement} */ (item); + const h = _registry.get(el); + if (h) { + el.removeEventListener('mouseenter', h.enter); + el.removeEventListener('mouseleave', h.leave); + _registry.delete(el); + } + } + _hide(); +} + +export { destroy, init }; diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 8da2aa07..126dbf7f 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -11,7 +11,7 @@ import { ResourceListComponent } from '../../components/resourceList.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { batchToolbar } from '../files/batchToolbar.js'; -import * as pathTooltip from '../pathTooltip.js'; +import * as itemTooltip from '../itemTooltip.js'; /** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */ @@ -208,7 +208,7 @@ const recent = { } batchToolbar.setActiveComponent(this._component); this._component.render(items); - pathTooltip.init(filesList); + itemTooltip.init(filesList); } } catch (error) { console.error('Error displaying recent files:', error); diff --git a/static/js/features/ownerTooltip.js b/static/js/features/ownerTooltip.js deleted file mode 100644 index 3349374d..00000000 --- a/static/js/features/ownerTooltip.js +++ /dev/null @@ -1,121 +0,0 @@ -// @ts-check - -/** - * Owner tooltip — shows "Shared by: " when hovering a - * `.file-item[data-owner-id]` element. - * - * Reuses the existing `#path-tooltip` DOM element (same position and style) - * so no extra CSS is needed. The tooltip is hidden immediately on mouseleave - * and the display-name resolution is async-but-usually-instant because - * `systemUsers` is pre-fetched when the Shared-with-me section is entered. - * - * Usage: - * ownerTooltip.init(containerEl) — call after rendering items - * ownerTooltip.destroy(containerEl) — call when leaving the section - */ - -import { i18n } from '../core/i18n.js'; -import { systemUsers } from '../model/systemUsers.js'; - -// ── Tooltip DOM ─────────────────────────────────────────────────────────────── - -/** @returns {HTMLElement} */ -function _getOrCreateTooltip() { - let el = document.getElementById('path-tooltip'); - if (!el) { - el = document.createElement('div'); - el.id = 'path-tooltip'; - el.className = 'path-tooltip hidden'; - document.querySelector('.main-content')?.appendChild(el); - } - return el; -} - -function _hide() { - document.getElementById('path-tooltip')?.classList.add('hidden'); -} - -// ── Event handlers ──────────────────────────────────────────────────────────── - -/** - * @param {MouseEvent} e - */ -async function _onEnter(e) { - const item = /** @type {HTMLElement} */ (e.currentTarget); - const ownerId = item.dataset.ownerId; - if (!ownerId) return; - - if (!systemUsers.isAvailable()) return; - - const tooltip = _getOrCreateTooltip(); - - // Show immediately with a placeholder so the tooltip appears without lag. - const label = i18n.t('sharedwithme_sharedBy', 'Shared by'); - tooltip.textContent = `${label}: …`; - tooltip.classList.remove('hidden'); - - // Resolve the name (usually instant from the pre-fetched cache). - const name = await systemUsers.getDisplayName(ownerId); - - // Guard: don't update if the user already moved away. - if (!tooltip.classList.contains('hidden')) { - tooltip.textContent = `${label}: ${name}`; - } -} - -function _onLeave() { - _hide(); -} - -// ── Listener registry (WeakMap for leak-free cleanup) ──────────────────────── - -/** - * @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers - */ - -/** @type {WeakMap} */ -const _registry = new WeakMap(); - -// ── Public API ──────────────────────────────────────────────────────────────── - -/** - * Attach owner-tooltip listeners to every `.file-item[data-owner-id]` - * inside `container`. - * @param {HTMLElement} container - */ -function init(container) { - for (const item of container.querySelectorAll('.file-item[data-owner-id]')) { - const el = /** @type {HTMLElement} */ (item); - if (_registry.has(el)) continue; // already wired - - /** @type {(e: MouseEvent) => void} */ - const enter = (e) => { - _onEnter(e); - }; // intentionally discard the Promise - const leave = () => _onLeave(); - - el.addEventListener('mouseenter', enter); - el.addEventListener('mouseleave', leave); - _registry.set(el, { enter, leave }); - } -} - -/** - * Remove owner-tooltip listeners from all `.file-item` elements inside - * `container` and hide any visible tooltip. - * @param {HTMLElement} container - */ -function destroy(container) { - for (const item of container.querySelectorAll('.file-item')) { - const el = /** @type {HTMLElement} */ (item); - const h = _registry.get(el); - if (h) { - el.removeEventListener('mouseenter', h.enter); - el.removeEventListener('mouseleave', h.leave); - _registry.delete(el); - } - } - _hide(); -} - -export const ownerTooltip = { init, destroy }; diff --git a/static/js/features/pathTooltip.js b/static/js/features/pathTooltip.js deleted file mode 100644 index 743f824b..00000000 --- a/static/js/features/pathTooltip.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Path tooltip — shows the full path of a hovered file/folder item - * in an overlay at the bottom-left of the content area. - * - * Usage: call init(container) after rendering items, destroy(container) on teardown. - * Only file-item elements with a data-path attribute trigger the tooltip. - */ - -/** @type {HTMLElement|null} */ -let _tooltip = null; - -function _getOrCreateTooltip() { - if (_tooltip) return _tooltip; - _tooltip = document.getElementById('path-tooltip'); - if (!_tooltip) { - _tooltip = document.createElement('div'); - _tooltip.id = 'path-tooltip'; - _tooltip.className = 'path-tooltip hidden'; - document.querySelector('.main-content')?.appendChild(_tooltip); - } - return _tooltip; -} - -/** - * @param {MouseEvent} e - */ -function _onEnter(e) { - const item = /** @type {HTMLElement} */ (e.currentTarget); - const path = item.dataset.path; - if (!path) return; - - const tooltip = _getOrCreateTooltip(); - tooltip.textContent = path; - tooltip.classList.remove('hidden'); -} - -function _onLeave() { - _tooltip?.classList.add('hidden'); -} - -/** - * @typedef {Object} EnterLeaveF - * @property {(e: MouseEvent) => void} enter - * @property {(e: MouseEvent) => void} leave - * - -/** @type {WeakMap} */ -const _listeners = new WeakMap(); - -/** - * Attach path tooltip listeners to all file-item elements inside container. - * @param {HTMLElement} container - */ -function init(container) { - const items = container.querySelectorAll('.file-item[data-path]'); - items.forEach((item) => { - const el = /** @type {HTMLElement} */ (item); - - /** @type {(e: MouseEvent) => void} */ - const enter = (e) => _onEnter(e); - el.addEventListener('mouseenter', enter); - - /** @type {(e: MouseEvent) => void} */ - const leave = (_e) => _onLeave(); - el.addEventListener('mouseleave', leave); - - _listeners.set(el, { enter, leave }); - }); -} - -/** - * Remove path tooltip listeners from all file-item elements inside container. - * @param {HTMLElement} container - */ -function destroy(container) { - const items = container.querySelectorAll('.file-item'); - items.forEach((item) => { - const el = /** @type {HTMLElement} */ (item); - const fns = _listeners.get(el); - if (fns) { - el.removeEventListener('mouseenter', fns.enter); - el.removeEventListener('mouseleave', fns.leave); - _listeners.delete(el); - } - }); - _onLeave(); -} - -export { destroy, init }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 1ddd6324..779e78df 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -15,8 +15,8 @@ import { ResourceListComponent } from '../../components/resourceList.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import { batchToolbar } from '../../features/files/batchToolbar.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; -import { ownerTooltip } from '../../features/ownerTooltip.js'; import { grants } from '../../model/grants.js'; import { systemUsers } from '../../model/systemUsers.js'; @@ -262,7 +262,7 @@ const sharedWithMeView = { batchToolbar.setActiveComponent(null); const filesList = document.getElementById('files-list'); - if (filesList) ownerTooltip.destroy(filesList); + if (filesList) itemTooltip.destroy(filesList); }, // ── Internal helpers ────────────────────────────────────────────────────── @@ -319,7 +319,7 @@ const sharedWithMeView = { // Wire owner tooltips after items are in the DOM const filesList = document.getElementById('files-list'); - if (filesList) ownerTooltip.init(filesList); + if (filesList) itemTooltip.init(filesList); // Fill the Owner column cells (idempotent: skips already-resolved rows). await this._component?.resolveOwnerCells(); From bd474aa67e968d964d2db288110cd518b0d2ac64 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 11:12:36 +0200 Subject: [PATCH 4/9] feat(viewPref): keep record of user view preferences per section (groupBy, order, list or grid) --- static/js/app/filesView.js | 10 +- static/js/app/navigation.js | 27 +++++ static/js/app/ui.js | 3 + static/js/core/groupBySync.js | 55 ++++++++++ static/js/core/viewPrefs.js | 100 ++++++++++++++++++ static/js/views/favorites/favoritesView.js | 8 +- .../js/views/sharedWithMe/sharedWithMeView.js | 8 +- 7 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 static/js/core/groupBySync.js create mode 100644 static/js/core/viewPrefs.js diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 4ea1749a..f2af0004 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -17,6 +17,7 @@ import { ResourceListComponent } from '../components/resourceList.js'; import { normalizeDateBucket, sizeBucket } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; +import * as viewPrefs from '../core/viewPrefs.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; import { favorites } from '../features/library/favorites.js'; @@ -163,6 +164,7 @@ const filesView = { setGroupBy(key) { if (_groupBy === key) return; _groupBy = key; + viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view); _nextCursor = null; _component?.clear(); _loadPage({ isFirstPage: true }); @@ -176,6 +178,7 @@ const filesView = { setDirection(reversed) { if (_reversed === reversed) return; _reversed = reversed; + viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view); _nextCursor = null; _component?.clear(); _loadPage({ isFirstPage: true }); @@ -359,10 +362,11 @@ async function loadFiles(options = { insertHistory: true }) { return; } - // Reset cursor, groupBy, and direction on navigation to a different folder. + // Reset cursor on navigation; restore saved group-by/direction preferences. _nextCursor = null; - _groupBy = ''; - _reversed = false; + const _savedPrefs = viewPrefs.load('files'); + _groupBy = _savedPrefs.groupBy; + _reversed = _savedPrefs.reversed; // Delay spinner so fast loads avoid the flash const spinnerTimeout = setTimeout(() => { diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index f26fd185..45540654 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -3,7 +3,9 @@ * Extracted from main.js to keep navigation concerns isolated. */ +import { applyGroupByMenuState } from '../core/groupBySync.js'; import { i18n } from '../core/i18n.js'; +import * as viewPrefs from '../core/viewPrefs.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; @@ -22,6 +24,14 @@ import { ui } from './ui.js'; * Sync the hidden class and inline display for the grid/list containers * based on the current view preference. */ +/** + * Restore the grid/list view preference for a section before rendering. + * @param {string} section Matches `app.currentSection` values. + */ +function restoreView(section) { + app.currentView = viewPrefs.resolveView(section); +} + function syncViewContainers() { const filesList = document.getElementById('files-list'); const gridViewBtn = document.getElementById('grid-view-btn'); @@ -225,11 +235,16 @@ function switchToSharedWithMeSection() { setGroupByView(sharedWithMeView); syncGroupByMenu(sharedWithMeView.groupByDefs); + // Restore the saved group-by selection in the dropdown. + const swmPrefs = viewPrefs.load('sharedwithme'); + applyGroupByMenuState(swmPrefs.groupBy, swmPrefs.reversed); + // Show the Owner column — names are resolved async after render. ui.setOwnerColumnVisible(true); // Show the standard files container and respect grid/list preference toggleFileContainer(true); + restoreView('sharedwithme'); syncViewContainers(); if (batchToolbar) batchToolbar.clear(); @@ -246,6 +261,10 @@ function switchToFilesSection() { setGroupByView(filesView); syncGroupByMenu(filesView.groupByDefs); + // Restore the saved group-by selection in the dropdown. + const filesPrefs = viewPrefs.load('files'); + applyGroupByMenuState(filesPrefs.groupBy, filesPrefs.reversed); + // Show owner column in the Files section ui.setOwnerColumnVisible(true); @@ -257,6 +276,7 @@ function switchToFilesSection() { toggleFileContainer(true); // ensure correct view + restoreView('files'); syncViewContainers(); //reset files view + remove any error @@ -282,6 +302,10 @@ function switchToFavoritesSection() { setGroupByView(favoritesView); syncGroupByMenu(favoritesView.groupByDefs); + // Restore the saved group-by selection in the dropdown. + const favPrefs = viewPrefs.load('favorites'); + applyGroupByMenuState(favPrefs.groupBy, favPrefs.reversed); + // Show the Owner column — names are resolved async after render. ui.setOwnerColumnVisible(true); @@ -293,6 +317,7 @@ function switchToFavoritesSection() { toggleFileContainer(true); // ensure correct view + restoreView('favorites'); syncViewContainers(); if (batchToolbar) batchToolbar.clear(); @@ -320,6 +345,7 @@ function switchToRecentFilesSection() { toggleFileContainer(true); // ensure correct view + restoreView('recent'); syncViewContainers(); //reset files view + remove any error @@ -381,6 +407,7 @@ function switchToTrashSection() { ui.resetFilesList(); //ensure buttons match the current view + restoreView('trash'); syncViewContainers(); // Load trash items diff --git a/static/js/app/ui.js b/static/js/app/ui.js index d4c7af10..991280a3 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -7,6 +7,7 @@ import { i18n } from '../core/i18n.js'; import { OxiIcons } from '../core/icons.js'; +import * as viewPrefs from '../core/viewPrefs.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; @@ -419,6 +420,7 @@ const ui = { switchToGridView() { app.currentView = 'grid'; localStorage.setItem('oxicloud-view', 'grid'); + if (app.currentSection) viewPrefs.saveView(app.currentSection, 'grid'); syncViewContainers(); }, @@ -429,6 +431,7 @@ const ui = { switchToListView() { app.currentView = 'list'; localStorage.setItem('oxicloud-view', 'list'); + if (app.currentSection) viewPrefs.saveView(app.currentSection, 'list'); syncViewContainers(); }, diff --git a/static/js/core/groupBySync.js b/static/js/core/groupBySync.js new file mode 100644 index 00000000..a0526836 --- /dev/null +++ b/static/js/core/groupBySync.js @@ -0,0 +1,55 @@ +// @ts-check + +/** + * groupBySync — apply groupBy + sort-direction state to the group-by menu UI. + * + * Pure DOM helper with no module imports so it can be safely imported by any + * view without creating circular dependencies. + * + * Call after `syncGroupByMenu()` has built the option list, e.g. when + * restoring saved preferences on section entry. + * + * Usage: + * import { applyGroupByMenuState } from '../core/groupBySync.js'; + * applyGroupByMenuState('type', true); + */ + +/** + * Reflect `groupBy` and `reversed` in the group-by menu DOM: + * - marks the matching `.group-by-option` as active + * - updates the group-by button label and active class + * - toggles the sort-direction button active class + * + * No-op when the menu elements are not in the DOM (e.g. before initApp). + * + * @param {string} groupBy Active group-by key, or `''` for "None". + * @param {boolean} reversed Whether sort direction is reversed. + */ +function applyGroupByMenuState(groupBy, reversed) { + // Mark the matching option as active; clear all others. + for (const b of document.querySelectorAll('.group-by-option')) { + const btn = /** @type {HTMLElement} */ (b); + btn.classList.toggle('active', (btn.dataset.groupBy ?? '') === groupBy); + } + + // Group-by button: active class + label text. + const groupByBtn = document.getElementById('group-by-btn'); + groupByBtn?.classList.toggle('active', groupBy !== ''); + + const lbl = groupByBtn?.querySelector('.group-by-label'); + if (lbl) { + if (groupBy === '') { + lbl.textContent = ''; + } else { + const activeOpt = /** @type {HTMLElement|null} */ ( + document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`) + ); + lbl.textContent = activeOpt?.textContent ?? ''; + } + } + + // Sort-direction button: active = reversed. + document.getElementById('sort-dir-btn')?.classList.toggle('active', reversed); +} + +export { applyGroupByMenuState }; diff --git a/static/js/core/viewPrefs.js b/static/js/core/viewPrefs.js new file mode 100644 index 00000000..63cbd7fa --- /dev/null +++ b/static/js/core/viewPrefs.js @@ -0,0 +1,100 @@ +// @ts-check + +/** + * View preferences — persist groupBy, sort-direction, and grid/list view + * per section in localStorage. + * + * Each section has its own key (`oxicloud.view.
`) so that, for + * example, Favorites can be in list view with "Type" grouping while Files + * is in grid view with no grouping. + * + * Section keys match `app.currentSection` values: + * 'files' | 'favorites' | 'sharedwithme' | 'recent' | 'trash' | + * 'photos' | 'music' | 'shared' + * + * All errors (quota, private browsing, JSON parse) are silently swallowed. + * + * Usage: + * import * as viewPrefs from '../core/viewPrefs.js'; + * + * // Read + * const { groupBy, reversed, view } = viewPrefs.load('files'); + * + * // Write all fields at once + * viewPrefs.save('files', 'type', true, 'list'); + * + * // Write only the view (grid/list) toggle, keeping stored groupBy/reversed + * viewPrefs.saveView('favorites', 'grid'); + * + * // Resolve which view (grid/list) to apply on section entry + * const v = viewPrefs.resolveView('sharedwithme'); // 'grid' | 'list' + */ + +const _PREFIX = 'oxicloud.view.'; + +/** + * @typedef {'grid'|'list'|''} ViewMode + * @typedef {{ groupBy: string, reversed: boolean, view: ViewMode }} ViewPrefs + */ + +/** + * Load saved preferences for a section. + * Returns safe defaults when nothing is stored or storage is unavailable. + * @param {string} section + * @returns {ViewPrefs} + */ +function load(section) { + try { + const raw = localStorage.getItem(_PREFIX + section); + if (!raw) return { groupBy: '', reversed: false, view: '' }; + const p = JSON.parse(raw); + return { + groupBy: typeof p.groupBy === 'string' ? p.groupBy : '', + reversed: Boolean(p.reversed), + view: p.view === 'grid' || p.view === 'list' ? p.view : '' + }; + } catch { + return { groupBy: '', reversed: false, view: '' }; + } +} + +/** + * Persist all preferences for a section. + * @param {string} section + * @param {string} groupBy + * @param {boolean} reversed + * @param {ViewMode} view + */ +function save(section, groupBy, reversed, view) { + try { + localStorage.setItem(_PREFIX + section, JSON.stringify({ groupBy, reversed, view })); + } catch { + // Silently ignore quota errors or restricted environments. + } +} + +/** + * Update only the grid/list view for a section, preserving groupBy and reversed. + * @param {string} section + * @param {ViewMode} view + */ +function saveView(section, view) { + const current = load(section); + save(section, current.groupBy, current.reversed, view); +} + +/** + * Resolve the view mode to apply when entering a section. + * Priority: section-specific pref → legacy global `oxicloud-view` key → `'grid'`. + * @param {string} section + * @returns {'grid'|'list'} + */ +function resolveView(section) { + const prefs = load(section); + if (prefs.view) return prefs.view; + // Fall back to the pre-existing global key (backward compatibility). + const global = localStorage.getItem('oxicloud-view'); + return global === 'list' ? 'list' : 'grid'; +} + +export { load, resolveView, save, saveView }; diff --git a/static/js/views/favorites/favoritesView.js b/static/js/views/favorites/favoritesView.js index b7dc9de0..86fffd07 100644 --- a/static/js/views/favorites/favoritesView.js +++ b/static/js/views/favorites/favoritesView.js @@ -20,6 +20,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; +import * as viewPrefs from '../../core/viewPrefs.js'; import { batchToolbar } from '../../features/files/batchToolbar.js'; import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; @@ -164,6 +165,7 @@ const favoritesView = { setGroupBy(key) { if (this._groupBy === key) return; this._groupBy = key; + viewPrefs.save('favorites', this._groupBy, this._reversed, viewPrefs.load('favorites').view); this._nextCursor = null; this._component?.clear(); this._loadPage(); @@ -177,6 +179,7 @@ const favoritesView = { setDirection(reversed) { if (this._reversed === reversed) return; this._reversed = reversed; + viewPrefs.save('favorites', this._groupBy, this._reversed, viewPrefs.load('favorites').view); this._nextCursor = null; this._component?.clear(); this._loadPage(); @@ -189,8 +192,9 @@ const favoritesView = { async init() { this._nextCursor = null; this._loading = false; - this._groupBy = ''; - this._reversed = false; + const _savedPrefs = viewPrefs.load('favorites'); + this._groupBy = _savedPrefs.groupBy; + this._reversed = _savedPrefs.reversed; this._ensureLoadMoreButton(); diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 779e78df..ba4cb839 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -14,6 +14,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; +import * as viewPrefs from '../../core/viewPrefs.js'; import { batchToolbar } from '../../features/files/batchToolbar.js'; import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; @@ -165,6 +166,7 @@ const sharedWithMeView = { setGroupBy(key) { if (this._groupBy === key) return; this._groupBy = key; + viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view); this._nextCursor = null; // restart from first page this._component?.clear(); this._loadPage(); @@ -178,6 +180,7 @@ const sharedWithMeView = { setDirection(reversed) { if (this._reversed === reversed) return; this._reversed = reversed; + viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view); this._nextCursor = null; this._component?.clear(); this._loadPage(); @@ -190,8 +193,9 @@ const sharedWithMeView = { async init() { this._nextCursor = null; this._loading = false; - this._groupBy = ''; - this._reversed = false; + const _savedPrefs = viewPrefs.load('sharedwithme'); + this._groupBy = _savedPrefs.groupBy; + this._reversed = _savedPrefs.reversed; this._ensureLoadMoreButton(); From 73e78904e7048476bd108f8c2976f85026275de7 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 11:50:32 +0200 Subject: [PATCH 5/9] feat(recent): add cursor + groupby support --- src/application/dtos/recent_dto.rs | 112 ++++- src/application/ports/recent_ports.rs | 11 + src/application/services/recent_service.rs | 100 +++- .../pg/recent_items_pg_repository.rs | 309 ++++++++++++- src/interfaces/api/handlers/recent_handler.rs | 131 +++++- src/interfaces/api/routes.rs | 2 + static/index.html | 2 + static/js/app/main.js | 5 +- static/js/app/navigation.js | 36 +- static/js/core/groupBySync.js | 4 +- static/js/features/library/recent.js | 159 +------ static/js/model/recentModel.js | 58 +++ static/js/views/recent/recentView.js | 433 ++++++++++++++++++ static/locales/en.json | 12 +- static/locales/fr.json | 12 +- 15 files changed, 1205 insertions(+), 181 deletions(-) create mode 100644 static/js/model/recentModel.js create mode 100644 static/js/views/recent/recentView.js diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 0f03cf83..cf3ca378 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -1,10 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; +use super::cursor::{CursorListResponse, CursorQuery, PageCursor}; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, }; +use super::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::domain::services::authorization::ResourceKind; /// DTO for recent items, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. @@ -84,3 +88,109 @@ impl RecentItemDto { self } } + +// ════════════════════════════════════════════════════════════════════════════ +// Cursor-paginated recent resources (GET /api/recent/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// Raw row returned by the UNION ALL query for `/api/recent/resources`. +pub struct RecentResourceRow { + pub resource_type: String, // "file" | "folder" + pub resource_id: Uuid, + pub name: String, + pub parent_id: Option, + pub mime_type: Option, + /// `-1` for folders, actual byte-count for files. + pub size: i64, + pub resource_created_at: DateTime, + pub modified_at: DateTime, + pub owner_id: Uuid, + /// `true` when `owner_id == requesting user_id`. + pub is_owner: bool, + pub accessed_at: DateTime, + /// Human-readable path. Always populated in the row; the handler clears it + /// to `""` when `is_owner` is false. + pub path: Option, + // Pre-computed sort fields for cursor construction. + pub sort_str: Option, + pub sort_int: Option, + pub sort_ts: Option>, +} + +/// Opaque keyset-pagination cursor for `GET /api/recent/resources`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentCursor { + /// Sort dimension active when this cursor was produced. + /// Values: `"accessed_at"` (default), `"name"`, `"type"`, `"modified_at"`, `"size"`, `"owner"`. + #[serde(default = "RecentCursor::default_order")] + pub order_by: String, + /// UUID of the last item on the previous page (tie-breaker). + pub resource_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_str: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_int: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_ts: Option>, + #[serde(default)] + pub reverse: bool, +} + +impl RecentCursor { + fn default_order() -> String { + "accessed_at".to_owned() + } +} + +impl PageCursor for RecentCursor {} + +/// Query parameters for `GET /api/recent/resources`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct RecentResourcesQuery { + /// Maximum items per page (1–200, default 50). + #[serde(default = "CursorQuery::default_limit")] + pub limit: u32, + /// Opaque cursor from a previous response. Omit to start from the first page. + pub cursor: Option, + /// Sort / group-by dimension. Supported: `"accessed_at"` (default), `"name"`, + /// `"type"`, `"modified_at"`, `"size"`, `"owner"`. + pub order_by: Option, + /// Comma-separated resource types to include, e.g. `"file,folder"`. + /// Omit to include both. + pub resource_types: Option, + /// Reverse the sort order. Default `false`. + #[serde(default)] + pub reverse: bool, +} + +impl RecentResourcesQuery { + pub fn limit_clamped(&self) -> usize { + self.limit.clamp(1, 200) as usize + } + + pub fn decode_cursor(&self) -> Option { + self.cursor.as_deref().and_then(RecentCursor::decode) + } + + /// Returns `None` when `resource_types` is absent (= include all). + pub fn resource_kinds(&self) -> Option> { + self.resource_types.as_deref().map(|s| { + s.split(',') + .filter_map(|t| ResourceKind::parse(t.trim())) + .collect() + }) + } +} + +/// One item in a `GET /api/recent/resources` page. +#[derive(Debug, Serialize, ToSchema)] +pub struct RecentResourceItemDto { + pub resource_type: ResourceTypeDto, + /// When the resource was last accessed. + pub accessed_at: DateTime, + /// Full resource details — shape determined by `resource_type`. + pub resource: ResourceContentDto, +} + +/// Response envelope for `GET /api/recent/resources`. +pub type RecentResourcesDto = CursorListResponse; diff --git a/src/application/ports/recent_ports.rs b/src/application/ports/recent_ports.rs index 91d6cfd7..b3181ec4 100644 --- a/src/application/ports/recent_ports.rs +++ b/src/application/ports/recent_ports.rs @@ -51,4 +51,15 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static { /// Removes items exceeding `max_items` (the oldest ones). async fn prune(&self, user_id: Uuid, max_items: i32) -> Result<()>; + + /// List recent items with cursor pagination, sorting, and optional type filter. + async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option<&crate::application::dtos::recent_dto::RecentCursor>, + order_by: &str, + kinds: Option<&[crate::domain::services::authorization::ResourceKind]>, + reverse: bool, + ) -> Result>; } diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs index 5632a5be..b54e318b 100644 --- a/src/application/services/recent_service.rs +++ b/src/application/services/recent_service.rs @@ -1,6 +1,8 @@ -use crate::application::dtos::recent_dto::RecentItemDto; +use crate::application::dtos::cursor::PageCursor; +use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow}; use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::domain::services::authorization::ResourceKind; use crate::infrastructure::repositories::pg::RecentItemsPgRepository; use std::sync::Arc; use tracing::info; @@ -109,3 +111,99 @@ impl RecentItemsUseCase for RecentService { Ok(()) } } + +impl RecentService { + /// No authz needed — recent items are strictly user-scoped; the repository + /// enforces `WHERE user_id = $1` so users can only see their own entries. + /// + /// Returns `(rows, next_cursor_encoded)`. + pub async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result<(Vec, Option)> { + // Fetch one extra row to detect whether a next page exists. + let mut rows = self + .repo + .list_resources_paged( + user_id, + limit + 1, + cursor.as_ref(), + order_by, + kinds, + reverse, + ) + .await?; + + let next_cursor = if rows.len() > limit { + let last = &rows[limit - 1]; + let c = build_recent_cursor(last, order_by, reverse); + rows.truncate(limit); + Some(c.encode()) + } else { + None + }; + + Ok((rows, next_cursor)) + } +} + +/// Build the next-page cursor from the last row of the current page. +/// `reverse` is stored in the cursor so subsequent pages use the same direction. +fn build_recent_cursor(row: &RecentResourceRow, order_by: &str, reverse: bool) -> RecentCursor { + match order_by { + "name" => RecentCursor { + order_by: "name".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(name) + sort_int: row.sort_int, // folder_first + sort_ts: None, + reverse, + }, + "type" => RecentCursor { + order_by: "type".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(name) + sort_int: row.sort_int, // type_order + sort_ts: None, + reverse, + }, + "modified_at" => RecentCursor { + order_by: "modified_at".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: None, + sort_ts: row.sort_ts, // modified_at timestamp + reverse, + }, + "size" => RecentCursor { + order_by: "size".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: row.sort_int, // size in bytes + sort_ts: None, + reverse, + }, + "owner" => RecentCursor { + order_by: "owner".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(username) + sort_int: None, + sort_ts: row.sort_ts, // accessed_at timestamp (secondary) + reverse, + }, + _ => RecentCursor { + // default: accessed_at DESC + order_by: "accessed_at".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: None, + sort_ts: row.sort_ts, // accessed_at timestamp + reverse, + }, + } +} diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index 867331b7..1a3cc33c 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use tracing::error; use uuid::Uuid; -use crate::application::dtos::recent_dto::RecentItemDto; +use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow}; use crate::application::ports::recent_ports::RecentItemsRepositoryPort; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::domain::services::authorization::ResourceKind; /// PostgreSQL implementation of the recent items persistence port. pub struct RecentItemsPgRepository { @@ -188,4 +189,310 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { Ok(()) } + + async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option<&RecentCursor>, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result> { + let include_folders = + kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder))); + let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File))); + + // ── Build the UNION ALL CTE ───────────────────────────────────────── + let mut cte_branches: Vec<&str> = Vec::new(); + + let folder_branch = r#" + SELECT + 'folder'::text AS resource_type, + fld.id AS resource_id, + fld.name, + fld.parent_id, + NULL::text AS mime_type, + -1::bigint AS size, + fld.created_at AS resource_created_at, + fld.updated_at AS modified_at, + fld.user_id AS owner_id, + (fld.user_id = $1::uuid) AS is_owner, + ur.accessed_at AS accessed_at, + fld.path::text AS resource_path, + LOWER(fld.name) AS sort_str, + 0::bigint AS type_order, + 0::int AS folder_first + FROM auth.user_recent_files ur + INNER JOIN storage.folders fld + ON fld.id = ur.item_id::UUID AND NOT fld.is_trashed + WHERE ur.user_id = $1::uuid AND ur.item_type = 'folder'"#; + + let file_branch = r#" + SELECT + 'file'::text AS resource_type, + f.id AS resource_id, + f.name, + f.folder_id AS parent_id, + f.mime_type, + f.size::bigint, + f.created_at AS resource_created_at, + f.updated_at AS modified_at, + f.user_id AS owner_id, + (f.user_id = $1::uuid) AS is_owner, + ur.accessed_at AS accessed_at, + COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, + LOWER(f.name) AS sort_str, + f.category_order::bigint AS type_order, + 1::int AS folder_first + FROM auth.user_recent_files ur + INNER JOIN storage.files f + ON f.id = ur.item_id::UUID AND NOT f.is_trashed + LEFT JOIN storage.folders pfld + ON pfld.id = f.folder_id + WHERE ur.user_id = $1::uuid AND ur.item_type = 'file'"#; + + if include_folders { + cte_branches.push(folder_branch); + } + if include_files { + cte_branches.push(file_branch); + } + + if cte_branches.is_empty() { + return Ok(Vec::new()); + } + + let union_sql = cte_branches.join("\n UNION ALL\n"); + let cte = format!("WITH resources AS ({union_sql}\n)"); + + // ── Cursor values ─────────────────────────────────────────────────── + let cur_str: Option<&str> = cursor.and_then(|c| c.sort_str.as_deref()); + let cur_int: Option = cursor.and_then(|c| c.sort_int); + let cur_ts: Option> = cursor.and_then(|c| c.sort_ts); + let cur_id: Option = cursor.map(|c| c.resource_id); + + // ── Per-dimension keyset WHERE + ORDER BY ─────────────────────────── + // Binds: $1=user_id (in CTE), $2=cur_str, $3=cur_int, $4=cur_ts, + // $5=cur_id, $6=limit (for "owner" sort: JOIN uses no extra binds) + let (keyset, order_by_clause, need_user_join) = match (order_by, reverse) { + // ── name ──────────────────────────────────────────────────────── + ("name", false) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str > $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)", + "ORDER BY folder_first ASC, sort_str ASC, resource_id ASC", + false, + ), + ("name", true) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str < $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)", + "ORDER BY folder_first ASC, sort_str DESC, resource_id DESC", + false, + ), + // ── type ──────────────────────────────────────────────────────── + ("type", false) => ( + "WHERE ($3::bigint IS NULL) + OR (type_order > $3) + OR (type_order = $3 AND sort_str > $2) + OR (type_order = $3 AND sort_str = $2 AND resource_id > $5::uuid)", + "ORDER BY type_order ASC, sort_str ASC, resource_id ASC", + false, + ), + ("type", true) => ( + "WHERE ($3::bigint IS NULL) + OR (type_order < $3) + OR (type_order = $3 AND sort_str < $2) + OR (type_order = $3 AND sort_str = $2 AND resource_id < $5::uuid)", + "ORDER BY type_order DESC, sort_str DESC, resource_id DESC", + false, + ), + // ── accessed_at ────────────────────────────────────────────────── + ("accessed_at", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at < $4) + OR (accessed_at = $4 AND resource_id < $5::uuid)", + "ORDER BY accessed_at DESC, resource_id DESC", + false, + ), + ("accessed_at", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at > $4) + OR (accessed_at = $4 AND resource_id > $5::uuid)", + "ORDER BY accessed_at ASC, resource_id ASC", + false, + ), + // ── modified_at ────────────────────────────────────────────────── + ("modified_at", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (modified_at < $4) + OR (modified_at = $4 AND resource_id < $5::uuid)", + "ORDER BY modified_at DESC, resource_id DESC", + false, + ), + ("modified_at", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (modified_at > $4) + OR (modified_at = $4 AND resource_id > $5::uuid)", + "ORDER BY modified_at ASC, resource_id ASC", + false, + ), + // ── size ───────────────────────────────────────────────────────── + ("size", false) => ( + "WHERE ($3::bigint IS NULL) + OR (size > $3) + OR (size = $3 AND resource_id > $5::uuid)", + "ORDER BY size ASC, resource_id ASC", + false, + ), + ("size", true) => ( + "WHERE ($3::bigint IS NULL) + OR (size < $3) + OR (size = $3 AND resource_id < $5::uuid)", + "ORDER BY size DESC, resource_id DESC", + false, + ), + // ── owner ──────────────────────────────────────────────────────── + ("owner", false) => ( + "WHERE ($2::text IS NULL) + OR (LOWER(u.username) > $2) + OR (LOWER(u.username) = $2 AND accessed_at < $4) + OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id < $5::uuid)", + "ORDER BY LOWER(u.username) ASC, accessed_at DESC, resource_id DESC", + true, + ), + ("owner", true) => ( + "WHERE ($2::text IS NULL) + OR (LOWER(u.username) < $2) + OR (LOWER(u.username) = $2 AND accessed_at > $4) + OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id > $5::uuid)", + "ORDER BY LOWER(u.username) DESC, accessed_at ASC, resource_id ASC", + true, + ), + // ── default: accessed_at DESC ───────────────────────────────────── + (_, false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at < $4) + OR (accessed_at = $4 AND resource_id < $5::uuid)", + "ORDER BY accessed_at DESC, resource_id DESC", + false, + ), + (_, true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at > $4) + OR (accessed_at = $4 AND resource_id > $5::uuid)", + "ORDER BY accessed_at ASC, resource_id ASC", + false, + ), + }; + + let user_join = if need_user_join { + "LEFT JOIN auth.users u ON u.id = r.owner_id" + } else { + "" + }; + // For "owner" sort the JOIN makes LOWER(u.username) available; add it to SELECT + // so the cursor can carry the correct sort key. + let username_col = if need_user_join { + ",\n LOWER(u.username) AS username_lower" + } else { + "" + }; + + let sql = format!( + "{cte} +SELECT + r.resource_type, r.resource_id, r.name, r.parent_id, + r.mime_type, r.size, r.resource_created_at, r.modified_at, + r.owner_id, r.is_owner, r.accessed_at, r.resource_path, + r.sort_str, r.type_order, r.folder_first{username_col} +FROM resources r +{user_join} +{keyset} +{order_by_clause} +LIMIT $6" + ); + + let rows = sqlx::query(&sql) + .bind(user_id) // $1 (in CTE + outer) + .bind(cur_str) // $2 + .bind(cur_int) // $3 + .bind(cur_ts) // $4 + .bind(cur_id) // $5 + .bind(limit as i64) // $6 + .fetch_all(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error listing recent resources: {e}"); + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Failed to list recent resources: {e}"), + ) + })?; + + let result = rows + .iter() + .map(|row| { + let resource_type: String = row.get("resource_type"); + let sort_str_val: Option = row.try_get("sort_str").ok(); + let type_order: i64 = row.try_get("type_order").unwrap_or(0); + let folder_first: i32 = row.try_get("folder_first").unwrap_or(0); + let size: i64 = row.get("size"); + + // Pre-compute the cursor sort fields based on order_by + let (c_sort_str, c_sort_int, c_sort_ts) = match order_by { + "name" => (sort_str_val, Some(folder_first as i64), None), + "type" => (sort_str_val, Some(type_order), None), + "size" => (None, Some(size), None), + "accessed_at" => { + let ts: Option> = + row.try_get("accessed_at").ok(); + (None, None, ts) + } + "modified_at" => { + let ts: Option> = + row.try_get("modified_at").ok(); + (None, None, ts) + } + "owner" => { + // For "owner" sort the JOIN added LOWER(u.username) AS username_lower. + // The cursor's sort_str must carry the username (not the file name). + let username: Option = row.try_get("username_lower").ok(); + let ts: Option> = + row.try_get("accessed_at").ok(); + (username, None, ts) + } + _ => { + let ts: Option> = + row.try_get("accessed_at").ok(); + (None, None, ts) + } + }; + + RecentResourceRow { + resource_type, + resource_id: row.get("resource_id"), + name: row.get("name"), + parent_id: row.try_get("parent_id").ok(), + mime_type: row.try_get("mime_type").ok(), + size, + resource_created_at: row.get("resource_created_at"), + modified_at: row.get("modified_at"), + owner_id: row.get("owner_id"), + is_owner: row.try_get("is_owner").unwrap_or(false), + accessed_at: row.get("accessed_at"), + path: row.try_get("resource_path").ok(), + sort_str: c_sort_str, + sort_int: c_sort_int, + sort_ts: c_sort_ts, + } + }) + .collect(); + + Ok(result) + } } diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index fdadfd8c..62f72126 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -8,8 +8,18 @@ use serde::Deserialize; use std::sync::Arc; use tracing::{error, info}; +use crate::application::dtos::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::application::dtos::recent_dto::{ + RecentResourceItemDto, RecentResourcesDto, RecentResourcesQuery, +}; use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::services::recent_service::RecentService; +use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; /// Query parameters for getting recent items @@ -19,7 +29,8 @@ pub struct GetRecentParams { limit: Option, } -/// Get user's recent items +/// Get user's recent items (deprecated — use `GET /api/recent/resources` instead) +#[deprecated = "Use GET /api/recent/resources instead"] #[utoipa::path( get, path = "/api/recent", @@ -213,3 +224,121 @@ pub async fn clear_recent_items( } } } + +/// List recently accessed resources with cursor pagination. +/// +/// Sorted by `accessed_at` DESC by default (most recently accessed first). +/// `path` is cleared when the resource is not owned by the requesting user. +#[utoipa::path( + get, + path = "/api/recent/resources", + params(RecentResourcesQuery), + responses( + (status = 200, description = "Paginated list of recently accessed resources", + body = RecentResourcesDto), + (status = 400, description = "Invalid cursor or query parameters"), + ), + security(("bearerAuth" = [])), + tag = "recent" +)] +pub async fn list_recent_resources( + State(recent_service): State>, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let user_id = auth_user.id; + + let order_by = q.order_by.as_deref().unwrap_or("accessed_at").to_owned(); + + // If a cursor exists, validate that it matches the requested sort/direction. + let cursor = q + .decode_cursor() + .filter(|c| c.order_by == order_by && c.reverse == q.reverse); + + let kinds = q.resource_kinds(); + + match recent_service + .list_resources_paged( + user_id, + q.limit_clamped(), + cursor, + &order_by, + kinds.as_deref(), + q.reverse, + ) + .await + { + Ok((rows, next_cursor)) => { + let items: Vec = rows + .into_iter() + .map(|row| { + // Path is only shown to the owner; non-owners see "" + // to avoid leaking another user's folder hierarchy. + let path = if row.is_owner { + row.path.clone().unwrap_or_default() + } else { + String::new() + }; + + if row.resource_type == "folder" { + let dto = FolderDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + parent_id: row.parent_id.map(|u| u.to_string()), + owner_id: Some(row.owner_id.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + is_root: false, + icon_class: std::sync::Arc::from("fas fa-folder"), + icon_special_class: std::sync::Arc::from("folder-icon"), + category: std::sync::Arc::from("Folder"), + }; + RecentResourceItemDto { + resource_type: ResourceTypeDto::Folder, + accessed_at: row.accessed_at, + resource: ResourceContentDto::Folder(dto), + } + } else { + let mime = row + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let size_bytes = row.size.max(0) as u64; + let dto = FileDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + size: size_bytes, + mime_type: std::sync::Arc::from(mime), + folder_id: row.parent_id.map(|u| u.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)), + icon_special_class: std::sync::Arc::from(icon_special_class_for( + &row.name, mime, + )), + category: std::sync::Arc::from(category_for(&row.name, mime)), + size_formatted: format_file_size(size_bytes), + owner_id: Some(row.owner_id.to_string()), + sort_date: None, + etag: String::new(), + }; + RecentResourceItemDto { + resource_type: ResourceTypeDto::File, + accessed_at: row.accessed_at, + resource: ResourceContentDto::File(dto), + } + } + }) + .collect(); + + ( + StatusCode::OK, + Json(RecentResourcesDto::with_cursor(items, next_cursor)), + ) + .into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 4d1f5355..82186958 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -355,10 +355,12 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // Create routes for recent items if the service is available let recent_router = if let Some(recent_service) = recent_service.clone() { + #[allow(deprecated)] use crate::interfaces::api::handlers::recent_handler; Router::new() .route("/", get(recent_handler::get_recent_items)) + .route("/resources", get(recent_handler::list_recent_resources)) .route( "/{item_type}/{item_id}", post(recent_handler::record_item_access), diff --git a/static/index.html b/static/index.html index 21faeed5..129ae26e 100644 --- a/static/index.html +++ b/static/index.html @@ -41,6 +41,8 @@ + + diff --git a/static/js/app/main.js b/static/js/app/main.js index a90b7e4d..cfc21802 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -17,6 +17,7 @@ import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; import { grants } from '../model/grants.js'; +import { recentView } from '../views/recent/recentView.js'; import { sharedView } from '../views/shared/sharedView.js'; import { checkAuthentication } from './authSession.js'; import { loadFiles } from './filesView.js'; @@ -345,8 +346,8 @@ function setupActionsBarDelegation() { break; case 'clear-recent-btn': if (recent) { - recent.clearRecentFiles(); - recent.displayRecentFiles(); + await recent.clearRecentFiles(); + await recentView.init(); ui.showNotification('Cleanup completed', 'Recent files history has been cleared'); } break; diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 45540654..0274f44a 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -10,8 +10,8 @@ import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; -import { recent } from '../features/library/recent.js'; import { favoritesView } from '../views/favorites/favoritesView.js'; +import { recentView } from '../views/recent/recentView.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; import { filesView, loadFiles } from './filesView.js'; @@ -180,6 +180,11 @@ function setCurrentSection(section) { favoritesView.hide(); } + // Hide recentView "Load more" button when leaving the recent section + if (section !== 'recent' && recentView) { + recentView.hide(); + } + // Reset owner column — sections that need it re-enable it explicitly below. ui.setOwnerColumnVisible(false); @@ -332,10 +337,17 @@ function switchToFavoritesSection() { function switchToRecentFilesSection() { if (!setCurrentSection('recent')) return; - // Set actions bar mode + // Set actions bar mode with group-by support setActionsBarMode('recent'); - setGroupByView(null); - syncGroupByMenu([]); + setGroupByView(recentView); + syncGroupByMenu(recentView.groupByDefs); + + // Restore the saved group-by selection in the dropdown. + const recentPrefs = viewPrefs.load('recent'); + applyGroupByMenuState(recentPrefs.groupBy, recentPrefs.reversed); + + // Show the Owner column + ui.setOwnerColumnVisible(true); // Hide breadcrumb (only shown in Files view) const breadcrumb = document.querySelector('.breadcrumb'); @@ -348,21 +360,9 @@ function switchToRecentFilesSection() { restoreView('recent'); syncViewContainers(); - //reset files view + remove any error - ui.resetFilesList(); - - if (recent) { - sharedView.loadItems().then(() => { - recent.displayRecentFiles(); - }); - } else { - console.error('Recent files module not loaded or initialized'); - ui.showError(` - -

Error loading the recent module

- `); - } if (batchToolbar) batchToolbar.clear(); + + recentView.init(); } function switchToPhotosSection() { diff --git a/static/js/core/groupBySync.js b/static/js/core/groupBySync.js index a0526836..47877a67 100644 --- a/static/js/core/groupBySync.js +++ b/static/js/core/groupBySync.js @@ -41,9 +41,7 @@ function applyGroupByMenuState(groupBy, reversed) { if (groupBy === '') { lbl.textContent = ''; } else { - const activeOpt = /** @type {HTMLElement|null} */ ( - document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`) - ); + const activeOpt = /** @type {HTMLElement|null} */ (document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`)); lbl.textContent = activeOpt?.textContent ?? ''; } } diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 126dbf7f..25708ae2 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -1,27 +1,20 @@ +// @ts-check + /** * OxiCloud - Recent Files Module (server-authoritative) * - * Source of truth: GET /api/recent (enriched with name/size/mime via SQL JOIN). - * File-access events are forwarded to the backend with POST /api/recent/{type}/{id}. - * No localStorage usage — the server persists and prunes recent items. + * Records file-access events via POST /api/recent/{type}/{id} and exposes + * `clearRecentFiles()` for the clear-all action. + * + * Display is now handled by `recentView.js` using the cursor-paginated + * `GET /api/recent/resources` endpoint. */ -import { ui } from '../../app/ui.js'; -import { ResourceListComponent } from '../../components/resourceList.js'; import { getCsrfHeaders } from '../../core/csrf.js'; -import { i18n } from '../../core/i18n.js'; -import { batchToolbar } from '../files/batchToolbar.js'; -import * as itemTooltip from '../itemTooltip.js'; -/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */ +/** @import {ItemTypeEnum} from '../../core/types.js' */ const recent = { - /** Maximum items to request from the server */ - MAX_RECENT_FILES: 20, - - /** @type {ResourceListComponent|null} */ - _component: null, - // ───────────────────── helpers ───────────────────── _authHeaders() { @@ -31,10 +24,9 @@ const recent = { // ───────────────────── lifecycle ───────────────────── /** - * Initialise the module. Called once from app.js on startup. + * Initialise the module. Called once from app.js on startup. */ init() { - console.log('Initializing recent files module (server-authoritative)'); this.setupEventListeners(); }, @@ -83,139 +75,6 @@ const recent = { } catch (err) { console.error('Error clearing recent files:', err); } - }, - - /** - * Fetch and display recent files. Data comes directly from the - * enriched backend response — zero extra per-item fetches. - */ - async displayRecentFiles() { - try { - const response = await fetch(`/api/recent?limit=${this.MAX_RECENT_FILES}`, { - headers: this._authHeaders() - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); - } - - const recentItems = /** @type {RecentItem[]} */ (await response.json()); - - // resetFilesList injects the standard list-header with the - // Modified column label; we swap the last header cell to "Accessed". - ui.resetFilesList(); - - const filesList = document.getElementById('files-list'); - if (filesList) { - // Relabel the date column header from "Modified" → "Accessed" - const dateHeader = /** @type {HTMLElement|null} */ ( - [...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified') - ); - if (dateHeader) { - dateHeader.removeAttribute('data-i18n'); - dateHeader.setAttribute('data-i18n', 'recent.accessed'); - dateHeader.textContent = i18n.t('recent.accessed', 'Accessed'); - } - } - - batchToolbar.clear(); - batchToolbar.init(); - ui.updateBreadcrumb(); - - if (recentItems.length === 0) { - ui.showError(` - -

${i18n.t('recent.empty_state')}

-

${i18n.t('recent.empty_hint')}

- `); - return; - } - - /** @type {Array} */ - const items = []; - - for (const item of recentItems) { - const isFolder = item.item_type === 'folder'; - if (isFolder) { - items.push( - /** @type {FolderItem} */ ({ - id: item.item_id, - name: item.item_name || item.item_id, - parent_id: item.parent_id || '', - modified_at: item.accessed_at, - path: item.item_path || '', - category: 'folder', - created_at: item.accessed_at, // Wrong information — server only stores accessed_at - icon_class: item.icon_class, - icon_special_class: item.icon_special_class, - owner_id: '', - is_root: false - }) - ); - } else { - if (item.item_mime_type === undefined || item.item_mime_type === null) { - // FIXME: this case should not be possible, is it an information badly cleaned up on server ? - console.warn('Broken information for RecentItem: ', item); - } - items.push( - /** @type {FileItem} */ ({ - id: item.item_id, - name: item.item_name || item.item_id, - folder_id: item.parent_id || '', - mime_type: item.item_mime_type, - icon_class: item.icon_class, - icon_special_class: item.icon_special_class, - category: item.category, - size: item.item_size || 0, - size_formatted: item.size_formatted, - modified_at: item.accessed_at, - path: item.item_path || '', - owner_id: '', - created_at: item.accessed_at, // Wrong information — server only stores accessed_at - sort_date: item.accessed_at - }) - ); - } - } - - if (filesList) { - if (!this._component) { - this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { - selectable: true, - showFavorite: true, - showOwner: false, - showShareBadge: false, - draggable: false, - showContextMenu: true, - itemModifierClass: 'recent-item', - dateField: 'modified_at', // mapped from accessed_at above - onOpen: (item) => ui.openItem(item), - onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), - onSelectionChange: (selectedItems) => { - batchToolbar._selected.clear(); - for (const sel of selectedItems) { - const isFile = 'mime_type' in sel; - batchToolbar._selected.set(sel.id, { - id: sel.id, - name: sel.name, - type: isFile ? 'file' : 'folder', - parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' - }); - } - batchToolbar._syncUI(); - } - }); - } - batchToolbar.setActiveComponent(this._component); - this._component.render(items); - itemTooltip.init(filesList); - } - } catch (error) { - console.error('Error displaying recent files:', error); - if (ui?.showNotification) { - ui.showNotification('Error', 'Error loading recent files'); - } - } } }; diff --git a/static/js/model/recentModel.js b/static/js/model/recentModel.js new file mode 100644 index 00000000..62e7fa94 --- /dev/null +++ b/static/js/model/recentModel.js @@ -0,0 +1,58 @@ +// @ts-check + +/** + * OxiCloud – Recent resources model. + * + * Thin fetch wrapper for `GET /api/recent/resources` (cursor-paginated). + * The old `GET /api/recent` endpoint is kept for backward compat — this + * module only handles the new endpoint. + */ + +/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../core/types.js' */ + +/** + * @typedef {Object} RecentResourceItem + * @property {ResourceTypeEnum} resource_type - 'file' | 'folder' + * @property {string} accessed_at - ISO-8601 timestamp + * @property {FileItem|FolderItem} resource - Full resource details + */ + +/** + * @typedef {Object} RecentResourcesResponse + * @property {RecentResourceItem[]} items + * @property {string|undefined} [next_cursor] + */ + +/** + * Fetch one page of the current user's recently accessed resources. + * + * @param {{ + * cursor?: string, + * orderBy?: string, + * limit?: number, + * reverse?: boolean, + * resourceTypes?: ResourceTypeEnum[], + * }} [opts] + * @returns {Promise} + */ +async function fetchRecentPage({ cursor, orderBy = 'accessed_at', limit = 50, reverse = false, resourceTypes } = {}) { + const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + if (reverse) params.set('reverse', 'true'); + if (resourceTypes?.length) params.set('resource_types', resourceTypes.join(',')); + + const res = await fetch(`/api/recent/resources?${params}`, { + credentials: 'same-origin', + cache: 'no-store' + }); + + if (!res.ok) { + const err = /** @type {any} */ (new Error(`GET /api/recent/resources failed: ${res.status}`)); + err.status = res.status; + throw err; + } + + return /** @type {Promise} */ (res.json()); +} + +export { fetchRecentPage }; diff --git a/static/js/views/recent/recentView.js b/static/js/views/recent/recentView.js new file mode 100644 index 00000000..e2190916 --- /dev/null +++ b/static/js/views/recent/recentView.js @@ -0,0 +1,433 @@ +// @ts-check + +/** + * OxiCloud – Recent view. + * + * Renders files and folders the current user has recently accessed, using the + * cursor-paginated `GET /api/recent/resources` endpoint. + * + * Default sort: `accessed_at` DESC (most recently accessed first, no swimlanes). + * The user can pick any group-by from the dropdown; viewPrefs persists the choice. + * + * Public API mirrors `favoritesView`: + * - `groupByDefs` — array of group-by dimension definitions + * - `setGroupBy(key)` — change active dimension + reload from page 1 + * - `setDirection(reversed)` — flip sort direction + reload from page 1 + * - `init()` — (re-)enter the section; restores prefs + loads page 1 + * - `hide()` — called when leaving this section + */ + +import { ui } from '../../app/ui.js'; +import { ResourceListComponent } from '../../components/resourceList.js'; +import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; +import { i18n } from '../../core/i18n.js'; +import * as viewPrefs from '../../core/viewPrefs.js'; +import { batchToolbar } from '../../features/files/batchToolbar.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; +import { favorites } from '../../features/library/favorites.js'; +import { fetchRecentPage } from '../../model/recentModel.js'; +import { systemUsers } from '../../model/systemUsers.js'; + +/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ + +/** + * @typedef {{ key: string, label: string, orderBy: string, + * keyFn: (item: FileItem|FolderItem) => string|null, + * labelFn?: (key: string) => string }} GroupByDef + */ + +/** + * @typedef {Object} RecentResourceItem + * @property {ResourceTypeEnum} resource_type + * @property {string} accessed_at + * @property {FileItem|FolderItem} resource + */ + +/** + * Group-by dimension definitions for the Recent section. + * + * When `_groupBy === ''` (None selected), items are sorted by `accessed_at` DESC — + * the natural expectation for a "Recent" section. "None" = flat chronological feed. + * + * @type {GroupByDef[]} + */ +const GROUP_BY_DEFS = [ + { + key: 'type', + get label() { + return i18n.t('groupby.type', 'Type'); + }, + orderBy: 'type', + keyFn: (item) => ('mime_type' in item ? /** @type {Record} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'), + labelFn: (key) => { + // biome-ignore format: keep indentation + /** @type {Record} */ + const labels = { + Folder: i18n.t('groupby.type.folders', 'Folders'), + Image: i18n.t('category.images', 'Images'), + Video: i18n.t('category.videos', 'Videos'), + Audio: i18n.t('category.audio', 'Audio'), + PDF: 'PDF', + Document: i18n.t('category.documents', 'Documents'), + Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'), + Presentation: i18n.t('category.presentations', 'Presentations'), + Archive: i18n.t('category.archives', 'Archives'), + Code: i18n.t('category.code', 'Code'), + Markdown: i18n.t('category.markdown', 'Markdown'), + Text: i18n.t('category.text', 'Text'), + Installer: i18n.t('category.installers', 'Installers') + }; + return labels[key] ?? key; + } + }, + { + key: 'accessedAt', + get label() { + return i18n.t('groupby.accessedAt', 'Accessed date'); + }, + orderBy: 'accessed_at', + // sort_date is unix seconds set in _mapItems(); keyFn returns the bucket label. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.sort_date ? normalizeDateBucket(r.sort_date) : null; + } + }, + { + key: 'modifiedAt', + get label() { + return i18n.t('groupby.modifiedAt', 'Modified date'); + }, + orderBy: 'modified_at', + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.modified_at ? normalizeDateBucket(r.modified_at) : null; + } + }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, + { + key: 'owner', + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'owner', + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + labelFn: (id) => systemUsers.getDisplayNameSync(id) + } +]; + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'recent-load-more-wrapper'; + +const recentView = { + // ── State ───────────────────────────────────────────────────────────────── + + /** @type {string|null} */ + _nextCursor: null, + + _loading: false, + + /** @type {ResourceListComponent|null} */ + _component: null, + + /** + * Active group-by key. '' = no grouping (sorted by accessed_at DESC). + * @type {string} + */ + _groupBy: '', + + /** Whether the current sort order is reversed. */ + _reversed: false, + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * The group-by dimension definitions for this section. + * `main.js` reads this to populate the Group-by dropdown dynamically. + * @returns {GroupByDef[]} + */ + get groupByDefs() { + return GROUP_BY_DEFS; + }, + + /** + * Change the active group-by dimension and reload from page 1. + * Calling with the current key is a no-op. + * @param {string} key + */ + setGroupBy(key) { + if (this._groupBy === key) return; + this._groupBy = key; + viewPrefs.save('recent', this._groupBy, this._reversed, viewPrefs.load('recent').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * Flip the sort direction and reload from page 1. + * Calling with the current value is a no-op. + * @param {boolean} reversed + */ + setDirection(reversed) { + if (this._reversed === reversed) return; + this._reversed = reversed; + viewPrefs.save('recent', this._groupBy, this._reversed, viewPrefs.load('recent').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * (Re-)enter the Recent section: restore saved prefs, create / reuse the + * component, and load page 1. + */ + async init() { + this._nextCursor = null; + this._loading = false; + const savedPrefs = viewPrefs.load('recent'); + this._groupBy = savedPrefs.groupBy; + this._reversed = savedPrefs.reversed; + + this._ensureLoadMoreButton(); + + // Prefetch system users so owner tooltips resolve without delay. + systemUsers.prefetch(); + + ui.resetFilesList(); + batchToolbar.init(); + ui.updateBreadcrumb(); + + const filesList = document.getElementById('files-list'); + if (filesList) { + if (!this._component) { + this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: true, + showFavorite: true, + showOwner: true, + showShareBadge: false, + draggable: false, + showContextMenu: true, + isFavorite: (id, type) => favorites.isFavorite(id, type), + isShared: () => false, + onOpen: (item) => ui.openItem(item), + onFavoriteToggle: async (item) => { + const isFile = 'mime_type' in item; + const type = isFile ? 'file' : 'folder'; + if (favorites.isFavorite(item.id, type)) { + await favorites.removeFromFavorites(item.id, type, item.name); + this._component?.setFavoriteVisualState(item.id, type, false); + } else { + await favorites.addToFavorites(item.id, item.name, type, null); + this._component?.setFavoriteVisualState(item.id, type, true); + } + }, + onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), + onSelectionChange: (selectedItems) => { + batchToolbar._selected.clear(); + for (const sel of selectedItems) { + const isFile = 'mime_type' in sel; + batchToolbar._selected.set(sel.id, { + id: sel.id, + name: sel.name, + type: isFile ? 'file' : 'folder', + parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' + }); + } + batchToolbar._syncUI(); + } + }); + } + batchToolbar.setActiveComponent(this._component); + } + + await this._loadPage(); + }, + + /** + * Hide the "Load more" button when leaving this section. + * The files container itself is managed by navigation.js. + */ + hide() { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.add('hidden'); + + batchToolbar.setActiveComponent(null); + + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.destroy(filesList); + }, + + // ── Internal helpers ────────────────────────────────────────────────────── + + /** + * Fetch one page, map items → FileItem / FolderItem, render them. + * @returns {Promise} + */ + async _loadPage() { + if (this._loading) return; + this._loading = true; + + const isFirstPage = this._nextCursor === null; + + try { + const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy); + // When no group-by is active, sort by accessed_at DESC (most recent first). + const orderBy = def?.orderBy ?? 'accessed_at'; + + const data = await fetchRecentPage({ + resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']), + limit: 50, + cursor: this._nextCursor ?? undefined, + orderBy, + reverse: this._reversed + }); + + this._nextCursor = data.next_cursor ?? null; + + if (data.items.length === 0 && isFirstPage) { + ui.showError(` + +

${i18n.t('recent.empty_state', 'No recent files')}

+

${i18n.t('recent.empty_hint', 'Files you open will appear here')}

+ `); + this._setLoadMoreVisible(false); + return; + } + + const items = this._mapItems(data.items); + + if (isFirstPage) { + this._component?.render(items, def?.keyFn, def?.labelFn); + } else { + this._component?.append(items, def?.keyFn, def?.labelFn); + } + + // Wire unified item tooltip (owner + path) after items are in the DOM. + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.init(filesList); + + await this._component?.resolveOwnerCells(); + + this._setLoadMoreVisible(!!this._nextCursor); + } catch (err) { + ui.showError(` + +

${i18n.t('errors_loadFailed', 'Failed to load items')}

+ `); + console.error('recentView: load error', err); + } finally { + this._loading = false; + } + }, + + /** + * Map `RecentResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving + * server order. Sets `sort_date` (unix seconds) to the `accessed_at` date + * so the `accessedAt` keyFn can bucket by when the item was accessed. + * + * @param {RecentResourceItem[]} items + * @returns {Array} + */ + _mapItems(items) { + /** @type {Array} */ + const result = []; + + /** @param {string} iso @returns {number} unix seconds */ + const toSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000); + + for (const item of items) { + if (item.resource_type === 'folder') { + const f = /** @type {FolderItem} */ (item.resource); + result.push( + /** @type {FolderItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + parent_id: f.parent_id ?? '', + owner_id: f.owner_id ?? '', + is_root: f.is_root ?? false, + created_at: f.created_at, + modified_at: f.modified_at, + // sort_date = accessed_at (unix seconds) for the accessedAt keyFn + sort_date: toSecs(item.accessed_at), + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: 'Folder' + }) + ); + } else if (item.resource_type === 'file') { + const f = /** @type {FileItem} */ (item.resource); + result.push( + /** @type {FileItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + folder_id: f.folder_id ?? '', + owner_id: f.owner_id ?? '', + mime_type: f.mime_type, + size: f.size, + size_formatted: f.size_formatted, + created_at: f.created_at, + modified_at: f.modified_at, + sort_date: toSecs(item.accessed_at), + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: f.category + }) + ); + } + } + + return result; + }, + + // ── "Load more" button ──────────────────────────────────────────────────── + + /** + * Create the "Load more" wrapper once and attach it below `.files-container`. + * Subsequent calls are no-ops. + */ + _ensureLoadMoreButton() { + if (document.getElementById(LOAD_MORE_ID)) return; + + const filesContainer = document.querySelector('.files-container'); + if (!filesContainer) return; + + const wrapper = document.createElement('div'); + wrapper.id = LOAD_MORE_ID; + wrapper.className = 'swm-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'recent-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('recent.loadMore', 'Load more'); + btn.addEventListener('click', () => this._loadPage()); + + wrapper.appendChild(btn); + filesContainer.after(wrapper); + }, + + /** + * @param {boolean} visible + */ + _setLoadMoreVisible(visible) { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.toggle('hidden', !visible); + } +}; + +export { recentView }; diff --git a/static/locales/en.json b/static/locales/en.json index 56421b8a..e1889828 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -430,7 +430,8 @@ "clear": "Clear recent", "accessed": "Accessed", "empty_state": "No recent files", - "empty_hint": "Files you open will appear here" + "empty_hint": "Files you open will appear here", + "loadMore": "Load more" }, "notifications": { "file_renamed": "File renamed", @@ -714,8 +715,15 @@ "groupby": { "none": "None", "title": "Group by", + "type": "Type", + "type.folders": "Folders", "owner": "Owner", - "shareDate": "Share date" + "shareDate": "Share date", + "favoriteDate": "Favorite date", + "accessedAt": "Accessed date", + "modifiedAt": "Modified date", + "createdAt": "Created date", + "size": "Size" }, "dateBucket": { "today": "Today", diff --git a/static/locales/fr.json b/static/locales/fr.json index 0777b11c..51bf3a90 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -430,7 +430,8 @@ "clear": "Effacer les récents", "accessed": "Consulté", "empty_state": "Aucun fichier récent", - "empty_hint": "Les fichiers que vous ouvrez apparaîtront ici" + "empty_hint": "Les fichiers que vous ouvrez apparaîtront ici", + "loadMore": "Charger plus" }, "notifications": { "file_renamed": "Fichier renommé", @@ -714,8 +715,15 @@ "groupby": { "none": "Aucun", "title": "Grouper par", + "type": "Type", + "type.folders": "Dossiers", "owner": "Propriétaire", - "shareDate": "Date de partage" + "shareDate": "Date de partage", + "favoriteDate": "Date d'ajout aux favoris", + "accessedAt": "Date d'accès", + "modifiedAt": "Date de modification", + "createdAt": "Date de création", + "size": "Taille" }, "dateBucket": { "today": "Aujourd'hui", From 80c9ace10826450479f2de634e94c15cc556fcf4 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 11:55:18 +0200 Subject: [PATCH 6/9] chore(i18n): add missing translations --- static/css/components/itemTooltip.css | 2 +- static/locales/ar.json | 12 ++++++++++-- static/locales/de.json | 12 ++++++++++-- static/locales/es.json | 12 ++++++++++-- static/locales/fa.json | 12 ++++++++++-- static/locales/hi.json | 12 ++++++++++-- static/locales/it.json | 12 ++++++++++-- static/locales/ja.json | 12 ++++++++++-- static/locales/ko.json | 12 ++++++++++-- static/locales/nl.json | 12 ++++++++++-- static/locales/pl.json | 12 ++++++++++-- static/locales/pt.json | 12 ++++++++++-- static/locales/ru.json | 12 ++++++++++-- static/locales/zh-TW.json | 12 ++++++++++-- static/locales/zh.json | 12 ++++++++++-- 15 files changed, 141 insertions(+), 29 deletions(-) diff --git a/static/css/components/itemTooltip.css b/static/css/components/itemTooltip.css index 0c4a0efc..eaf3b5d8 100644 --- a/static/css/components/itemTooltip.css +++ b/static/css/components/itemTooltip.css @@ -63,7 +63,7 @@ } .path-tooltip__label::after { - content: ':'; + content: ":"; } /* Value column */ diff --git a/static/locales/ar.json b/static/locales/ar.json index e6c0f39f..7a433772 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -430,7 +430,8 @@ "clear": "مسح الأخيرة", "accessed": "تم الوصول", "empty_state": "لا توجد ملفات حديثة", - "empty_hint": "الملفات التي تفتحها ستظهر هنا" + "empty_hint": "الملفات التي تفتحها ستظهر هنا", + "loadMore": "تحميل المزيد" }, "notifications": { "file_renamed": "تمت إعادة تسمية الملف", @@ -715,7 +716,14 @@ "none": "لا شيء", "title": "التجميع حسب", "owner": "المالك", - "shareDate": "تاريخ المشاركة" + "shareDate": "تاريخ المشاركة", + "type": "النوع", + "type.folders": "المجلدات", + "accessedAt": "تاريخ الوصول", + "modifiedAt": "تاريخ التعديل", + "createdAt": "تاريخ الإنشاء", + "size": "الحجم", + "favoriteDate": "تاريخ المفضلة" }, "dateBucket": { "today": "اليوم", diff --git a/static/locales/de.json b/static/locales/de.json index 5351fec6..afee44b1 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -430,7 +430,8 @@ "clear": "Zuletzt verwendete löschen", "accessed": "Zugegriffen", "empty_state": "Keine zuletzt verwendeten Dateien", - "empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt" + "empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt", + "loadMore": "Mehr laden" }, "notifications": { "file_renamed": "Datei umbenannt", @@ -715,7 +716,14 @@ "none": "Keine", "title": "Gruppieren nach", "owner": "Eigentümer", - "shareDate": "Freigabedatum" + "shareDate": "Freigabedatum", + "type": "Typ", + "type.folders": "Ordner", + "accessedAt": "Zugriffsdatum", + "modifiedAt": "Änderungsdatum", + "createdAt": "Erstellungsdatum", + "size": "Größe", + "favoriteDate": "Datum der Markierung" }, "dateBucket": { "today": "Heute", diff --git a/static/locales/es.json b/static/locales/es.json index c9d878ec..9a29e8ae 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -430,7 +430,8 @@ "clear": "Limpiar recientes", "accessed": "Accedido", "empty_state": "No hay archivos recientes", - "empty_hint": "Los archivos que abras aparecerán aquí" + "empty_hint": "Los archivos que abras aparecerán aquí", + "loadMore": "Cargar más" }, "notifications": { "file_renamed": "Archivo renombrado", @@ -715,7 +716,14 @@ "none": "Ninguno", "title": "Agrupar por", "owner": "Propietario", - "shareDate": "Fecha de compartición" + "shareDate": "Fecha de compartición", + "type": "Tipo", + "type.folders": "Carpetas", + "accessedAt": "Fecha de acceso", + "modifiedAt": "Fecha de modificación", + "createdAt": "Fecha de creación", + "size": "Tamaño", + "favoriteDate": "Fecha de favorito" }, "dateBucket": { "today": "Hoy", diff --git a/static/locales/fa.json b/static/locales/fa.json index 836106b5..83c636ef 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -430,7 +430,8 @@ "clear": "پاک کردن اخیر", "accessed": "دسترسی یافته", "empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد", - "empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند" + "empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند", + "loadMore": "بارگذاری بیشتر" }, "batch": { "one_selected": "۱ مورد انتخاب شده", @@ -715,7 +716,14 @@ "none": "هیچ", "title": "گروه‌بندی بر اساس", "owner": "مالک", - "shareDate": "تاریخ اشتراک" + "shareDate": "تاریخ اشتراک", + "type": "نوع", + "type.folders": "پوشه‌ها", + "accessedAt": "تاریخ دسترسی", + "modifiedAt": "تاریخ تغییر", + "createdAt": "تاریخ ایجاد", + "size": "اندازه", + "favoriteDate": "تاریخ مورد علاقه" }, "dateBucket": { "today": "امروز", diff --git a/static/locales/hi.json b/static/locales/hi.json index b7adb1f0..fb827113 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -430,7 +430,8 @@ "clear": "हाल ही का साफ़ करें", "accessed": "एक्सेस किया", "empty_state": "कोई हाल की फ़ाइलें नहीं", - "empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी" + "empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी", + "loadMore": "और लोड करें" }, "notifications": { "file_renamed": "फ़ाइल का नाम बदला गया", @@ -715,7 +716,14 @@ "none": "कोई नहीं", "title": "इसके अनुसार समूहीकृत करें", "owner": "स्वामी", - "shareDate": "साझा तिथि" + "shareDate": "साझा तिथि", + "type": "प्रकार", + "type.folders": "फ़ोल्डर", + "accessedAt": "पहुँच की तारीख", + "modifiedAt": "संशोधन की तारीख", + "createdAt": "बनाने की तारीख", + "size": "आकार", + "favoriteDate": "पसंदीदा की तारीख" }, "dateBucket": { "today": "आज", diff --git a/static/locales/it.json b/static/locales/it.json index f3334e33..bf8eb86f 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -430,7 +430,8 @@ "clear": "Cancella recenti", "accessed": "Accesso", "empty_state": "Nessun file recente", - "empty_hint": "I file che apri appariranno qui" + "empty_hint": "I file che apri appariranno qui", + "loadMore": "Carica altri" }, "notifications": { "file_renamed": "File rinominato", @@ -715,7 +716,14 @@ "none": "Nessuno", "title": "Raggruppa per", "owner": "Proprietario", - "shareDate": "Data condivisione" + "shareDate": "Data condivisione", + "type": "Tipo", + "type.folders": "Cartelle", + "accessedAt": "Data di accesso", + "modifiedAt": "Data di modifica", + "createdAt": "Data di creazione", + "size": "Dimensione", + "favoriteDate": "Data preferito" }, "dateBucket": { "today": "Oggi", diff --git a/static/locales/ja.json b/static/locales/ja.json index d9105cc8..720eb836 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -430,7 +430,8 @@ "clear": "最近をクリア", "accessed": "アクセス日", "empty_state": "最近のファイルはありません", - "empty_hint": "開いたファイルがここに表示されます" + "empty_hint": "開いたファイルがここに表示されます", + "loadMore": "さらに読み込む" }, "notifications": { "file_renamed": "ファイル名を変更しました", @@ -715,7 +716,14 @@ "none": "なし", "title": "グループ化", "owner": "オーナー", - "shareDate": "共有日" + "shareDate": "共有日", + "type": "種類", + "type.folders": "フォルダー", + "accessedAt": "アクセス日", + "modifiedAt": "更新日", + "createdAt": "作成日", + "size": "サイズ", + "favoriteDate": "お気に入り登録日" }, "dateBucket": { "today": "今日", diff --git a/static/locales/ko.json b/static/locales/ko.json index 13c13dd3..a4428fbd 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -430,7 +430,8 @@ "clear": "최근 항목 지우기", "accessed": "접근일", "empty_state": "최근 파일이 없습니다", - "empty_hint": "열어본 파일이 여기에 표시됩니다" + "empty_hint": "열어본 파일이 여기에 표시됩니다", + "loadMore": "더 불러오기" }, "notifications": { "file_renamed": "파일 이름이 변경되었습니다", @@ -715,7 +716,14 @@ "none": "없음", "title": "그룹화 기준", "owner": "소유자", - "shareDate": "공유 날짜" + "shareDate": "공유 날짜", + "type": "유형", + "type.folders": "폴더", + "accessedAt": "접근 날짜", + "modifiedAt": "수정 날짜", + "createdAt": "생성 날짜", + "size": "크기", + "favoriteDate": "즐겨찾기 날짜" }, "dateBucket": { "today": "오늘", diff --git a/static/locales/nl.json b/static/locales/nl.json index 8af98820..4b4f3c72 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -430,7 +430,8 @@ "clear": "Recente wissen", "accessed": "Geopend", "empty_state": "Geen recente bestanden", - "empty_hint": "Bestanden die je opent verschijnen hier" + "empty_hint": "Bestanden die je opent verschijnen hier", + "loadMore": "Meer laden" }, "notifications": { "file_renamed": "Bestand hernoemd", @@ -715,7 +716,14 @@ "none": "Geen", "title": "Groeperen op", "owner": "Eigenaar", - "shareDate": "Deeldatum" + "shareDate": "Deeldatum", + "type": "Type", + "type.folders": "Mappen", + "accessedAt": "Toegangsdatum", + "modifiedAt": "Wijzigingsdatum", + "createdAt": "Aanmaakdatum", + "size": "Grootte", + "favoriteDate": "Favoritendatum" }, "dateBucket": { "today": "Vandaag", diff --git a/static/locales/pl.json b/static/locales/pl.json index 6228f7b3..724125f4 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -430,7 +430,8 @@ "clear": "Wyczyść ostatnie", "accessed": "Otwarte", "empty_state": "Brak ostatnich plików", - "empty_hint": "Otwarte pliki pojawią się tutaj" + "empty_hint": "Otwarte pliki pojawią się tutaj", + "loadMore": "Załaduj więcej" }, "notifications": { "file_renamed": "Zmieniono nazwę pliku", @@ -715,7 +716,14 @@ "none": "Brak", "title": "Grupuj według", "owner": "Właściciel", - "shareDate": "Data udostępnienia" + "shareDate": "Data udostępnienia", + "type": "Typ", + "type.folders": "Foldery", + "accessedAt": "Data dostępu", + "modifiedAt": "Data modyfikacji", + "createdAt": "Data utworzenia", + "size": "Rozmiar", + "favoriteDate": "Data dodania do ulubionych" }, "dateBucket": { "today": "Dzisiaj", diff --git a/static/locales/pt.json b/static/locales/pt.json index 5368127b..63cada28 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -430,7 +430,8 @@ "clear": "Limpar recentes", "accessed": "Acessado", "empty_state": "Nenhum arquivo recente", - "empty_hint": "Os arquivos que você abrir aparecerão aqui" + "empty_hint": "Os arquivos que você abrir aparecerão aqui", + "loadMore": "Carregar mais" }, "notifications": { "file_renamed": "Arquivo renomeado", @@ -715,7 +716,14 @@ "none": "Nenhum", "title": "Agrupar por", "owner": "Proprietário", - "shareDate": "Data de partilha" + "shareDate": "Data de partilha", + "type": "Tipo", + "type.folders": "Pastas", + "accessedAt": "Data de acesso", + "modifiedAt": "Data de modificação", + "createdAt": "Data de criação", + "size": "Tamanho", + "favoriteDate": "Data de favorito" }, "dateBucket": { "today": "Hoje", diff --git a/static/locales/ru.json b/static/locales/ru.json index 42bb904f..1232cdf4 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -430,7 +430,8 @@ "clear": "Очистить недавние", "accessed": "Открыт", "empty_state": "Нет недавних файлов", - "empty_hint": "Открытые вами файлы будут отображаться здесь" + "empty_hint": "Открытые вами файлы будут отображаться здесь", + "loadMore": "Загрузить ещё" }, "notifications": { "file_renamed": "Файл переименован", @@ -715,7 +716,14 @@ "none": "Нет", "title": "Группировать по", "owner": "Владелец", - "shareDate": "Дата общего доступа" + "shareDate": "Дата общего доступа", + "type": "Тип", + "type.folders": "Папки", + "accessedAt": "Дата доступа", + "modifiedAt": "Дата изменения", + "createdAt": "Дата создания", + "size": "Размер", + "favoriteDate": "Дата добавления в избранное" }, "dateBucket": { "today": "Сегодня", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 4fbd9743..7e053115 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -430,7 +430,8 @@ "clear": "清除最近", "accessed": "訪問於", "empty_state": "沒有最近檔案", - "empty_hint": "您開啟的檔案將顯示在這裡" + "empty_hint": "您開啟的檔案將顯示在這裡", + "loadMore": "載入更多" }, "batch": { "one_selected": "已選擇 1 個專案", @@ -715,7 +716,14 @@ "none": "無", "title": "分組方式", "owner": "擁有者", - "shareDate": "分享日期" + "shareDate": "分享日期", + "type": "類型", + "type.folders": "資料夾", + "accessedAt": "存取日期", + "modifiedAt": "修改日期", + "createdAt": "建立日期", + "size": "大小", + "favoriteDate": "收藏日期" }, "dateBucket": { "today": "今天", diff --git a/static/locales/zh.json b/static/locales/zh.json index 22d9b8d1..81a0ab32 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -430,7 +430,8 @@ "clear": "清除最近", "accessed": "访问于", "empty_state": "没有最近文件", - "empty_hint": "您打开的文件将显示在这里" + "empty_hint": "您打开的文件将显示在这里", + "loadMore": "加载更多" }, "batch": { "one_selected": "已选择 1 个项目", @@ -715,7 +716,14 @@ "none": "无", "title": "分组方式", "owner": "所有者", - "shareDate": "分享日期" + "shareDate": "分享日期", + "type": "类型", + "type.folders": "文件夹", + "accessedAt": "访问日期", + "modifiedAt": "修改日期", + "createdAt": "创建日期", + "size": "大小", + "favoriteDate": "收藏日期" }, "dateBucket": { "today": "今天", From 29d8aab9c09a227e1b2d47ccdba2c190fd9b62de Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 12:07:56 +0200 Subject: [PATCH 7/9] feat(resourceList): keep consistence in groupby options order accross sectons + add userVignette on owner's swimlane --- static/css/components/resourceList.css | 14 +++++ static/js/app/filesView.js | 26 ++++---- static/js/components/resourceList.js | 46 +++++++++++--- static/js/views/favorites/favoritesView.js | 61 ++++++++++--------- static/js/views/recent/recentView.js | 57 +++++++++-------- .../js/views/sharedWithMe/sharedWithMeView.js | 43 +++++++------ 6 files changed, 148 insertions(+), 99 deletions(-) diff --git a/static/css/components/resourceList.css b/static/css/components/resourceList.css index a26e0d90..d80ffd41 100644 --- a/static/css/components/resourceList.css +++ b/static/css/components/resourceList.css @@ -574,6 +574,20 @@ margin-top: 0; } +/* When the header contains a rich DOM node (e.g. a user vignette for the + "owner" group-by), reset the typographic overrides that only make sense + for plain-text labels, and lay the node out inline. */ +.resource-list__swimlane-header--node { + display: flex; + align-items: center; + padding: 4px 12px; + text-transform: none; + letter-spacing: normal; + font-size: inherit; + font-weight: normal; + color: inherit; +} + /* ── Swimlane group card (list view only) ────────────────── */ /* When swimlane groups are present, dissolve the outer container into the diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index f2af0004..086e4964 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -76,6 +76,19 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // sizeBucket(-1) → "Folders" sentinel; no labelFn needed. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, { key: 'modifiedAt', get label() { @@ -98,19 +111,6 @@ const GROUP_BY_DEFS = [ const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.created_at ? normalizeDateBucket(r.created_at) : null; } - }, - { - key: 'size', - get label() { - return i18n.t('groupby.size', 'Size'); - }, - orderBy: 'size', - // sizeBucket(-1) → "Folders" sentinel; no labelFn needed. - keyFn: (item) => { - if (!('mime_type' in item)) return sizeBucket(-1); - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return sizeBucket(r.size ?? 0); - } } ]; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index d1393997..67f64d8e 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -115,6 +115,14 @@ export class ResourceListComponent { */ this._groupLabelFn = undefined; + /** + * Optional node-builder stored between `render()` and `append()` calls. + * When set, the swimlane header renders a DOM node instead of plain text + * (e.g. a user vignette for the "owner" group-by dimension). + * @type {((key: string) => HTMLElement) | undefined} + */ + this._headerNodeFn = undefined; + this._ownerVisible = this._cfg.showOwner; this._initDelegation(); @@ -138,8 +146,12 @@ export class ResourceListComponent { * @param {((key: string) => string)=} groupLabelFn * Optional: converts the raw grouping key to a human-readable header * label. When omitted the key itself is used. + * @param {((key: string) => HTMLElement)=} headerNodeFn + * Optional: builds a rich DOM node for the swimlane header (e.g. a user + * vignette for the "owner" group-by). When provided, `groupLabelFn` is + * ignored for the header and the returned node is appended instead. */ - render(items, groupFn, groupLabelFn) { + render(items, groupFn, groupLabelFn, headerNodeFn) { const header = this._container.querySelector('.list-header'); this._container.innerHTML = ''; if (header) this._container.appendChild(header); @@ -151,11 +163,12 @@ export class ResourceListComponent { this._lastGroupKey = undefined; this._lastGroupEl = null; this._groupLabelFn = groupLabelFn; + this._headerNodeFn = headerNodeFn; // Prevent ui.js global delegation from firing on this container this._container.dataset.managedBy = 'resource-list'; - this._appendItems(items, groupFn, groupLabelFn); + this._appendItems(items, groupFn, groupLabelFn, headerNodeFn); this._wireSelectAll(); } @@ -167,9 +180,10 @@ export class ResourceListComponent { * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn * @param {((key: string) => string)=} groupLabelFn + * @param {((key: string) => HTMLElement)=} headerNodeFn */ - append(items, groupFn, groupLabelFn) { - this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn); + append(items, groupFn, groupLabelFn, headerNodeFn) { + this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn); } /** Remove all items (but keep `.list-header` if present). */ @@ -328,8 +342,9 @@ export class ResourceListComponent { * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn * @param {((key: string) => string)=} groupLabelFn + * @param {((key: string) => HTMLElement)=} headerNodeFn */ - _appendItems(items, groupFn, groupLabelFn) { + _appendItems(items, groupFn, groupLabelFn, headerNodeFn) { const fragment = document.createDocumentFragment(); // Start from the persisted key so load-more pages continue seamlessly. @@ -354,7 +369,7 @@ export class ResourceListComponent { if (key !== null) { fragmentGroup = document.createElement('div'); fragmentGroup.className = 'resource-list__swimlane-group'; - fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn)); + fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn)); fragment.appendChild(fragmentGroup); } } @@ -382,14 +397,25 @@ export class ResourceListComponent { /** * Create a swimlane divider element. - * @param {string} key - Raw grouping key (e.g. UUID or bucket name). - * @param {((key: string) => string)=} labelFn - Optional human-readable resolver. + * + * When `headerNodeFn` is supplied the header renders a rich DOM node + * (e.g. a user vignette) instead of plain text; the `--node` CSS modifier + * is added to suppress the small-caps / uppercase text styles. + * + * @param {string} key - Raw grouping key (e.g. UUID or bucket name). + * @param {((key: string) => string)=} labelFn - Optional plain-text resolver. + * @param {((key: string) => HTMLElement)=} headerNodeFn - Optional rich-node builder. */ - _createGroupHeader(key, labelFn) { + _createGroupHeader(key, labelFn, headerNodeFn) { const el = document.createElement('div'); el.className = 'resource-list__swimlane-header'; el.dataset.swimlaneHeader = 'true'; - el.textContent = labelFn ? labelFn(key) : key; + if (headerNodeFn) { + el.classList.add('resource-list__swimlane-header--node'); + el.appendChild(headerNodeFn(key)); + } else { + el.textContent = labelFn ? labelFn(key) : key; + } return el; } diff --git a/static/js/views/favorites/favoritesView.js b/static/js/views/favorites/favoritesView.js index 86fffd07..c721e92a 100644 --- a/static/js/views/favorites/favoritesView.js +++ b/static/js/views/favorites/favoritesView.js @@ -18,6 +18,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { createUserVignette } from '../../components/userVignette.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import * as viewPrefs from '../../core/viewPrefs.js'; @@ -32,7 +33,8 @@ import { systemUsers } from '../../model/systemUsers.js'; /** * @typedef {{ key: string, label: string, orderBy: string, * keyFn: (item: FileItem|FolderItem) => string|null, - * labelFn?: (key: string) => string }} GroupByDef + * labelFn?: (key: string) => string, + * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef */ /** @@ -42,6 +44,20 @@ import { systemUsers } from '../../model/systemUsers.js'; * @type {GroupByDef[]} */ const GROUP_BY_DEFS = [ + { + key: 'owner', + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'owner', + // keyFn groups by UUID — stable, avoids collisions on identical display names. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + labelFn: (id) => systemUsers.getDisplayNameSync(id), + headerNodeFn: (id) => createUserVignette(id, 'sm') + }, { key: 'type', get label() { @@ -71,6 +87,19 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // Folders have no size — sizeBucket(-1) returns the "Folders" label. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, { key: 'favoriteDate', get label() { @@ -94,32 +123,6 @@ const GROUP_BY_DEFS = [ const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.modified_at ? normalizeDateBucket(r.modified_at) : null; } - }, - { - key: 'size', - get label() { - return i18n.t('groupby.size', 'Size'); - }, - orderBy: 'size', - // Folders have no size — sizeBucket(-1) returns the "Folders" label. - keyFn: (item) => { - if (!('mime_type' in item)) return sizeBucket(-1); - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return sizeBucket(r.size ?? 0); - } - }, - { - key: 'owner', - get label() { - return i18n.t('groupby.owner', 'Owner'); - }, - orderBy: 'owner', - // keyFn groups by UUID — stable, avoids collisions on identical display names. - keyFn: (item) => { - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return r.owner_id || null; - }, - labelFn: (id) => systemUsers.getDisplayNameSync(id) } ]; @@ -304,9 +307,9 @@ const favoritesView = { const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(items, def?.keyFn, def?.labelFn); + this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } else { - this._component?.append(items, def?.keyFn, def?.labelFn); + this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } // Wire unified item tooltip (owner + path) after items are in the DOM diff --git a/static/js/views/recent/recentView.js b/static/js/views/recent/recentView.js index e2190916..a758bb3a 100644 --- a/static/js/views/recent/recentView.js +++ b/static/js/views/recent/recentView.js @@ -19,6 +19,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { createUserVignette } from '../../components/userVignette.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import * as viewPrefs from '../../core/viewPrefs.js'; @@ -33,7 +34,8 @@ import { systemUsers } from '../../model/systemUsers.js'; /** * @typedef {{ key: string, label: string, orderBy: string, * keyFn: (item: FileItem|FolderItem) => string|null, - * labelFn?: (key: string) => string }} GroupByDef + * labelFn?: (key: string) => string, + * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef */ /** @@ -52,6 +54,19 @@ import { systemUsers } from '../../model/systemUsers.js'; * @type {GroupByDef[]} */ const GROUP_BY_DEFS = [ + { + key: 'owner', + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'owner', + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + labelFn: (id) => systemUsers.getDisplayNameSync(id), + headerNodeFn: (id) => createUserVignette(id, 'sm') + }, { key: 'type', get label() { @@ -80,6 +95,18 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, { key: 'accessedAt', get label() { @@ -102,30 +129,6 @@ const GROUP_BY_DEFS = [ const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.modified_at ? normalizeDateBucket(r.modified_at) : null; } - }, - { - key: 'size', - get label() { - return i18n.t('groupby.size', 'Size'); - }, - orderBy: 'size', - keyFn: (item) => { - if (!('mime_type' in item)) return sizeBucket(-1); - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return sizeBucket(r.size ?? 0); - } - }, - { - key: 'owner', - get label() { - return i18n.t('groupby.owner', 'Owner'); - }, - orderBy: 'owner', - keyFn: (item) => { - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return r.owner_id || null; - }, - labelFn: (id) => systemUsers.getDisplayNameSync(id) } ]; @@ -311,9 +314,9 @@ const recentView = { const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(items, def?.keyFn, def?.labelFn); + this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } else { - this._component?.append(items, def?.keyFn, def?.labelFn); + this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } // Wire unified item tooltip (owner + path) after items are in the DOM. diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index ba4cb839..d8323b87 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -12,6 +12,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { createUserVignette } from '../../components/userVignette.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import * as viewPrefs from '../../core/viewPrefs.js'; @@ -26,7 +27,8 @@ import { systemUsers } from '../../model/systemUsers.js'; /** * @typedef {{ key: string, label: string, orderBy: string, * keyFn: (item: FileItem|FolderItem) => string|null, - * labelFn?: (key: string) => string }} GroupByDef + * labelFn?: (key: string) => string, + * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef */ /** @@ -42,6 +44,24 @@ import { systemUsers } from '../../model/systemUsers.js'; * @type {GroupByDef[]} */ const GROUP_BY_DEFS = [ + { + key: 'owner', + // label is accessed via syncGroupByMenu → read at section-switch time, + // when translations are guaranteed to be loaded. + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'granted_by', + // keyFn groups by UUID — stable and unique, avoids collisions between + // users with the same display name. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + // labelFn resolves UUID → display name from the pre-fetched cache. + labelFn: (id) => systemUsers.getDisplayNameSync(id), + headerNodeFn: (id) => createUserVignette(id, 'sm') + }, { key: 'type', get label() { @@ -74,23 +94,6 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, - { - key: 'owner', - // label is accessed via syncGroupByMenu → read at section-switch time, - // when translations are guaranteed to be loaded. - get label() { - return i18n.t('groupby.owner', 'Owner'); - }, - orderBy: 'granted_by', - // keyFn groups by UUID — stable and unique, avoids collisions between - // users with the same display name. - keyFn: (item) => { - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return r.owner_id || null; - }, - // labelFn resolves UUID → display name from the pre-fetched cache. - labelFn: (id) => systemUsers.getDisplayNameSync(id) - }, { key: 'size', get label() { @@ -316,9 +319,9 @@ const sharedWithMeView = { const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(items, def?.keyFn, def?.labelFn); + this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } else { - this._component?.append(items, def?.keyFn, def?.labelFn); + this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } // Wire owner tooltips after items are in the DOM From 636f0b87bd147b1c7c74e06353a003eab53936ab Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 13:23:17 +0200 Subject: [PATCH 8/9] fix(builder): add OXC semantic to deconflic JS modules using the same global variables/functions - this solve issues like: Uncaught SyntaxError: Identifier 'GROUP_BY_DEFS' has already been declared (at app.XXXX.js) - add a protection on build: test to reload bundled app, on failure exit immediately (prevent the build or a broken bundle) - add final check during CI: node --check on the bundle to brevent going to production too if any failure example of a bundle: ``` warning: oxicloud@0.6.0: bundle: 50 files in dependency order: warning: oxicloud@0.6.0: bundle: deconflicting 2 name(s): GROUP_BY_DEFS, LOAD_MORE_ID warning: oxicloud@0.6.0: bundle: [29] GROUP_BY_DEFS -> GROUP_BY_DEFS_29 (favoritesView.js) warning: oxicloud@0.6.0: bundle: [29] LOAD_MORE_ID -> LOAD_MORE_ID_29 (favoritesView.js) warning: oxicloud@0.6.0: bundle: [31] GROUP_BY_DEFS -> GROUP_BY_DEFS_31 (recentView.js) warning: oxicloud@0.6.0: bundle: [31] LOAD_MORE_ID -> LOAD_MORE_ID_31 (recentView.js) warning: oxicloud@0.6.0: bundle: [34] GROUP_BY_DEFS -> GROUP_BY_DEFS_34 (sharedWithMeView.js) warning: oxicloud@0.6.0: bundle: [34] LOAD_MORE_ID -> LOAD_MORE_ID_34 (sharedWithMeView.js) warning: oxicloud@0.6.0: bundle: [44] GROUP_BY_DEFS -> GROUP_BY_DEFS_44 (filesView.js) warning: oxicloud@0.6.0: bundle: [44] LOAD_MORE_ID -> LOAD_MORE_ID_44 (filesView.js) ``` --- .github/workflows/ci.yml | 7 ++ Cargo.lock | 1 + Cargo.toml | 1 + build.rs | 228 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 226 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68d17aea..9aaa3b23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,13 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo build --release + # build.rs runs the deconflict pass and js_bundle_validate() — any + # duplicate declaration or parse error in the JS bundle fails here. + - name: Validate JS bundle (node --check) + # belt-and-suspenders: node --check parses the bundle without executing it. + # Catches SyntaxErrors that OXC's parse check inside build.rs would also + # catch, but gives a human-readable error line in the CI log. + run: node --check static-dist/js/app.*.js - uses: actions/upload-artifact@v4 with: name: oxicloud-release diff --git a/Cargo.lock b/Cargo.lock index 61afa35b..d89b24f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3692,6 +3692,7 @@ dependencies = [ "oxc_codegen", "oxc_minifier", "oxc_parser", + "oxc_semantic", "oxc_span", "percent-encoding", "quick-xml 0.39.2", diff --git a/Cargo.toml b/Cargo.toml index 1dff6f8f..67d1108b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,7 @@ path = "src/bin/generate-openapi.rs" [build-dependencies] oxc_allocator = "0.125.0" oxc_parser = "0.125.0" +oxc_semantic = "0.125.0" oxc_span = "0.125.0" oxc_codegen = "0.125.0" oxc_minifier = "0.125.0" diff --git a/build.rs b/build.rs index 1635803b..e0a2b905 100644 --- a/build.rs +++ b/build.rs @@ -119,6 +119,9 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) { let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html"); let module_scripts = extract_module_scripts(&index_html); let js_raw = build_js_module_bundle(&module_scripts, static_dir); + // Validate the raw bundle with OXC before minifying — catches re-declaration + // collisions and other syntax errors that would silently survive minification. + js_bundle_validate(&js_raw); let js_bundle = js_minify_script_safe(&js_raw); let js_hash = fnv_hash(js_bundle.as_bytes()); let js_name = format!("app.{js_hash}.js"); @@ -273,8 +276,12 @@ fn extract_module_scripts(html: &str) -> Vec { /// 1. DFS from each entry point, following `import … from '…'` edges. /// 2. Post-order traversal ensures every dependency is emitted before its importer. /// 3. Cycles are broken by marking files as visited before recursing. -/// 4. Each file has its import/export syntax stripped before being appended. -/// 5. The result is wrapped in `(function(){"use strict"; …})();`. +/// 4. **Deconflict pass**: any top-level binding that is private (not exported) +/// and shared across two or more modules is renamed `NAME_` in every +/// module that declares it. This prevents `SyntaxError: already declared` +/// when all modules land in the same IIFE scope. +/// 5. Each file has its import/export syntax stripped before being appended. +/// 6. The result is wrapped in `(function(){"use strict"; …})();`. fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String { use std::collections::HashSet; @@ -290,28 +297,206 @@ fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String "cargo:warning=bundle: {} files in dependency order:", order.len() ); + + // Read all sources upfront — the deconflict pass needs the full set. + let mut sources: Vec = order + .iter() + .map(|f| match fs::read_to_string(f) { + Ok(s) => s, + Err(e) => { + eprintln!("cargo:warning=bundle: cannot read {}: {e}", f.display()); + String::new() + } + }) + .collect(); + + // Deconflict: rename private top-level bindings that collide across modules. + deconflict_module_sources(&order, &mut sources); + + // Concatenate into a single IIFE. let mut bundle = String::with_capacity(2 * 1024 * 1024); bundle.push_str("(function(){\n\"use strict\";\n"); - let mut declared_namespaces = std::collections::HashSet::new(); - for (i, file) in order.iter().enumerate() { + let mut declared_namespaces = HashSet::new(); + for (i, (file, src)) in order.iter().zip(sources.iter()).enumerate() { println!( "cargo:warning=bundle [{:>3}/{}] {}", i + 1, order.len(), file.display() ); - match fs::read_to_string(file) { - Ok(src) => { - bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces)); - bundle.push('\n'); - } - Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()), - } + bundle.push_str(&strip_esm_syntax(src, file, &mut declared_namespaces)); + bundle.push('\n'); } bundle.push_str("})();\n"); bundle } +// ───────────────────────────────────────────────────────────────────────────── +// Deconflict pass +// ───────────────────────────────────────────────────────────────────────────── + +/// Rename private top-level bindings that appear in more than one module so +/// they don't collide when all modules are concatenated into one IIFE scope. +/// +/// Only **private** (non-exported) names are renamed. Exported names are the +/// public API — other modules reference them directly by name after +/// import-stripping and must not be touched. +/// +/// The renamed form is `NAME_` where the index is the position +/// of the module in the bundle order — guaranteed unique within the bundle. +fn deconflict_module_sources(order: &[PathBuf], sources: &mut [String]) { + use std::collections::{HashMap, HashSet}; + + // Per-module: private (non-exported) top-level binding names. + let private_bindings: Vec> = sources + .iter() + .map(|src| { + let all = top_level_bindings(src); + let exported: HashSet = extract_exported_names(src).into_iter().collect(); + all.into_iter().filter(|n| !exported.contains(n)).collect() + }) + .collect(); + + // Count how many modules declare each private name. + let mut name_count: HashMap = HashMap::new(); + for bindings in &private_bindings { + for name in bindings { + *name_count.entry(name.clone()).or_insert(0) += 1; + } + } + + // Collision set: names declared privately in more than one module. + let collisions: HashSet = name_count + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(name, _)| name) + .collect(); + + if collisions.is_empty() { + return; + } + + let mut sorted: Vec<&str> = collisions.iter().map(String::as_str).collect(); + sorted.sort_unstable(); + println!( + "cargo:warning=bundle: deconflicting {} name(s): {}", + sorted.len(), + sorted.join(", ") + ); + + // Rename each colliding binding within every module that declares it. + for (idx, src) in sources.iter_mut().enumerate() { + for name in &private_bindings[idx] { + if collisions.contains(name) { + let new_name = format!("{name}_{idx}"); + *src = rename_binding(src, name, &new_name); + println!( + "cargo:warning=bundle: [{idx}] {name} -> {new_name} ({})", + order[idx].file_name().unwrap_or_default().to_string_lossy() + ); + } + } + } +} + +/// Return the names of all top-level bindings in an ES-module source file that +/// are **locally declared AND not exported**, using OXC for accurate analysis. +/// +/// Two categories are excluded so the deconflict pass never touches them: +/// +/// 1. **Import bindings** (`import { ui } from '…'`): after import-stripping, +/// these names resolve directly to the exporting module's declaration already +/// present in the IIFE scope — renaming them would break those references. +/// +/// 2. **Exported bindings**: other modules import these by name after stripping, +/// so they must keep their original names. `ParseReturn::module_record` is +/// used instead of the text-based `extract_exported_names` helper because +/// multi-line `export { … }` blocks would otherwise be missed. +fn top_level_bindings(source: &str) -> Vec { + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_semantic::SemanticBuilder; + use oxc_span::SourceType; + + let allocator = Allocator::default(); + let ret = Parser::new(&allocator, source, SourceType::mjs()).parse(); + if !ret.errors.is_empty() { + return Vec::new(); // parse failed — skip deconflict for this file + } + + // Collect exported local names from the module record (handles single-line + // and multi-line export blocks, re-exports, export-declarations, etc.). + let exported: std::collections::HashSet<&str> = ret + .module_record + .local_export_entries + .iter() + .filter_map(|e| e.local_name.name()) + .map(|s| s.as_str()) + .collect(); + + let semantic = SemanticBuilder::new().build(&ret.program).semantic; + let scoping = semantic.scoping(); + let root = scoping.root_scope_id(); + scoping + .get_bindings(root) + .iter() + .filter_map(|(ident, &symbol_id)| { + let name = ident.as_str(); + // Skip import bindings and exported bindings. + let flags = scoping.symbol_flags(symbol_id); + if flags.is_import() || exported.contains(name) { + None + } else { + Some(name.to_string()) + } + }) + .collect() +} + +/// Replace every whole-word occurrence of `old` with `new` in `source`. +/// +/// "Whole word" means the characters immediately before and after the match +/// are not JavaScript identifier characters (`[a-zA-Z0-9_$]`). This prevents +/// `LOAD_MORE_ID` from being accidentally renamed when the source contains +/// `LOAD_MORE_ID_EXTRA`. +fn rename_binding(source: &str, old: &str, new: &str) -> String { + let old_len = old.len(); + let mut out = String::with_capacity(source.len()); + let mut start = 0; + + while let Some(rel) = source[start..].find(old) { + let pos = start + rel; + let after = pos + old_len; + + let boundary_before = pos == 0 + || source[..pos] + .chars() + .next_back() + .is_none_or(|c| !is_js_ident_char(c)); + + let boundary_after = after >= source.len() + || source[after..] + .chars() + .next() + .is_none_or(|c| !is_js_ident_char(c)); + + out.push_str(&source[start..pos]); + if boundary_before && boundary_after { + out.push_str(new); + } else { + out.push_str(old); + } + start = after; + } + out.push_str(&source[start..]); + out +} + +#[inline] +fn is_js_ident_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' +} + /// DFS post-order: push `file` to `order` after all its imports. /// Marks files as seen before recursing to break circular dependencies. fn collect_module_deps( @@ -666,6 +851,27 @@ fn collect_import_aliases(stmt: &str, declared: &mut std::collections::HashSet String { js_minify_inner(source, true) From 99da12438868b9aeb882d6c5e8df9800cdc17e44 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 14:17:20 +0200 Subject: [PATCH 9/9] fix(ui/thumbnail): fix thumbnail generation in release - reference to /vendors/ must be absolute path (the bundle isn't the same path as the current library doing the lazy loading) --- static/js/components/resourceList.js | 1 + static/js/features/thumbnail.js | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 67f64d8e..a160ac3c 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -517,6 +517,7 @@ export class ResourceListComponent { const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb')); if (thumb) { thumb.addEventListener('error', () => { + console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`); thumb.classList.add('hidden'); thumbnail?.queueGenerate(file, (dataUrl) => { thumb.src = dataUrl; diff --git a/static/js/features/thumbnail.js b/static/js/features/thumbnail.js index 0d8c3a0d..17f23922 100644 --- a/static/js/features/thumbnail.js +++ b/static/js/features/thumbnail.js @@ -17,8 +17,11 @@ let _pdfjsLib = null; */ async function getPdfjsLib() { if (_pdfjsLib) return _pdfjsLib; - // IMPORTANT: this hack (const lib=...) so tsc will not load vendors library - const lib = '../vendors/pdf.min.mjs'; + // IMPORTANT: use an absolute path so the import resolves correctly both in + // dev mode (native ESM, module at /js/features/thumbnail.js) and in release + // mode (IIFE bundle at /js/app.{hash}.js — relative '../vendors/…' would + // incorrectly resolve to /vendors/… instead of /js/vendors/…). + const lib = '/js/vendors/pdf.min.mjs'; _pdfjsLib = /** @type {any} */ (await import(lib)); _pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs'; return _pdfjsLib;