From ea83891a610336004a08d4a33a1fe636efcf2895 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 30 May 2026 00:15:07 +0200 Subject: [PATCH] feat(trash): move trash API to normalized version (with cursor, orderBy) + normalize Trash section to existing components normalize also component to format badges (expiry, role, etc) --- src/application/dtos/trash_dto.rs | 126 ++++- src/application/services/trash_service.rs | 132 ++++- .../repositories/pg/trash_db_repository.rs | 275 ++++++++++- src/interfaces/api/handlers/trash_handler.rs | 86 +++- src/interfaces/api/mod.rs | 6 +- src/interfaces/api/routes.rs | 8 +- static/css/base/variables.css | 7 - static/css/components/csp-utilities.css | 2 +- static/css/components/expiryChip.css | 63 +++ static/css/components/resourceList.css | 78 ++- static/css/components/roleChip.css | 46 ++ static/css/components/shareModal.css | 4 +- static/css/main.css | 2 + static/css/themes/dark.css | 32 +- static/css/views/mySharesView.css | 74 +-- static/css/views/trash.css | 98 ++-- static/index.html | 2 +- static/js/app/main.js | 4 +- static/js/app/navigation.js | 15 +- static/js/app/trashView.js | 132 ----- static/js/components/mySharesList.js | 79 +-- static/js/components/resourceList.js | 68 ++- static/js/components/roleChip.js | 81 ++++ static/js/core/formatters.js | 162 ++++++- static/js/core/icons.js | 5 +- static/js/core/types.js | 34 +- static/js/features/files/fileOperations.js | 24 - static/js/model/trashModel.js | 59 +++ static/js/utils/expiryChip.js | 12 +- static/js/views/trash/trashView.js | 449 ++++++++++++++++++ static/locales/en.json | 21 +- tests/api/run.sh | 1 + tests/api/trash_resources.hurl | 270 +++++++++++ tests/common/server.env | 4 +- 34 files changed, 2043 insertions(+), 418 deletions(-) create mode 100644 static/css/components/expiryChip.css create mode 100644 static/css/components/roleChip.css delete mode 100644 static/js/app/trashView.js create mode 100644 static/js/components/roleChip.js create mode 100644 static/js/model/trashModel.js create mode 100644 static/js/views/trash/trashView.js create mode 100644 tests/api/trash_resources.hurl diff --git a/src/application/dtos/trash_dto.rs b/src/application/dtos/trash_dto.rs index e16ff4c7..5715c9ac 100644 --- a/src/application/dtos/trash_dto.rs +++ b/src/application/dtos/trash_dto.rs @@ -1,6 +1,11 @@ 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::grant_dto::{ResourceContentDto, ResourceTypeDto}; +use crate::domain::services::authorization::ResourceKind; /// DTO representing an item in the trash #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -38,3 +43,122 @@ pub struct RestoreFromTrashRequest { pub struct DeletePermanentlyRequest { pub trash_id: String, } + +// ════════════════════════════════════════════════════════════════════════════ +// Cursor-paginated trash resources (GET /api/trash/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// Raw row returned by the UNION-ALL query over `storage.files`/`storage.folders` +/// where `is_trashed = TRUE`. Never serialised directly. +pub struct TrashResourceRow { + pub resource_type: String, // "file" | "folder" + pub resource_id: Uuid, + pub name: String, + pub parent_id: Option, + pub mime_type: Option, + /// `-1` for folders (sentinel), actual byte-count for files. + pub size: i64, + pub resource_created_at: DateTime, + pub modified_at: DateTime, + pub owner_id: Uuid, + pub trashed_at: DateTime, + pub deletion_date: DateTime, + /// Original location path (for folders: `path`; for files: `parent.path || '/' || name`). + 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/trash/resources`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrashCursor { + /// Sort dimension active when this cursor was produced. + /// Values: `"deletion_date"` (default), `"trashed_at"`, `"name"`, `"type"`, `"size"`. + #[serde(default = "TrashCursor::default_order")] + pub order_by: String, + /// UUID of the last item on the previous page (tie-breaker). + pub resource_id: Uuid, + /// `LOWER(name)` for `name`/`type` sorts. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_str: Option, + /// Multipurpose integer: `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 `deletion_date` and `trashed_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 TrashCursor { + fn default_order() -> String { + "deletion_date".to_owned() + } +} + +impl PageCursor for TrashCursor {} + +/// Query parameters for `GET /api/trash/resources`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct TrashResourcesQuery { + /// 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: `"deletion_date"` (default — soonest + /// expiry first), `"trashed_at"` (most recently trashed first), `"name"`, + /// `"type"`, `"size"`. + pub order_by: Option, + /// Comma-separated resource types to include, e.g. `"file,folder"`. + /// Omit to include both. + pub resource_types: Option, + /// Reverse the sort order. Default `false`. + #[serde(default)] + pub reverse: bool, +} + +impl TrashResourcesQuery { + 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(TrashCursor::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/trash/resources` page. +/// +/// `deletion_date` is the real timestamp at which the item will be permanently +/// deleted (= `trashed_at + retention_days`). The client derives "days until +/// deletion" itself from this + the current clock — the wire format does not +/// duplicate that derivation. `resource.path` carries the original location +/// (soft-delete preserves the row's `path` column). +#[derive(Debug, Serialize, ToSchema)] +pub struct TrashResourceItemDto { + pub resource_type: ResourceTypeDto, + /// When the user moved the item to trash. + pub trashed_at: DateTime, + /// When the item will be permanently deleted by the retention sweeper. + pub deletion_date: DateTime, + /// Full resource details — shape determined by `resource_type`. + pub resource: ResourceContentDto, +} + +/// Response envelope for `GET /api/trash/resources`. +pub type TrashResourcesDto = CursorListResponse; diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 67bf1c72..073aae40 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -2,10 +2,16 @@ use std::sync::Arc; use tracing::{debug, error, info, instrument, warn}; use uuid::Uuid; +use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::display_helpers::{ - category_for, icon_class_for, icon_special_class_for, + 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::trash_dto::{ + TrashCursor, TrashResourceItemDto, TrashResourceRow, TrashedItemDto, }; -use crate::application::dtos::trash_dto::TrashedItemDto; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_lifecycle::FileLifecycleHook; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; @@ -14,6 +20,7 @@ use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::trash_repository::TrashRepository; +use crate::domain::services::authorization::ResourceKind; use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; @@ -731,3 +738,124 @@ impl TrashUseCase for TrashService { Ok(()) } } + +// ════════════════════════════════════════════════════════════════════════════ +// Cursor-paginated trash listing (GET /api/trash/resources) +// ════════════════════════════════════════════════════════════════════════════ +impl TrashService { + /// Cursor-paginated list of the user's trashed resources. + /// + /// No `authz.require()` here — trashed items are strictly user-scoped and + /// the repository enforces `WHERE user_id = $1`. This matches the pattern + /// used by favorites and recent listing endpoints. Mutations (restore, + /// delete permanently, move to trash) keep their per-item authz checks. + /// + /// Returns `(page items, 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 + .trash_repository + .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_trash_cursor(last, order_by, reverse); + rows.truncate(limit); + Some(c.encode()) + } else { + None + }; + + let items: Vec = rows.into_iter().map(row_to_item_dto).collect(); + + Ok((items, 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_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) -> TrashCursor { + let order_by_owned = match order_by { + "deletion_date" | "trashed_at" | "name" | "type" | "size" => order_by.to_owned(), + _ => "deletion_date".to_owned(), + }; + TrashCursor { + order_by: order_by_owned, + resource_id: row.resource_id, + sort_str: row.sort_str.clone(), + sort_int: row.sort_int, + sort_ts: row.sort_ts, + reverse, + } +} + +/// Convert a raw repository row into the API DTO. +fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { + let path = row.path.clone().unwrap_or_default(); + 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"), + }; + TrashResourceItemDto { + resource_type: ResourceTypeDto::Folder, + trashed_at: row.trashed_at, + deletion_date: row.deletion_date, + 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(), + }; + TrashResourceItemDto { + resource_type: ResourceTypeDto::File, + trashed_at: row.trashed_at, + deletion_date: row.deletion_date, + resource: ResourceContentDto::File(dto), + } + } +} diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index a6cf0172..191ccecb 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -5,13 +5,16 @@ //! are files/folders with `is_trashed = TRUE`. use chrono::{DateTime, Utc}; -use sqlx::PgPool; +use sqlx::{PgPool, Row}; use std::sync::Arc; +use tracing::error; use uuid::Uuid; -use crate::common::errors::{DomainError, Result}; +use crate::application::dtos::trash_dto::{TrashCursor, TrashResourceRow}; +use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::repositories::trash_repository::TrashRepository; +use crate::domain::services::authorization::ResourceKind; /// Default retention period (days) used when computing deletion_date. const _DEFAULT_RETENTION_DAYS: i64 = 30; @@ -215,3 +218,271 @@ impl TrashRepository for TrashDbRepository { Ok((files_deleted, folders_deleted)) } } + +// ════════════════════════════════════════════════════════════════════════════ +// Cursor-paginated trash listing (used by GET /api/trash/resources) +// ════════════════════════════════════════════════════════════════════════════ +impl TrashDbRepository { + /// Cursor-paginated list of the user's trashed resources. + /// + /// Mirrors the favorites/grants pattern: a UNION-ALL CTE over folder and + /// file branches (each pre-computing sort columns), then a per-dimension + /// keyset WHERE + ORDER BY. + /// + /// Returns rows in caller-requested sort order. The caller is expected to + /// fetch `limit + 1` to detect end-of-results. + pub async fn list_resources_paged( + &self, + user_id: Uuid, + limit: usize, + cursor: Option<&TrashCursor>, + 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 ───────────────────────────────────────── + // Only top-level trashed items: a file/folder whose parent is itself + // trashed is implicitly in trash as a descendant, mirroring the + // `storage.trash_items` view's filter. + 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.trashed_at AS trashed_at, + (fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, + fld.path::text AS resource_path, + LOWER(fld.name) AS sort_str, + 0::bigint AS type_order, + 0::int AS folder_first + FROM storage.folders fld + WHERE fld.user_id = $1::uuid + AND fld.is_trashed = TRUE + AND (fld.parent_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM storage.folders p + WHERE p.id = fld.parent_id AND p.is_trashed = TRUE))"#; + + 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 AS size, + f.created_at AS resource_created_at, + f.updated_at AS modified_at, + f.user_id AS owner_id, + f.trashed_at AS trashed_at, + (f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, + 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 storage.files f + LEFT JOIN storage.folders pfld + ON pfld.id = f.folder_id + WHERE f.user_id = $1::uuid + AND f.is_trashed = TRUE + AND (f.folder_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM storage.folders p + WHERE p.id = f.folder_id AND p.is_trashed = TRUE))"#; + + 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, $2=cur_str, $3=cur_int, $4=cur_ts, + // $5=cur_id, $6=limit, $7=retention_days. + let (keyset, order_by_clause) = match (order_by, reverse) { + // ── deletion_date (DEFAULT) — ASC = expiring soonest first ─────── + ("deletion_date", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (deletion_date > $4) + OR (deletion_date = $4 AND resource_id > $5::uuid)", + "ORDER BY deletion_date ASC, resource_id ASC", + ), + ("deletion_date", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (deletion_date < $4) + OR (deletion_date = $4 AND resource_id < $5::uuid)", + "ORDER BY deletion_date DESC, resource_id DESC", + ), + // ── trashed_at — DESC = most recently trashed first ────────────── + ("trashed_at", false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (trashed_at < $4) + OR (trashed_at = $4 AND resource_id < $5::uuid)", + "ORDER BY trashed_at DESC, resource_id DESC", + ), + ("trashed_at", true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (trashed_at > $4) + OR (trashed_at = $4 AND resource_id > $5::uuid)", + "ORDER BY trashed_at ASC, resource_id ASC", + ), + // ── name — folders first ───────────────────────────────────────── + ("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", + ), + ("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", + ), + // ── type — folders get type_order=0 so they sort first naturally ─ + ("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", + ), + ("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", + ), + // ── size — folders first (via -1 sentinel grouping at top) ────── + ("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", + ), + ("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", + ), + // ── default = deletion_date ASC ───────────────────────────────── + (_, false) => ( + "WHERE ($4::timestamptz IS NULL) + OR (deletion_date > $4) + OR (deletion_date = $4 AND resource_id > $5::uuid)", + "ORDER BY deletion_date ASC, resource_id ASC", + ), + (_, true) => ( + "WHERE ($4::timestamptz IS NULL) + OR (deletion_date < $4) + OR (deletion_date = $4 AND resource_id < $5::uuid)", + "ORDER BY deletion_date DESC, resource_id DESC", + ), + }; + + 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.trashed_at, r.deletion_date, r.resource_path, + r.sort_str, r.type_order, r.folder_first +FROM resources r +{keyset} +{order_by_clause} +LIMIT $6" + ); + + let rows = sqlx::query(&sql) + .bind(user_id) // $1 + .bind(cur_str) // $2 + .bind(cur_int) // $3 + .bind(cur_ts) // $4 + .bind(cur_id) // $5 + .bind(limit as i64) // $6 + .bind(self.retention_days as i32) // $7 + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + error!("Database error listing trash resources: {e}"); + DomainError::new( + ErrorKind::InternalError, + "Trash", + format!("Failed to list trash 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"); + let trashed_at: DateTime = row.get("trashed_at"); + let deletion_date: DateTime = row.get("deletion_date"); + + // Pre-compute the cursor sort fields based on order_by. + let (c_sort_str, c_sort_int, c_sort_ts) = match order_by { + "deletion_date" => (None, None, Some(deletion_date)), + "trashed_at" => (None, None, Some(trashed_at)), + "name" => (sort_str_val, Some(folder_first as i64), None), + "type" => (sort_str_val, Some(type_order), None), + "size" => (None, Some(size), None), + _ => (None, None, Some(deletion_date)), + }; + + TrashResourceRow { + 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"), + trashed_at, + deletion_date, + 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/trash_handler.rs b/src/interfaces/api/handlers/trash_handler.rs index 79b2c756..52bf3b9d 100644 --- a/src/interfaces/api/handlers/trash_handler.rs +++ b/src/interfaces/api/handlers/trash_handler.rs @@ -1,20 +1,28 @@ use axum::Json; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; +use axum::response::IntoResponse; use serde_json::json; use tracing::{debug, error, instrument, warn}; +use crate::application::dtos::trash_dto::{TrashResourcesDto, TrashResourcesQuery}; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; +use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; -/// Gets all items in the trash for the current user +/// Gets all items in the trash for the current user. +/// +/// # Deprecated +/// Use `GET /api/trash/resources` instead. This endpoint is kept for +/// backwards compatibility but will be removed in a future release. +#[deprecated = "Use GET /api/trash/resources instead"] #[utoipa::path( get, path = "/api/trash", responses( - (status = 200, description = "List of trashed items"), + (status = 200, description = "List of trashed items (deprecated — use /api/trash/resources)"), (status = 501, description = "Trash feature not enabled") ), security(("bearerAuth" = [])), @@ -30,6 +38,10 @@ pub async fn get_trash_items( // privilege escalation attacks. let effective_user = auth_user.id; + warn!( + "Deprecated endpoint called: GET /api/trash — use GET /api/trash/resources instead (user {effective_user})" + ); + debug!("Request to list trash items for user {}", effective_user); let trash_service = match state.trash_service.as_ref() { @@ -63,6 +75,74 @@ pub async fn get_trash_items( } } +/// Cursor-paginated list of a user's trashed resources. +/// +/// Sorts by `deletion_date` (default — soonest expiry first), `trashed_at` +/// (most recently trashed first), `name`, `type`, or `size`. Filter on +/// `resource_types=file` or `resource_types=folder` to narrow to one kind. +/// Items implicitly trashed as descendants of a trashed parent are excluded +/// (only top-level trashed items appear). +#[utoipa::path( + get, + path = "/api/trash/resources", + params(TrashResourcesQuery), + responses( + (status = 200, description = "Paginated list of trashed resources", + body = crate::application::dtos::trash_dto::TrashResourcesDto), + (status = 400, description = "Invalid cursor or query parameters"), + (status = 501, description = "Trash feature not enabled"), + ), + security(("bearerAuth" = [])), + tag = "trash" +)] +#[instrument(skip_all)] +pub async fn get_trash_resources( + State(state): State>, + auth_user: AuthUser, + Query(q): Query, +) -> axum::response::Response { + let user_id = auth_user.id; + + let trash_service = match state.trash_service.as_ref() { + Some(service) => service, + None => { + return ( + StatusCode::NOT_IMPLEMENTED, + Json(json!({ "error": "Trash feature is not enabled" })), + ) + .into_response(); + } + }; + + let order_by = q.order_by.as_deref().unwrap_or("deletion_date").to_owned(); + + // Discard cursor if sort dimension or direction changed between pages. + let cursor = q + .decode_cursor() + .filter(|c| c.order_by == order_by && c.reverse == q.reverse); + + let kinds = q.resource_kinds(); + + match trash_service + .list_resources_paged( + user_id, + q.limit_clamped(), + cursor, + &order_by, + kinds.as_deref(), + q.reverse, + ) + .await + { + Ok((items, next_cursor)) => ( + StatusCode::OK, + Json(TrashResourcesDto::with_cursor(items, next_cursor)), + ) + .into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + /// Moves a file to the trash #[utoipa::path( delete, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index f1f3996b..1682a549 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -36,7 +36,8 @@ use crate::application::dtos::search_dto::{ }; use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto}; use crate::application::dtos::trash_dto::{ - DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto, + DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashResourceItemDto, + TrashResourcesDto, TrashedItemDto, }; use crate::application::dtos::user_dto::{ AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto, @@ -121,6 +122,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::dedup_handler::recalculate_stats, // Trash handlers (free functions) handlers::trash_handler::get_trash_items, + handlers::trash_handler::get_trash_resources, handlers::trash_handler::move_file_to_trash, handlers::trash_handler::move_folder_to_trash, handlers::trash_handler::restore_from_trash, @@ -259,6 +261,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; UpdateShareDto, // Trash schemas TrashedItemDto, + TrashResourceItemDto, + TrashResourcesDto, MoveToTrashRequest, RestoreFromTrashRequest, DeletePermanentlyRequest, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 17ae3e82..90e22246 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -431,13 +431,17 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { if let Some(_trash_service_ref) = trash_service.clone() { tracing::info!("Setting up trash routes for trash view"); + #[allow(deprecated)] let trash_router = Router::new() - .route("/", get(trash_handler::get_trash_items)) + // Literal paths first — order matters for axum overlap handling + // when a wildcard like /{id} could otherwise capture them. + .route("/", get(trash_handler::get_trash_items)) // deprecated — kept for external compat + .route("/resources", get(trash_handler::get_trash_resources)) + .route("/empty", delete(trash_handler::empty_trash)) .route("/files/{id}", delete(trash_handler::move_file_to_trash)) .route("/folders/{id}", delete(trash_handler::move_folder_to_trash)) .route("/{id}/restore", post(trash_handler::restore_from_trash)) .route("/{id}", delete(trash_handler::delete_permanently)) - .route("/empty", delete(trash_handler::empty_trash)) .with_state(app_state.clone()); router = router.nest("/trash", trash_router); diff --git a/static/css/base/variables.css b/static/css/base/variables.css index 212a5fce..cb953e83 100644 --- a/static/css/base/variables.css +++ b/static/css/base/variables.css @@ -290,13 +290,6 @@ --color-share-remove-text: #b71c1c; --color-share-owner-text: #757575; - /* Trash view */ - --color-trash-surface: #ffffff; - --color-trash-border: #ddd; - --color-trash-restore: #4caf50; - --color-trash-delete: #f44336; - --color-trash-empty-bg: #f0f0f0; - /* Primary (style.css) */ --color-primary: #2563eb; --color-primary-hover: #1d4ed8; diff --git a/static/css/components/csp-utilities.css b/static/css/components/csp-utilities.css index c9305fe9..adb5795a 100644 --- a/static/css/components/csp-utilities.css +++ b/static/css/components/csp-utilities.css @@ -7,7 +7,7 @@ margin-bottom: 16px; } .empty-state-icon.error { - color: var(--color-trash-delete); + color: var(--color-danger-text-alt); } .empty-state-icon.spinner { color: var(--color-text-medium); diff --git a/static/css/components/expiryChip.css b/static/css/components/expiryChip.css new file mode 100644 index 00000000..5b02895f --- /dev/null +++ b/static/css/components/expiryChip.css @@ -0,0 +1,63 @@ +/* + * Shared expiry chip — used by My Shares (link expiration), Trash + * (remaining lifetime), and any future expiry display. + * + * Produced by `formatExpiryChip(value)` in `core/formatters.js`. + * Six tiers map a date to an urgency colour: + * + * never → null value — neutral, infinity icon + * normal → > 30 days away — neutral grey + * caution → 8–30 days — soft amber + * soon → 2–7 days — soft orange + * urgent → today or tomorrow — soft red + * expired → past — deeper red, warning icon + * + * Colours are pastel/tinted on purpose: the chip should give an + * at-a-glance cue without competing visually with surrounding content. + */ + +.expiry-chip { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 4px; + padding: 2px 7px; + border-radius: 10px; + font-size: 11px; + white-space: nowrap; +} + +.expiry-chip__icon { + font-size: 10px; + opacity: 0.7; +} + +.expiry-chip--never { + background-color: var(--color-bg-muted); + color: var(--color-text-faint); +} + +.expiry-chip--normal { + background-color: var(--color-bg-muted); + color: var(--color-text-muted); +} + +.expiry-chip--caution { + background-color: var(--color-warning-bg-light); + color: var(--color-warning-text-amber); +} + +.expiry-chip--soon { + background-color: var(--color-warning-orange-bg); + color: var(--color-warning-orange-text); +} + +.expiry-chip--urgent { + background-color: var(--color-danger-light-bg); + color: var(--color-danger-text-alt); +} + +.expiry-chip--expired { + background-color: var(--color-error-bg); + color: var(--color-error-text-dark); +} diff --git a/static/css/components/resourceList.css b/static/css/components/resourceList.css index d80ffd41..94ee1e0e 100644 --- a/static/css/components/resourceList.css +++ b/static/css/components/resourceList.css @@ -118,12 +118,32 @@ align-items: center; } -.list-header.trash-header { - grid-template-columns: minmax(180px, 1.5fr) 0.5fr 1fr 140px 100px; +/* Trash view: Name → [Path] → Size → Date → Actions + * Override --files-list-columns so the trash header AND trash items align. + * No checkbox (selectable=false), no owner cell visible, no type column. + * + * Mobile-first: the Path column is hidden by default to keep the layout + * legible on narrow screens (path stays accessible via itemTooltip on hover). + * From 1000px upward, Path reappears and claims roughly half the table + * width via a 3fr share against Name's 1fr. */ +.files-list-view.trash-list { + --files-list-columns: minmax(180px, 1fr) 110px 130px 100px; } -.trash-item.file-item { - grid-template-columns: minmax(180px, 1.5fr) 0.5fr 1fr 140px 100px; +.files-list-view.trash-list .file-item .path-cell, +.files-list-view.trash-list .list-header.trash-header > div:nth-child(2) { + display: none; +} + +@media (min-width: 1000px) { + .files-list-view.trash-list { + --files-list-columns: minmax(180px, 1fr) 3fr 110px 130px 100px; + } + + .files-list-view.trash-list .file-item .path-cell, + .files-list-view.trash-list .list-header.trash-header > div:nth-child(2) { + display: block; + } } .list-header > div, @@ -238,7 +258,10 @@ text-align: right; } -.files-list-view .file-item .action-cell button, +/* Styles for the built-in action-cell buttons (favorite-star, kebab). + * `.btn-action` is excluded — it owns its own colors via the generic + * `.btn-action` rule + variant modifiers (e.g. `.btn-action--delete`). */ +.files-list-view .file-item .action-cell button:not(.btn-action), .files-list-view .file-item .action-cell div { display: inline; width: 28px; @@ -253,7 +276,7 @@ font-size: 16px; } -.files-list-view .file-item .action-cell button:hover { +.files-list-view .file-item .action-cell button:not(.btn-action):hover { background: var(--color-border-subtle); color: var(--color-text-dark); } @@ -677,9 +700,46 @@ height: 30px; } -/* — Trash — */ +/* ── Path column (opt-in via ResourceListConfig.showPath) ────────── + * Visible in list view only — grid cards hide it because they have no + * dedicated column slot. itemTooltip still surfaces the path on hover. + */ +.files-list-view .file-item .path-cell { + color: var(--color-text-secondary); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} -/* path-cell is shown in trashView; hide it in non-trash contexts */ -.file-item.trash-item > .path-cell { +.files-grid-view .file-item .path-cell { display: none; } + +/* ── Custom inline actions (ResourceListConfig.customActions) ────── + * Always visible in both list and grid view — used by trash for the + * restore / permanent-delete buttons that must be one click away. + */ +.btn-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + border: none; + background: transparent; + cursor: pointer; + color: var(--color-text-subtle); + font-size: 16px; + padding: 0; +} + +.btn-action:hover { + background: var(--color-border-subtle); + color: var(--color-text-dark); +} + +.files-grid-view .file-item .btn-action { + margin-top: 4px; +} diff --git a/static/css/components/roleChip.css b/static/css/components/roleChip.css new file mode 100644 index 00000000..e794766e --- /dev/null +++ b/static/css/components/roleChip.css @@ -0,0 +1,46 @@ +/* + * Shared role chip — used by My Shares (and any other surface that + * needs to display a permission role with consistent styling). + * + * Produced by `formatRoleChip(role)` / `buildRoleChip(role)` in + * `components/roleChip.js`. Three tiers: + * + * manage → orange (admin) + * edit → blue (editor) + * view → muted (viewer) + * + * Sized to match `.expiry-chip` so the role and expiry chips line up + * visually side by side. + */ + +.role-chip { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.role-chip__icon { + font-size: 10px; + opacity: 0.85; +} + +.role-chip--manage { + background: var(--color-badge-orange-bg); + color: var(--color-badge-orange-text); +} + +.role-chip--edit { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} + +.role-chip--view { + background: var(--color-bg-muted); + color: var(--color-text-muted); +} diff --git a/static/css/components/shareModal.css b/static/css/components/shareModal.css index c1e98753..48b9db26 100644 --- a/static/css/components/shareModal.css +++ b/static/css/components/shareModal.css @@ -177,7 +177,7 @@ border: 0.5px solid var(--color-border-medium); border-radius: 20px; background: var(--color-bg-hover); - font-size: 13px; + font-size: 14px; color: var(--color-text-heading); } @@ -485,7 +485,7 @@ width: 100%; box-sizing: border-box; padding: 4px 8px; - font-size: 12px; + font-size: 14px; border: 1px dashed var(--color-border-medium); border-radius: 6px; background: transparent; diff --git a/static/css/main.css b/static/css/main.css index 44c2bbcb..93d68f9f 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -15,6 +15,8 @@ @import url("./components/fileType.css"); @import url("./components/fileManager.css"); @import url("./components/resourceList.css"); +@import url("./components/expiryChip.css"); +@import url("./components/roleChip.css"); @import url("./components/contextMenu.css"); @import url("./components/dialogs.css"); @import url("./components/modals.css"); diff --git a/static/css/themes/dark.css b/static/css/themes/dark.css index 182f337c..5f7b1df3 100644 --- a/static/css/themes/dark.css +++ b/static/css/themes/dark.css @@ -58,9 +58,6 @@ --color-warning-bg: #3d2e00; --color-warning-bg-dark: #5a4200; --color-notification-bg: #1e293b; - --color-trash-surface: #1e293b; - --color-trash-border: #334155; - --color-trash-empty-bg: #334155; --color-user-menu-header-bg: linear-gradient(135deg, #1a2332 0%, #1e2940 100%); --color-user-menu-header-border: #3a2520; --color-info-bg: #0c1e35; @@ -68,6 +65,20 @@ --color-info-surface: #0c1e35; --color-danger-light-bg: #2a0c0c; --color-danger-lighter: #2a0c0c; + --color-error-text-dark: #f87171; + + /* Pastel chip backgrounds need dark equivalents — otherwise the light + * cream / white-blue / pink-cream backdrops glow against dark surfaces + * and the saturated text colours become unreadable on the now-dark fill. + * Same pattern: dark tinted background + lighter pastel text. */ + --color-badge-orange-bg: #2a1814; + --color-badge-orange-text: #ff8a65; + --color-badge-blue-bg: #0c2d48; + --color-badge-blue-text: #93c5fd; + --color-warning-bg-light: #2a2410; + --color-warning-text-amber: #fbbf24; + --color-warning-orange-bg: #2a1c10; + --color-warning-orange-text: #fb923c; --color-purple-bg: #1a0a33; --color-success-bg-alt: #0a2015; --color-success-bg-green: #0a2015; @@ -106,21 +117,6 @@ background-color: #1e293b; } -/* ── trash.css ── */ -/* FIXME avoir as much as possible specific cases */ -[data-theme="dark"] .trash-actions button, -[data-theme="dark"] .actions-cell button { - background: var(--color-bg-surface); - border-color: var(--color-border); - color: var(--color-text); -} - -[data-theme="dark"] .trash-actions button:hover, -[data-theme="dark"] .actions-cell button:hover { - background: var(--color-bg-alt); - color: var(--color-accent); -} - [data-theme="dark"] .smd-expiry-date-input::-webkit-calendar-picker-indicator { filter: invert(1); } diff --git a/static/css/views/mySharesView.css b/static/css/views/mySharesView.css index bf780612..800f5239 100644 --- a/static/css/views/mySharesView.css +++ b/static/css/views/mySharesView.css @@ -132,8 +132,12 @@ /* ── Grant row ───────────────────────────────────────────────────────────── */ +/* Four-column grid so the role pill and expiry chip line up vertically + * across every row in a lane. Reserved widths fit the longest expected + * label ("Can manage" → ~110px, "Expires Mar 5, 2026" → ~180px). */ .ms-grant-row { - display: flex; + display: grid; + grid-template-columns: 1fr 110px 180px auto; align-items: center; gap: 8px; padding: 7px 14px 7px 28px; @@ -141,6 +145,13 @@ transition: background 0.1s; } +/* Pill / chip sit at the start of their column rather than stretching + * to fill it — keeps the natural rounded shape. */ +.ms-grant-row > .role-chip, +.ms-grant-row > .expiry-chip { + justify-self: start; +} + .ms-grant-row:last-child { border-bottom: none; } @@ -212,65 +223,8 @@ text-decoration: underline; } -/* ── Role pill ───────────────────────────────────────────────────────────── */ - -.ms-role-pill { - display: inline-block; - flex-shrink: 0; - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - white-space: nowrap; -} - -.ms-role-pill--manage { - background: var(--color-badge-orange-bg); - color: var(--color-badge-orange-text); -} - -.ms-role-pill--edit { - background: var(--color-badge-blue-bg); - color: var(--color-badge-blue-text); -} - -.ms-role-pill--view { - background: var(--color-bg-muted); - color: var(--color-text-muted); -} - -/* ── Expiry chip ─────────────────────────────────────────────────────────── */ - -.ms-expiry-chip { - display: inline-flex; - align-items: center; - flex-shrink: 0; - gap: 4px; - padding: 2px 7px; - border-radius: 10px; - font-size: 11px; - white-space: nowrap; -} - -.ms-expiry-chip--never { - background: var(--color-bg-muted); - color: var(--color-text-faint); -} - -.ms-expiry-chip--active { - background: var(--color-bg-muted); - color: var(--color-text-muted); -} - -.ms-expiry-chip--soon { - background: var(--color-badge-amber-bg); - color: var(--color-badge-amber-text); -} - -.ms-expiry-chip--expired { - background: var(--color-danger-lighter); - color: var(--color-danger-text-alt); -} +/* Role chip styles moved to components/roleChip.css (shared component). + * Expiry chip styles moved to components/expiryChip.css (shared with Trash). */ /* ── Kebab / icon buttons ────────────────────────────────────────────────── */ diff --git a/static/css/views/trash.css b/static/css/views/trash.css index 53899cfb..24ab9369 100644 --- a/static/css/views/trash.css +++ b/static/css/views/trash.css @@ -1,58 +1,52 @@ -.trash-item { - position: relative; -} +/* + * Trash-specific styling. + * + * The trash list reuses the generic ResourceList layout entirely. Only the + * permanent-delete tint, the "Empty trash" danger button, and the + * remaining-days badge live here — everything else inherits from + * `.btn-action` and the generic `--color-*` tokens. + * + * Restore is intentionally a NEUTRAL action (no tint) — it just undoes a + * previous delete and shouldn't compete visually with the destructive action. + */ -.trash-actions { - position: absolute; - top: 10px; - right: 10px; - display: none; - gap: 8px; -} - -.files-grid-view .file-card.trash-item:hover .trash-actions, -.files-list-view .file-item.trash-item:hover .actions-cell { - display: flex; -} - -.trash-actions button, -.actions-cell button { - background: var(--color-trash-surface); - border: 1px solid var(--color-trash-border); - border-radius: 4px; - padding: 4px 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - transition: all 0.2s; -} - -.trash-actions button:hover, -.actions-cell button:hover { - background: var(--color-trash-empty-bg); -} - -.btn-restore { - color: var(--color-trash-restore); -} - -.btn-delete { - color: var(--color-trash-delete); -} - -/* .file-item.trash-item > .path-cell → resourceList.css */ - -.actions-cell { - display: flex; - gap: 8px; - justify-content: flex-start; - align-items: center; +.btn-action--delete { + color: var(--color-danger-text-alt); } .btn-danger { - background-color: var(--color-trash-delete); + background-color: var(--color-danger-bg); color: var(--color-danger-text); } + +.btn-danger:hover { + background-color: var(--color-danger-bg-hover); +} + +/* Expiry chip itself (`.expiry-chip`) lives in components/expiryChip.css + * since it's shared with My Shares. Below is just the Trash-specific + * grid-view overlay positioning. */ + +/* ── Grid view: anchor the chip on the card's top-right corner ─────── + * The generic `.date-cell` is hidden in grid view (resourceList.css); + * trash overrides this so the badge stays visible as an overlay. + * Scoped to `.trash-list` so other sections are unaffected. + * + * Negative offsets make the chip straddle the card edge so it visually + * "tags" the corner without eating into the centred icon/name area — + * no extra padding is needed on the card body. + */ +.files-grid-view.trash-list .file-item .date-cell { + display: block; + position: absolute; + top: -1px; + right: 3px; + z-index: 2; + padding: 0; +} + +/* A subtle shadow lifts the chip above the card border so it reads as + * a tag sitting on top, not as misaligned content. */ +.files-grid-view.trash-list .file-item .date-cell .expiry-chip { + box-shadow: 0 1px 3px var(--color-shadow-xs); +} diff --git a/static/index.html b/static/index.html index d5526fb6..9a74a6e9 100644 --- a/static/index.html +++ b/static/index.html @@ -49,7 +49,7 @@ - + diff --git a/static/js/app/main.js b/static/js/app/main.js index 26f67204..2fa50895 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -18,6 +18,7 @@ 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 { trashView } from '../views/trash/trashView.js'; import { checkAuthentication } from './authSession.js'; import { loadFiles } from './filesView.js'; import { @@ -34,7 +35,6 @@ import { } from './navigation.js'; import { performSearch } from './searchView.js'; import { app, appElements as elements } from './state.js'; -import { loadTrashItems } from './trashView.js'; import { ui } from './ui.js'; import { setupUserMenu } from './userMenu.js'; @@ -362,7 +362,7 @@ function setupActionsBarDelegation() { break; case 'empty-trash-btn': if (await fileOps.emptyTrash()) { - loadTrashItems(); + await trashView.init(); } break; case 'clear-recent-btn': diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index adec3924..9ec60235 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -15,10 +15,10 @@ import { favoritesView } from '../views/favorites/favoritesView.js'; import { mySharesView } from '../views/myShares/mySharesView.js'; import { recentView } from '../views/recent/recentView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; +import { trashView } from '../views/trash/trashView.js'; import { filesView, loadFiles, refreshSharedBadges } from './filesView.js'; import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js'; import { app, appElements } from './state.js'; -import { loadTrashItems } from './trashView.js'; import { ui } from './ui.js'; /** @@ -186,6 +186,11 @@ function setCurrentSection(section) { recentView.hide(); } + // Hide trashView "Load more" button when leaving the trash section + if (section !== 'trash' && trashView) { + trashView.hide(); + } + // Reset owner column — sections that need it re-enable it explicitly below. ui.setOwnerColumnVisible(false); @@ -410,8 +415,8 @@ function switchToTrashSection() { toggleFileContainer(true); setActionsBarMode('trash'); - setGroupByView(null); - syncGroupByMenu([]); + setGroupByView(trashView); + syncGroupByMenu(trashView.groupByDefs); //reset files view + remove any error ui.resetFilesList(); @@ -420,8 +425,8 @@ function switchToTrashSection() { restoreView('trash'); syncViewContainers(); - // Load trash items - loadTrashItems(); + // Load trash items (cursor-based view) + trashView.init(); if (batchToolbar) batchToolbar.clear(); } diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js deleted file mode 100644 index 91414e31..00000000 --- a/static/js/app/trashView.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Trash view loading and rendering logic - */ - -import { escapeHtml, formatDateTime } from '../core/formatters.js'; -import { i18n } from '../core/i18n.js'; -import { batchToolbar } from '../features/files/batchToolbar.js'; -import { fileOps } from '../features/files/fileOperations.js'; -import * as itemTooltip from '../features/itemTooltip.js'; -import { appElements } from './state.js'; -import { ui } from './ui.js'; - -/** Categories whose items have a server-side thumbnail. */ -const THUMBNAILABLE = new Set(['image', 'video', 'pdf']); - -/** - * - * @import {TrashItem} from '../core/types.js' - */ - -async function loadTrashItems() { - const elements = appElements; - - try { - if (batchToolbar) batchToolbar.clear(); - itemTooltip.destroy(elements.filesList); - ui.resetFilesList(); // ensure also list visible & error hidden - elements.filesList.innerHTML = ` -
-
${i18n.t('files.name')}
-
${i18n.t('files.type')}
-
${i18n.t('trash.original_location')}
-
${i18n.t('trash.deleted_date')}
-
${i18n.t('trash.actions')}
-
- `; - - ui.updateBreadcrumb(); - - const trashItems = await fileOps.getTrashItems(); - - if (trashItems.length === 0) { - ui.showError(` - -

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

- `); - return; - } - - trashItems.forEach((item) => { - addTrashItemToView(item); - }); - itemTooltip.init(elements.filesList); - } catch (error) { - console.error('Error loading trash items:', error); - ui.showNotification('Error', 'Error loading trash items'); - } -} - -/** - * - * @param {TrashItem} item - */ -function addTrashItemToView(item) { - const elements = appElements; - const isFile = item.item_type === 'file'; - - const formattedDate = formatDateTime(item.trashed_at); - - let iconClass; - let typeLabel; - let iconSpecialClass = ''; - if (!isFile) { - iconClass = item.icon_class || 'fas fa-folder'; - typeLabel = i18n.t('files.file_types.folder'); - } else { - iconClass = item.icon_class || (ui?.getIconClass ? ui.getIconClass(item.name) : 'fas fa-file'); - iconSpecialClass = ui?.getIconSpecialClass ? ui.getIconSpecialClass(item.name) : ''; - const cat = item.category || ''; - typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document'); - } - - const isFolder = !isFile; - const iconWrapClass = isFolder ? 'file-icon folder-icon' : `file-icon ${iconSpecialClass}`.trim(); - const canThumbnail = isFile && THUMBNAILABLE.has((item.category || '').toLowerCase()); - - const listElement = document.createElement('div'); - listElement.className = 'file-item trash-item'; - listElement.dataset.trashId = item.id; - listElement.dataset.originalId = item.original_id; - listElement.dataset.itemType = item.item_type; - if (item.original_path) listElement.dataset.path = item.original_path; - - listElement.innerHTML = ` -
-
- - ${canThumbnail ? `` : ''} -
- ${escapeHtml(item.name)} -
-
${escapeHtml(typeLabel)}
-
${escapeHtml(item.original_path || '--')}
-
${escapeHtml(formattedDate)}
-
- - -
- `; - - listElement.querySelector('.btn-restore').addEventListener('click', async (e) => { - e.stopPropagation(); - if (await fileOps.restoreFromTrash(item.id)) { - loadTrashItems(); - } - }); - - listElement.querySelector('.btn-delete').addEventListener('click', async (e) => { - e.stopPropagation(); - if (await fileOps.deletePermanently(item.id)) { - loadTrashItems(); - } - }); - - elements.filesList.appendChild(listElement); -} - -export { loadTrashItems }; diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js index 49a4ae2e..5c04e202 100644 --- a/static/js/components/mySharesList.js +++ b/static/js/components/mySharesList.js @@ -9,6 +9,7 @@ * 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource */ +import { formatExpiryChip } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; import { grants } from '../model/grants.js'; @@ -16,6 +17,7 @@ import { buildExpiryChip } from '../utils/expiryChip.js'; import { buildPasswordChip } from '../utils/passwordChip.js'; import { buildLinkChip } from './linkChip.js'; import { buildResourceIcon } from './resourceIcon.js'; +import { buildRoleChip, roleLabel } from './roleChip.js'; import { createUserVignette } from './userVignette.js'; /** @@ -38,24 +40,6 @@ function _expiryState(expiresAt) { return 'active'; } -/** @param {string} role @returns {string} */ -function _roleLabel(role) { - /** @type {Record} */ - const m = { - admin: i18n.t('share.role.canManage', 'Can manage'), - editor: i18n.t('share.role.canEdit', 'Can edit'), - viewer: i18n.t('share.role.canView', 'Can view') - }; - return m[role] ?? role; -} - -/** @param {string} role @returns {'manage'|'edit'|'view'} */ -function _roleMod(role) { - if (role === 'admin') return 'manage'; - if (role === 'editor') return 'edit'; - return 'view'; -} - class MySharesList { /** * @param {HTMLElement} container @@ -307,53 +291,23 @@ class MySharesList { /** @param {string} role @returns {HTMLElement} */ _buildRolePill(role) { - const pill = document.createElement('span'); - pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`; - pill.textContent = _roleLabel(role); - return pill; + return buildRoleChip(role); } /** - * 4-state expiry chip: never / active / soon / expired. + * Build the expiry chip as a DOM element. + * + * Delegates label/tier/icon decisions to the shared `formatExpiryChip` + * helper (used by Trash too) so all expiration chips look identical + * across the app and stay in sync as the design evolves. + * * @param {string|null} expiresAt * @returns {HTMLElement} */ _buildExpiryChip(expiresAt) { - const state = _expiryState(expiresAt); - const chip = document.createElement('span'); - chip.className = `ms-expiry-chip ms-expiry-chip--${state}`; - - const icon = document.createElement('i'); - const text = document.createTextNode(''); - - if (state === 'never') { - icon.className = 'fas fa-infinity'; - chip.appendChild(icon); - chip.appendChild(document.createTextNode(` ${i18n.t('myshares.neverExpires', 'Never expires')}`)); - } else if (state === 'expired') { - icon.className = 'fas fa-exclamation-triangle'; - chip.appendChild(icon); - chip.appendChild(document.createTextNode(` ${i18n.t('myshares.expired', 'Expired')}`)); - } else if (state === 'soon' && expiresAt) { - icon.className = 'fas fa-clock'; - const days = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000); - const label = - days <= 1 - ? i18n.t('myshares.expiresTomorrow', 'Expires tomorrow') - : i18n.t('myshares.expiresInDays', 'Expires in {n} days').replace('{n}', String(days)); - chip.appendChild(icon); - chip.appendChild(document.createTextNode(` ${label}`)); - } else if (expiresAt) { - icon.className = 'fas fa-clock'; - const d = new Date(expiresAt); - const fmt = d.toLocaleDateString('default', { day: 'numeric', month: 'short', year: 'numeric' }); - chip.appendChild(icon); - chip.appendChild(document.createTextNode(` ${i18n.t('myshares.until', 'Until')} ${fmt}`)); - } - - // unused ref kept to avoid TS unused-var warning suppression - void text; - return chip; + const tpl = document.createElement('template'); + tpl.innerHTML = formatExpiryChip(expiresAt); + return /** @type {HTMLElement} */ (tpl.content.firstElementChild); } // ── Kebab menu ──────────────────────────────────────────────────────────── @@ -395,7 +349,7 @@ class MySharesList { if (grant.subject_type === 'user') { for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) { const isCurrent = grant.role === role; - const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', _roleLabel(role), false, async () => { + const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => { menu.remove(); if (isCurrent) return; await grants.updateRole({ @@ -403,11 +357,8 @@ class MySharesList { resource: { type: item.resource_type, id: item.resource.id }, role }); - const pill = rowEl.querySelector('.ms-role-pill'); - if (pill) { - pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`; - pill.textContent = _roleLabel(role); - } + const pill = rowEl.querySelector('.role-chip'); + if (pill) pill.replaceWith(buildRoleChip(role)); grant.role = role; }); if (isCurrent) mi.classList.add('ms-menu-item--current'); diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 564c7807..56e39b8b 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -28,6 +28,14 @@ import { createUserVignette } from './userVignette.js'; * @import {FileItem, FolderItem} from '../core/types.js' */ +/** + * @typedef {Object} CustomAction + * @property {string} iconHtml - Inner HTML for the button icon (e.g. ``). + * @property {string} [labelKey] - i18n key used for the button's `title` / `aria-label`. + * @property {string} [className] - Extra CSS class(es) appended to `btn-action`. + * @property {(item: FileItem|FolderItem) => (void|Promise)} onClick + */ + /** * @typedef {Object} ResourceListConfig * @@ -38,12 +46,17 @@ import { createUserVignette } from './userVignette.js'; * @property {boolean} [showShareBadge=true] - Show the shared-resource badge on items. * @property {boolean} [draggable=false] - Mark items as draggable (HTML attribute). * @property {boolean} [showContextMenu=true] - Enable the three-dots button and right-click menu. + * @property {boolean} [showType=true] - Render the Type column. + * @property {boolean} [showPath=false] - Render the Path column (CSS hides it in grid mode). * * Appearance * @property {string} [itemModifierClass] - Extra CSS class applied to every .file-item * (e.g. 'favorite-item', 'recent-item'). * @property {string} [dateField='modified_at'] - Which date field to display in the date column. * @property {string} [dateLabel] - Column header label for the date column (i18n key). + * @property {(value: string | number | Date | null | undefined) => string} [dateFormatter] + * Override the date-cell formatter. Defaults to `formatDateTime`. Pass + * `formatDaysRemaining` for the Trash view to surface remaining lifetime. * * State providers (called at item-creation time) * @property {(id: string, type: 'file'|'folder') => boolean} [isFavorite] @@ -60,6 +73,11 @@ import { createUserVignette } from './userVignette.js'; * Called when the user clicks the shared badge. Falls back to onContextMenu if absent. * @property {(selected: Array) => void} [onSelectionChange] * Called whenever the selection set changes. + * + * Per-section inline actions + * @property {CustomAction[]} [customActions] + * Extra buttons rendered in the action cell (always visible, both grid and list view). + * Use this for section-specific verbs like restore / permanently-delete on trash. */ export class ResourceListComponent { @@ -70,7 +88,7 @@ export class ResourceListComponent { constructor(container, config) { this._container = container; - /** @type {Required> & ResourceListConfig} */ + /** @type {Required> & ResourceListConfig} */ this._cfg = { selectable: true, showFavorite: true, @@ -78,6 +96,8 @@ export class ResourceListComponent { showShareBadge: true, draggable: false, showContextMenu: true, + showType: true, + showPath: false, dateField: 'modified_at', ...config }; @@ -454,7 +474,7 @@ export class ResourceListComponent { const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false; const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false; const dateVal = /** @type {Record} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at; - const formattedDate = formatDateTime(new Date(dateVal)); + const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal)); el.innerHTML = ` ${cfg.selectable ? '
' : ''} @@ -465,10 +485,12 @@ export class ResourceListComponent { ${cfg.showShareBadge ? `
` : ''}
-
${i18n.t('files.file_types.folder')}
+ ${cfg.showPath ? `
${escapeHtml(folder.path || '')}
` : ''} + ${cfg.showType ? `
${i18n.t('files.file_types.folder')}
` : ''}
--
${formattedDate}
+ ${this._renderCustomActions()} ${cfg.showFavorite ? `` : ''} ${cfg.showContextMenu ? '' : ''}
@@ -490,7 +512,7 @@ export class ResourceListComponent { const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document'); const fileSize = file.size_formatted || formatFileSize(file.size); const dateVal = /** @type {Record} */ (/** @type {unknown} */ (file))[cfg.dateField] ?? file.modified_at; - const formattedDate = formatDateTime(new Date(dateVal)); + const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal)); const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false; const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false; @@ -513,10 +535,12 @@ export class ResourceListComponent { ${cfg.showShareBadge ? `
` : ''}
-
${typeLabel}
+ ${cfg.showPath ? `
${escapeHtml(file.path || '')}
` : ''} + ${cfg.showType ? `
${typeLabel}
` : ''}
${fileSize}
${formattedDate}
+ ${this._renderCustomActions()} ${cfg.showFavorite ? `` : ''} ${cfg.showContextMenu ? '' : ''}
@@ -527,6 +551,25 @@ export class ResourceListComponent { return el; } + /** + * Render the inline action buttons declared in `cfg.customActions`. + * Each button gets `data-custom-action=""` so the binder can + * dispatch by position. Returns an empty string when no actions are + * configured. + * @returns {string} + */ + _renderCustomActions() { + const actions = this._cfg.customActions; + if (!actions?.length) return ''; + return actions + .map((a, i) => { + const cls = a.className ? ` ${a.className}` : ''; + const label = a.labelKey ? escapeHtml(i18n.t(a.labelKey)) : ''; + return ``; + }) + .join(''); + } + /** * Attach direct event listeners to interactive elements inside a .file-item. * This covers buttons that must stop propagation before the delegated listener runs. @@ -547,6 +590,21 @@ export class ResourceListComponent { }); } + // Custom inline actions (e.g. restore / delete-permanently on trash) — + // bound directly so they stop propagation before the card-open handler. + if (cfg.customActions?.length) { + el.querySelectorAll('button[data-custom-action]').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + e.stopImmediatePropagation(); + e.preventDefault(); + const idx = Number(/** @type {HTMLElement} */ (btn).dataset.customAction); + const action = cfg.customActions?.[idx]; + if (action) action.onClick(item); + }); + }); + } + // Shared-badge click → open share modal (or fall back to context menu) if (cfg.showShareBadge && (cfg.onShareBadgeClick || cfg.onContextMenu)) { const badge = el.querySelector('.file-badge-shared'); diff --git a/static/js/components/roleChip.js b/static/js/components/roleChip.js new file mode 100644 index 00000000..de37dac9 --- /dev/null +++ b/static/js/components/roleChip.js @@ -0,0 +1,81 @@ +// @ts-check + +/** + * roleChip — shared role indicator (`Can manage` / `Can edit` / `Can view`). + * + * Returns the same chip HTML / element used by My Shares and any other + * surface that needs to display a permission role. Three states: + * + * admin → "Can manage" — crown icon, orange palette + * editor → "Can edit" — pencil icon, blue palette + * viewer → "Can view" — eye icon, neutral palette + * + * CSS lives in `components/roleChip.css`. + */ + +import { escapeHtml } from '../core/formatters.js'; +import { i18n } from '../core/i18n.js'; + +/** + * Translate a role identifier into the modifier suffix used for the chip's + * CSS class (`role-chip--`). Unknown roles default to `view`. + * @param {string} role + * @returns {'manage'|'edit'|'view'} + */ +function roleMod(role) { + if (role === 'admin') return 'manage'; + if (role === 'editor') return 'edit'; + return 'view'; +} + +/** + * Translate a role identifier into a localized human-readable label. + * Exported so callers that just want the label (e.g. context-menu rows) + * can reuse the same wording the chip uses. + * @param {string} role + * @returns {string} + */ +export function roleLabel(role) { + /** @type {Record} */ + const m = { + admin: i18n.t('share.role.canManage', 'Can manage'), + editor: i18n.t('share.role.canEdit', 'Can edit'), + viewer: i18n.t('share.role.canView', 'Can view') + }; + return m[role] ?? role; +} + +/** + * Map a role to its FontAwesome icon class. + * @param {string} role + * @returns {string} + */ +function roleIcon(role) { + if (role === 'admin') return 'fa-crown'; + if (role === 'editor') return 'fa-pencil-alt'; + return 'fa-eye'; +} + +/** + * Render the role chip as an HTML snippet. + * @param {string} role + * @returns {string} + */ +export function formatRoleChip(role) { + const mod = roleMod(role); + const icon = roleIcon(role); + const label = roleLabel(role); + return `${escapeHtml(label)}`; +} + +/** + * Build the role chip as a DOM element. Convenience for callers that need + * an Element (e.g. `row.appendChild(...)`). + * @param {string} role + * @returns {HTMLElement} + */ +export function buildRoleChip(role) { + const tpl = document.createElement('template'); + tpl.innerHTML = formatRoleChip(role); + return /** @type {HTMLElement} */ (tpl.content.firstElementChild); +} diff --git a/static/js/core/formatters.js b/static/js/core/formatters.js index 2b328dc1..a6ce579d 100644 --- a/static/js/core/formatters.js +++ b/static/js/core/formatters.js @@ -162,12 +162,169 @@ function normalizeExpiryBucket(value) { } const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000); if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired'); - if (daysUntil <= 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow'); + if (daysUntil === 0) return i18n.t('expiryBucket.today', 'Today'); + if (daysUntil === 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow'); if (daysUntil <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days'); if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days'); return String(date.getFullYear()); } +/** + * Format a future timestamp as a precise "days until" label. + * + * Used by the Trash view's date column to surface the remaining lifetime + * before the retention sweeper purges an item. Unlike `normalizeExpiryBucket` + * (which produces coarse bucket labels for grouping), this returns an + * exact-day count so users can see "In 27 days" at a glance. + * + * Buckets: + * < 0 → Expired + * = 0 → Today + * = 1 → Tomorrow + * > 1 → In N days + * + * @param {string | number | Date | null | undefined} value + * @returns {string} + */ +function formatDaysRemaining(value) { + if (value === null || value === undefined) return ''; + /** @type {Date} */ + let date; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number') { + date = new Date(value < 1e12 ? value * 1000 : value); + } else { + date = new Date(value); + } + if (Number.isNaN(date.getTime())) return String(value); + + const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000); + if (daysUntil < 0) return i18n.t('daysRemaining.expired', 'Expired'); + if (daysUntil === 0) return i18n.t('daysRemaining.today', 'Today'); + if (daysUntil === 1) return i18n.t('daysRemaining.tomorrow', 'Tomorrow'); + // Translation file holds the `{{count}}` template; the fallback is only + // used pre-load and embeds the literal count. + const translated = i18n.t('daysRemaining.inDays', { count: daysUntil }); + return translated === 'daysRemaining.inDays' ? `${daysUntil} days` : translated; +} + +/** + * Format a date as a compact "Mar 5, 2026" label. + * + * Shared helper used by `formatExpiryChip` (read-only chip) and by + * `buildExpiryChip` in `utils/expiryChip.js` (interactive editor) so the + * displayed deadline reads identically across both surfaces. + * + * Accepts: + * - `string` YYYY-MM-DD — parsed at LOCAL midnight (avoids the off-by-one + * shift `new Date("2026-05-30")` causes in negative-offset zones). + * - `string` ISO-8601 with time — parsed as-is. + * - `number` Unix seconds/ms (auto-detected at 1e12). + * - `Date` object — used directly. + * + * @param {string | number | Date | null | undefined} value + * @returns {string} + */ +function formatExpiryDate(value) { + if (value === null || value === undefined) return ''; + /** @type {Date} */ + let date; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number') { + date = new Date(value < 1e12 ? value * 1000 : value); + } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + // Bare YYYY-MM-DD: pin to local midnight so the day doesn't shift. + date = new Date(`${value}T00:00:00`); + } else { + date = new Date(value); + } + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +/** + * Render an expiry / retention date as a tiered chip. + * + * Single shared formatter used by My Shares (link expiry), Trash + * (remaining lifetime before purge), and any other section that needs + * to surface a future deadline. Output: + * + * `LABEL` + * + * Six tiers escalate cool → hot: + * - null value → `never` (neutral, infinity icon, "Never") + * - > 30 days → `normal` (neutral grey, "Until DATE") + * - 8–30 days → `caution` (soft amber, "N days") + * - 2–7 days → `soon` (soft orange, "N days") + * - 0–1 days → `urgent` (soft red, "Today" / "Tomorrow") + * - past deadline → `expired` (deeper red, warning icon, "Expired") + * + * The CSS lives in `components/expiryChip.css`. + * Caller is responsible for embedding the returned HTML safely — the + * label is taken from a controlled i18n key set (no user input). + * + * @param {string | number | Date | null | undefined} value + * @returns {string} HTML snippet + */ +function formatExpiryChip(value) { + // null/undefined is a valid input meaning "no deadline". + if (value === null || value === undefined) { + const label = i18n.t('expiryChip.never', 'Never expires'); + return `${escapeHtml(label)}`; + } + + /** @type {Date} */ + let date; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number') { + date = new Date(value < 1e12 ? value * 1000 : value); + } else { + date = new Date(value); + } + if (Number.isNaN(date.getTime())) return escapeHtml(String(value)); + + const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000); + + let tier; + let icon; + let label; + if (daysUntil < 0) { + tier = 'expired'; + icon = 'fa-exclamation-triangle'; + label = i18n.t('expiryChip.expired', 'Expired'); + } else if (daysUntil === 0) { + tier = 'urgent'; + icon = 'fa-clock'; + label = i18n.t('expiryChip.today', 'Expires today'); + } else if (daysUntil === 1) { + tier = 'urgent'; + icon = 'fa-clock'; + label = i18n.t('expiryChip.tomorrow', 'Expires tomorrow'); + } else if (daysUntil <= 7) { + tier = 'soon'; + icon = 'fa-calendar'; + const translated = i18n.t('expiryChip.inDays', { count: daysUntil }); + label = translated === 'expiryChip.inDays' ? `Expires in ${daysUntil} days` : translated; + } else if (daysUntil <= 30) { + tier = 'caution'; + icon = 'fa-calendar'; + const translated = i18n.t('expiryChip.inDays', { count: daysUntil }); + label = translated === 'expiryChip.inDays' ? `Expires in ${daysUntil} days` : translated; + } else { + // Far future — show absolute date so users see the exact deadline. + tier = 'normal'; + icon = 'fa-calendar'; + const fmt = formatExpiryDate(date); + const translated = i18n.t('expiryChip.onDate', { date: fmt }); + label = translated === 'expiryChip.onDate' ? `Expires ${fmt}` : translated; + } + + return `${escapeHtml(label)}`; +} + /** * Maps a file size in bytes to a coarse, human-readable bucket label. * @@ -201,6 +358,9 @@ export { escapeHtml, formatDateShort, formatDateTime, + formatDaysRemaining, + formatExpiryChip, + formatExpiryDate, formatFileSize, formatQuotaSize, isEmailValid, diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 679caff1..2ec374be 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -41,7 +41,6 @@ const OxiIcons = { 576, 'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L320 96z' ], - ban: [ 512, 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z' @@ -66,6 +65,10 @@ const OxiIcons = { 576, 'M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6c-12.5 0-22.6 10.1-22.6 22.6l0 29.1L364.3 320l29.1 0c12.5 0 22.6-10.1 22.6-22.6c0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8C76.5 307.5 64 337.7 64 369.2c0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9C0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8c30.6-30.6 45.9-73.1 42.3-115.8z' ], + calendar: [ + 512, + 'M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z' + ], 'caret-down': [ 320, 'M137.4 374.6c12.5 12.5 32.8 12.5 45.3 0l128-128c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8L32 192c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l128 128z' diff --git a/static/js/core/types.js b/static/js/core/types.js index d89054a8..8be5b305 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -115,17 +115,29 @@ */ /** - * @typedef {Object} TrashItem - * @property {string} id - * @property {string} original_id - * @property {ItemTypeEnum} item_type - * @property {string} name - * @property {string} original_path - timestamp - * @property {number} trashed_at - * @property {number} days_until_deletion - * @property {string} category - * @property {string} icon_class - * @property {string} icon_special_class + * One item returned by `GET /api/trash/resources`. + * `resource_type` discriminates the shape of `resource`. + * + * `deletion_date` is the real timestamp at which the retention sweeper will + * permanently delete the item (= trashed_at + retention_days). Days remaining + * is derived client-side from `deletion_date` and the current clock — it is + * not duplicated in the wire format. + * + * `resource.path` carries the item's original location (soft-delete preserves + * the row's `path` column). + * + * @typedef {Object} TrashResourceItem + * @property {ResourceTypeEnum} resource_type - 'file' | 'folder' + * @property {string} trashed_at - ISO-8601: when the user sent it to trash. + * @property {string} deletion_date - ISO-8601: when retention will purge it. + * @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type. + */ + +/** + * Response for `GET /api/trash/resources`. + * @typedef {Object} TrashResourcesResponse + * @property {TrashResourceItem[]} items + * @property {string|undefined} [next_cursor] - Absent when the last page is reached. */ /** diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index f5e2a78e..8cb6a3a7 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -11,8 +11,6 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { notifications } from '../../core/notifications.js'; -/** @import {TrashItem} from '../../core/types.js' */ - /** * @typedef {Object} BatchResult * @property {number} success number of files|folders sucessfully updated @@ -1246,28 +1244,6 @@ const fileOps = { } }, - /** - * Get trash items - * @returns {Promise>} - List of trash items - */ - async getTrashItems() { - try { - const response = await fetch('/api/trash', { - headers: getAuthHeaders() - }); - - if (response.ok) { - return /** @type {TrashItem[]} */ (await response.json()); - } else { - console.error('Error fetching trash items:', response.statusText); - return []; - } - } catch (error) { - console.error('Error fetching trash items:', error); - return []; - } - }, - /** * Restore an item from trash * @param {string} trashId - Trash item ID diff --git a/static/js/model/trashModel.js b/static/js/model/trashModel.js new file mode 100644 index 00000000..7d052ad3 --- /dev/null +++ b/static/js/model/trashModel.js @@ -0,0 +1,59 @@ +// @ts-check + +/** + * OxiCloud – Trash resources model. + * + * Thin fetch wrapper for `GET /api/trash/resources` (cursor-paginated). + * The legacy `GET /api/trash` endpoint is deprecated server-side and is no + * longer called from the UI. + */ + +/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../core/types.js' */ + +/** + * @typedef {Object} TrashResourceItem + * @property {ResourceTypeEnum} resource_type - 'file' | 'folder' + * @property {string} trashed_at - ISO-8601: when the item was sent to trash + * @property {string} deletion_date - ISO-8601: when retention will purge it + * @property {FileItem|FolderItem} resource - Full resource details (resource.path = original location) + */ + +/** + * @typedef {Object} TrashResourcesResponse + * @property {TrashResourceItem[]} items + * @property {string|undefined} [next_cursor] + */ + +/** + * Fetch one page of the current user's trashed resources. + * + * @param {{ + * cursor?: string, + * orderBy?: string, + * limit?: number, + * reverse?: boolean, + * resourceTypes?: ResourceTypeEnum[], + * }} [opts] + * @returns {Promise} + */ +async function fetchTrashPage({ cursor, orderBy = 'deletion_date', 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/trash/resources?${params}`, { + credentials: 'same-origin', + cache: 'no-store' + }); + + if (!res.ok) { + const err = /** @type {any} */ (new Error(`GET /api/trash/resources failed: ${res.status}`)); + err.status = res.status; + throw err; + } + + return /** @type {Promise} */ (res.json()); +} + +export { fetchTrashPage }; diff --git a/static/js/utils/expiryChip.js b/static/js/utils/expiryChip.js index 248412b6..344c25bc 100644 --- a/static/js/utils/expiryChip.js +++ b/static/js/utils/expiryChip.js @@ -10,17 +10,11 @@ * live in shareModal.css. */ +import { formatExpiryDate } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; -/** - * Format a YYYY-MM-DD string for display ("Dec 31, 2026"). - * @param {string} dateStr - * @returns {string} - */ -export function formatExpiryDate(dateStr) { - const d = new Date(`${dateStr}T00:00:00`); - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); -} +// Re-export so existing import sites keep working after the move to core/formatters.js. +export { formatExpiryDate }; /** * Build an interactive expiry chip. diff --git a/static/js/views/trash/trashView.js b/static/js/views/trash/trashView.js new file mode 100644 index 00000000..ad09e579 --- /dev/null +++ b/static/js/views/trash/trashView.js @@ -0,0 +1,449 @@ +// @ts-check + +/** + * OxiCloud – Trash view. + * + * Renders the user's trashed files and folders using the cursor-paginated + * `GET /api/trash/resources` endpoint. Default sort is by `deletion_date` ASC + * (items expiring soonest first) with group-by "remaining days" — the user's + * primary concern in this section. + * + * Public API mirrors `recentView`: + * - `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 { formatExpiryChip, normalizeDateBucket, normalizeExpiryBucket, sizeBucket } from '../../core/formatters.js'; +import { i18n } from '../../core/i18n.js'; +import * as viewPrefs from '../../core/viewPrefs.js'; +import { fileOps } from '../../features/files/fileOperations.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; +import { fetchTrashPage } from '../../model/trashModel.js'; + +/** @import {FileItem, FolderItem, ResourceTypeEnum, TrashResourceItem} from '../../core/types.js' */ + +/** + * @typedef {{ key: string, label: string, orderBy: string, reverseDefault?: boolean, + * keyFn: (item: FileItem|FolderItem) => string|null, + * labelFn?: (key: string) => string, + * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef + */ + +/** + * Group-by dimension definitions for the Trash section. + * + * The default `remainingDays` mode answers the user's most-asked question: + * "what's about to be deleted?" It orders by `deletion_date` ASC (soonest first) + * and groups via the existing `normalizeExpiryBucket` aggregator + * ("Tomorrow", "In less than 7 days", "In less than 30 days", …). + * + * `remainingDays` and `trashedTime` both touch the timestamp axis but use + * distinct server `orderBy` values so the API is self-documenting and the + * defaults can differ (ASC vs DESC). + * + * @type {GroupByDef[]} + */ +const GROUP_BY_DEFS = [ + { + key: 'remainingDays', + get label() { + return i18n.t('trash.groupby.remaining_days', 'Remaining days'); + }, + orderBy: 'deletion_date', + reverseDefault: false, + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.deletion_date ? normalizeExpiryBucket(r.deletion_date) : null; + } + }, + { + key: 'type', + get label() { + return i18n.t('groupby.type', 'Type'); + }, + orderBy: 'type', + reverseDefault: false, + 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: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + reverseDefault: false, + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, + { + key: 'trashedTime', + get label() { + return i18n.t('trash.groupby.trashed_time', 'Trashed time'); + }, + orderBy: 'trashed_at', + reverseDefault: false, + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.trashed_at ? normalizeDateBucket(r.trashed_at) : null; + } + } +]; + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'trash-load-more-wrapper'; + +const trashView = { + // ── State ───────────────────────────────────────────────────────────────── + + /** @type {string|null} */ + _nextCursor: null, + + _loading: false, + + /** @type {ResourceListComponent|null} */ + _component: null, + + /** + * Active group-by key. Default is `'remainingDays'` — items expiring soonest first. + * @type {string} + */ + _groupBy: 'remainingDays', + + /** 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('trash', this._groupBy, this._reversed, viewPrefs.load('trash').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('trash', this._groupBy, this._reversed, viewPrefs.load('trash').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * (Re-)enter the Trash section: restore saved prefs, create / reuse the + * component, and load page 1. + */ + async init() { + this._nextCursor = null; + this._loading = false; + const savedPrefs = viewPrefs.load('trash'); + this._groupBy = savedPrefs.groupBy || 'remainingDays'; + this._reversed = savedPrefs.reversed; + + this._ensureLoadMoreButton(); + + ui.resetFilesList(); + ui.updateBreadcrumb(); + + const filesList = document.getElementById('files-list'); + if (filesList) { + // Marker class so trash-specific CSS (column widths, corner badge) + // can scope itself without :has() and is removed when navigating away. + filesList.classList.add('trash-list'); + // Replace the generic header with a trash-specific one so the + // column labels match what ResourceList actually renders + // (Name → Path → Size → Date → Actions; no checkbox, no owner, no type). + const header = filesList.querySelector('.list-header'); + if (header) { + header.classList.add('trash-header'); + header.innerHTML = ` +
${i18n.t('files.name', 'Name')}
+
${i18n.t('trash.original_location', 'Original location')}
+
${i18n.t('files.size', 'Size')}
+
${i18n.t('trash.remaining', 'Remaining')}
+
+ `; + } + + if (!this._component) { + this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: false, + showFavorite: false, + showOwner: false, + showShareBadge: false, + showContextMenu: false, + showType: false, + showPath: true, + draggable: false, + // Show the *remaining lifetime* before retention purges the item + // ("In 27 days", "Tomorrow", "Expired") rather than a raw + // timestamp — that is what the user actually wants to know in + // this section. The "trashed time" is still available via the + // trashedTime groupBy header. + dateField: 'deletion_date', + dateLabel: 'trash.deleted_date', + dateFormatter: formatExpiryChip, + customActions: [ + { + iconHtml: '', + labelKey: 'trash.restore', + className: 'btn-action--restore', + onClick: async (item) => { + if (await fileOps.restoreFromTrash(item.id)) { + await this._reloadFromTop(); + } + } + }, + { + iconHtml: '', + labelKey: 'trash.delete_permanently', + className: 'btn-action--delete', + onClick: async (item) => { + if (await fileOps.deletePermanently(item.id)) { + await this._reloadFromTop(); + } + } + } + ] + }); + } + } + + await this._loadPage(); + }, + + /** + * Hide the "Load more" button when leaving this section. + */ + hide() { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.add('hidden'); + + const filesList = document.getElementById('files-list'); + if (filesList) { + filesList.classList.remove('trash-list'); + itemTooltip.destroy(filesList); + } + }, + + // ── Internal helpers ────────────────────────────────────────────────────── + + /** + * Discard the current page state and re-fetch from the start. + * Used after restore / permanent-delete to refresh the visible set. + * @returns {Promise} + */ + async _reloadFromTop() { + this._nextCursor = null; + this._component?.clear(); + await this._loadPage(); + }, + + /** + * 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 ?? 'deletion_date'; + + const data = await fetchTrashPage({ + 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('trash.empty_state', 'Trash is empty')}

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

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

+ `); + console.error('trashView: load error', err); + } finally { + this._loading = false; + } + }, + + /** + * Map `TrashResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving + * server order. Stamps `trashed_at` and `deletion_date` onto each item so + * the date column and the `remainingDays` / `trashedTime` keyFns can read + * them directly. + * + * @param {TrashResourceItem[]} items + * @returns {Array} + */ + _mapItems(items) { + /** @type {Array} */ + const result = []; + + 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, + // Stamp trash-specific timestamps so the date column + + // remainingDays/trashedTime keyFns can read them. + trashed_at: item.trashed_at, + deletion_date: item.deletion_date, + 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 is required by FileItem but unused in Trash — + // we group by trashed_at / deletion_date instead. + sort_date: 0, + trashed_at: item.trashed_at, + deletion_date: item.deletion_date, + 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 = 'trash-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 { trashView }; diff --git a/static/locales/en.json b/static/locales/en.json index 5e1fa75c..220fcfc5 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -341,10 +341,29 @@ "empty_state": "Trash is empty", "original_location": "Original location", "deleted_date": "Deletion date", + "remaining": "Remaining", "actions": "Actions", "restore": "Restore", "delete_permanently": "Delete permanently", - "empty_confirm": "Are you sure you want to empty the trash? This will permanently delete all items." + "empty_confirm": "Are you sure you want to empty the trash? This will permanently delete all items.", + "groupby": { + "remaining_days": "Remaining days", + "trashed_time": "Trashed time" + } + }, + "daysRemaining": { + "expired": "Expired", + "today": "Today", + "tomorrow": "Tomorrow", + "inDays": "{{count}} days" + }, + "expiryChip": { + "never": "Never expires", + "expired": "Expired", + "today": "Expires today", + "tomorrow": "Expires tomorrow", + "inDays": "Expires in {{count}} days", + "onDate": "Expires {{date}}" }, "auth": { "login_title": "Sign in", diff --git a/tests/api/run.sh b/tests/api/run.sh index 3284c903..f25e3c7d 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -93,6 +93,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/files-folders.hurl" \ "$API_DIR/favorites.hurl" \ "$API_DIR/trash.hurl" \ + "$API_DIR/trash_resources.hurl" \ "$API_DIR/recent.hurl" \ "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ diff --git a/tests/api/trash_resources.hurl b/tests/api/trash_resources.hurl new file mode 100644 index 00000000..a7721895 --- /dev/null +++ b/tests/api/trash_resources.hurl @@ -0,0 +1,270 @@ +# ============================================================= +# OxiCloud – Cursor-paginated trash listing (GET /api/trash/resources) +# ============================================================= +# Depends on files-folders.hurl having run first (home folder exists). +# Runs after trash.hurl, which leaves an empty trash. +# +# Verifies: +# - Empty trash returns items:[] and no next_cursor +# - 401 without auth +# - Default order_by = deletion_date (soonest-expiring first) +# - order_by=name groups folders before files +# - order_by=size: folders cluster via -1 sentinel +# - resource_types=file / =folder filters narrow the result set +# - Pagination: limit=1 + cursor walks the whole set +# - Cursor is opaque (returned next_cursor is a non-empty string) +# +# Run: +# hurl --variables-file tests/api/test.env --test tests/api/trash_resources.hurl +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Login + capture token +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – Trash starts empty (trash.hurl left it that way) +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/trash/resources +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" isCollection +jsonpath "$.items" count == 0 +jsonpath "$.next_cursor" not exists + + +# ───────────────────────────────────────────────────────────── +# Step 4 – Capture the home folder ID +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +home_folder_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 – Create three folders + two files to trash +# folders: trash-res-A, trash-res-B +# files: alpha.txt (in home), beta.txt (in home) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "trash-res-A", + "parent_id": "{{home_folder_id}}" +} +HTTP 201 +[Captures] +folder_a_id: jsonpath "$.id" + + +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "trash-res-B", + "parent_id": "{{home_folder_id}}" +} +HTTP 201 +[Captures] +folder_b_id: jsonpath "$.id" + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{token}} +[MultipartFormData] +folder_id: {{home_folder_id}} +file: file,fixtures/hello.txt; text/plain +HTTP 201 +[Captures] +file_alpha_id: jsonpath "$.id" + + +# Rename uploaded file to alpha.txt so order_by=name has a predictable result +PUT {{base_url}}/api/files/{{file_alpha_id}}/rename +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "alpha.txt" +} + +HTTP 200 + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{token}} +[MultipartFormData] +folder_id: {{home_folder_id}} +file: file,fixtures/hello.txt; text/plain +HTTP 201 +[Captures] +file_beta_id: jsonpath "$.id" + + +PUT {{base_url}}/api/files/{{file_beta_id}}/rename +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "beta.txt" +} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 6 – Move all four items to trash +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/trash/files/{{file_alpha_id}} +Authorization: Bearer {{token}} +HTTP 200 + + +DELETE {{base_url}}/api/trash/files/{{file_beta_id}} +Authorization: Bearer {{token}} +HTTP 200 + + +DELETE {{base_url}}/api/trash/folders/{{folder_a_id}} +Authorization: Bearer {{token}} +HTTP 200 + + +DELETE {{base_url}}/api/trash/folders/{{folder_b_id}} +Authorization: Bearer {{token}} +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 7 – Default listing: 4 items, all carry trashed_at + deletion_date +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/trash/resources +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 4 +jsonpath "$.items[0].trashed_at" isString +jsonpath "$.items[0].deletion_date" isString +jsonpath "$.items[0].resource_type" matches "^(file|folder)$" +jsonpath "$.items[*].resource.id" contains {{folder_a_id}} +jsonpath "$.items[*].resource.id" contains {{folder_b_id}} +jsonpath "$.items[*].resource.id" contains {{file_alpha_id}} +jsonpath "$.items[*].resource.id" contains {{file_beta_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 8 – order_by=name: folders sort before files (folder_first axis), +# then alphabetic. Expected order: +# [trash-res-A, trash-res-B, alpha.txt, beta.txt] +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/trash/resources?order_by=name +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items[0].resource.name" == "trash-res-A" +jsonpath "$.items[1].resource.name" == "trash-res-B" +jsonpath "$.items[2].resource.name" == "alpha.txt" +jsonpath "$.items[3].resource.name" == "beta.txt" + + +# ───────────────────────────────────────────────────────────── +# Step 9 – order_by=size: folders use sentinel -1 so they cluster +# together (before files with size >= 0). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/trash/resources?order_by=size +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 4 +jsonpath "$.items[0].resource_type" == "folder" +jsonpath "$.items[1].resource_type" == "folder" +jsonpath "$.items[2].resource_type" == "file" +jsonpath "$.items[3].resource_type" == "file" + + +# ───────────────────────────────────────────────────────────── +# Step 10 – resource_types=file → only files (2) +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/trash/resources?resource_types=file +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 2 +jsonpath "$.items[0].resource_type" == "file" +jsonpath "$.items[1].resource_type" == "file" + + +# ───────────────────────────────────────────────────────────── +# Step 11 – resource_types=folder → only folders (2) +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/trash/resources?resource_types=folder +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 2 +jsonpath "$.items[0].resource_type" == "folder" +jsonpath "$.items[1].resource_type" == "folder" + + +# ───────────────────────────────────────────────────────────── +# Step 12 – Pagination: limit=1 returns 1 item + a next_cursor +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/trash/resources?order_by=name&limit=1 +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +page1_cursor: jsonpath "$.next_cursor" +[Asserts] +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource.name" == "trash-res-A" +jsonpath "$.next_cursor" isString + + +# Step 13 – Walk to page 2 with the captured cursor +GET {{base_url}}/api/trash/resources?order_by=name&limit=1&cursor={{page1_cursor}} +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 1 +jsonpath "$.items[0].resource.name" == "trash-res-B" +jsonpath "$.next_cursor" isString + + +# ───────────────────────────────────────────────────────────── +# Step 14 – Cleanup: empty trash so subsequent tests see a clean slate +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/trash/empty +Authorization: Bearer {{token}} + +HTTP 200 + + +GET {{base_url}}/api/trash/resources +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 0 diff --git a/tests/common/server.env b/tests/common/server.env index b8f142f5..5b2e0b4d 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -20,5 +20,5 @@ RUST_LOG=warn #RUST_LOG=info # grow up limits for tests -OXICLOUD_RATE_LIMIT_REFRESH_MAX=120 -OXICLOUD_RATE_LIMIT_LOGIN_MAX=120 +OXICLOUD_RATE_LIMIT_REFRESH_MAX=360 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=360