From 5790a1459f35bad79d079504a4e4d86e5eb8bb40 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 00:44:20 +0200 Subject: [PATCH] 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;