diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 0f03cf83..cf3ca378 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -1,10 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; +use super::cursor::{CursorListResponse, CursorQuery, PageCursor}; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, }; +use super::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::domain::services::authorization::ResourceKind; /// DTO for recent items, enriched with item metadata via SQL JOIN /// so the frontend does not need N+1 requests to resolve names/sizes. @@ -84,3 +88,109 @@ impl RecentItemDto { self } } + +// ════════════════════════════════════════════════════════════════════════════ +// Cursor-paginated recent resources (GET /api/recent/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// Raw row returned by the UNION ALL query for `/api/recent/resources`. +pub struct RecentResourceRow { + pub resource_type: String, // "file" | "folder" + pub resource_id: Uuid, + pub name: String, + pub parent_id: Option, + pub mime_type: Option, + /// `-1` for folders, actual byte-count for files. + pub size: i64, + pub resource_created_at: DateTime, + pub modified_at: DateTime, + pub owner_id: Uuid, + /// `true` when `owner_id == requesting user_id`. + pub is_owner: bool, + pub accessed_at: DateTime, + /// Human-readable path. Always populated in the row; the handler clears it + /// to `""` when `is_owner` is false. + pub path: Option, + // Pre-computed sort fields for cursor construction. + pub sort_str: Option, + pub sort_int: Option, + pub sort_ts: Option>, +} + +/// Opaque keyset-pagination cursor for `GET /api/recent/resources`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentCursor { + /// Sort dimension active when this cursor was produced. + /// Values: `"accessed_at"` (default), `"name"`, `"type"`, `"modified_at"`, `"size"`, `"owner"`. + #[serde(default = "RecentCursor::default_order")] + pub order_by: String, + /// UUID of the last item on the previous page (tie-breaker). + pub resource_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_str: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_int: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_ts: Option>, + #[serde(default)] + pub reverse: bool, +} + +impl RecentCursor { + fn default_order() -> String { + "accessed_at".to_owned() + } +} + +impl PageCursor for RecentCursor {} + +/// Query parameters for `GET /api/recent/resources`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct RecentResourcesQuery { + /// Maximum items per page (1–200, default 50). + #[serde(default = "CursorQuery::default_limit")] + pub limit: u32, + /// Opaque cursor from a previous response. Omit to start from the first page. + pub cursor: Option, + /// Sort / group-by dimension. Supported: `"accessed_at"` (default), `"name"`, + /// `"type"`, `"modified_at"`, `"size"`, `"owner"`. + pub order_by: Option, + /// Comma-separated resource types to include, e.g. `"file,folder"`. + /// Omit to include both. + pub resource_types: Option, + /// Reverse the sort order. Default `false`. + #[serde(default)] + pub reverse: bool, +} + +impl RecentResourcesQuery { + pub fn limit_clamped(&self) -> usize { + self.limit.clamp(1, 200) as usize + } + + pub fn decode_cursor(&self) -> Option { + self.cursor.as_deref().and_then(RecentCursor::decode) + } + + /// Returns `None` when `resource_types` is absent (= include all). + pub fn resource_kinds(&self) -> Option> { + self.resource_types.as_deref().map(|s| { + s.split(',') + .filter_map(|t| ResourceKind::parse(t.trim())) + .collect() + }) + } +} + +/// One item in a `GET /api/recent/resources` page. +#[derive(Debug, Serialize, ToSchema)] +pub struct RecentResourceItemDto { + pub resource_type: ResourceTypeDto, + /// When the resource was last accessed. + pub accessed_at: DateTime, + /// Full resource details — shape determined by `resource_type`. + pub resource: ResourceContentDto, +} + +/// Response envelope for `GET /api/recent/resources`. +pub type RecentResourcesDto = CursorListResponse; diff --git a/src/application/ports/recent_ports.rs b/src/application/ports/recent_ports.rs index 91d6cfd7..b3181ec4 100644 --- a/src/application/ports/recent_ports.rs +++ b/src/application/ports/recent_ports.rs @@ -51,4 +51,15 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static { /// Removes items exceeding `max_items` (the oldest ones). async fn prune(&self, user_id: Uuid, max_items: i32) -> Result<()>; + + /// List recent items with cursor pagination, sorting, and optional type filter. + async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option<&crate::application::dtos::recent_dto::RecentCursor>, + order_by: &str, + kinds: Option<&[crate::domain::services::authorization::ResourceKind]>, + reverse: bool, + ) -> Result>; } diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs index 5632a5be..b54e318b 100644 --- a/src/application/services/recent_service.rs +++ b/src/application/services/recent_service.rs @@ -1,6 +1,8 @@ -use crate::application::dtos::recent_dto::RecentItemDto; +use crate::application::dtos::cursor::PageCursor; +use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow}; use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::domain::services::authorization::ResourceKind; use crate::infrastructure::repositories::pg::RecentItemsPgRepository; use std::sync::Arc; use tracing::info; @@ -109,3 +111,99 @@ impl RecentItemsUseCase for RecentService { Ok(()) } } + +impl RecentService { + /// No authz needed — recent items are strictly user-scoped; the repository + /// enforces `WHERE user_id = $1` so users can only see their own entries. + /// + /// Returns `(rows, next_cursor_encoded)`. + pub async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result<(Vec, Option)> { + // Fetch one extra row to detect whether a next page exists. + let mut rows = self + .repo + .list_resources_paged( + user_id, + limit + 1, + cursor.as_ref(), + order_by, + kinds, + reverse, + ) + .await?; + + let next_cursor = if rows.len() > limit { + let last = &rows[limit - 1]; + let c = build_recent_cursor(last, order_by, reverse); + rows.truncate(limit); + Some(c.encode()) + } else { + None + }; + + Ok((rows, next_cursor)) + } +} + +/// Build the next-page cursor from the last row of the current page. +/// `reverse` is stored in the cursor so subsequent pages use the same direction. +fn build_recent_cursor(row: &RecentResourceRow, order_by: &str, reverse: bool) -> RecentCursor { + match order_by { + "name" => RecentCursor { + order_by: "name".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(name) + sort_int: row.sort_int, // folder_first + sort_ts: None, + reverse, + }, + "type" => RecentCursor { + order_by: "type".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(name) + sort_int: row.sort_int, // type_order + sort_ts: None, + reverse, + }, + "modified_at" => RecentCursor { + order_by: "modified_at".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: None, + sort_ts: row.sort_ts, // modified_at timestamp + reverse, + }, + "size" => RecentCursor { + order_by: "size".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: row.sort_int, // size in bytes + sort_ts: None, + reverse, + }, + "owner" => RecentCursor { + order_by: "owner".to_owned(), + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), // LOWER(username) + sort_int: None, + sort_ts: row.sort_ts, // accessed_at timestamp (secondary) + reverse, + }, + _ => RecentCursor { + // default: accessed_at DESC + order_by: "accessed_at".to_owned(), + resource_id: row.resource_id, + sort_str: None, + sort_int: None, + sort_ts: row.sort_ts, // accessed_at timestamp + reverse, + }, + } +} diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index 867331b7..1a3cc33c 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use tracing::error; use uuid::Uuid; -use crate::application::dtos::recent_dto::RecentItemDto; +use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow}; use crate::application::ports::recent_ports::RecentItemsRepositoryPort; use crate::common::errors::{DomainError, ErrorKind, Result}; +use crate::domain::services::authorization::ResourceKind; /// PostgreSQL implementation of the recent items persistence port. pub struct RecentItemsPgRepository { @@ -188,4 +189,310 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { Ok(()) } + + async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option<&RecentCursor>, + order_by: &str, + kinds: Option<&[ResourceKind]>, + reverse: bool, + ) -> Result> { + let include_folders = + kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder))); + let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File))); + + // ── Build the UNION ALL CTE ───────────────────────────────────────── + let mut cte_branches: Vec<&str> = Vec::new(); + + let folder_branch = r#" + SELECT + 'folder'::text AS resource_type, + fld.id AS resource_id, + fld.name, + fld.parent_id, + NULL::text AS mime_type, + -1::bigint AS size, + fld.created_at AS resource_created_at, + fld.updated_at AS modified_at, + fld.user_id AS owner_id, + (fld.user_id = $1::uuid) AS is_owner, + ur.accessed_at AS accessed_at, + fld.path::text AS resource_path, + LOWER(fld.name) AS sort_str, + 0::bigint AS type_order, + 0::int AS folder_first + FROM auth.user_recent_files ur + INNER JOIN storage.folders fld + ON fld.id = ur.item_id::UUID AND NOT fld.is_trashed + WHERE ur.user_id = $1::uuid AND ur.item_type = 'folder'"#; + + let file_branch = r#" + SELECT + 'file'::text AS resource_type, + f.id AS resource_id, + f.name, + f.folder_id AS parent_id, + f.mime_type, + f.size::bigint, + f.created_at AS resource_created_at, + f.updated_at AS modified_at, + f.user_id AS owner_id, + (f.user_id = $1::uuid) AS is_owner, + ur.accessed_at AS accessed_at, + COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, + LOWER(f.name) AS sort_str, + f.category_order::bigint AS type_order, + 1::int AS folder_first + FROM auth.user_recent_files ur + INNER JOIN storage.files f + ON f.id = ur.item_id::UUID AND NOT f.is_trashed + LEFT JOIN storage.folders pfld + ON pfld.id = f.folder_id + WHERE ur.user_id = $1::uuid AND ur.item_type = 'file'"#; + + if include_folders { + cte_branches.push(folder_branch); + } + if include_files { + cte_branches.push(file_branch); + } + + if cte_branches.is_empty() { + return Ok(Vec::new()); + } + + let union_sql = cte_branches.join("\n UNION ALL\n"); + let cte = format!("WITH resources AS ({union_sql}\n)"); + + // ── Cursor values ─────────────────────────────────────────────────── + let cur_str: Option<&str> = cursor.and_then(|c| c.sort_str.as_deref()); + let cur_int: Option = cursor.and_then(|c| c.sort_int); + let cur_ts: Option> = cursor.and_then(|c| c.sort_ts); + let cur_id: Option = cursor.map(|c| c.resource_id); + + // ── Per-dimension keyset WHERE + ORDER BY ─────────────────────────── + // Binds: $1=user_id (in CTE), $2=cur_str, $3=cur_int, $4=cur_ts, + // $5=cur_id, $6=limit (for "owner" sort: JOIN uses no extra binds) + let (keyset, order_by_clause, need_user_join) = match (order_by, reverse) { + // ── name ──────────────────────────────────────────────────────── + ("name", false) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str > $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)", + "ORDER BY folder_first ASC, sort_str ASC, resource_id ASC", + false, + ), + ("name", true) => ( + "WHERE ($3::bigint IS NULL) + OR (folder_first::bigint > $3) + OR (folder_first::bigint = $3 AND sort_str < $2) + OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)", + "ORDER BY folder_first ASC, sort_str DESC, resource_id DESC", + false, + ), + // ── type ──────────────────────────────────────────────────────── + ("type", false) => ( + "WHERE ($3::bigint IS NULL) + OR (type_order > $3) + OR (type_order = $3 AND sort_str > $2) + OR (type_order = $3 AND sort_str = $2 AND resource_id > $5::uuid)", + "ORDER BY type_order ASC, sort_str ASC, resource_id ASC", + false, + ), + ("type", true) => ( + "WHERE ($3::bigint IS NULL) + OR (type_order < $3) + OR (type_order = $3 AND sort_str < $2) + OR (type_order = $3 AND sort_str = $2 AND resource_id < $5::uuid)", + "ORDER BY type_order DESC, sort_str DESC, resource_id DESC", + false, + ), + // ── accessed_at ────────────────────────────────────────────────── + ("accessed_at", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at < $4) + OR (accessed_at = $4 AND resource_id < $5::uuid)", + "ORDER BY accessed_at DESC, resource_id DESC", + false, + ), + ("accessed_at", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at > $4) + OR (accessed_at = $4 AND resource_id > $5::uuid)", + "ORDER BY accessed_at ASC, resource_id ASC", + false, + ), + // ── modified_at ────────────────────────────────────────────────── + ("modified_at", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (modified_at < $4) + OR (modified_at = $4 AND resource_id < $5::uuid)", + "ORDER BY modified_at DESC, resource_id DESC", + false, + ), + ("modified_at", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (modified_at > $4) + OR (modified_at = $4 AND resource_id > $5::uuid)", + "ORDER BY modified_at ASC, resource_id ASC", + false, + ), + // ── size ───────────────────────────────────────────────────────── + ("size", false) => ( + "WHERE ($3::bigint IS NULL) + OR (size > $3) + OR (size = $3 AND resource_id > $5::uuid)", + "ORDER BY size ASC, resource_id ASC", + false, + ), + ("size", true) => ( + "WHERE ($3::bigint IS NULL) + OR (size < $3) + OR (size = $3 AND resource_id < $5::uuid)", + "ORDER BY size DESC, resource_id DESC", + false, + ), + // ── owner ──────────────────────────────────────────────────────── + ("owner", false) => ( + "WHERE ($2::text IS NULL) + OR (LOWER(u.username) > $2) + OR (LOWER(u.username) = $2 AND accessed_at < $4) + OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id < $5::uuid)", + "ORDER BY LOWER(u.username) ASC, accessed_at DESC, resource_id DESC", + true, + ), + ("owner", true) => ( + "WHERE ($2::text IS NULL) + OR (LOWER(u.username) < $2) + OR (LOWER(u.username) = $2 AND accessed_at > $4) + OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id > $5::uuid)", + "ORDER BY LOWER(u.username) DESC, accessed_at ASC, resource_id ASC", + true, + ), + // ── default: accessed_at DESC ───────────────────────────────────── + (_, false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at < $4) + OR (accessed_at = $4 AND resource_id < $5::uuid)", + "ORDER BY accessed_at DESC, resource_id DESC", + false, + ), + (_, true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (accessed_at > $4) + OR (accessed_at = $4 AND resource_id > $5::uuid)", + "ORDER BY accessed_at ASC, resource_id ASC", + false, + ), + }; + + let user_join = if need_user_join { + "LEFT JOIN auth.users u ON u.id = r.owner_id" + } else { + "" + }; + // For "owner" sort the JOIN makes LOWER(u.username) available; add it to SELECT + // so the cursor can carry the correct sort key. + let username_col = if need_user_join { + ",\n LOWER(u.username) AS username_lower" + } else { + "" + }; + + let sql = format!( + "{cte} +SELECT + r.resource_type, r.resource_id, r.name, r.parent_id, + r.mime_type, r.size, r.resource_created_at, r.modified_at, + r.owner_id, r.is_owner, r.accessed_at, r.resource_path, + r.sort_str, r.type_order, r.folder_first{username_col} +FROM resources r +{user_join} +{keyset} +{order_by_clause} +LIMIT $6" + ); + + let rows = sqlx::query(&sql) + .bind(user_id) // $1 (in CTE + outer) + .bind(cur_str) // $2 + .bind(cur_int) // $3 + .bind(cur_ts) // $4 + .bind(cur_id) // $5 + .bind(limit as i64) // $6 + .fetch_all(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error listing recent resources: {e}"); + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Failed to list recent resources: {e}"), + ) + })?; + + let result = rows + .iter() + .map(|row| { + let resource_type: String = row.get("resource_type"); + let sort_str_val: Option = row.try_get("sort_str").ok(); + let type_order: i64 = row.try_get("type_order").unwrap_or(0); + let folder_first: i32 = row.try_get("folder_first").unwrap_or(0); + let size: i64 = row.get("size"); + + // Pre-compute the cursor sort fields based on order_by + let (c_sort_str, c_sort_int, c_sort_ts) = match order_by { + "name" => (sort_str_val, Some(folder_first as i64), None), + "type" => (sort_str_val, Some(type_order), None), + "size" => (None, Some(size), None), + "accessed_at" => { + let ts: Option> = + row.try_get("accessed_at").ok(); + (None, None, ts) + } + "modified_at" => { + let ts: Option> = + row.try_get("modified_at").ok(); + (None, None, ts) + } + "owner" => { + // For "owner" sort the JOIN added LOWER(u.username) AS username_lower. + // The cursor's sort_str must carry the username (not the file name). + let username: Option = row.try_get("username_lower").ok(); + let ts: Option> = + row.try_get("accessed_at").ok(); + (username, None, ts) + } + _ => { + let ts: Option> = + row.try_get("accessed_at").ok(); + (None, None, ts) + } + }; + + RecentResourceRow { + resource_type, + resource_id: row.get("resource_id"), + name: row.get("name"), + parent_id: row.try_get("parent_id").ok(), + mime_type: row.try_get("mime_type").ok(), + size, + resource_created_at: row.get("resource_created_at"), + modified_at: row.get("modified_at"), + owner_id: row.get("owner_id"), + is_owner: row.try_get("is_owner").unwrap_or(false), + accessed_at: row.get("accessed_at"), + path: row.try_get("resource_path").ok(), + sort_str: c_sort_str, + sort_int: c_sort_int, + sort_ts: c_sort_ts, + } + }) + .collect(); + + Ok(result) + } } diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index fdadfd8c..62f72126 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -8,8 +8,18 @@ use serde::Deserialize; use std::sync::Arc; use tracing::{error, info}; +use crate::application::dtos::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::application::dtos::recent_dto::{ + RecentResourceItemDto, RecentResourcesDto, RecentResourcesQuery, +}; use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::services::recent_service::RecentService; +use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; /// Query parameters for getting recent items @@ -19,7 +29,8 @@ pub struct GetRecentParams { limit: Option, } -/// Get user's recent items +/// Get user's recent items (deprecated — use `GET /api/recent/resources` instead) +#[deprecated = "Use GET /api/recent/resources instead"] #[utoipa::path( get, path = "/api/recent", @@ -213,3 +224,121 @@ pub async fn clear_recent_items( } } } + +/// List recently accessed resources with cursor pagination. +/// +/// Sorted by `accessed_at` DESC by default (most recently accessed first). +/// `path` is cleared when the resource is not owned by the requesting user. +#[utoipa::path( + get, + path = "/api/recent/resources", + params(RecentResourcesQuery), + responses( + (status = 200, description = "Paginated list of recently accessed resources", + body = RecentResourcesDto), + (status = 400, description = "Invalid cursor or query parameters"), + ), + security(("bearerAuth" = [])), + tag = "recent" +)] +pub async fn list_recent_resources( + State(recent_service): State>, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let user_id = auth_user.id; + + let order_by = q.order_by.as_deref().unwrap_or("accessed_at").to_owned(); + + // If a cursor exists, validate that it matches the requested sort/direction. + let cursor = q + .decode_cursor() + .filter(|c| c.order_by == order_by && c.reverse == q.reverse); + + let kinds = q.resource_kinds(); + + match recent_service + .list_resources_paged( + user_id, + q.limit_clamped(), + cursor, + &order_by, + kinds.as_deref(), + q.reverse, + ) + .await + { + Ok((rows, next_cursor)) => { + let items: Vec = rows + .into_iter() + .map(|row| { + // Path is only shown to the owner; non-owners see "" + // to avoid leaking another user's folder hierarchy. + let path = if row.is_owner { + row.path.clone().unwrap_or_default() + } else { + String::new() + }; + + if row.resource_type == "folder" { + let dto = FolderDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + parent_id: row.parent_id.map(|u| u.to_string()), + owner_id: Some(row.owner_id.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + is_root: false, + icon_class: std::sync::Arc::from("fas fa-folder"), + icon_special_class: std::sync::Arc::from("folder-icon"), + category: std::sync::Arc::from("Folder"), + }; + RecentResourceItemDto { + resource_type: ResourceTypeDto::Folder, + accessed_at: row.accessed_at, + resource: ResourceContentDto::Folder(dto), + } + } else { + let mime = row + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"); + let size_bytes = row.size.max(0) as u64; + let dto = FileDto { + id: row.resource_id.to_string(), + name: row.name.clone(), + path, + size: size_bytes, + mime_type: std::sync::Arc::from(mime), + folder_id: row.parent_id.map(|u| u.to_string()), + created_at: row.resource_created_at.timestamp() as u64, + modified_at: row.modified_at.timestamp() as u64, + icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)), + icon_special_class: std::sync::Arc::from(icon_special_class_for( + &row.name, mime, + )), + category: std::sync::Arc::from(category_for(&row.name, mime)), + size_formatted: format_file_size(size_bytes), + owner_id: Some(row.owner_id.to_string()), + sort_date: None, + etag: String::new(), + }; + RecentResourceItemDto { + resource_type: ResourceTypeDto::File, + accessed_at: row.accessed_at, + resource: ResourceContentDto::File(dto), + } + } + }) + .collect(); + + ( + StatusCode::OK, + Json(RecentResourcesDto::with_cursor(items, next_cursor)), + ) + .into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 4d1f5355..82186958 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -355,10 +355,12 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // Create routes for recent items if the service is available let recent_router = if let Some(recent_service) = recent_service.clone() { + #[allow(deprecated)] use crate::interfaces::api::handlers::recent_handler; Router::new() .route("/", get(recent_handler::get_recent_items)) + .route("/resources", get(recent_handler::list_recent_resources)) .route( "/{item_type}/{item_id}", post(recent_handler::record_item_access), diff --git a/static/index.html b/static/index.html index 21faeed5..129ae26e 100644 --- a/static/index.html +++ b/static/index.html @@ -41,6 +41,8 @@ + + diff --git a/static/js/app/main.js b/static/js/app/main.js index a90b7e4d..cfc21802 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -17,6 +17,7 @@ import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; import { grants } from '../model/grants.js'; +import { recentView } from '../views/recent/recentView.js'; import { sharedView } from '../views/shared/sharedView.js'; import { checkAuthentication } from './authSession.js'; import { loadFiles } from './filesView.js'; @@ -345,8 +346,8 @@ function setupActionsBarDelegation() { break; case 'clear-recent-btn': if (recent) { - recent.clearRecentFiles(); - recent.displayRecentFiles(); + await recent.clearRecentFiles(); + await recentView.init(); ui.showNotification('Cleanup completed', 'Recent files history has been cleared'); } break; diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 45540654..0274f44a 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -10,8 +10,8 @@ import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; -import { recent } from '../features/library/recent.js'; import { favoritesView } from '../views/favorites/favoritesView.js'; +import { recentView } from '../views/recent/recentView.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; import { filesView, loadFiles } from './filesView.js'; @@ -180,6 +180,11 @@ function setCurrentSection(section) { favoritesView.hide(); } + // Hide recentView "Load more" button when leaving the recent section + if (section !== 'recent' && recentView) { + recentView.hide(); + } + // Reset owner column — sections that need it re-enable it explicitly below. ui.setOwnerColumnVisible(false); @@ -332,10 +337,17 @@ function switchToFavoritesSection() { function switchToRecentFilesSection() { if (!setCurrentSection('recent')) return; - // Set actions bar mode + // Set actions bar mode with group-by support setActionsBarMode('recent'); - setGroupByView(null); - syncGroupByMenu([]); + setGroupByView(recentView); + syncGroupByMenu(recentView.groupByDefs); + + // Restore the saved group-by selection in the dropdown. + const recentPrefs = viewPrefs.load('recent'); + applyGroupByMenuState(recentPrefs.groupBy, recentPrefs.reversed); + + // Show the Owner column + ui.setOwnerColumnVisible(true); // Hide breadcrumb (only shown in Files view) const breadcrumb = document.querySelector('.breadcrumb'); @@ -348,21 +360,9 @@ function switchToRecentFilesSection() { restoreView('recent'); syncViewContainers(); - //reset files view + remove any error - ui.resetFilesList(); - - if (recent) { - sharedView.loadItems().then(() => { - recent.displayRecentFiles(); - }); - } else { - console.error('Recent files module not loaded or initialized'); - ui.showError(` - -

Error loading the recent module

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

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

-

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

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

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

+

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

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

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

+ `); + console.error('recentView: load error', err); + } finally { + this._loading = false; + } + }, + + /** + * Map `RecentResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving + * server order. Sets `sort_date` (unix seconds) to the `accessed_at` date + * so the `accessedAt` keyFn can bucket by when the item was accessed. + * + * @param {RecentResourceItem[]} items + * @returns {Array} + */ + _mapItems(items) { + /** @type {Array} */ + const result = []; + + /** @param {string} iso @returns {number} unix seconds */ + const toSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000); + + for (const item of items) { + if (item.resource_type === 'folder') { + const f = /** @type {FolderItem} */ (item.resource); + result.push( + /** @type {FolderItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + parent_id: f.parent_id ?? '', + owner_id: f.owner_id ?? '', + is_root: f.is_root ?? false, + created_at: f.created_at, + modified_at: f.modified_at, + // sort_date = accessed_at (unix seconds) for the accessedAt keyFn + sort_date: toSecs(item.accessed_at), + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: 'Folder' + }) + ); + } else if (item.resource_type === 'file') { + const f = /** @type {FileItem} */ (item.resource); + result.push( + /** @type {FileItem} */ ({ + id: f.id, + name: f.name, + path: f.path ?? '', + folder_id: f.folder_id ?? '', + owner_id: f.owner_id ?? '', + mime_type: f.mime_type, + size: f.size, + size_formatted: f.size_formatted, + created_at: f.created_at, + modified_at: f.modified_at, + sort_date: toSecs(item.accessed_at), + icon_class: f.icon_class, + icon_special_class: f.icon_special_class ?? '', + category: f.category + }) + ); + } + } + + return result; + }, + + // ── "Load more" button ──────────────────────────────────────────────────── + + /** + * Create the "Load more" wrapper once and attach it below `.files-container`. + * Subsequent calls are no-ops. + */ + _ensureLoadMoreButton() { + if (document.getElementById(LOAD_MORE_ID)) return; + + const filesContainer = document.querySelector('.files-container'); + if (!filesContainer) return; + + const wrapper = document.createElement('div'); + wrapper.id = LOAD_MORE_ID; + wrapper.className = 'swm-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'recent-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('recent.loadMore', 'Load more'); + btn.addEventListener('click', () => this._loadPage()); + + wrapper.appendChild(btn); + filesContainer.after(wrapper); + }, + + /** + * @param {boolean} visible + */ + _setLoadMoreVisible(visible) { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.toggle('hidden', !visible); + } +}; + +export { recentView }; diff --git a/static/locales/en.json b/static/locales/en.json index 56421b8a..e1889828 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -430,7 +430,8 @@ "clear": "Clear recent", "accessed": "Accessed", "empty_state": "No recent files", - "empty_hint": "Files you open will appear here" + "empty_hint": "Files you open will appear here", + "loadMore": "Load more" }, "notifications": { "file_renamed": "File renamed", @@ -714,8 +715,15 @@ "groupby": { "none": "None", "title": "Group by", + "type": "Type", + "type.folders": "Folders", "owner": "Owner", - "shareDate": "Share date" + "shareDate": "Share date", + "favoriteDate": "Favorite date", + "accessedAt": "Accessed date", + "modifiedAt": "Modified date", + "createdAt": "Created date", + "size": "Size" }, "dateBucket": { "today": "Today", diff --git a/static/locales/fr.json b/static/locales/fr.json index 0777b11c..51bf3a90 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -430,7 +430,8 @@ "clear": "Effacer les récents", "accessed": "Consulté", "empty_state": "Aucun fichier récent", - "empty_hint": "Les fichiers que vous ouvrez apparaîtront ici" + "empty_hint": "Les fichiers que vous ouvrez apparaîtront ici", + "loadMore": "Charger plus" }, "notifications": { "file_renamed": "Fichier renommé", @@ -714,8 +715,15 @@ "groupby": { "none": "Aucun", "title": "Grouper par", + "type": "Type", + "type.folders": "Dossiers", "owner": "Propriétaire", - "shareDate": "Date de partage" + "shareDate": "Date de partage", + "favoriteDate": "Date d'ajout aux favoris", + "accessedAt": "Date d'accès", + "modifiedAt": "Date de modification", + "createdAt": "Date de création", + "size": "Taille" }, "dateBucket": { "today": "Aujourd'hui",