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)
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
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
|
/// DTO representing an item in the trash
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -38,3 +43,122 @@ pub struct RestoreFromTrashRequest {
|
|||||||
pub struct DeletePermanentlyRequest {
|
pub struct DeletePermanentlyRequest {
|
||||||
pub trash_id: String,
|
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<Uuid>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
/// `-1` for folders (sentinel), actual byte-count for files.
|
||||||
|
pub size: i64,
|
||||||
|
pub resource_created_at: DateTime<Utc>,
|
||||||
|
pub modified_at: DateTime<Utc>,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub trashed_at: DateTime<Utc>,
|
||||||
|
pub deletion_date: DateTime<Utc>,
|
||||||
|
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
|
||||||
|
pub path: Option<String>,
|
||||||
|
// Pre-computed sort fields for cursor construction.
|
||||||
|
pub sort_str: Option<String>,
|
||||||
|
pub sort_int: Option<i64>,
|
||||||
|
pub sort_ts: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<i64>,
|
||||||
|
/// Timestamp for `deletion_date` and `trashed_at` sorts.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sort_ts: Option<DateTime<Utc>>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Sort / group-by dimension. Supported: `"deletion_date"` (default — soonest
|
||||||
|
/// expiry first), `"trashed_at"` (most recently trashed first), `"name"`,
|
||||||
|
/// `"type"`, `"size"`.
|
||||||
|
pub order_by: Option<String>,
|
||||||
|
/// Comma-separated resource types to include, e.g. `"file,folder"`.
|
||||||
|
/// Omit to include both.
|
||||||
|
pub resource_types: Option<String>,
|
||||||
|
/// 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<TrashCursor> {
|
||||||
|
self.cursor.as_deref().and_then(TrashCursor::decode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `None` when `resource_types` is absent (= include all).
|
||||||
|
pub fn resource_kinds(&self) -> Option<Vec<ResourceKind>> {
|
||||||
|
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<Utc>,
|
||||||
|
/// When the item will be permanently deleted by the retention sweeper.
|
||||||
|
pub deletion_date: DateTime<Utc>,
|
||||||
|
/// Full resource details — shape determined by `resource_type`.
|
||||||
|
pub resource: ResourceContentDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response envelope for `GET /api/trash/resources`.
|
||||||
|
pub type TrashResourcesDto = CursorListResponse<TrashResourceItemDto>;
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ use std::sync::Arc;
|
|||||||
use tracing::{debug, error, info, instrument, warn};
|
use tracing::{debug, error, info, instrument, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::cursor::PageCursor;
|
||||||
use crate::application::dtos::display_helpers::{
|
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::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
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::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
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_read_repository::FileBlobReadRepository;
|
||||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||||
@@ -731,3 +738,124 @@ impl TrashUseCase for TrashService {
|
|||||||
Ok(())
|
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<TrashCursor>,
|
||||||
|
order_by: &str,
|
||||||
|
kinds: Option<&[ResourceKind]>,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<(Vec<TrashResourceItemDto>, Option<String>)> {
|
||||||
|
// 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<TrashResourceItemDto> = 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
//! are files/folders with `is_trashed = TRUE`.
|
//! are files/folders with `is_trashed = TRUE`.
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::PgPool;
|
use sqlx::{PgPool, Row};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tracing::error;
|
||||||
use uuid::Uuid;
|
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::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// Default retention period (days) used when computing deletion_date.
|
/// Default retention period (days) used when computing deletion_date.
|
||||||
const _DEFAULT_RETENTION_DAYS: i64 = 30;
|
const _DEFAULT_RETENTION_DAYS: i64 = 30;
|
||||||
@@ -215,3 +218,271 @@ impl TrashRepository for TrashDbRepository {
|
|||||||
Ok((files_deleted, folders_deleted))
|
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<Vec<TrashResourceRow>> {
|
||||||
|
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<i64> = cursor.and_then(|c| c.sort_int);
|
||||||
|
let cur_ts: Option<chrono::DateTime<chrono::Utc>> = cursor.and_then(|c| c.sort_ts);
|
||||||
|
let cur_id: Option<Uuid> = 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<String> = 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<Utc> = row.get("trashed_at");
|
||||||
|
let deletion_date: DateTime<Utc> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::{debug, error, instrument, warn};
|
use tracing::{debug, error, instrument, warn};
|
||||||
|
|
||||||
|
use crate::application::dtos::trash_dto::{TrashResourcesDto, TrashResourcesQuery};
|
||||||
use crate::application::ports::trash_ports::TrashUseCase;
|
use crate::application::ports::trash_ports::TrashUseCase;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
use std::sync::Arc;
|
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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/trash",
|
path = "/api/trash",
|
||||||
responses(
|
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")
|
(status = 501, description = "Trash feature not enabled")
|
||||||
),
|
),
|
||||||
security(("bearerAuth" = [])),
|
security(("bearerAuth" = [])),
|
||||||
@@ -30,6 +38,10 @@ pub async fn get_trash_items(
|
|||||||
// privilege escalation attacks.
|
// privilege escalation attacks.
|
||||||
let effective_user = auth_user.id;
|
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);
|
debug!("Request to list trash items for user {}", effective_user);
|
||||||
|
|
||||||
let trash_service = match state.trash_service.as_ref() {
|
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<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Query(q): Query<TrashResourcesQuery>,
|
||||||
|
) -> 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
|
/// Moves a file to the trash
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ use crate::application::dtos::search_dto::{
|
|||||||
};
|
};
|
||||||
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
|
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
|
||||||
use crate::application::dtos::trash_dto::{
|
use crate::application::dtos::trash_dto::{
|
||||||
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
|
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashResourceItemDto,
|
||||||
|
TrashResourcesDto, TrashedItemDto,
|
||||||
};
|
};
|
||||||
use crate::application::dtos::user_dto::{
|
use crate::application::dtos::user_dto::{
|
||||||
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
|
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
|
||||||
@@ -121,6 +122,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
handlers::dedup_handler::recalculate_stats,
|
handlers::dedup_handler::recalculate_stats,
|
||||||
// Trash handlers (free functions)
|
// Trash handlers (free functions)
|
||||||
handlers::trash_handler::get_trash_items,
|
handlers::trash_handler::get_trash_items,
|
||||||
|
handlers::trash_handler::get_trash_resources,
|
||||||
handlers::trash_handler::move_file_to_trash,
|
handlers::trash_handler::move_file_to_trash,
|
||||||
handlers::trash_handler::move_folder_to_trash,
|
handlers::trash_handler::move_folder_to_trash,
|
||||||
handlers::trash_handler::restore_from_trash,
|
handlers::trash_handler::restore_from_trash,
|
||||||
@@ -259,6 +261,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
UpdateShareDto,
|
UpdateShareDto,
|
||||||
// Trash schemas
|
// Trash schemas
|
||||||
TrashedItemDto,
|
TrashedItemDto,
|
||||||
|
TrashResourceItemDto,
|
||||||
|
TrashResourcesDto,
|
||||||
MoveToTrashRequest,
|
MoveToTrashRequest,
|
||||||
RestoreFromTrashRequest,
|
RestoreFromTrashRequest,
|
||||||
DeletePermanentlyRequest,
|
DeletePermanentlyRequest,
|
||||||
|
|||||||
@@ -431,13 +431,17 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
if let Some(_trash_service_ref) = trash_service.clone() {
|
if let Some(_trash_service_ref) = trash_service.clone() {
|
||||||
tracing::info!("Setting up trash routes for trash view");
|
tracing::info!("Setting up trash routes for trash view");
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
let trash_router = Router::new()
|
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("/files/{id}", delete(trash_handler::move_file_to_trash))
|
||||||
.route("/folders/{id}", delete(trash_handler::move_folder_to_trash))
|
.route("/folders/{id}", delete(trash_handler::move_folder_to_trash))
|
||||||
.route("/{id}/restore", post(trash_handler::restore_from_trash))
|
.route("/{id}/restore", post(trash_handler::restore_from_trash))
|
||||||
.route("/{id}", delete(trash_handler::delete_permanently))
|
.route("/{id}", delete(trash_handler::delete_permanently))
|
||||||
.route("/empty", delete(trash_handler::empty_trash))
|
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
router = router.nest("/trash", trash_router);
|
router = router.nest("/trash", trash_router);
|
||||||
|
|||||||
@@ -290,13 +290,6 @@
|
|||||||
--color-share-remove-text: #b71c1c;
|
--color-share-remove-text: #b71c1c;
|
||||||
--color-share-owner-text: #757575;
|
--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) */
|
/* Primary (style.css) */
|
||||||
--color-primary: #2563eb;
|
--color-primary: #2563eb;
|
||||||
--color-primary-hover: #1d4ed8;
|
--color-primary-hover: #1d4ed8;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.empty-state-icon.error {
|
.empty-state-icon.error {
|
||||||
color: var(--color-trash-delete);
|
color: var(--color-danger-text-alt);
|
||||||
}
|
}
|
||||||
.empty-state-icon.spinner {
|
.empty-state-icon.spinner {
|
||||||
color: var(--color-text-medium);
|
color: var(--color-text-medium);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -118,12 +118,32 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-header.trash-header {
|
/* Trash view: Name → [Path] → Size → Date → Actions
|
||||||
grid-template-columns: minmax(180px, 1.5fr) 0.5fr 1fr 140px 100px;
|
* 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 {
|
.files-list-view.trash-list .file-item .path-cell,
|
||||||
grid-template-columns: minmax(180px, 1.5fr) 0.5fr 1fr 140px 100px;
|
.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,
|
.list-header > div,
|
||||||
@@ -238,7 +258,10 @@
|
|||||||
text-align: right;
|
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 {
|
.files-list-view .file-item .action-cell div {
|
||||||
display: inline;
|
display: inline;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -253,7 +276,7 @@
|
|||||||
font-size: 16px;
|
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);
|
background: var(--color-border-subtle);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
}
|
}
|
||||||
@@ -677,9 +700,46 @@
|
|||||||
height: 30px;
|
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 */
|
.files-grid-view .file-item .path-cell {
|
||||||
.file-item.trash-item > .path-cell {
|
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
border: 0.5px solid var(--color-border-medium);
|
border: 0.5px solid var(--color-border-medium);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: var(--color-bg-hover);
|
background: var(--color-bg-hover);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +485,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
border: 1px dashed var(--color-border-medium);
|
border: 1px dashed var(--color-border-medium);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
@import url("./components/fileType.css");
|
@import url("./components/fileType.css");
|
||||||
@import url("./components/fileManager.css");
|
@import url("./components/fileManager.css");
|
||||||
@import url("./components/resourceList.css");
|
@import url("./components/resourceList.css");
|
||||||
|
@import url("./components/expiryChip.css");
|
||||||
|
@import url("./components/roleChip.css");
|
||||||
@import url("./components/contextMenu.css");
|
@import url("./components/contextMenu.css");
|
||||||
@import url("./components/dialogs.css");
|
@import url("./components/dialogs.css");
|
||||||
@import url("./components/modals.css");
|
@import url("./components/modals.css");
|
||||||
|
|||||||
+14
-18
@@ -58,9 +58,6 @@
|
|||||||
--color-warning-bg: #3d2e00;
|
--color-warning-bg: #3d2e00;
|
||||||
--color-warning-bg-dark: #5a4200;
|
--color-warning-bg-dark: #5a4200;
|
||||||
--color-notification-bg: #1e293b;
|
--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-bg: linear-gradient(135deg, #1a2332 0%, #1e2940 100%);
|
||||||
--color-user-menu-header-border: #3a2520;
|
--color-user-menu-header-border: #3a2520;
|
||||||
--color-info-bg: #0c1e35;
|
--color-info-bg: #0c1e35;
|
||||||
@@ -68,6 +65,20 @@
|
|||||||
--color-info-surface: #0c1e35;
|
--color-info-surface: #0c1e35;
|
||||||
--color-danger-light-bg: #2a0c0c;
|
--color-danger-light-bg: #2a0c0c;
|
||||||
--color-danger-lighter: #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-purple-bg: #1a0a33;
|
||||||
--color-success-bg-alt: #0a2015;
|
--color-success-bg-alt: #0a2015;
|
||||||
--color-success-bg-green: #0a2015;
|
--color-success-bg-green: #0a2015;
|
||||||
@@ -106,21 +117,6 @@
|
|||||||
background-color: #1e293b;
|
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 {
|
[data-theme="dark"] .smd-expiry-date-input::-webkit-calendar-picker-indicator {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,8 +132,12 @@
|
|||||||
|
|
||||||
/* ── Grant row ───────────────────────────────────────────────────────────── */
|
/* ── 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 {
|
.ms-grant-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 110px 180px auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 7px 14px 7px 28px;
|
padding: 7px 14px 7px 28px;
|
||||||
@@ -141,6 +145,13 @@
|
|||||||
transition: background 0.1s;
|
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 {
|
.ms-grant-row:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
@@ -212,65 +223,8 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Role pill ───────────────────────────────────────────────────────────── */
|
/* Role chip styles moved to components/roleChip.css (shared component).
|
||||||
|
* Expiry chip styles moved to components/expiryChip.css (shared with Trash). */
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Kebab / icon buttons ────────────────────────────────────────────────── */
|
/* ── Kebab / icon buttons ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
+46
-52
@@ -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 {
|
.btn-action--delete {
|
||||||
position: absolute;
|
color: var(--color-danger-text-alt);
|
||||||
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-danger {
|
.btn-danger {
|
||||||
background-color: var(--color-trash-delete);
|
background-color: var(--color-danger-bg);
|
||||||
color: var(--color-danger-text);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@
|
|||||||
<script defer type="module" src="/js/app/authSession.js"></script>
|
<script defer type="module" src="/js/app/authSession.js"></script>
|
||||||
<script defer type="module" src="/js/app/userMenu.js"></script>
|
<script defer type="module" src="/js/app/userMenu.js"></script>
|
||||||
<script defer type="module" src="/js/app/filesView.js"></script>
|
<script defer type="module" src="/js/app/filesView.js"></script>
|
||||||
<script defer type="module" src="/js/app/trashView.js"></script>
|
<script defer type="module" src="/js/views/trash/trashView.js"></script>
|
||||||
<script defer type="module" src="/js/app/searchView.js"></script>
|
<script defer type="module" src="/js/app/searchView.js"></script>
|
||||||
<script defer type="module" src="/js/app/main.js"></script>
|
<script defer type="module" src="/js/app/main.js"></script>
|
||||||
<script defer type="module" src="/js/app/bootstrap.js"></script>
|
<script defer type="module" src="/js/app/bootstrap.js"></script>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { recent } from '../features/library/recent.js';
|
|||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
import { grants } from '../model/grants.js';
|
import { grants } from '../model/grants.js';
|
||||||
import { recentView } from '../views/recent/recentView.js';
|
import { recentView } from '../views/recent/recentView.js';
|
||||||
|
import { trashView } from '../views/trash/trashView.js';
|
||||||
import { checkAuthentication } from './authSession.js';
|
import { checkAuthentication } from './authSession.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,6 @@ import {
|
|||||||
} from './navigation.js';
|
} from './navigation.js';
|
||||||
import { performSearch } from './searchView.js';
|
import { performSearch } from './searchView.js';
|
||||||
import { app, appElements as elements } from './state.js';
|
import { app, appElements as elements } from './state.js';
|
||||||
import { loadTrashItems } from './trashView.js';
|
|
||||||
import { ui } from './ui.js';
|
import { ui } from './ui.js';
|
||||||
import { setupUserMenu } from './userMenu.js';
|
import { setupUserMenu } from './userMenu.js';
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ function setupActionsBarDelegation() {
|
|||||||
break;
|
break;
|
||||||
case 'empty-trash-btn':
|
case 'empty-trash-btn':
|
||||||
if (await fileOps.emptyTrash()) {
|
if (await fileOps.emptyTrash()) {
|
||||||
loadTrashItems();
|
await trashView.init();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'clear-recent-btn':
|
case 'clear-recent-btn':
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import { favoritesView } from '../views/favorites/favoritesView.js';
|
|||||||
import { mySharesView } from '../views/myShares/mySharesView.js';
|
import { mySharesView } from '../views/myShares/mySharesView.js';
|
||||||
import { recentView } from '../views/recent/recentView.js';
|
import { recentView } from '../views/recent/recentView.js';
|
||||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||||
|
import { trashView } from '../views/trash/trashView.js';
|
||||||
import { filesView, loadFiles, refreshSharedBadges } from './filesView.js';
|
import { filesView, loadFiles, refreshSharedBadges } from './filesView.js';
|
||||||
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||||
import { app, appElements } from './state.js';
|
import { app, appElements } from './state.js';
|
||||||
import { loadTrashItems } from './trashView.js';
|
|
||||||
import { ui } from './ui.js';
|
import { ui } from './ui.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,6 +186,11 @@ function setCurrentSection(section) {
|
|||||||
recentView.hide();
|
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.
|
// Reset owner column — sections that need it re-enable it explicitly below.
|
||||||
ui.setOwnerColumnVisible(false);
|
ui.setOwnerColumnVisible(false);
|
||||||
|
|
||||||
@@ -410,8 +415,8 @@ function switchToTrashSection() {
|
|||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|
||||||
setActionsBarMode('trash');
|
setActionsBarMode('trash');
|
||||||
setGroupByView(null);
|
setGroupByView(trashView);
|
||||||
syncGroupByMenu([]);
|
syncGroupByMenu(trashView.groupByDefs);
|
||||||
|
|
||||||
//reset files view + remove any error
|
//reset files view + remove any error
|
||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
@@ -420,8 +425,8 @@ function switchToTrashSection() {
|
|||||||
restoreView('trash');
|
restoreView('trash');
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
|
|
||||||
// Load trash items
|
// Load trash items (cursor-based view)
|
||||||
loadTrashItems();
|
trashView.init();
|
||||||
|
|
||||||
if (batchToolbar) batchToolbar.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = `
|
|
||||||
<div class="list-header trash-header">
|
|
||||||
<div data-i18n="files.name">${i18n.t('files.name')}</div>
|
|
||||||
<div data-i18n="files.type">${i18n.t('files.type')}</div>
|
|
||||||
<div data-i18n="trash.original_location">${i18n.t('trash.original_location')}</div>
|
|
||||||
<div data-i18n="trash.deleted_date">${i18n.t('trash.deleted_date')}</div>
|
|
||||||
<div data-i18n="trash.actions">${i18n.t('trash.actions')}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
ui.updateBreadcrumb();
|
|
||||||
|
|
||||||
const trashItems = await fileOps.getTrashItems();
|
|
||||||
|
|
||||||
if (trashItems.length === 0) {
|
|
||||||
ui.showError(`
|
|
||||||
<i class="fas fa-trash empty-state-icon"></i>
|
|
||||||
<p>${i18n.t('trash.empty_state')}</p>
|
|
||||||
`);
|
|
||||||
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 = `
|
|
||||||
<div class="name-cell">
|
|
||||||
<div class="${iconWrapClass}">
|
|
||||||
<i class="${iconClass}"></i>
|
|
||||||
${canThumbnail ? `<img class="file-thumb" src="/api/files/${item.original_id}/thumbnail/icon" loading="lazy" alt="">` : ''}
|
|
||||||
</div>
|
|
||||||
<span>${escapeHtml(item.name)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="type-cell">${escapeHtml(typeLabel)}</div>
|
|
||||||
<div class="path-cell">${escapeHtml(item.original_path || '--')}</div>
|
|
||||||
<div class="date-cell">${escapeHtml(formattedDate)}</div>
|
|
||||||
<div class="actions-cell">
|
|
||||||
<button class="btn-restore" title="${i18n.t('trash.restore')}">
|
|
||||||
<i class="fas fa-undo"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn-delete" title="${i18n.t('trash.delete_permanently')}">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 };
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
* 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource
|
* 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { formatExpiryChip } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
import { grants } from '../model/grants.js';
|
import { grants } from '../model/grants.js';
|
||||||
@@ -16,6 +17,7 @@ import { buildExpiryChip } from '../utils/expiryChip.js';
|
|||||||
import { buildPasswordChip } from '../utils/passwordChip.js';
|
import { buildPasswordChip } from '../utils/passwordChip.js';
|
||||||
import { buildLinkChip } from './linkChip.js';
|
import { buildLinkChip } from './linkChip.js';
|
||||||
import { buildResourceIcon } from './resourceIcon.js';
|
import { buildResourceIcon } from './resourceIcon.js';
|
||||||
|
import { buildRoleChip, roleLabel } from './roleChip.js';
|
||||||
import { createUserVignette } from './userVignette.js';
|
import { createUserVignette } from './userVignette.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,24 +40,6 @@ function _expiryState(expiresAt) {
|
|||||||
return 'active';
|
return 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} role @returns {string} */
|
|
||||||
function _roleLabel(role) {
|
|
||||||
/** @type {Record<string,string>} */
|
|
||||||
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 {
|
class MySharesList {
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container
|
||||||
@@ -307,53 +291,23 @@ class MySharesList {
|
|||||||
|
|
||||||
/** @param {string} role @returns {HTMLElement} */
|
/** @param {string} role @returns {HTMLElement} */
|
||||||
_buildRolePill(role) {
|
_buildRolePill(role) {
|
||||||
const pill = document.createElement('span');
|
return buildRoleChip(role);
|
||||||
pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`;
|
|
||||||
pill.textContent = _roleLabel(role);
|
|
||||||
return pill;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* @param {string|null} expiresAt
|
||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
_buildExpiryChip(expiresAt) {
|
_buildExpiryChip(expiresAt) {
|
||||||
const state = _expiryState(expiresAt);
|
const tpl = document.createElement('template');
|
||||||
const chip = document.createElement('span');
|
tpl.innerHTML = formatExpiryChip(expiresAt);
|
||||||
chip.className = `ms-expiry-chip ms-expiry-chip--${state}`;
|
return /** @type {HTMLElement} */ (tpl.content.firstElementChild);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Kebab menu ────────────────────────────────────────────────────────────
|
// ── Kebab menu ────────────────────────────────────────────────────────────
|
||||||
@@ -395,7 +349,7 @@ class MySharesList {
|
|||||||
if (grant.subject_type === 'user') {
|
if (grant.subject_type === 'user') {
|
||||||
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
|
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
|
||||||
const isCurrent = grant.role === role;
|
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();
|
menu.remove();
|
||||||
if (isCurrent) return;
|
if (isCurrent) return;
|
||||||
await grants.updateRole({
|
await grants.updateRole({
|
||||||
@@ -403,11 +357,8 @@ class MySharesList {
|
|||||||
resource: { type: item.resource_type, id: item.resource.id },
|
resource: { type: item.resource_type, id: item.resource.id },
|
||||||
role
|
role
|
||||||
});
|
});
|
||||||
const pill = rowEl.querySelector('.ms-role-pill');
|
const pill = rowEl.querySelector('.role-chip');
|
||||||
if (pill) {
|
if (pill) pill.replaceWith(buildRoleChip(role));
|
||||||
pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`;
|
|
||||||
pill.textContent = _roleLabel(role);
|
|
||||||
}
|
|
||||||
grant.role = role;
|
grant.role = role;
|
||||||
});
|
});
|
||||||
if (isCurrent) mi.classList.add('ms-menu-item--current');
|
if (isCurrent) mi.classList.add('ms-menu-item--current');
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import { createUserVignette } from './userVignette.js';
|
|||||||
* @import {FileItem, FolderItem} from '../core/types.js'
|
* @import {FileItem, FolderItem} from '../core/types.js'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CustomAction
|
||||||
|
* @property {string} iconHtml - Inner HTML for the button icon (e.g. `<i class="fas fa-undo"></i>`).
|
||||||
|
* @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<void>)} onClick
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ResourceListConfig
|
* @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} [showShareBadge=true] - Show the shared-resource badge on items.
|
||||||
* @property {boolean} [draggable=false] - Mark items as draggable (HTML attribute).
|
* @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} [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
|
* Appearance
|
||||||
* @property {string} [itemModifierClass] - Extra CSS class applied to every .file-item
|
* @property {string} [itemModifierClass] - Extra CSS class applied to every .file-item
|
||||||
* (e.g. 'favorite-item', 'recent-item').
|
* (e.g. 'favorite-item', 'recent-item').
|
||||||
* @property {string} [dateField='modified_at'] - Which date field to display in the date column.
|
* @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 {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)
|
* State providers (called at item-creation time)
|
||||||
* @property {(id: string, type: 'file'|'folder') => boolean} [isFavorite]
|
* @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.
|
* Called when the user clicks the shared badge. Falls back to onContextMenu if absent.
|
||||||
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange]
|
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange]
|
||||||
* Called whenever the selection set changes.
|
* 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 {
|
export class ResourceListComponent {
|
||||||
@@ -70,7 +88,7 @@ export class ResourceListComponent {
|
|||||||
constructor(container, config) {
|
constructor(container, config) {
|
||||||
this._container = container;
|
this._container = container;
|
||||||
|
|
||||||
/** @type {Required<Pick<ResourceListConfig,'selectable'|'showFavorite'|'showOwner'|'showShareBadge'|'draggable'|'showContextMenu'|'dateField'>> & ResourceListConfig} */
|
/** @type {Required<Pick<ResourceListConfig,'selectable'|'showFavorite'|'showOwner'|'showShareBadge'|'draggable'|'showContextMenu'|'showType'|'showPath'|'dateField'>> & ResourceListConfig} */
|
||||||
this._cfg = {
|
this._cfg = {
|
||||||
selectable: true,
|
selectable: true,
|
||||||
showFavorite: true,
|
showFavorite: true,
|
||||||
@@ -78,6 +96,8 @@ export class ResourceListComponent {
|
|||||||
showShareBadge: true,
|
showShareBadge: true,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
showContextMenu: true,
|
showContextMenu: true,
|
||||||
|
showType: true,
|
||||||
|
showPath: false,
|
||||||
dateField: 'modified_at',
|
dateField: 'modified_at',
|
||||||
...config
|
...config
|
||||||
};
|
};
|
||||||
@@ -454,7 +474,7 @@ export class ResourceListComponent {
|
|||||||
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
|
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
|
||||||
const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false;
|
const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false;
|
||||||
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at;
|
const dateVal = /** @type {Record<string,string>} */ (/** @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 = `
|
el.innerHTML = `
|
||||||
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||||
@@ -465,10 +485,12 @@ export class ResourceListComponent {
|
|||||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
|
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
|
||||||
<div class="type-cell">${i18n.t('files.file_types.folder')}</div>
|
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(folder.path || '')}">${escapeHtml(folder.path || '')}</div>` : ''}
|
||||||
|
${cfg.showType ? `<div class="type-cell">${i18n.t('files.file_types.folder')}</div>` : ''}
|
||||||
<div class="size-cell">--</div>
|
<div class="size-cell">--</div>
|
||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
|
${this._renderCustomActions()}
|
||||||
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
||||||
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 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 fileSize = file.size_formatted || formatFileSize(file.size);
|
||||||
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (file))[cfg.dateField] ?? file.modified_at;
|
const dateVal = /** @type {Record<string,string>} */ (/** @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 isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
|
||||||
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
|
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
|
||||||
|
|
||||||
@@ -513,10 +535,12 @@ export class ResourceListComponent {
|
|||||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
|
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
|
||||||
<div class="type-cell">${typeLabel}</div>
|
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(file.path || '')}">${escapeHtml(file.path || '')}</div>` : ''}
|
||||||
|
${cfg.showType ? `<div class="type-cell">${typeLabel}</div>` : ''}
|
||||||
<div class="size-cell">${fileSize}</div>
|
<div class="size-cell">${fileSize}</div>
|
||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
|
${this._renderCustomActions()}
|
||||||
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
||||||
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -527,6 +551,25 @@ export class ResourceListComponent {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the inline action buttons declared in `cfg.customActions`.
|
||||||
|
* Each button gets `data-custom-action="<index>"` 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 `<button type="button" class="btn-action${cls}" data-custom-action="${i}" title="${label}" aria-label="${label}">${a.iconHtml}</button>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach direct event listeners to interactive elements inside a .file-item.
|
* Attach direct event listeners to interactive elements inside a .file-item.
|
||||||
* This covers buttons that must stop propagation before the delegated listener runs.
|
* 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)
|
// Shared-badge click → open share modal (or fall back to context menu)
|
||||||
if (cfg.showShareBadge && (cfg.onShareBadgeClick || cfg.onContextMenu)) {
|
if (cfg.showShareBadge && (cfg.onShareBadgeClick || cfg.onContextMenu)) {
|
||||||
const badge = el.querySelector('.file-badge-shared');
|
const badge = el.querySelector('.file-badge-shared');
|
||||||
|
|||||||
@@ -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--<mod>`). 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<string,string>} */
|
||||||
|
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 `<span class="role-chip role-chip--${mod}"><i class="fas ${icon} role-chip__icon"></i>${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -162,12 +162,169 @@ function normalizeExpiryBucket(value) {
|
|||||||
}
|
}
|
||||||
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
|
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
|
||||||
if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired');
|
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 <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days');
|
||||||
if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days');
|
if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days');
|
||||||
return String(date.getFullYear());
|
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:
|
||||||
|
*
|
||||||
|
* `<span class="expiry-chip expiry-chip--{tier}"><i class="..."></i>LABEL</span>`
|
||||||
|
*
|
||||||
|
* 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 `<span class="expiry-chip expiry-chip--never"><i class="fas fa-infinity expiry-chip__icon"></i>${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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 `<span class="expiry-chip expiry-chip--${tier}"><i class="fas ${icon} expiry-chip__icon"></i>${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a file size in bytes to a coarse, human-readable bucket label.
|
* Maps a file size in bytes to a coarse, human-readable bucket label.
|
||||||
*
|
*
|
||||||
@@ -201,6 +358,9 @@ export {
|
|||||||
escapeHtml,
|
escapeHtml,
|
||||||
formatDateShort,
|
formatDateShort,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
|
formatDaysRemaining,
|
||||||
|
formatExpiryChip,
|
||||||
|
formatExpiryDate,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
formatQuotaSize,
|
formatQuotaSize,
|
||||||
isEmailValid,
|
isEmailValid,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const OxiIcons = {
|
|||||||
576,
|
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'
|
'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: [
|
ban: [
|
||||||
512,
|
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'
|
'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,
|
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'
|
'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': [
|
'caret-down': [
|
||||||
320,
|
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'
|
'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'
|
||||||
|
|||||||
+23
-11
@@ -115,17 +115,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} TrashItem
|
* One item returned by `GET /api/trash/resources`.
|
||||||
* @property {string} id
|
* `resource_type` discriminates the shape of `resource`.
|
||||||
* @property {string} original_id
|
*
|
||||||
* @property {ItemTypeEnum} item_type
|
* `deletion_date` is the real timestamp at which the retention sweeper will
|
||||||
* @property {string} name
|
* permanently delete the item (= trashed_at + retention_days). Days remaining
|
||||||
* @property {string} original_path - timestamp
|
* is derived client-side from `deletion_date` and the current clock — it is
|
||||||
* @property {number} trashed_at
|
* not duplicated in the wire format.
|
||||||
* @property {number} days_until_deletion
|
*
|
||||||
* @property {string} category
|
* `resource.path` carries the item's original location (soft-delete preserves
|
||||||
* @property {string} icon_class
|
* the row's `path` column).
|
||||||
* @property {string} icon_special_class
|
*
|
||||||
|
* @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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
|||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { notifications } from '../../core/notifications.js';
|
import { notifications } from '../../core/notifications.js';
|
||||||
|
|
||||||
/** @import {TrashItem} from '../../core/types.js' */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} BatchResult
|
* @typedef {Object} BatchResult
|
||||||
* @property {number} success number of files|folders sucessfully updated
|
* @property {number} success number of files|folders sucessfully updated
|
||||||
@@ -1246,28 +1244,6 @@ const fileOps = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get trash items
|
|
||||||
* @returns {Promise<Array<TrashItem>>} - 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
|
* Restore an item from trash
|
||||||
* @param {string} trashId - Trash item ID
|
* @param {string} trashId - Trash item ID
|
||||||
|
|||||||
@@ -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<TrashResourcesResponse>}
|
||||||
|
*/
|
||||||
|
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<TrashResourcesResponse>} */ (res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
export { fetchTrashPage };
|
||||||
@@ -10,17 +10,11 @@
|
|||||||
* live in shareModal.css.
|
* live in shareModal.css.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { formatExpiryDate } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
|
|
||||||
/**
|
// Re-export so existing import sites keep working after the move to core/formatters.js.
|
||||||
* Format a YYYY-MM-DD string for display ("Dec 31, 2026").
|
export { formatExpiryDate };
|
||||||
* @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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an interactive expiry chip.
|
* Build an interactive expiry chip.
|
||||||
|
|||||||
@@ -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<string,string>} */ (/** @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<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
|
||||||
|
labelFn: (key) => {
|
||||||
|
// biome-ignore format: keep indentation
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
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<string,number>} */ (/** @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<string,string>} */ (/** @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 = `
|
||||||
|
<div data-i18n="files.name">${i18n.t('files.name', 'Name')}</div>
|
||||||
|
<div data-i18n="trash.original_location">${i18n.t('trash.original_location', 'Original location')}</div>
|
||||||
|
<div data-i18n="files.size">${i18n.t('files.size', 'Size')}</div>
|
||||||
|
<div data-i18n="trash.remaining">${i18n.t('trash.remaining', 'Remaining')}</div>
|
||||||
|
<div></div><!-- actions -->
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '<i class="fas fa-undo"></i>',
|
||||||
|
labelKey: 'trash.restore',
|
||||||
|
className: 'btn-action--restore',
|
||||||
|
onClick: async (item) => {
|
||||||
|
if (await fileOps.restoreFromTrash(item.id)) {
|
||||||
|
await this._reloadFromTop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconHtml: '<i class="fas fa-trash"></i>',
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
async _reloadFromTop() {
|
||||||
|
this._nextCursor = null;
|
||||||
|
this._component?.clear();
|
||||||
|
await this._loadPage();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one page, map items → FileItem / FolderItem, render them.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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(`
|
||||||
|
<i class="fas fa-trash empty-state-icon"></i>
|
||||||
|
<p>${i18n.t('trash.empty_state', 'Trash is empty')}</p>
|
||||||
|
`);
|
||||||
|
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(`
|
||||||
|
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
|
||||||
|
<p>${i18n.t('errors_loadFailed', 'Failed to load items')}</p>
|
||||||
|
`);
|
||||||
|
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<FileItem|FolderItem>}
|
||||||
|
*/
|
||||||
|
_mapItems(items) {
|
||||||
|
/** @type {Array<FileItem|FolderItem>} */
|
||||||
|
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 };
|
||||||
+20
-1
@@ -341,10 +341,29 @@
|
|||||||
"empty_state": "Trash is empty",
|
"empty_state": "Trash is empty",
|
||||||
"original_location": "Original location",
|
"original_location": "Original location",
|
||||||
"deleted_date": "Deletion date",
|
"deleted_date": "Deletion date",
|
||||||
|
"remaining": "Remaining",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
"delete_permanently": "Delete permanently",
|
"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": {
|
"auth": {
|
||||||
"login_title": "Sign in",
|
"login_title": "Sign in",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/files-folders.hurl" \
|
"$API_DIR/files-folders.hurl" \
|
||||||
"$API_DIR/favorites.hurl" \
|
"$API_DIR/favorites.hurl" \
|
||||||
"$API_DIR/trash.hurl" \
|
"$API_DIR/trash.hurl" \
|
||||||
|
"$API_DIR/trash_resources.hurl" \
|
||||||
"$API_DIR/recent.hurl" \
|
"$API_DIR/recent.hurl" \
|
||||||
"$API_DIR/batch_folder_copy.hurl" \
|
"$API_DIR/batch_folder_copy.hurl" \
|
||||||
"$API_DIR/dedup_blob_cleanup.hurl" \
|
"$API_DIR/dedup_blob_cleanup.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
|
||||||
@@ -20,5 +20,5 @@ RUST_LOG=warn
|
|||||||
#RUST_LOG=info
|
#RUST_LOG=info
|
||||||
|
|
||||||
# grow up limits for tests
|
# grow up limits for tests
|
||||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=120
|
OXICLOUD_RATE_LIMIT_REFRESH_MAX=360
|
||||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=120
|
OXICLOUD_RATE_LIMIT_LOGIN_MAX=360
|
||||||
|
|||||||
Reference in New Issue
Block a user