feat(resources): add cursor, group by on /api/favorites/resources
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
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::display_helpers::{
|
use super::display_helpers::{
|
||||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||||
};
|
};
|
||||||
|
use super::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// DTO for favorites item, enriched with item metadata via SQL JOIN
|
/// DTO for favorites item, enriched with item metadata via SQL JOIN
|
||||||
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
||||||
@@ -104,6 +108,120 @@ pub struct BatchFavoritesResult {
|
|||||||
pub favorites: Vec<FavoriteItemDto>,
|
pub favorites: Vec<FavoriteItemDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Cursor-paginated favorites resources (GET /api/favorites/resources)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Raw row returned by the UNION ALL query that joins `auth.user_favorites`
|
||||||
|
/// with `storage.files` / `storage.folders`. Never serialised directly.
|
||||||
|
pub struct FavoriteResourceRow {
|
||||||
|
pub resource_type: String, // "file" | "folder"
|
||||||
|
pub resource_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<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 favorited_at: DateTime<Utc>,
|
||||||
|
/// Human-readable path (e.g. `Documents/Work` for a folder,
|
||||||
|
/// `Documents/Work/report.pdf` for a file). Always populated; the
|
||||||
|
/// handler clears it to `""` when `is_owner` is false.
|
||||||
|
pub path: Option<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/favorites/resources`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FavoritesCursor {
|
||||||
|
/// Sort dimension active when this cursor was produced.
|
||||||
|
/// Values: `"name"` (default), `"type"`, `"favorited_at"`, `"modified_at"`,
|
||||||
|
/// `"size"`, `"owner"`.
|
||||||
|
#[serde(default = "FavoritesCursor::default_order")]
|
||||||
|
pub order_by: String,
|
||||||
|
/// UUID of the last item on the previous page (tie-breaker).
|
||||||
|
pub resource_id: Uuid,
|
||||||
|
/// `LOWER(name)` for `name`/`type` sorts; `LOWER(username)` for `owner`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sort_str: Option<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 `favorited_at` and `modified_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 FavoritesCursor {
|
||||||
|
fn default_order() -> String {
|
||||||
|
"name".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageCursor for FavoritesCursor {}
|
||||||
|
|
||||||
|
/// Query parameters for `GET /api/favorites/resources`.
|
||||||
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
|
pub struct FavoritesResourcesQuery {
|
||||||
|
/// Maximum items per page (1–200, default 50).
|
||||||
|
#[serde(default = "CursorQuery::default_limit")]
|
||||||
|
pub limit: u32,
|
||||||
|
/// Opaque cursor from a previous response. Omit to start from the first page.
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
/// Sort / group-by dimension. Supported: `"name"` (default), `"type"`,
|
||||||
|
/// `"favorited_at"`, `"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 FavoritesResourcesQuery {
|
||||||
|
pub fn limit_clamped(&self) -> usize {
|
||||||
|
self.limit.clamp(1, 200) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_cursor(&self) -> Option<FavoritesCursor> {
|
||||||
|
self.cursor.as_deref().and_then(FavoritesCursor::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/favorites/resources` page.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct FavoritesResourceItemDto {
|
||||||
|
pub resource_type: ResourceTypeDto,
|
||||||
|
/// When the resource was added to the user's favorites.
|
||||||
|
pub favorited_at: DateTime<Utc>,
|
||||||
|
/// Full resource details — shape determined by `resource_type`.
|
||||||
|
pub resource: ResourceContentDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response envelope for `GET /api/favorites/resources`.
|
||||||
|
pub type FavoritesResourcesDto = CursorListResponse<FavoritesResourceItemDto>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct BatchFavoritesStats {
|
pub struct BatchFavoritesStats {
|
||||||
/// How many items were requested
|
/// How many items were requested
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
|
use crate::application::dtos::favorites_dto::{
|
||||||
|
BatchFavoritesResult, FavoriteItemDto, FavoriteResourceRow, FavoritesCursor,
|
||||||
|
};
|
||||||
use crate::common::errors::Result;
|
use crate::common::errors::Result;
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// Defines operations for managing user favorites
|
/// Defines operations for managing user favorites
|
||||||
pub trait FavoritesUseCase: Send + Sync {
|
pub trait FavoritesUseCase: Send + Sync {
|
||||||
@@ -74,4 +77,17 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
|
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
|
||||||
) -> Result<HashSet<String>>;
|
) -> Result<HashSet<String>>;
|
||||||
|
|
||||||
|
/// Cursor-paginated list of a user's favorited resources.
|
||||||
|
/// Items that no longer exist (deleted/trashed) are silently excluded.
|
||||||
|
/// `kinds = None` → both files and folders.
|
||||||
|
async fn list_resources_paged(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&FavoritesCursor>,
|
||||||
|
order_by: &str,
|
||||||
|
kinds: Option<&[ResourceKind]>,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<Vec<FavoriteResourceRow>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ use std::sync::Arc;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::cursor::PageCursor;
|
||||||
use crate::application::dtos::favorites_dto::{
|
use crate::application::dtos::favorites_dto::{
|
||||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow,
|
||||||
|
FavoritesCursor,
|
||||||
};
|
};
|
||||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||||
|
|
||||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||||
@@ -155,3 +158,105 @@ impl FavoritesUseCase for FavoritesService {
|
|||||||
self.repo.batch_check_favorites(user_id, item_ids).await
|
self.repo.batch_check_favorites(user_id, item_ids).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FavoritesService {
|
||||||
|
/// Cursor-paginated list of the user's favorited resources.
|
||||||
|
///
|
||||||
|
/// No authz needed — favorites are strictly user-scoped; the repository
|
||||||
|
/// enforces `WHERE user_id = $1` so users can only see their own entries.
|
||||||
|
///
|
||||||
|
/// Returns `(rows, next_cursor_encoded)`.
|
||||||
|
pub async fn list_resources_paged(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<FavoritesCursor>,
|
||||||
|
order_by: &str,
|
||||||
|
kinds: Option<&[ResourceKind]>,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<(Vec<FavoriteResourceRow>, 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_favorites_cursor(last, order_by, reverse);
|
||||||
|
rows.truncate(limit);
|
||||||
|
Some(c.encode())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((rows, next_cursor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the next-page cursor from the last row of the current page.
|
||||||
|
/// `reverse` is stored in the cursor so subsequent pages use the same direction.
|
||||||
|
fn build_favorites_cursor(
|
||||||
|
row: &FavoriteResourceRow,
|
||||||
|
order_by: &str,
|
||||||
|
reverse: bool,
|
||||||
|
) -> FavoritesCursor {
|
||||||
|
match order_by {
|
||||||
|
"type" => FavoritesCursor {
|
||||||
|
order_by: "type".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: row.sort_str.clone(), // LOWER(name)
|
||||||
|
sort_int: row.sort_int, // type_order
|
||||||
|
sort_ts: None,
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"favorited_at" => FavoritesCursor {
|
||||||
|
order_by: "favorited_at".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: None,
|
||||||
|
sort_int: None,
|
||||||
|
sort_ts: row.sort_ts, // favorited_at timestamp
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"modified_at" => FavoritesCursor {
|
||||||
|
order_by: "modified_at".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: None,
|
||||||
|
sort_int: None,
|
||||||
|
sort_ts: row.sort_ts, // modified_at timestamp
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"size" => FavoritesCursor {
|
||||||
|
order_by: "size".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: None,
|
||||||
|
sort_int: row.sort_int, // file size in bytes
|
||||||
|
sort_ts: None,
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"owner" => FavoritesCursor {
|
||||||
|
order_by: "owner".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: row.sort_str.clone(), // LOWER(username)
|
||||||
|
sort_int: None,
|
||||||
|
sort_ts: row.sort_ts, // favorited_at (secondary sort)
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
_ => FavoritesCursor {
|
||||||
|
// "name" (default): sort_str = LOWER(name), sort_int = folder_first (0 = folder, 1 = file)
|
||||||
|
order_by: "name".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: row.sort_str.clone(),
|
||||||
|
sort_int: row.sort_int, // folder_first
|
||||||
|
sort_ts: None,
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ use std::sync::Arc;
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::dtos::favorites_dto::FavoriteItemDto;
|
use crate::application::dtos::favorites_dto::{
|
||||||
|
FavoriteItemDto, FavoriteResourceRow, FavoritesCursor,
|
||||||
|
};
|
||||||
use crate::application::ports::favorites_ports::FavoritesRepositoryPort;
|
use crate::application::ports::favorites_ports::FavoritesRepositoryPort;
|
||||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// PostgreSQL implementation of the favorites persistence port.
|
/// PostgreSQL implementation of the favorites persistence port.
|
||||||
pub struct FavoritesPgRepository {
|
pub struct FavoritesPgRepository {
|
||||||
@@ -279,4 +282,308 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
|
|
||||||
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
|
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_resources_paged(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&FavoritesCursor>,
|
||||||
|
order_by: &str,
|
||||||
|
kinds: Option<&[ResourceKind]>,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<Vec<FavoriteResourceRow>> {
|
||||||
|
let include_folders =
|
||||||
|
kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder)));
|
||||||
|
let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File)));
|
||||||
|
|
||||||
|
// ── Build the UNION ALL CTE ─────────────────────────────────────────
|
||||||
|
let mut cte_branches: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
let folder_branch = r#"
|
||||||
|
SELECT
|
||||||
|
'folder'::text AS resource_type,
|
||||||
|
fld.id AS resource_id,
|
||||||
|
fld.name,
|
||||||
|
fld.parent_id,
|
||||||
|
NULL::text AS mime_type,
|
||||||
|
-1::bigint AS size,
|
||||||
|
fld.created_at AS resource_created_at,
|
||||||
|
fld.updated_at AS modified_at,
|
||||||
|
fld.user_id AS owner_id,
|
||||||
|
(fld.user_id = $1::uuid) AS is_owner,
|
||||||
|
uf.created_at AS favorited_at,
|
||||||
|
fld.path::text AS resource_path,
|
||||||
|
LOWER(fld.name) AS sort_str,
|
||||||
|
0::bigint AS type_order,
|
||||||
|
0::int AS folder_first
|
||||||
|
FROM auth.user_favorites uf
|
||||||
|
INNER JOIN storage.folders fld
|
||||||
|
ON fld.id = uf.item_id::UUID AND NOT fld.is_trashed
|
||||||
|
WHERE uf.user_id = $1::uuid AND uf.item_type = 'folder'"#;
|
||||||
|
|
||||||
|
let file_branch = r#"
|
||||||
|
SELECT
|
||||||
|
'file'::text AS resource_type,
|
||||||
|
f.id AS resource_id,
|
||||||
|
f.name,
|
||||||
|
f.folder_id AS parent_id,
|
||||||
|
f.mime_type,
|
||||||
|
f.size::bigint,
|
||||||
|
f.created_at AS resource_created_at,
|
||||||
|
f.updated_at AS modified_at,
|
||||||
|
f.user_id AS owner_id,
|
||||||
|
(f.user_id = $1::uuid) AS is_owner,
|
||||||
|
uf.created_at AS favorited_at,
|
||||||
|
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
|
||||||
|
LOWER(f.name) AS sort_str,
|
||||||
|
f.category_order::bigint AS type_order,
|
||||||
|
1::int AS folder_first
|
||||||
|
FROM auth.user_favorites uf
|
||||||
|
INNER JOIN storage.files f
|
||||||
|
ON f.id = uf.item_id::UUID AND NOT f.is_trashed
|
||||||
|
LEFT JOIN storage.folders pfld
|
||||||
|
ON pfld.id = f.folder_id
|
||||||
|
WHERE uf.user_id = $1::uuid AND uf.item_type = 'file'"#;
|
||||||
|
|
||||||
|
if include_folders {
|
||||||
|
cte_branches.push(folder_branch);
|
||||||
|
}
|
||||||
|
if include_files {
|
||||||
|
cte_branches.push(file_branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cte_branches.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let union_sql = cte_branches.join("\n UNION ALL\n");
|
||||||
|
let cte = format!("WITH resources AS ({union_sql}\n)");
|
||||||
|
|
||||||
|
// ── Cursor values ───────────────────────────────────────────────────
|
||||||
|
let cur_str: Option<&str> = cursor.and_then(|c| c.sort_str.as_deref());
|
||||||
|
let cur_int: Option<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,
|
||||||
|
),
|
||||||
|
// ── favorited_at ─────────────────────────────────────────────────
|
||||||
|
("favorited_at", false) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (favorited_at < $4)
|
||||||
|
OR (favorited_at = $4 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY favorited_at DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("favorited_at", true) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (favorited_at > $4)
|
||||||
|
OR (favorited_at = $4 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY favorited_at ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── modified_at ──────────────────────────────────────────────────
|
||||||
|
("modified_at", false) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (modified_at < $4)
|
||||||
|
OR (modified_at = $4 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY modified_at DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("modified_at", true) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (modified_at > $4)
|
||||||
|
OR (modified_at = $4 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY modified_at ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── size ─────────────────────────────────────────────────────────
|
||||||
|
("size", false) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (size > $3)
|
||||||
|
OR (size = $3 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY size ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("size", true) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (size < $3)
|
||||||
|
OR (size = $3 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY size DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── owner ────────────────────────────────────────────────────────
|
||||||
|
("owner", false) => (
|
||||||
|
"WHERE ($2::text IS NULL)
|
||||||
|
OR (LOWER(u.username) > $2)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at < $4)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY LOWER(u.username) ASC, favorited_at DESC, resource_id DESC",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("owner", true) => (
|
||||||
|
"WHERE ($2::text IS NULL)
|
||||||
|
OR (LOWER(u.username) < $2)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at > $4)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY LOWER(u.username) DESC, favorited_at ASC, resource_id ASC",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
// ── default: same as name, ascending ─────────────────────────────
|
||||||
|
(_, false) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (folder_first::bigint > $3)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str > $2)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY folder_first ASC, sort_str ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(_, true) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (folder_first::bigint > $3)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str < $2)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY folder_first ASC, sort_str DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_join = if need_user_join {
|
||||||
|
"LEFT JOIN auth.users u ON u.id = r.owner_id"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
// For "owner" sort the JOIN makes LOWER(u.username) available; add it to SELECT
|
||||||
|
// so the cursor can carry the correct sort key.
|
||||||
|
let username_col = if need_user_join {
|
||||||
|
",\n LOWER(u.username) AS username_lower"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"{cte}
|
||||||
|
SELECT
|
||||||
|
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||||
|
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||||
|
r.owner_id, r.is_owner, r.favorited_at, r.resource_path,
|
||||||
|
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||||
|
FROM resources r
|
||||||
|
{user_join}
|
||||||
|
{keyset}
|
||||||
|
{order_by_clause}
|
||||||
|
LIMIT $6"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.bind(user_id) // $1 (in CTE + outer)
|
||||||
|
.bind(cur_str) // $2
|
||||||
|
.bind(cur_int) // $3
|
||||||
|
.bind(cur_ts) // $4
|
||||||
|
.bind(cur_id) // $5
|
||||||
|
.bind(limit as i64) // $6
|
||||||
|
.fetch_all(&*self.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Database error listing favorite resources: {e}");
|
||||||
|
DomainError::new(
|
||||||
|
ErrorKind::InternalError,
|
||||||
|
"Favorites",
|
||||||
|
format!("Failed to list favorite resources: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let resource_type: String = row.get("resource_type");
|
||||||
|
let sort_str_val: Option<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),
|
||||||
|
"favorited_at" => {
|
||||||
|
let ts: Option<chrono::DateTime<chrono::Utc>> =
|
||||||
|
row.try_get("favorited_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("favorited_at").ok();
|
||||||
|
(username, None, ts)
|
||||||
|
}
|
||||||
|
_ => (sort_str_val, Some(folder_first as i64), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
FavoriteResourceRow {
|
||||||
|
resource_type,
|
||||||
|
resource_id: row.get("resource_id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
parent_id: row.try_get("parent_id").ok(),
|
||||||
|
mime_type: row.try_get("mime_type").ok(),
|
||||||
|
size,
|
||||||
|
resource_created_at: row.get("resource_created_at"),
|
||||||
|
modified_at: row.get("modified_at"),
|
||||||
|
owner_id: row.get("owner_id"),
|
||||||
|
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||||
|
favorited_at: row.get("favorited_at"),
|
||||||
|
path: row.try_get("resource_path").ok(),
|
||||||
|
sort_str: c_sort_str,
|
||||||
|
sort_int: c_sort_int,
|
||||||
|
sort_ts: c_sort_ts,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
@@ -9,8 +9,18 @@ use std::sync::Arc;
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::application::dtos::display_helpers::{
|
||||||
|
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||||
|
};
|
||||||
|
use crate::application::dtos::favorites_dto::{
|
||||||
|
FavoritesResourceItemDto, FavoritesResourcesDto, FavoritesResourcesQuery,
|
||||||
|
};
|
||||||
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
|
use crate::application::dtos::folder_dto::FolderDto;
|
||||||
|
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||||
use crate::application::services::favorites_service::FavoritesService;
|
use crate::application::services::favorites_service::FavoritesService;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
/// Single item in a batch-add-favorites request.
|
/// Single item in a batch-add-favorites request.
|
||||||
@@ -27,11 +37,16 @@ pub struct BatchFavoritesRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for favorite-related API endpoints
|
/// Handler for favorite-related API endpoints
|
||||||
|
///
|
||||||
|
/// # Deprecated
|
||||||
|
/// Use `GET /api/favorites/resources` instead. This endpoint is kept for
|
||||||
|
/// backwards compatibility but will be removed in a future release.
|
||||||
|
#[deprecated = "Use GET /api/favorites/resources instead"]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/favorites",
|
path = "/api/favorites",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "List of favorites", body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
|
(status = 200, description = "List of favorites (deprecated — use /api/favorites/resources)", body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
|
||||||
),
|
),
|
||||||
security(("bearerAuth" = [])),
|
security(("bearerAuth" = [])),
|
||||||
tag = "favorites"
|
tag = "favorites"
|
||||||
@@ -178,6 +193,125 @@ pub async fn remove_favorite(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cursor-paginated list of a user's favorited resources.
|
||||||
|
///
|
||||||
|
/// Supports sorting by `name`, `type`, `favorited_at`, `modified_at`, `size`, or `owner`.
|
||||||
|
/// Items that have been deleted/trashed are silently excluded.
|
||||||
|
/// `path` is cleared when the resource is not owned by the requesting user.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/favorites/resources",
|
||||||
|
params(FavoritesResourcesQuery),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Paginated list of favorited resources",
|
||||||
|
body = crate::application::dtos::favorites_dto::FavoritesResourcesDto),
|
||||||
|
(status = 400, description = "Invalid cursor or query parameters"),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "favorites"
|
||||||
|
)]
|
||||||
|
pub async fn list_favorites_resources(
|
||||||
|
State(favorites_service): State<Arc<FavoritesService>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Query(q): Query<FavoritesResourcesQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_id = auth_user.id;
|
||||||
|
|
||||||
|
let order_by = q.order_by.as_deref().unwrap_or("name").to_owned();
|
||||||
|
|
||||||
|
// If a cursor exists, validate that it matches the requested sort/direction.
|
||||||
|
let cursor = q
|
||||||
|
.decode_cursor()
|
||||||
|
.filter(|c| c.order_by == order_by && c.reverse == q.reverse);
|
||||||
|
|
||||||
|
let kinds = q.resource_kinds();
|
||||||
|
|
||||||
|
match favorites_service
|
||||||
|
.list_resources_paged(
|
||||||
|
user_id,
|
||||||
|
q.limit_clamped(),
|
||||||
|
cursor,
|
||||||
|
&order_by,
|
||||||
|
kinds.as_deref(),
|
||||||
|
q.reverse,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((rows, next_cursor)) => {
|
||||||
|
let items: Vec<FavoritesResourceItemDto> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
// Path is only shown to the owner; non-owners see ""
|
||||||
|
// to avoid leaking another user's folder hierarchy.
|
||||||
|
let path = if row.is_owner {
|
||||||
|
row.path.clone().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
if row.resource_type == "folder" {
|
||||||
|
let dto = FolderDto {
|
||||||
|
id: row.resource_id.to_string(),
|
||||||
|
name: row.name.clone(),
|
||||||
|
path,
|
||||||
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
|
created_at: row.resource_created_at.timestamp() as u64,
|
||||||
|
modified_at: row.modified_at.timestamp() as u64,
|
||||||
|
is_root: false,
|
||||||
|
icon_class: std::sync::Arc::from("fas fa-folder"),
|
||||||
|
icon_special_class: std::sync::Arc::from("folder-icon"),
|
||||||
|
category: std::sync::Arc::from("Folder"),
|
||||||
|
};
|
||||||
|
FavoritesResourceItemDto {
|
||||||
|
resource_type: ResourceTypeDto::Folder,
|
||||||
|
favorited_at: row.favorited_at,
|
||||||
|
resource: ResourceContentDto::Folder(dto),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mime = row
|
||||||
|
.mime_type
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("application/octet-stream");
|
||||||
|
let size_bytes = row.size.max(0) as u64;
|
||||||
|
let dto = FileDto {
|
||||||
|
id: row.resource_id.to_string(),
|
||||||
|
name: row.name.clone(),
|
||||||
|
path,
|
||||||
|
size: size_bytes,
|
||||||
|
mime_type: std::sync::Arc::from(mime),
|
||||||
|
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||||
|
created_at: row.resource_created_at.timestamp() as u64,
|
||||||
|
modified_at: row.modified_at.timestamp() as u64,
|
||||||
|
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
|
||||||
|
icon_special_class: std::sync::Arc::from(icon_special_class_for(
|
||||||
|
&row.name, mime,
|
||||||
|
)),
|
||||||
|
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
||||||
|
size_formatted: format_file_size(size_bytes),
|
||||||
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
|
sort_date: None,
|
||||||
|
etag: String::new(),
|
||||||
|
};
|
||||||
|
FavoritesResourceItemDto {
|
||||||
|
resource_type: ResourceTypeDto::File,
|
||||||
|
favorited_at: row.favorited_at,
|
||||||
|
resource: ResourceContentDto::File(dto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(FavoritesResourcesDto::with_cursor(items, next_cursor)),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => AppError::from(e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Add multiple items to favourites in a single transaction.
|
/// Add multiple items to favourites in a single transaction.
|
||||||
/// POST /api/favorites/batch
|
/// POST /api/favorites/batch
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|||||||
@@ -331,10 +331,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
// Create a router without the i18n routes
|
// Create a router without the i18n routes
|
||||||
// Create routes for favorites if the service is available
|
// Create routes for favorites if the service is available
|
||||||
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
|
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
|
||||||
use crate::interfaces::api::handlers::favorites_handler;
|
#[allow(deprecated)]
|
||||||
|
use crate::interfaces::api::handlers::favorites_handler::{
|
||||||
|
self, get_favorites, list_favorites_resources,
|
||||||
|
};
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(favorites_handler::get_favorites))
|
.route("/", get(get_favorites)) // deprecated, kept for compat
|
||||||
|
.route("/resources", get(list_favorites_resources)) // new cursor-paginated endpoint
|
||||||
.route("/batch", post(favorites_handler::batch_add_favorites))
|
.route("/batch", post(favorites_handler::batch_add_favorites))
|
||||||
.route(
|
.route(
|
||||||
"/{item_type}/{item_id}",
|
"/{item_type}/{item_id}",
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ function _ensureComponent() {
|
|||||||
const isFile = 'mime_type' in item;
|
const isFile = 'mime_type' in item;
|
||||||
const type = isFile ? 'file' : 'folder';
|
const type = isFile ? 'file' : 'folder';
|
||||||
if (favorites.isFavorite(item.id, type)) {
|
if (favorites.isFavorite(item.id, type)) {
|
||||||
await favorites.removeFromFavorites(item.id, type);
|
await favorites.removeFromFavorites(item.id, type, item.name);
|
||||||
_component?.setFavoriteVisualState(item.id, type, false);
|
_component?.setFavoriteVisualState(item.id, type, false);
|
||||||
} else {
|
} else {
|
||||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
|
|||||||
+14
-18
@@ -9,6 +9,7 @@ import { favorites } from '../features/library/favorites.js';
|
|||||||
import { musicView } from '../features/library/music.js';
|
import { musicView } from '../features/library/music.js';
|
||||||
import { photosView } from '../features/library/photos.js';
|
import { photosView } from '../features/library/photos.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
|
import { favoritesView } from '../views/favorites/favoritesView.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
import { sharedView } from '../views/shared/sharedView.js';
|
||||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||||
import { filesView, loadFiles } from './filesView.js';
|
import { filesView, loadFiles } from './filesView.js';
|
||||||
@@ -164,6 +165,11 @@ function setCurrentSection(section) {
|
|||||||
sharedWithMeView.hide();
|
sharedWithMeView.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide favoritesView "Load more" button when leaving the favorites section
|
||||||
|
if (section !== 'favorites' && favoritesView) {
|
||||||
|
favoritesView.hide();
|
||||||
|
}
|
||||||
|
|
||||||
// Reset owner column — sections that need it re-enable it explicitly below.
|
// Reset owner column — sections that need it re-enable it explicitly below.
|
||||||
ui.setOwnerColumnVisible(false);
|
ui.setOwnerColumnVisible(false);
|
||||||
|
|
||||||
@@ -273,8 +279,8 @@ function switchToFavoritesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('favorites');
|
setActionsBarMode('favorites');
|
||||||
setGroupByView(null);
|
setGroupByView(favoritesView);
|
||||||
syncGroupByMenu([]);
|
syncGroupByMenu(favoritesView.groupByDefs);
|
||||||
|
|
||||||
// Show the Owner column — names are resolved async after render.
|
// Show the Owner column — names are resolved async after render.
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
@@ -289,23 +295,13 @@ function switchToFavoritesSection() {
|
|||||||
// ensure correct view
|
// ensure correct view
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
|
|
||||||
//reset files view + remove any error
|
|
||||||
ui.resetFilesList();
|
|
||||||
|
|
||||||
if (favorites) {
|
|
||||||
// temp solution
|
|
||||||
sharedView.loadItems().then(() => {
|
|
||||||
favorites.displayFavorites();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Favorites module not loaded or initialized');
|
|
||||||
ui.showError(`
|
|
||||||
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
|
|
||||||
<p>Error loading the favorites module</p>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batchToolbar) batchToolbar.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
|
|
||||||
|
// Prefetch isFavorite cache in background (non-blocking)
|
||||||
|
favorites.init();
|
||||||
|
|
||||||
|
// Load and render via the cursor-paginated view
|
||||||
|
favoritesView.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToRecentFilesSection() {
|
function switchToRecentFilesSection() {
|
||||||
|
|||||||
@@ -333,6 +333,22 @@
|
|||||||
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One item returned by `GET /api/favorites/resources`.
|
||||||
|
* `resource_type` discriminates the shape of `resource`.
|
||||||
|
* @typedef {Object} FavoritesResourceItem
|
||||||
|
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
|
||||||
|
* @property {string} favorited_at - ISO-8601 timestamp when the item was starred.
|
||||||
|
* @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for `GET /api/favorites/resources`.
|
||||||
|
* @typedef {Object} FavoritesResourcesResponse
|
||||||
|
* @property {FavoritesResourceItem[]} items
|
||||||
|
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ContactEmail
|
* @typedef {Object} ContactEmail
|
||||||
* @property {string} email
|
* @property {string} email
|
||||||
|
|||||||
@@ -508,12 +508,8 @@ const batchToolbar = {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const inserted = data.stats?.inserted || 0;
|
const inserted = data.stats?.inserted || 0;
|
||||||
|
|
||||||
// Replace cache directly from response (no extra GET)
|
// Re-fetch the isFavorite cache from the server.
|
||||||
if (data.favorites && favorites._replaceCacheFromResponse) {
|
await favorites._fetchFromServer();
|
||||||
favorites._replaceCacheFromResponse(data.favorites);
|
|
||||||
} else {
|
|
||||||
await favorites._fetchFromServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clear();
|
this.clear();
|
||||||
loadFiles();
|
loadFiles();
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const contextMenus = {
|
|||||||
// Check if folder is already in favorites to toggle
|
// Check if folder is already in favorites to toggle
|
||||||
if (favorites?.isFavorite(folder.id, 'folder')) {
|
if (favorites?.isFavorite(folder.id, 'folder')) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
const ok = await favorites.removeFromFavorites(folder.id, 'folder');
|
const ok = await favorites.removeFromFavorites(folder.id, 'folder', folder.name);
|
||||||
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
||||||
ui.setFavoriteVisualState(folder.id, 'folder', false);
|
ui.setFavoriteVisualState(folder.id, 'folder', false);
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ const contextMenus = {
|
|||||||
// Check if file is already in favorites to toggle
|
// Check if file is already in favorites to toggle
|
||||||
if (favorites?.isFavorite(file.id, 'file')) {
|
if (favorites?.isFavorite(file.id, 'file')) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
const ok = await favorites.removeFromFavorites(file.id, 'file');
|
const ok = await favorites.removeFromFavorites(file.id, 'file', file.name);
|
||||||
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
||||||
ui.setFavoriteVisualState(file.id, 'file', false);
|
ui.setFavoriteVisualState(file.id, 'file', false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* OxiCloud - Favorites Module (server-authoritative)
|
* OxiCloud - Favorites Module (server-authoritative)
|
||||||
*
|
*
|
||||||
* Source of truth: GET /api/favorites (enriched with name/size/mime via SQL JOIN).
|
* Source of truth: GET /api/favorites/resources (cursor-paginated).
|
||||||
* Local in-memory cache (`_cache`) keeps `isFavorite()` synchronous for the
|
* The in-memory cache (`_cache`) is a Set of "type:id" keys that keeps
|
||||||
* rendering path so star icons can be painted without a round-trip.
|
* `isFavorite()` synchronous so star icons are painted without a round-trip.
|
||||||
|
*
|
||||||
|
* Display is handled by `views/favorites/favoritesView.js`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { batchToolbar } from '../files/batchToolbar.js';
|
import { fetchFavoritesPage } from '../../model/favoritesModel.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
|
||||||
|
|
||||||
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
|
||||||
|
|
||||||
const favorites = {
|
const favorites = {
|
||||||
/** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
|
/**
|
||||||
_cache: new Map(),
|
* Set of "type:id" cache keys. A Set is enough — we only need O(1) lookups.
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
_cache: new Set(),
|
||||||
|
|
||||||
/** Whether the initial fetch from the server has completed */
|
/** Whether the initial fetch from the server has completed. */
|
||||||
_ready: false,
|
_ready: false,
|
||||||
|
|
||||||
/** @type {ResourceListComponent|null} */
|
|
||||||
_component: null,
|
|
||||||
|
|
||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
@@ -34,56 +32,43 @@ const favorites = {
|
|||||||
/**
|
/**
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
_cacheKey(id, type) {
|
_cacheKey(id, type) {
|
||||||
return `${type}:${id}`;
|
return `${type}:${id}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the entire in-memory cache from an array of FavoriteItemDto
|
|
||||||
* objects (as returned by the batch endpoint). Avoids an extra
|
|
||||||
* GET /api/favorites round-trip.
|
|
||||||
* @param {any[]} items
|
|
||||||
*/
|
|
||||||
_replaceCacheFromResponse(items) {
|
|
||||||
this._cache.clear();
|
|
||||||
for (const item of items) {
|
|
||||||
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
|
|
||||||
}
|
|
||||||
this._ready = true;
|
|
||||||
console.log(`Favorites cache replaced from response: ${this._cache.size} items`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ───────────────────── lifecycle ─────────────────────
|
// ───────────────────── lifecycle ─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise the module: fetch the full list from the server and populate
|
* Initialise the module: fetch the full favorites list from the server and
|
||||||
* the in-memory cache. Called once from app.js on startup.
|
* populate the in-memory cache. Called from navigation.js every time the
|
||||||
|
* Favorites section is entered (non-blocking — the view loads in parallel).
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
console.log('Initializing favorites module (server-authoritative)');
|
|
||||||
await this._fetchFromServer();
|
await this._fetchFromServer();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch favourites from the backend and rebuild the cache.
|
* Fetch all favorited resource IDs from the server and rebuild the cache.
|
||||||
|
* Paginates through `GET /api/favorites/resources` until exhausted.
|
||||||
*/
|
*/
|
||||||
async _fetchFromServer() {
|
async _fetchFromServer() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/favorites', {
|
|
||||||
headers: this._authHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(`Favorites API returned ${response.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {FavoriteItem[]} */
|
|
||||||
const items = await response.json();
|
|
||||||
this._cache.clear();
|
this._cache.clear();
|
||||||
for (const item of items) {
|
|
||||||
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
|
let cursor = /** @type {string|undefined} */ (undefined);
|
||||||
|
|
||||||
|
// Paginate with the max page size so most users need only one request.
|
||||||
|
while (true) {
|
||||||
|
const data = await fetchFavoritesPage({ limit: 200, cursor, orderBy: 'name' });
|
||||||
|
for (const item of data.items) {
|
||||||
|
// `item.resource.id` works for both FileItem (id) and FolderItem (id).
|
||||||
|
const r = /** @type {Record<string, string>} */ (/** @type {unknown} */ (item.resource));
|
||||||
|
this._cache.add(this._cacheKey(r.id, item.resource_type));
|
||||||
|
}
|
||||||
|
if (!data.next_cursor) break;
|
||||||
|
cursor = data.next_cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._ready = true;
|
this._ready = true;
|
||||||
@@ -96,20 +81,22 @@ const favorites = {
|
|||||||
// ───────────────────── public API ─────────────────────
|
// ───────────────────── public API ─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous check used by ui.js to paint star icons.
|
* Synchronous check used by the rendering layer to paint star icons.
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
isFavorite(id, type) {
|
isFavorite(id, type) {
|
||||||
return this._cache.has(this._cacheKey(id, type));
|
return this._cache.has(this._cacheKey(id, type));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an item to favourites (server-first).
|
* Add an item to favourites (server-first, then update local cache).
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
* @param {string | null} _parentId
|
* @param {string | null} _parentId - unused, kept for call-site compatibility
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async addToFavorites(id, name, type, _parentId) {
|
async addToFavorites(id, name, type, _parentId) {
|
||||||
try {
|
try {
|
||||||
@@ -122,10 +109,9 @@ const favorites = {
|
|||||||
throw new Error(`Server returned ${response.status}`);
|
throw new Error(`Server returned ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh cache from server to get enriched data
|
// Optimistically update local cache without a full re-fetch.
|
||||||
await this._fetchFromServer();
|
this._cache.add(this._cacheKey(id, type));
|
||||||
|
|
||||||
// Notify user
|
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
ui.showNotification(i18n.t('favorites.added_title'), `"${name}" ${i18n.t('favorites.added_msg')}`);
|
ui.showNotification(i18n.t('favorites.added_title'), `"${name}" ${i18n.t('favorites.added_msg')}`);
|
||||||
}
|
}
|
||||||
@@ -138,16 +124,14 @@ const favorites = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an item from favourites (server-first).
|
* Remove an item from favourites (server-first, then update local cache).
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
* @param {string} [name] - Display name for the notification; falls back to `id`.
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async removeFromFavorites(id, type) {
|
async removeFromFavorites(id, type, name = id) {
|
||||||
try {
|
try {
|
||||||
// Remember name for notification before removing from cache
|
|
||||||
const cached = this._cache.get(this._cacheKey(id, type));
|
|
||||||
const itemName = cached?.item_name || id;
|
|
||||||
|
|
||||||
const response = await fetch(`/api/favorites/${type}/${id}`, {
|
const response = await fetch(`/api/favorites/${type}/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: this._authHeaders()
|
headers: this._authHeaders()
|
||||||
@@ -157,11 +141,10 @@ const favorites = {
|
|||||||
throw new Error(`Server returned ${response.status}`);
|
throw new Error(`Server returned ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from local cache
|
|
||||||
this._cache.delete(this._cacheKey(id, type));
|
this._cache.delete(this._cacheKey(id, type));
|
||||||
|
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
ui.showNotification(i18n.t('favorites.removed_title'), `"${itemName}" ${i18n.t('favorites.removed_msg')}`);
|
ui.showNotification(i18n.t('favorites.removed_title'), `"${name}" ${i18n.t('favorites.removed_msg')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -171,124 +154,32 @@ const favorites = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ───────────────────── display ─────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the favourites view. All data comes from the in-memory cache
|
* Batch-add multiple items to favourites in a single server call.
|
||||||
* (which was populated from the enriched backend response — zero extra
|
* Re-fetches the cache after success to stay consistent.
|
||||||
* fetches).
|
*
|
||||||
|
* @param {Array<{item_id: string, item_type: string}>} items
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async displayFavorites() {
|
async batchAdd(items) {
|
||||||
try {
|
try {
|
||||||
|
const response = await fetch('/api/favorites/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this._authHeaders() },
|
||||||
|
body: JSON.stringify({ items })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Server returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the full cache so the Set reflects the latest server state.
|
||||||
await this._fetchFromServer();
|
await this._fetchFromServer();
|
||||||
|
|
||||||
ui.resetFilesList();
|
return true;
|
||||||
batchToolbar.init();
|
} catch (err) {
|
||||||
ui.updateBreadcrumb();
|
console.error('Error in batchAdd:', err);
|
||||||
|
return false;
|
||||||
if (this._cache.size === 0) {
|
|
||||||
ui.showError(`
|
|
||||||
<i class="fas fa-star empty-state-icon"></i>
|
|
||||||
<p>${i18n.t('favorites.empty_state')}</p>
|
|
||||||
<p>${i18n.t('favorites.empty_hint')}</p>
|
|
||||||
`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {Array<FileItem|FolderItem>} */
|
|
||||||
const items = [];
|
|
||||||
|
|
||||||
for (const item of this._cache.values()) {
|
|
||||||
// owner_id comes from the backend JOIN (actual file/folder owner)
|
|
||||||
if (item.item_type === 'folder') {
|
|
||||||
items.push(
|
|
||||||
/** @type {FolderItem} */ ({
|
|
||||||
id: item.item_id,
|
|
||||||
name: item.item_name || item.item_id,
|
|
||||||
parent_id: item.parent_id || '',
|
|
||||||
modified_at: item.modified_at || item.created_at,
|
|
||||||
path: item.item_path || '',
|
|
||||||
category: 'folder',
|
|
||||||
created_at: item.created_at,
|
|
||||||
icon_class: item.icon_class,
|
|
||||||
icon_special_class: item.icon_special_class,
|
|
||||||
owner_id: item.owner_id ?? '',
|
|
||||||
is_root: false
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
items.push(
|
|
||||||
/** @type {FileItem} */ ({
|
|
||||||
id: item.item_id,
|
|
||||||
name: item.item_name || item.item_id,
|
|
||||||
folder_id: item.parent_id || '',
|
|
||||||
mime_type: item.item_mime_type,
|
|
||||||
icon_class: item.icon_class,
|
|
||||||
icon_special_class: item.icon_special_class,
|
|
||||||
category: item.category,
|
|
||||||
size: item.item_size || 0,
|
|
||||||
size_formatted: item.size_formatted,
|
|
||||||
modified_at: item.modified_at || item.created_at,
|
|
||||||
path: item.item_path || '',
|
|
||||||
owner_id: item.owner_id ?? '',
|
|
||||||
created_at: item.created_at,
|
|
||||||
sort_date: item.created_at
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
|
||||||
if (filesList) {
|
|
||||||
if (!this._component) {
|
|
||||||
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
|
||||||
selectable: true,
|
|
||||||
showFavorite: true,
|
|
||||||
showOwner: true,
|
|
||||||
showShareBadge: true,
|
|
||||||
draggable: false,
|
|
||||||
showContextMenu: true,
|
|
||||||
itemModifierClass: 'favorite-item',
|
|
||||||
isFavorite: (id, type) => this.isFavorite(id, type),
|
|
||||||
onOpen: (item) => ui.openItem(item),
|
|
||||||
onFavoriteToggle: async (item) => {
|
|
||||||
const isFile = 'mime_type' in item;
|
|
||||||
const type = isFile ? 'file' : 'folder';
|
|
||||||
if (this.isFavorite(item.id, type)) {
|
|
||||||
await this.removeFromFavorites(item.id, type);
|
|
||||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
|
||||||
} else {
|
|
||||||
await this.addToFavorites(item.id, item.name, type, null);
|
|
||||||
this._component?.setFavoriteVisualState(item.id, type, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
|
||||||
onSelectionChange: (selectedItems) => {
|
|
||||||
batchToolbar._selected.clear();
|
|
||||||
for (const sel of selectedItems) {
|
|
||||||
const isFile = 'mime_type' in sel;
|
|
||||||
batchToolbar._selected.set(sel.id, {
|
|
||||||
id: sel.id,
|
|
||||||
name: sel.name,
|
|
||||||
type: isFile ? 'file' : 'folder',
|
|
||||||
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
batchToolbar._syncUI();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
batchToolbar.setActiveComponent(this._component);
|
|
||||||
this._component.render(items);
|
|
||||||
pathTooltip.init(filesList);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this._component?.resolveOwnerCells();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error displaying favorites:', error);
|
|
||||||
if (ui?.showNotification) {
|
|
||||||
ui.showNotification('Error', 'Error loading favorite items');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* OxiCloud – Favorites resource model.
|
||||||
|
*
|
||||||
|
* Thin fetch wrapper for `GET /api/favorites/resources` (cursor-paginated).
|
||||||
|
* The old `GET /api/favorites` endpoint is kept for the isFavorite cache in
|
||||||
|
* `features/library/favorites.js` — this module only handles the new endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../core/types.js' */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FavoritesResourceItem
|
||||||
|
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
|
||||||
|
* @property {string} favorited_at - ISO-8601 timestamp
|
||||||
|
* @property {FileItem|FolderItem} resource - Full resource details
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FavoritesResourcesResponse
|
||||||
|
* @property {FavoritesResourceItem[]} items
|
||||||
|
* @property {string|undefined} [next_cursor]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one page of the current user's favorited resources.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* cursor?: string,
|
||||||
|
* orderBy?: string,
|
||||||
|
* limit?: number,
|
||||||
|
* reverse?: boolean,
|
||||||
|
* resourceTypes?: ResourceTypeEnum[],
|
||||||
|
* }} [opts]
|
||||||
|
* @returns {Promise<FavoritesResourcesResponse>}
|
||||||
|
*/
|
||||||
|
async function fetchFavoritesPage({ cursor, orderBy = 'name', limit = 50, reverse = false, resourceTypes } = {}) {
|
||||||
|
const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) });
|
||||||
|
if (cursor) params.set('cursor', cursor);
|
||||||
|
if (reverse) params.set('reverse', 'true');
|
||||||
|
if (resourceTypes?.length) params.set('resource_types', resourceTypes.join(','));
|
||||||
|
|
||||||
|
const res = await fetch(`/api/favorites/resources?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(`Failed to fetch favorites: HTTP ${res.status}`);
|
||||||
|
/** @type {any} */ (err).status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { fetchFavoritesPage };
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* OxiCloud – Favorites view.
|
||||||
|
*
|
||||||
|
* Renders files and folders the current user has starred, using the
|
||||||
|
* cursor-paginated `GET /api/favorites/resources` endpoint.
|
||||||
|
*
|
||||||
|
* Uses `ResourceListComponent` so the grid ↔ list toggle and all card
|
||||||
|
* components work out of the box. A "Load more" button is injected below
|
||||||
|
* the files container for cursor-based pagination.
|
||||||
|
*
|
||||||
|
* Public API mirrors `sharedWithMeView`:
|
||||||
|
* - `groupByDefs` — array of group-by dimension definitions
|
||||||
|
* - `setGroupBy(key)` — change active dimension + reload from page 1
|
||||||
|
* - `setDirection(reversed)` — flip sort direction + reload from page 1
|
||||||
|
* - `init()` — (re-)enter the section; resets state + loads page 1
|
||||||
|
* - `hide()` — called when leaving this section
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ui } from '../../app/ui.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
|
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
|
||||||
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { batchToolbar } from '../../features/files/batchToolbar.js';
|
||||||
|
import * as itemTooltip from '../../features/itemTooltip.js';
|
||||||
|
import { favorites } from '../../features/library/favorites.js';
|
||||||
|
import { fetchFavoritesPage } from '../../model/favoritesModel.js';
|
||||||
|
import { systemUsers } from '../../model/systemUsers.js';
|
||||||
|
|
||||||
|
/** @import {FavoritesResourceItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ key: string, label: string, orderBy: string,
|
||||||
|
* keyFn: (item: FileItem|FolderItem) => string|null,
|
||||||
|
* labelFn?: (key: string) => string }} GroupByDef
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group-by dimension definitions for the Favorites section.
|
||||||
|
* The empty-key entry (no grouping) is the default; it sorts by `name`.
|
||||||
|
*
|
||||||
|
* @type {GroupByDef[]}
|
||||||
|
*/
|
||||||
|
const GROUP_BY_DEFS = [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
get label() {
|
||||||
|
return i18n.t('groupby.type', 'Type');
|
||||||
|
},
|
||||||
|
orderBy: 'type',
|
||||||
|
// keyFn: folders → 'Folder' swimlane; files → their `category` field.
|
||||||
|
keyFn: (item) => ('mime_type' in item ? /** @type {Record<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: 'favoriteDate',
|
||||||
|
get label() {
|
||||||
|
return i18n.t('groupby.favoriteDate', 'Favorite date');
|
||||||
|
},
|
||||||
|
orderBy: 'favorited_at',
|
||||||
|
// sort_date is stored as unix seconds in _mapItems().
|
||||||
|
keyFn: (item) => {
|
||||||
|
const r = /** @type {Record<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',
|
||||||
|
// modified_at is a unix seconds timestamp on FileItem/FolderItem.
|
||||||
|
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',
|
||||||
|
// Folders have no size — sizeBucket(-1) returns the "Folders" label.
|
||||||
|
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 groups by UUID — stable, avoids collisions on identical display names.
|
||||||
|
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 = 'fav-load-more-wrapper';
|
||||||
|
|
||||||
|
const favoritesView = {
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @type {string|null} */
|
||||||
|
_nextCursor: null,
|
||||||
|
|
||||||
|
_loading: false,
|
||||||
|
|
||||||
|
/** @type {ResourceListComponent|null} */
|
||||||
|
_component: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active group-by key. '' = no grouping (sorted by name).
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
_groupBy: '',
|
||||||
|
|
||||||
|
/** Whether the current sort order is reversed. */
|
||||||
|
_reversed: false,
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group-by dimension definitions for this section.
|
||||||
|
* `main.js` reads this to populate the Group-by dropdown.
|
||||||
|
* @returns {GroupByDef[]}
|
||||||
|
*/
|
||||||
|
get groupByDefs() {
|
||||||
|
return GROUP_BY_DEFS;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the active group-by dimension and reload from page 1.
|
||||||
|
* Calling with the current key is a no-op.
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
setGroupBy(key) {
|
||||||
|
if (this._groupBy === key) return;
|
||||||
|
this._groupBy = key;
|
||||||
|
this._nextCursor = null;
|
||||||
|
this._component?.clear();
|
||||||
|
this._loadPage();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip the sort direction and reload from page 1.
|
||||||
|
* Calling with the current value is a no-op.
|
||||||
|
* @param {boolean} reversed
|
||||||
|
*/
|
||||||
|
setDirection(reversed) {
|
||||||
|
if (this._reversed === reversed) return;
|
||||||
|
this._reversed = reversed;
|
||||||
|
this._nextCursor = null;
|
||||||
|
this._component?.clear();
|
||||||
|
this._loadPage();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Re-)enter the Favorites section: reset state, create / reuse the
|
||||||
|
* component, and load page 1.
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
this._nextCursor = null;
|
||||||
|
this._loading = false;
|
||||||
|
this._groupBy = '';
|
||||||
|
this._reversed = false;
|
||||||
|
|
||||||
|
this._ensureLoadMoreButton();
|
||||||
|
|
||||||
|
// Prefetch system users so owner tooltips resolve without delay.
|
||||||
|
systemUsers.prefetch();
|
||||||
|
|
||||||
|
ui.resetFilesList();
|
||||||
|
batchToolbar.init();
|
||||||
|
ui.updateBreadcrumb();
|
||||||
|
|
||||||
|
const filesList = document.getElementById('files-list');
|
||||||
|
if (filesList) {
|
||||||
|
if (!this._component) {
|
||||||
|
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||||
|
selectable: true,
|
||||||
|
showFavorite: true,
|
||||||
|
showOwner: true,
|
||||||
|
showShareBadge: false,
|
||||||
|
draggable: false,
|
||||||
|
showContextMenu: true,
|
||||||
|
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||||||
|
isShared: () => false,
|
||||||
|
onOpen: (item) => ui.openItem(item),
|
||||||
|
onFavoriteToggle: async (item) => {
|
||||||
|
const isFile = 'mime_type' in item;
|
||||||
|
const type = isFile ? 'file' : 'folder';
|
||||||
|
if (favorites.isFavorite(item.id, type)) {
|
||||||
|
await favorites.removeFromFavorites(item.id, type, item.name);
|
||||||
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
|
} else {
|
||||||
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
|
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||||
|
onSelectionChange: (selectedItems) => {
|
||||||
|
batchToolbar._selected.clear();
|
||||||
|
for (const sel of selectedItems) {
|
||||||
|
const isFile = 'mime_type' in sel;
|
||||||
|
batchToolbar._selected.set(sel.id, {
|
||||||
|
id: sel.id,
|
||||||
|
name: sel.name,
|
||||||
|
type: isFile ? 'file' : 'folder',
|
||||||
|
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchToolbar._syncUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchToolbar.setActiveComponent(this._component);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._loadPage();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the "Load more" button when leaving this section.
|
||||||
|
* The files container itself is managed by navigation.js.
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
const w = document.getElementById(LOAD_MORE_ID);
|
||||||
|
if (w) w.classList.add('hidden');
|
||||||
|
|
||||||
|
batchToolbar.setActiveComponent(null);
|
||||||
|
|
||||||
|
const filesList = document.getElementById('files-list');
|
||||||
|
if (filesList) itemTooltip.destroy(filesList);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one page, map items → FileItem / FolderItem, render them.
|
||||||
|
* @returns {Promise<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 ?? 'name';
|
||||||
|
|
||||||
|
const data = await fetchFavoritesPage({
|
||||||
|
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||||
|
limit: 50,
|
||||||
|
cursor: this._nextCursor ?? undefined,
|
||||||
|
orderBy,
|
||||||
|
reverse: this._reversed
|
||||||
|
});
|
||||||
|
|
||||||
|
this._nextCursor = data.next_cursor ?? null;
|
||||||
|
|
||||||
|
if (data.items.length === 0 && isFirstPage) {
|
||||||
|
ui.showError(`
|
||||||
|
<i class="fas fa-star empty-state-icon"></i>
|
||||||
|
<p>${i18n.t('favorites.empty_state', 'No favorites yet')}</p>
|
||||||
|
<p>${i18n.t('favorites.empty_hint', 'Star files and folders to find them here quickly')}</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('favoritesView: load error', err);
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map `FavoritesResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving
|
||||||
|
* server order. Sets `sort_date` (unix seconds) to the favorite date so the
|
||||||
|
* `favoriteDate` keyFn can bucket by when the item was starred.
|
||||||
|
*
|
||||||
|
* @param {FavoritesResourceItem[]} items
|
||||||
|
* @returns {Array<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 = favorited_at (unix seconds) for the favoriteDate keyFn
|
||||||
|
sort_date: toSecs(item.favorited_at),
|
||||||
|
icon_class: f.icon_class,
|
||||||
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
|
category: 'Folder'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (item.resource_type === 'file') {
|
||||||
|
const f = /** @type {FileItem} */ (item.resource);
|
||||||
|
result.push(
|
||||||
|
/** @type {FileItem} */ ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
path: f.path ?? '',
|
||||||
|
folder_id: f.folder_id ?? '',
|
||||||
|
owner_id: f.owner_id ?? '',
|
||||||
|
mime_type: f.mime_type,
|
||||||
|
size: f.size,
|
||||||
|
size_formatted: f.size_formatted,
|
||||||
|
created_at: f.created_at,
|
||||||
|
modified_at: f.modified_at,
|
||||||
|
sort_date: toSecs(item.favorited_at),
|
||||||
|
icon_class: f.icon_class,
|
||||||
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
|
category: f.category
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── "Load more" button ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the "Load more" wrapper once and attach it below `.files-container`.
|
||||||
|
* Subsequent calls are no-ops.
|
||||||
|
*/
|
||||||
|
_ensureLoadMoreButton() {
|
||||||
|
if (document.getElementById(LOAD_MORE_ID)) return;
|
||||||
|
|
||||||
|
const filesContainer = document.querySelector('.files-container');
|
||||||
|
if (!filesContainer) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.id = LOAD_MORE_ID;
|
||||||
|
wrapper.className = 'swm-load-more-wrapper hidden';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'fav-load-more';
|
||||||
|
btn.className = 'button secondary';
|
||||||
|
btn.textContent = i18n.t('favorites.loadMore', 'Load more');
|
||||||
|
btn.addEventListener('click', () => this._loadPage());
|
||||||
|
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
filesContainer.after(wrapper);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} visible
|
||||||
|
*/
|
||||||
|
_setLoadMoreVisible(visible) {
|
||||||
|
const w = document.getElementById(LOAD_MORE_ID);
|
||||||
|
if (w) w.classList.toggle('hidden', !visible);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { favoritesView };
|
||||||
@@ -222,7 +222,7 @@ const sharedWithMeView = {
|
|||||||
const isFile = 'mime_type' in item;
|
const isFile = 'mime_type' in item;
|
||||||
const type = isFile ? 'file' : 'folder';
|
const type = isFile ? 'file' : 'folder';
|
||||||
if (favorites.isFavorite(item.id, type)) {
|
if (favorites.isFavorite(item.id, type)) {
|
||||||
await favorites.removeFromFavorites(item.id, type);
|
await favorites.removeFromFavorites(item.id, type, item.name);
|
||||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
} else {
|
} else {
|
||||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
|
|||||||
Reference in New Issue
Block a user