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);