feat(recent): add cursor + groupby support

This commit is contained in:
Edouard Vanbelle
2026-05-28 11:50:32 +02:00
parent bd474aa67e
commit 73e78904e7
15 changed files with 1205 additions and 181 deletions
+111 -1
View File
@@ -1,10 +1,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use super::cursor::{CursorListResponse, CursorQuery, PageCursor};
use super::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use super::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::domain::services::authorization::ResourceKind;
/// DTO for recent items, enriched with item metadata via SQL JOIN
/// so the frontend does not need N+1 requests to resolve names/sizes.
@@ -84,3 +88,109 @@ impl RecentItemDto {
self
}
}
// ════════════════════════════════════════════════════════════════════════════
// Cursor-paginated recent resources (GET /api/recent/resources)
// ════════════════════════════════════════════════════════════════════════════
/// Raw row returned by the UNION ALL query for `/api/recent/resources`.
pub struct RecentResourceRow {
pub resource_type: String, // "file" | "folder"
pub resource_id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub mime_type: Option<String>,
/// `-1` for folders, actual byte-count for files.
pub size: i64,
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub accessed_at: DateTime<Utc>,
/// Human-readable path. Always populated in the row; the handler clears it
/// to `""` when `is_owner` is false.
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/recent/resources`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentCursor {
/// Sort dimension active when this cursor was produced.
/// Values: `"accessed_at"` (default), `"name"`, `"type"`, `"modified_at"`, `"size"`, `"owner"`.
#[serde(default = "RecentCursor::default_order")]
pub order_by: String,
/// UUID of the last item on the previous page (tie-breaker).
pub resource_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_str: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_int: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_ts: Option<DateTime<Utc>>,
#[serde(default)]
pub reverse: bool,
}
impl RecentCursor {
fn default_order() -> String {
"accessed_at".to_owned()
}
}
impl PageCursor for RecentCursor {}
/// Query parameters for `GET /api/recent/resources`.
#[derive(Debug, Deserialize, IntoParams)]
pub struct RecentResourcesQuery {
/// Maximum items per page (1–200, default 50).
#[serde(default = "CursorQuery::default_limit")]
pub limit: u32,
/// Opaque cursor from a previous response. Omit to start from the first page.
pub cursor: Option<String>,
/// Sort / group-by dimension. Supported: `"accessed_at"` (default), `"name"`,
/// `"type"`, `"modified_at"`, `"size"`, `"owner"`.
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 RecentResourcesQuery {
pub fn limit_clamped(&self) -> usize {
self.limit.clamp(1, 200) as usize
}
pub fn decode_cursor(&self) -> Option<RecentCursor> {
self.cursor.as_deref().and_then(RecentCursor::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/recent/resources` page.
#[derive(Debug, Serialize, ToSchema)]
pub struct RecentResourceItemDto {
pub resource_type: ResourceTypeDto,
/// When the resource was last accessed.
pub accessed_at: DateTime<Utc>,
/// Full resource details — shape determined by `resource_type`.
pub resource: ResourceContentDto,
}
/// Response envelope for `GET /api/recent/resources`.
pub type RecentResourcesDto = CursorListResponse<RecentResourceItemDto>;
+11
View File
@@ -51,4 +51,15 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static {
/// Removes items exceeding `max_items` (the oldest ones).
async fn prune(&self, user_id: Uuid, max_items: i32) -> Result<()>;
/// List recent items with cursor pagination, sorting, and optional type filter.
async fn list_resources_paged(
&self,
user_id: Uuid,
limit: usize,
cursor: Option<&crate::application::dtos::recent_dto::RecentCursor>,
order_by: &str,
kinds: Option<&[crate::domain::services::authorization::ResourceKind]>,
reverse: bool,
) -> Result<Vec<crate::application::dtos::recent_dto::RecentResourceRow>>;
}
+99 -1
View File
@@ -1,6 +1,8 @@
use crate::application::dtos::recent_dto::RecentItemDto;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow};
use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::services::authorization::ResourceKind;
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
use std::sync::Arc;
use tracing::info;
@@ -109,3 +111,99 @@ impl RecentItemsUseCase for RecentService {
Ok(())
}
}
impl RecentService {
/// No authz needed — recent items are strictly user-scoped; the repository
/// enforces `WHERE user_id = $1` so users can only see their own entries.
///
/// Returns `(rows, next_cursor_encoded)`.
pub async fn list_resources_paged(
&self,
user_id: Uuid,
limit: usize,
cursor: Option<RecentCursor>,
order_by: &str,
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<(Vec<RecentResourceRow>, Option<String>)> {
// Fetch one extra row to detect whether a next page exists.
let mut rows = self
.repo
.list_resources_paged(
user_id,
limit + 1,
cursor.as_ref(),
order_by,
kinds,
reverse,
)
.await?;
let next_cursor = if rows.len() > limit {
let last = &rows[limit - 1];
let c = build_recent_cursor(last, order_by, reverse);
rows.truncate(limit);
Some(c.encode())
} else {
None
};
Ok((rows, next_cursor))
}
}
/// Build the next-page cursor from the last row of the current page.
/// `reverse` is stored in the cursor so subsequent pages use the same direction.
fn build_recent_cursor(row: &RecentResourceRow, order_by: &str, reverse: bool) -> RecentCursor {
match order_by {
"name" => RecentCursor {
order_by: "name".to_owned(),
resource_id: row.resource_id,
sort_str: row.sort_str.clone(), // LOWER(name)
sort_int: row.sort_int, // folder_first
sort_ts: None,
reverse,
},
"type" => RecentCursor {
order_by: "type".to_owned(),
resource_id: row.resource_id,
sort_str: row.sort_str.clone(), // LOWER(name)
sort_int: row.sort_int, // type_order
sort_ts: None,
reverse,
},
"modified_at" => RecentCursor {
order_by: "modified_at".to_owned(),
resource_id: row.resource_id,
sort_str: None,
sort_int: None,
sort_ts: row.sort_ts, // modified_at timestamp
reverse,
},
"size" => RecentCursor {
order_by: "size".to_owned(),
resource_id: row.resource_id,
sort_str: None,
sort_int: row.sort_int, // size in bytes
sort_ts: None,
reverse,
},
"owner" => RecentCursor {
order_by: "owner".to_owned(),
resource_id: row.resource_id,
sort_str: row.sort_str.clone(), // LOWER(username)
sort_int: None,
sort_ts: row.sort_ts, // accessed_at timestamp (secondary)
reverse,
},
_ => RecentCursor {
// default: accessed_at DESC
order_by: "accessed_at".to_owned(),
resource_id: row.resource_id,
sort_str: None,
sort_int: None,
sort_ts: row.sort_ts, // accessed_at timestamp
reverse,
},
}
}
@@ -3,9 +3,10 @@ use std::sync::Arc;
use tracing::error;
use uuid::Uuid;
use crate::application::dtos::recent_dto::RecentItemDto;
use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow};
use crate::application::ports::recent_ports::RecentItemsRepositoryPort;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::services::authorization::ResourceKind;
/// PostgreSQL implementation of the recent items persistence port.
pub struct RecentItemsPgRepository {
@@ -188,4 +189,310 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
Ok(())
}
async fn list_resources_paged(
&self,
user_id: Uuid,
limit: usize,
cursor: Option<&RecentCursor>,
order_by: &str,
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<Vec<RecentResourceRow>> {
let include_folders =
kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder)));
let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File)));
// ── Build the UNION ALL CTE ─────────────────────────────────────────
let mut cte_branches: Vec<&str> = Vec::new();
let folder_branch = r#"
SELECT
'folder'::text AS resource_type,
fld.id AS resource_id,
fld.name,
fld.parent_id,
NULL::text AS mime_type,
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
(fld.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at,
fld.path::text AS resource_path,
LOWER(fld.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
FROM auth.user_recent_files ur
INNER JOIN storage.folders fld
ON fld.id = ur.item_id::UUID AND NOT fld.is_trashed
WHERE ur.user_id = $1::uuid AND ur.item_type = 'folder'"#;
let file_branch = r#"
SELECT
'file'::text AS resource_type,
f.id AS resource_id,
f.name,
f.folder_id AS parent_id,
f.mime_type,
f.size::bigint,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
(f.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
LOWER(f.name) AS sort_str,
f.category_order::bigint AS type_order,
1::int AS folder_first
FROM auth.user_recent_files ur
INNER JOIN storage.files f
ON f.id = ur.item_id::UUID AND NOT f.is_trashed
LEFT JOIN storage.folders pfld
ON pfld.id = f.folder_id
WHERE ur.user_id = $1::uuid AND ur.item_type = 'file'"#;
if include_folders {
cte_branches.push(folder_branch);
}
if include_files {
cte_branches.push(file_branch);
}
if cte_branches.is_empty() {
return Ok(Vec::new());
}
let union_sql = cte_branches.join("\n UNION ALL\n");
let cte = format!("WITH resources AS ({union_sql}\n)");
// ── Cursor values ───────────────────────────────────────────────────
let cur_str: Option<&str> = cursor.and_then(|c| c.sort_str.as_deref());
let cur_int: Option<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 (in CTE), $2=cur_str, $3=cur_int, $4=cur_ts,
// $5=cur_id, $6=limit (for "owner" sort: JOIN uses no extra binds)
let (keyset, order_by_clause, need_user_join) = match (order_by, reverse) {
// ── name ────────────────────────────────────────────────────────
("name", false) => (
"WHERE ($3::bigint IS NULL)
OR (folder_first::bigint > $3)
OR (folder_first::bigint = $3 AND sort_str > $2)
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
"ORDER BY folder_first ASC, sort_str ASC, resource_id ASC",
false,
),
("name", true) => (
"WHERE ($3::bigint IS NULL)
OR (folder_first::bigint > $3)
OR (folder_first::bigint = $3 AND sort_str < $2)
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
"ORDER BY folder_first ASC, sort_str DESC, resource_id DESC",
false,
),
// ── type ────────────────────────────────────────────────────────
("type", false) => (
"WHERE ($3::bigint IS NULL)
OR (type_order > $3)
OR (type_order = $3 AND sort_str > $2)
OR (type_order = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
"ORDER BY type_order ASC, sort_str ASC, resource_id ASC",
false,
),
("type", true) => (
"WHERE ($3::bigint IS NULL)
OR (type_order < $3)
OR (type_order = $3 AND sort_str < $2)
OR (type_order = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
"ORDER BY type_order DESC, sort_str DESC, resource_id DESC",
false,
),
// ── accessed_at ──────────────────────────────────────────────────
("accessed_at", false) => (
"WHERE ($4::timestamptz IS NULL)
OR (accessed_at < $4)
OR (accessed_at = $4 AND resource_id < $5::uuid)",
"ORDER BY accessed_at DESC, resource_id DESC",
false,
),
("accessed_at", true) => (
"WHERE ($4::timestamptz IS NULL)
OR (accessed_at > $4)
OR (accessed_at = $4 AND resource_id > $5::uuid)",
"ORDER BY accessed_at ASC, resource_id ASC",
false,
),
// ── modified_at ──────────────────────────────────────────────────
("modified_at", false) => (
"WHERE ($4::timestamptz IS NULL)
OR (modified_at < $4)
OR (modified_at = $4 AND resource_id < $5::uuid)",
"ORDER BY modified_at DESC, resource_id DESC",
false,
),
("modified_at", true) => (
"WHERE ($4::timestamptz IS NULL)
OR (modified_at > $4)
OR (modified_at = $4 AND resource_id > $5::uuid)",
"ORDER BY modified_at ASC, resource_id ASC",
false,
),
// ── size ─────────────────────────────────────────────────────────
("size", false) => (
"WHERE ($3::bigint IS NULL)
OR (size > $3)
OR (size = $3 AND resource_id > $5::uuid)",
"ORDER BY size ASC, resource_id ASC",
false,
),
("size", true) => (
"WHERE ($3::bigint IS NULL)
OR (size < $3)
OR (size = $3 AND resource_id < $5::uuid)",
"ORDER BY size DESC, resource_id DESC",
false,
),
// ── owner ────────────────────────────────────────────────────────
("owner", false) => (
"WHERE ($2::text IS NULL)
OR (LOWER(u.username) > $2)
OR (LOWER(u.username) = $2 AND accessed_at < $4)
OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id < $5::uuid)",
"ORDER BY LOWER(u.username) ASC, accessed_at DESC, resource_id DESC",
true,
),
("owner", true) => (
"WHERE ($2::text IS NULL)
OR (LOWER(u.username) < $2)
OR (LOWER(u.username) = $2 AND accessed_at > $4)
OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id > $5::uuid)",
"ORDER BY LOWER(u.username) DESC, accessed_at ASC, resource_id ASC",
true,
),
// ── default: accessed_at DESC ─────────────────────────────────────
(_, false) => (
"WHERE ($4::timestamptz IS NULL)
OR (accessed_at < $4)
OR (accessed_at = $4 AND resource_id < $5::uuid)",
"ORDER BY accessed_at DESC, resource_id DESC",
false,
),
(_, true) => (
"WHERE ($4::timestamptz IS NULL)
OR (accessed_at > $4)
OR (accessed_at = $4 AND resource_id > $5::uuid)",
"ORDER BY accessed_at ASC, resource_id ASC",
false,
),
};
let user_join = if need_user_join {
"LEFT JOIN auth.users u ON u.id = r.owner_id"
} else {
""
};
// For "owner" sort the JOIN makes LOWER(u.username) available; add it to SELECT
// so the cursor can carry the correct sort key.
let username_col = if need_user_join {
",\n LOWER(u.username) AS username_lower"
} else {
""
};
let sql = format!(
"{cte}
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
{keyset}
{order_by_clause}
LIMIT $6"
);
let rows = sqlx::query(&sql)
.bind(user_id) // $1 (in CTE + outer)
.bind(cur_str) // $2
.bind(cur_int) // $3
.bind(cur_ts) // $4
.bind(cur_id) // $5
.bind(limit as i64) // $6
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error listing recent resources: {e}");
DomainError::new(
ErrorKind::InternalError,
"RecentItems",
format!("Failed to list recent resources: {e}"),
)
})?;
let result = rows
.iter()
.map(|row| {
let resource_type: String = row.get("resource_type");
let sort_str_val: Option<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");
// Pre-compute the cursor sort fields based on order_by
let (c_sort_str, c_sort_int, c_sort_ts) = match order_by {
"name" => (sort_str_val, Some(folder_first as i64), None),
"type" => (sort_str_val, Some(type_order), None),
"size" => (None, Some(size), None),
"accessed_at" => {
let ts: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("accessed_at").ok();
(None, None, ts)
}
"modified_at" => {
let ts: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("modified_at").ok();
(None, None, ts)
}
"owner" => {
// For "owner" sort the JOIN added LOWER(u.username) AS username_lower.
// The cursor's sort_str must carry the username (not the file name).
let username: Option<String> = row.try_get("username_lower").ok();
let ts: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("accessed_at").ok();
(username, None, ts)
}
_ => {
let ts: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("accessed_at").ok();
(None, None, ts)
}
};
RecentResourceRow {
resource_type,
resource_id: row.get("resource_id"),
name: row.get("name"),
parent_id: row.try_get("parent_id").ok(),
mime_type: row.try_get("mime_type").ok(),
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(),
sort_str: c_sort_str,
sort_int: c_sort_int,
sort_ts: c_sort_ts,
}
})
.collect();
Ok(result)
}
}
+130 -1
View File
@@ -8,8 +8,18 @@ use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::dtos::recent_dto::{
RecentResourceItemDto, RecentResourcesDto, RecentResourcesQuery,
};
use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::application::services::recent_service::RecentService;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
/// Query parameters for getting recent items
@@ -19,7 +29,8 @@ pub struct GetRecentParams {
limit: Option<i32>,
}
/// Get user's recent items
/// Get user's recent items (deprecated — use `GET /api/recent/resources` instead)
#[deprecated = "Use GET /api/recent/resources instead"]
#[utoipa::path(
get,
path = "/api/recent",
@@ -213,3 +224,121 @@ pub async fn clear_recent_items(
}
}
}
/// List recently accessed resources with cursor pagination.
///
/// Sorted by `accessed_at` DESC by default (most recently accessed first).
/// `path` is cleared when the resource is not owned by the requesting user.
#[utoipa::path(
get,
path = "/api/recent/resources",
params(RecentResourcesQuery),
responses(
(status = 200, description = "Paginated list of recently accessed resources",
body = RecentResourcesDto),
(status = 400, description = "Invalid cursor or query parameters"),
),
security(("bearerAuth" = [])),
tag = "recent"
)]
pub async fn list_recent_resources(
State(recent_service): State<Arc<RecentService>>,
auth_user: AuthUser,
Query(q): Query<RecentResourcesQuery>,
) -> impl IntoResponse {
let user_id = auth_user.id;
let order_by = q.order_by.as_deref().unwrap_or("accessed_at").to_owned();
// If a cursor exists, validate that it matches the requested sort/direction.
let cursor = q
.decode_cursor()
.filter(|c| c.order_by == order_by && c.reverse == q.reverse);
let kinds = q.resource_kinds();
match recent_service
.list_resources_paged(
user_id,
q.limit_clamped(),
cursor,
&order_by,
kinds.as_deref(),
q.reverse,
)
.await
{
Ok((rows, next_cursor)) => {
let items: Vec<RecentResourceItemDto> = rows
.into_iter()
.map(|row| {
// Path is only shown to the owner; non-owners see ""
// to avoid leaking another user's folder hierarchy.
let path = if row.is_owner {
row.path.clone().unwrap_or_default()
} else {
String::new()
};
if row.resource_type == "folder" {
let dto = FolderDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder,
accessed_at: row.accessed_at,
resource: ResourceContentDto::Folder(dto),
}
} else {
let mime = row
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
path,
size: size_bytes,
mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for(
&row.name, mime,
)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
etag: String::new(),
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
accessed_at: row.accessed_at,
resource: ResourceContentDto::File(dto),
}
}
})
.collect();
(
StatusCode::OK,
Json(RecentResourcesDto::with_cursor(items, next_cursor)),
)
.into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
+2
View File
@@ -355,10 +355,12 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Create routes for recent items if the service is available
let recent_router = if let Some(recent_service) = recent_service.clone() {
#[allow(deprecated)]
use crate::interfaces::api::handlers::recent_handler;
Router::new()
.route("/", get(recent_handler::get_recent_items))
.route("/resources", get(recent_handler::list_recent_resources))
.route(
"/{item_type}/{item_id}",
post(recent_handler::record_item_access),
+2
View File
@@ -41,6 +41,8 @@
<script defer type="module" src="/js/features/library/music.js"></script>
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
<script defer type="module" src="/js/views/shared/sharedView.js"></script>
<script defer type="module" src="/js/model/recentModel.js"></script>
<script defer type="module" src="/js/views/recent/recentView.js"></script>
<script defer type="module" src="/js/features/files/inlineViewer.js"></script>
<script defer type="module" src="/js/features/files/wopiEditor.js"></script>
<script defer type="module" src="/js/core/icons.js"></script>
+3 -2
View File
@@ -17,6 +17,7 @@ import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { grants } from '../model/grants.js';
import { recentView } from '../views/recent/recentView.js';
import { sharedView } from '../views/shared/sharedView.js';
import { checkAuthentication } from './authSession.js';
import { loadFiles } from './filesView.js';
@@ -345,8 +346,8 @@ function setupActionsBarDelegation() {
break;
case 'clear-recent-btn':
if (recent) {
recent.clearRecentFiles();
recent.displayRecentFiles();
await recent.clearRecentFiles();
await recentView.init();
ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
}
break;
+18 -18
View File
@@ -10,8 +10,8 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
import { favorites } from '../features/library/favorites.js';
import { musicView } from '../features/library/music.js';
import { photosView } from '../features/library/photos.js';
import { recent } from '../features/library/recent.js';
import { favoritesView } from '../views/favorites/favoritesView.js';
import { recentView } from '../views/recent/recentView.js';
import { sharedView } from '../views/shared/sharedView.js';
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
import { filesView, loadFiles } from './filesView.js';
@@ -180,6 +180,11 @@ function setCurrentSection(section) {
favoritesView.hide();
}
// Hide recentView "Load more" button when leaving the recent section
if (section !== 'recent' && recentView) {
recentView.hide();
}
// Reset owner column — sections that need it re-enable it explicitly below.
ui.setOwnerColumnVisible(false);
@@ -332,10 +337,17 @@ function switchToFavoritesSection() {
function switchToRecentFilesSection() {
if (!setCurrentSection('recent')) return;
// Set actions bar mode
// Set actions bar mode with group-by support
setActionsBarMode('recent');
setGroupByView(null);
syncGroupByMenu([]);
setGroupByView(recentView);
syncGroupByMenu(recentView.groupByDefs);
// Restore the saved group-by selection in the dropdown.
const recentPrefs = viewPrefs.load('recent');
applyGroupByMenuState(recentPrefs.groupBy, recentPrefs.reversed);
// Show the Owner column
ui.setOwnerColumnVisible(true);
// Hide breadcrumb (only shown in Files view)
const breadcrumb = document.querySelector('.breadcrumb');
@@ -348,21 +360,9 @@ function switchToRecentFilesSection() {
restoreView('recent');
syncViewContainers();
//reset files view + remove any error
ui.resetFilesList();
if (recent) {
sharedView.loadItems().then(() => {
recent.displayRecentFiles();
});
} else {
console.error('Recent files module not loaded or initialized');
ui.showError(`
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
<p>Error loading the recent module</p>
`);
}
if (batchToolbar) batchToolbar.clear();
recentView.init();
}
function switchToPhotosSection() {
+1 -3
View File
@@ -41,9 +41,7 @@ function applyGroupByMenuState(groupBy, reversed) {
if (groupBy === '') {
lbl.textContent = '';
} else {
const activeOpt = /** @type {HTMLElement|null} */ (
document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`)
);
const activeOpt = /** @type {HTMLElement|null} */ (document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`));
lbl.textContent = activeOpt?.textContent ?? '';
}
}
+9 -150
View File
@@ -1,27 +1,20 @@
// @ts-check
/**
* OxiCloud - Recent Files Module (server-authoritative)
*
* Source of truth: GET /api/recent (enriched with name/size/mime via SQL JOIN).
* File-access events are forwarded to the backend with POST /api/recent/{type}/{id}.
* No localStorage usage — the server persists and prunes recent items.
* Records file-access events via POST /api/recent/{type}/{id} and exposes
* `clearRecentFiles()` for the clear-all action.
*
* Display is now handled by `recentView.js` using the cursor-paginated
* `GET /api/recent/resources` endpoint.
*/
import { ui } from '../../app/ui.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { batchToolbar } from '../files/batchToolbar.js';
import * as itemTooltip from '../itemTooltip.js';
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
/** @import {ItemTypeEnum} from '../../core/types.js' */
const recent = {
/** Maximum items to request from the server */
MAX_RECENT_FILES: 20,
/** @type {ResourceListComponent|null} */
_component: null,
// ───────────────────── helpers ─────────────────────
_authHeaders() {
@@ -31,10 +24,9 @@ const recent = {
// ───────────────────── lifecycle ─────────────────────
/**
* Initialise the module. Called once from app.js on startup.
* Initialise the module. Called once from app.js on startup.
*/
init() {
console.log('Initializing recent files module (server-authoritative)');
this.setupEventListeners();
},
@@ -83,139 +75,6 @@ const recent = {
} catch (err) {
console.error('Error clearing recent files:', err);
}
},
/**
* Fetch and display recent files. Data comes directly from the
* enriched backend response — zero extra per-item fetches.
*/
async displayRecentFiles() {
try {
const response = await fetch(`/api/recent?limit=${this.MAX_RECENT_FILES}`, {
headers: this._authHeaders()
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
const recentItems = /** @type {RecentItem[]} */ (await response.json());
// resetFilesList injects the standard list-header with the
// Modified column label; we swap the last header cell to "Accessed".
ui.resetFilesList();
const filesList = document.getElementById('files-list');
if (filesList) {
// Relabel the date column header from "Modified" → "Accessed"
const dateHeader = /** @type {HTMLElement|null} */ (
[...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified')
);
if (dateHeader) {
dateHeader.removeAttribute('data-i18n');
dateHeader.setAttribute('data-i18n', 'recent.accessed');
dateHeader.textContent = i18n.t('recent.accessed', 'Accessed');
}
}
batchToolbar.clear();
batchToolbar.init();
ui.updateBreadcrumb();
if (recentItems.length === 0) {
ui.showError(`
<i class="fas fa-clock empty-state-icon"></i>
<p>${i18n.t('recent.empty_state')}</p>
<p>${i18n.t('recent.empty_hint')}</p>
`);
return;
}
/** @type {Array<FileItem|FolderItem>} */
const items = [];
for (const item of recentItems) {
const isFolder = item.item_type === 'folder';
if (isFolder) {
items.push(
/** @type {FolderItem} */ ({
id: item.item_id,
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
modified_at: item.accessed_at,
path: item.item_path || '',
category: 'folder',
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: '',
is_root: false
})
);
} else {
if (item.item_mime_type === undefined || item.item_mime_type === null) {
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
console.warn('Broken information for RecentItem: ', item);
}
items.push(
/** @type {FileItem} */ ({
id: item.item_id,
name: item.item_name || item.item_id,
folder_id: item.parent_id || '',
mime_type: item.item_mime_type,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
category: item.category,
size: item.item_size || 0,
size_formatted: item.size_formatted,
modified_at: item.accessed_at,
path: item.item_path || '',
owner_id: '',
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
sort_date: item.accessed_at
})
);
}
}
if (filesList) {
if (!this._component) {
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
selectable: true,
showFavorite: true,
showOwner: false,
showShareBadge: false,
draggable: false,
showContextMenu: true,
itemModifierClass: 'recent-item',
dateField: 'modified_at', // mapped from accessed_at above
onOpen: (item) => ui.openItem(item),
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
onSelectionChange: (selectedItems) => {
batchToolbar._selected.clear();
for (const sel of selectedItems) {
const isFile = 'mime_type' in sel;
batchToolbar._selected.set(sel.id, {
id: sel.id,
name: sel.name,
type: isFile ? 'file' : 'folder',
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
});
}
batchToolbar._syncUI();
}
});
}
batchToolbar.setActiveComponent(this._component);
this._component.render(items);
itemTooltip.init(filesList);
}
} catch (error) {
console.error('Error displaying recent files:', error);
if (ui?.showNotification) {
ui.showNotification('Error', 'Error loading recent files');
}
}
}
};
+58
View File
@@ -0,0 +1,58 @@
// @ts-check
/**
* OxiCloud – Recent resources model.
*
* Thin fetch wrapper for `GET /api/recent/resources` (cursor-paginated).
* The old `GET /api/recent` endpoint is kept for backward compat — this
* module only handles the new endpoint.
*/
/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../core/types.js' */
/**
* @typedef {Object} RecentResourceItem
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
* @property {string} accessed_at - ISO-8601 timestamp
* @property {FileItem|FolderItem} resource - Full resource details
*/
/**
* @typedef {Object} RecentResourcesResponse
* @property {RecentResourceItem[]} items
* @property {string|undefined} [next_cursor]
*/
/**
* Fetch one page of the current user's recently accessed resources.
*
* @param {{
* cursor?: string,
* orderBy?: string,
* limit?: number,
* reverse?: boolean,
* resourceTypes?: ResourceTypeEnum[],
* }} [opts]
* @returns {Promise<RecentResourcesResponse>}
*/
async function fetchRecentPage({ cursor, orderBy = 'accessed_at', limit = 50, reverse = false, resourceTypes } = {}) {
const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) });
if (cursor) params.set('cursor', cursor);
if (reverse) params.set('reverse', 'true');
if (resourceTypes?.length) params.set('resource_types', resourceTypes.join(','));
const res = await fetch(`/api/recent/resources?${params}`, {
credentials: 'same-origin',
cache: 'no-store'
});
if (!res.ok) {
const err = /** @type {any} */ (new Error(`GET /api/recent/resources failed: ${res.status}`));
err.status = res.status;
throw err;
}
return /** @type {Promise<RecentResourcesResponse>} */ (res.json());
}
export { fetchRecentPage };
+433
View File
@@ -0,0 +1,433 @@
// @ts-check
/**
* OxiCloud – Recent view.
*
* Renders files and folders the current user has recently accessed, using the
* cursor-paginated `GET /api/recent/resources` endpoint.
*
* Default sort: `accessed_at` DESC (most recently accessed first, no swimlanes).
* The user can pick any group-by from the dropdown; viewPrefs persists the choice.
*
* Public API mirrors `favoritesView`:
* - `groupByDefs` — array of group-by dimension definitions
* - `setGroupBy(key)` — change active dimension + reload from page 1
* - `setDirection(reversed)` — flip sort direction + reload from page 1
* - `init()` — (re-)enter the section; restores prefs + loads page 1
* - `hide()` — called when leaving this section
*/
import { ui } from '../../app/ui.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import * as viewPrefs from '../../core/viewPrefs.js';
import { batchToolbar } from '../../features/files/batchToolbar.js';
import * as itemTooltip from '../../features/itemTooltip.js';
import { favorites } from '../../features/library/favorites.js';
import { fetchRecentPage } from '../../model/recentModel.js';
import { systemUsers } from '../../model/systemUsers.js';
/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string,
* keyFn: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string }} GroupByDef
*/
/**
* @typedef {Object} RecentResourceItem
* @property {ResourceTypeEnum} resource_type
* @property {string} accessed_at
* @property {FileItem|FolderItem} resource
*/
/**
* Group-by dimension definitions for the Recent section.
*
* When `_groupBy === ''` (None selected), items are sorted by `accessed_at` DESC —
* the natural expectation for a "Recent" section. "None" = flat chronological feed.
*
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: 'type',
get label() {
return i18n.t('groupby.type', 'Type');
},
orderBy: 'type',
keyFn: (item) => ('mime_type' in item ? /** @type {Record<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: 'accessedAt',
get label() {
return i18n.t('groupby.accessedAt', 'Accessed date');
},
orderBy: 'accessed_at',
// sort_date is unix seconds set in _mapItems(); keyFn returns the bucket label.
keyFn: (item) => {
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
return r.sort_date ? normalizeDateBucket(r.sort_date) : null;
}
},
{
key: 'modifiedAt',
get label() {
return i18n.t('groupby.modifiedAt', 'Modified date');
},
orderBy: 'modified_at',
keyFn: (item) => {
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
return r.modified_at ? normalizeDateBucket(r.modified_at) : null;
}
},
{
key: 'size',
get label() {
return i18n.t('groupby.size', 'Size');
},
orderBy: 'size',
keyFn: (item) => {
if (!('mime_type' in item)) return sizeBucket(-1);
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
return sizeBucket(r.size ?? 0);
}
},
{
key: 'owner',
get label() {
return i18n.t('groupby.owner', 'Owner');
},
orderBy: 'owner',
keyFn: (item) => {
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
return r.owner_id || null;
},
labelFn: (id) => systemUsers.getDisplayNameSync(id)
}
];
/** ID of the "Load more" wrapper injected below `.files-container`. */
const LOAD_MORE_ID = 'recent-load-more-wrapper';
const recentView = {
// ── State ─────────────────────────────────────────────────────────────────
/** @type {string|null} */
_nextCursor: null,
_loading: false,
/** @type {ResourceListComponent|null} */
_component: null,
/**
* Active group-by key. '' = no grouping (sorted by accessed_at DESC).
* @type {string}
*/
_groupBy: '',
/** Whether the current sort order is reversed. */
_reversed: false,
// ── Public API ────────────────────────────────────────────────────────────
/**
* The group-by dimension definitions for this section.
* `main.js` reads this to populate the Group-by dropdown dynamically.
* @returns {GroupByDef[]}
*/
get groupByDefs() {
return GROUP_BY_DEFS;
},
/**
* Change the active group-by dimension and reload from page 1.
* Calling with the current key is a no-op.
* @param {string} key
*/
setGroupBy(key) {
if (this._groupBy === key) return;
this._groupBy = key;
viewPrefs.save('recent', this._groupBy, this._reversed, viewPrefs.load('recent').view);
this._nextCursor = null;
this._component?.clear();
this._loadPage();
},
/**
* Flip the sort direction and reload from page 1.
* Calling with the current value is a no-op.
* @param {boolean} reversed
*/
setDirection(reversed) {
if (this._reversed === reversed) return;
this._reversed = reversed;
viewPrefs.save('recent', this._groupBy, this._reversed, viewPrefs.load('recent').view);
this._nextCursor = null;
this._component?.clear();
this._loadPage();
},
/**
* (Re-)enter the Recent section: restore saved prefs, create / reuse the
* component, and load page 1.
*/
async init() {
this._nextCursor = null;
this._loading = false;
const savedPrefs = viewPrefs.load('recent');
this._groupBy = savedPrefs.groupBy;
this._reversed = savedPrefs.reversed;
this._ensureLoadMoreButton();
// Prefetch system users so owner tooltips resolve without delay.
systemUsers.prefetch();
ui.resetFilesList();
batchToolbar.init();
ui.updateBreadcrumb();
const filesList = document.getElementById('files-list');
if (filesList) {
if (!this._component) {
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
selectable: true,
showFavorite: true,
showOwner: true,
showShareBadge: false,
draggable: false,
showContextMenu: true,
isFavorite: (id, type) => favorites.isFavorite(id, type),
isShared: () => false,
onOpen: (item) => ui.openItem(item),
onFavoriteToggle: async (item) => {
const isFile = 'mime_type' in item;
const type = isFile ? 'file' : 'folder';
if (favorites.isFavorite(item.id, type)) {
await favorites.removeFromFavorites(item.id, type, item.name);
this._component?.setFavoriteVisualState(item.id, type, false);
} else {
await favorites.addToFavorites(item.id, item.name, type, null);
this._component?.setFavoriteVisualState(item.id, type, true);
}
},
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
onSelectionChange: (selectedItems) => {
batchToolbar._selected.clear();
for (const sel of selectedItems) {
const isFile = 'mime_type' in sel;
batchToolbar._selected.set(sel.id, {
id: sel.id,
name: sel.name,
type: isFile ? 'file' : 'folder',
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
});
}
batchToolbar._syncUI();
}
});
}
batchToolbar.setActiveComponent(this._component);
}
await this._loadPage();
},
/**
* Hide the "Load more" button when leaving this section.
* The files container itself is managed by navigation.js.
*/
hide() {
const w = document.getElementById(LOAD_MORE_ID);
if (w) w.classList.add('hidden');
batchToolbar.setActiveComponent(null);
const filesList = document.getElementById('files-list');
if (filesList) itemTooltip.destroy(filesList);
},
// ── Internal helpers ──────────────────────────────────────────────────────
/**
* Fetch one page, map items → FileItem / FolderItem, render them.
* @returns {Promise<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);
// When no group-by is active, sort by accessed_at DESC (most recent first).
const orderBy = def?.orderBy ?? 'accessed_at';
const data = await fetchRecentPage({
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
limit: 50,
cursor: this._nextCursor ?? undefined,
orderBy,
reverse: this._reversed
});
this._nextCursor = data.next_cursor ?? null;
if (data.items.length === 0 && isFirstPage) {
ui.showError(`
<i class="fas fa-clock empty-state-icon"></i>
<p>${i18n.t('recent.empty_state', 'No recent files')}</p>
<p>${i18n.t('recent.empty_hint', 'Files you open will appear here')}</p>
`);
this._setLoadMoreVisible(false);
return;
}
const items = this._mapItems(data.items);
if (isFirstPage) {
this._component?.render(items, def?.keyFn, def?.labelFn);
} else {
this._component?.append(items, def?.keyFn, def?.labelFn);
}
// Wire unified item tooltip (owner + path) after items are in the DOM.
const filesList = document.getElementById('files-list');
if (filesList) itemTooltip.init(filesList);
await this._component?.resolveOwnerCells();
this._setLoadMoreVisible(!!this._nextCursor);
} catch (err) {
ui.showError(`
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
<p>${i18n.t('errors_loadFailed', 'Failed to load items')}</p>
`);
console.error('recentView: load error', err);
} finally {
this._loading = false;
}
},
/**
* Map `RecentResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving
* server order. Sets `sort_date` (unix seconds) to the `accessed_at` date
* so the `accessedAt` keyFn can bucket by when the item was accessed.
*
* @param {RecentResourceItem[]} items
* @returns {Array<FileItem|FolderItem>}
*/
_mapItems(items) {
/** @type {Array<FileItem|FolderItem>} */
const result = [];
/** @param {string} iso @returns {number} unix seconds */
const toSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
for (const item of items) {
if (item.resource_type === 'folder') {
const f = /** @type {FolderItem} */ (item.resource);
result.push(
/** @type {FolderItem} */ ({
id: f.id,
name: f.name,
path: f.path ?? '',
parent_id: f.parent_id ?? '',
owner_id: f.owner_id ?? '',
is_root: f.is_root ?? false,
created_at: f.created_at,
modified_at: f.modified_at,
// sort_date = accessed_at (unix seconds) for the accessedAt keyFn
sort_date: toSecs(item.accessed_at),
icon_class: f.icon_class,
icon_special_class: f.icon_special_class ?? '',
category: 'Folder'
})
);
} else if (item.resource_type === 'file') {
const f = /** @type {FileItem} */ (item.resource);
result.push(
/** @type {FileItem} */ ({
id: f.id,
name: f.name,
path: f.path ?? '',
folder_id: f.folder_id ?? '',
owner_id: f.owner_id ?? '',
mime_type: f.mime_type,
size: f.size,
size_formatted: f.size_formatted,
created_at: f.created_at,
modified_at: f.modified_at,
sort_date: toSecs(item.accessed_at),
icon_class: f.icon_class,
icon_special_class: f.icon_special_class ?? '',
category: f.category
})
);
}
}
return result;
},
// ── "Load more" button ────────────────────────────────────────────────────
/**
* Create the "Load more" wrapper once and attach it below `.files-container`.
* Subsequent calls are no-ops.
*/
_ensureLoadMoreButton() {
if (document.getElementById(LOAD_MORE_ID)) return;
const filesContainer = document.querySelector('.files-container');
if (!filesContainer) return;
const wrapper = document.createElement('div');
wrapper.id = LOAD_MORE_ID;
wrapper.className = 'swm-load-more-wrapper hidden';
const btn = document.createElement('button');
btn.id = 'recent-load-more';
btn.className = 'button secondary';
btn.textContent = i18n.t('recent.loadMore', 'Load more');
btn.addEventListener('click', () => this._loadPage());
wrapper.appendChild(btn);
filesContainer.after(wrapper);
},
/**
* @param {boolean} visible
*/
_setLoadMoreVisible(visible) {
const w = document.getElementById(LOAD_MORE_ID);
if (w) w.classList.toggle('hidden', !visible);
}
};
export { recentView };
+10 -2
View File
@@ -430,7 +430,8 @@
"clear": "Clear recent",
"accessed": "Accessed",
"empty_state": "No recent files",
"empty_hint": "Files you open will appear here"
"empty_hint": "Files you open will appear here",
"loadMore": "Load more"
},
"notifications": {
"file_renamed": "File renamed",
@@ -714,8 +715,15 @@
"groupby": {
"none": "None",
"title": "Group by",
"type": "Type",
"type.folders": "Folders",
"owner": "Owner",
"shareDate": "Share date"
"shareDate": "Share date",
"favoriteDate": "Favorite date",
"accessedAt": "Accessed date",
"modifiedAt": "Modified date",
"createdAt": "Created date",
"size": "Size"
},
"dateBucket": {
"today": "Today",
+10 -2
View File
@@ -430,7 +430,8 @@
"clear": "Effacer les récents",
"accessed": "Consulté",
"empty_state": "Aucun fichier récent",
"empty_hint": "Les fichiers que vous ouvrez apparaîtront ici"
"empty_hint": "Les fichiers que vous ouvrez apparaîtront ici",
"loadMore": "Charger plus"
},
"notifications": {
"file_renamed": "Fichier renommé",
@@ -714,8 +715,15 @@
"groupby": {
"none": "Aucun",
"title": "Grouper par",
"type": "Type",
"type.folders": "Dossiers",
"owner": "Propriétaire",
"shareDate": "Date de partage"
"shareDate": "Date de partage",
"favoriteDate": "Date d'ajout aux favoris",
"accessedAt": "Date d'accès",
"modifiedAt": "Date de modification",
"createdAt": "Date de création",
"size": "Taille"
},
"dateBucket": {
"today": "Aujourd'hui",