Merge pull request #399 from EdouardVanbelle/feat/implement-cursor-on-main-lists
This commit is contained in:
@@ -154,6 +154,13 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --release
|
||||
# build.rs runs the deconflict pass and js_bundle_validate() — any
|
||||
# duplicate declaration or parse error in the JS bundle fails here.
|
||||
- name: Validate JS bundle (node --check)
|
||||
# belt-and-suspenders: node --check parses the bundle without executing it.
|
||||
# Catches SyntaxErrors that OXC's parse check inside build.rs would also
|
||||
# catch, but gives a human-readable error line in the CI log.
|
||||
run: node --check static-dist/js/app.*.js
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oxicloud-release
|
||||
|
||||
Generated
+1
@@ -3692,6 +3692,7 @@ dependencies = [
|
||||
"oxc_codegen",
|
||||
"oxc_minifier",
|
||||
"oxc_parser",
|
||||
"oxc_semantic",
|
||||
"oxc_span",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.39.2",
|
||||
|
||||
@@ -84,6 +84,7 @@ path = "src/bin/generate-openapi.rs"
|
||||
[build-dependencies]
|
||||
oxc_allocator = "0.125.0"
|
||||
oxc_parser = "0.125.0"
|
||||
oxc_semantic = "0.125.0"
|
||||
oxc_span = "0.125.0"
|
||||
oxc_codegen = "0.125.0"
|
||||
oxc_minifier = "0.125.0"
|
||||
|
||||
@@ -119,6 +119,9 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) {
|
||||
let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html");
|
||||
let module_scripts = extract_module_scripts(&index_html);
|
||||
let js_raw = build_js_module_bundle(&module_scripts, static_dir);
|
||||
// Validate the raw bundle with OXC before minifying — catches re-declaration
|
||||
// collisions and other syntax errors that would silently survive minification.
|
||||
js_bundle_validate(&js_raw);
|
||||
let js_bundle = js_minify_script_safe(&js_raw);
|
||||
let js_hash = fnv_hash(js_bundle.as_bytes());
|
||||
let js_name = format!("app.{js_hash}.js");
|
||||
@@ -273,8 +276,12 @@ fn extract_module_scripts(html: &str) -> Vec<String> {
|
||||
/// 1. DFS from each entry point, following `import … from '…'` edges.
|
||||
/// 2. Post-order traversal ensures every dependency is emitted before its importer.
|
||||
/// 3. Cycles are broken by marking files as visited before recursing.
|
||||
/// 4. Each file has its import/export syntax stripped before being appended.
|
||||
/// 5. The result is wrapped in `(function(){"use strict"; …})();`.
|
||||
/// 4. **Deconflict pass**: any top-level binding that is private (not exported)
|
||||
/// and shared across two or more modules is renamed `NAME_<idx>` in every
|
||||
/// module that declares it. This prevents `SyntaxError: already declared`
|
||||
/// when all modules land in the same IIFE scope.
|
||||
/// 5. Each file has its import/export syntax stripped before being appended.
|
||||
/// 6. The result is wrapped in `(function(){"use strict"; …})();`.
|
||||
fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String {
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -290,28 +297,206 @@ fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String
|
||||
"cargo:warning=bundle: {} files in dependency order:",
|
||||
order.len()
|
||||
);
|
||||
|
||||
// Read all sources upfront — the deconflict pass needs the full set.
|
||||
let mut sources: Vec<String> = order
|
||||
.iter()
|
||||
.map(|f| match fs::read_to_string(f) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=bundle: cannot read {}: {e}", f.display());
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Deconflict: rename private top-level bindings that collide across modules.
|
||||
deconflict_module_sources(&order, &mut sources);
|
||||
|
||||
// Concatenate into a single IIFE.
|
||||
let mut bundle = String::with_capacity(2 * 1024 * 1024);
|
||||
bundle.push_str("(function(){\n\"use strict\";\n");
|
||||
let mut declared_namespaces = std::collections::HashSet::new();
|
||||
for (i, file) in order.iter().enumerate() {
|
||||
let mut declared_namespaces = HashSet::new();
|
||||
for (i, (file, src)) in order.iter().zip(sources.iter()).enumerate() {
|
||||
println!(
|
||||
"cargo:warning=bundle [{:>3}/{}] {}",
|
||||
i + 1,
|
||||
order.len(),
|
||||
file.display()
|
||||
);
|
||||
match fs::read_to_string(file) {
|
||||
Ok(src) => {
|
||||
bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces));
|
||||
bundle.push('\n');
|
||||
}
|
||||
Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()),
|
||||
}
|
||||
bundle.push_str(&strip_esm_syntax(src, file, &mut declared_namespaces));
|
||||
bundle.push('\n');
|
||||
}
|
||||
bundle.push_str("})();\n");
|
||||
bundle
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deconflict pass
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Rename private top-level bindings that appear in more than one module so
|
||||
/// they don't collide when all modules are concatenated into one IIFE scope.
|
||||
///
|
||||
/// Only **private** (non-exported) names are renamed. Exported names are the
|
||||
/// public API — other modules reference them directly by name after
|
||||
/// import-stripping and must not be touched.
|
||||
///
|
||||
/// The renamed form is `NAME_<module_index>` where the index is the position
|
||||
/// of the module in the bundle order — guaranteed unique within the bundle.
|
||||
fn deconflict_module_sources(order: &[PathBuf], sources: &mut [String]) {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// Per-module: private (non-exported) top-level binding names.
|
||||
let private_bindings: Vec<Vec<String>> = sources
|
||||
.iter()
|
||||
.map(|src| {
|
||||
let all = top_level_bindings(src);
|
||||
let exported: HashSet<String> = extract_exported_names(src).into_iter().collect();
|
||||
all.into_iter().filter(|n| !exported.contains(n)).collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Count how many modules declare each private name.
|
||||
let mut name_count: HashMap<String, usize> = HashMap::new();
|
||||
for bindings in &private_bindings {
|
||||
for name in bindings {
|
||||
*name_count.entry(name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Collision set: names declared privately in more than one module.
|
||||
let collisions: HashSet<String> = name_count
|
||||
.into_iter()
|
||||
.filter(|(_, count)| *count > 1)
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
|
||||
if collisions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&str> = collisions.iter().map(String::as_str).collect();
|
||||
sorted.sort_unstable();
|
||||
println!(
|
||||
"cargo:warning=bundle: deconflicting {} name(s): {}",
|
||||
sorted.len(),
|
||||
sorted.join(", ")
|
||||
);
|
||||
|
||||
// Rename each colliding binding within every module that declares it.
|
||||
for (idx, src) in sources.iter_mut().enumerate() {
|
||||
for name in &private_bindings[idx] {
|
||||
if collisions.contains(name) {
|
||||
let new_name = format!("{name}_{idx}");
|
||||
*src = rename_binding(src, name, &new_name);
|
||||
println!(
|
||||
"cargo:warning=bundle: [{idx}] {name} -> {new_name} ({})",
|
||||
order[idx].file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the names of all top-level bindings in an ES-module source file that
|
||||
/// are **locally declared AND not exported**, using OXC for accurate analysis.
|
||||
///
|
||||
/// Two categories are excluded so the deconflict pass never touches them:
|
||||
///
|
||||
/// 1. **Import bindings** (`import { ui } from '…'`): after import-stripping,
|
||||
/// these names resolve directly to the exporting module's declaration already
|
||||
/// present in the IIFE scope — renaming them would break those references.
|
||||
///
|
||||
/// 2. **Exported bindings**: other modules import these by name after stripping,
|
||||
/// so they must keep their original names. `ParseReturn::module_record` is
|
||||
/// used instead of the text-based `extract_exported_names` helper because
|
||||
/// multi-line `export { … }` blocks would otherwise be missed.
|
||||
fn top_level_bindings(source: &str) -> Vec<String> {
|
||||
use oxc_allocator::Allocator;
|
||||
use oxc_parser::Parser;
|
||||
use oxc_semantic::SemanticBuilder;
|
||||
use oxc_span::SourceType;
|
||||
|
||||
let allocator = Allocator::default();
|
||||
let ret = Parser::new(&allocator, source, SourceType::mjs()).parse();
|
||||
if !ret.errors.is_empty() {
|
||||
return Vec::new(); // parse failed — skip deconflict for this file
|
||||
}
|
||||
|
||||
// Collect exported local names from the module record (handles single-line
|
||||
// and multi-line export blocks, re-exports, export-declarations, etc.).
|
||||
let exported: std::collections::HashSet<&str> = ret
|
||||
.module_record
|
||||
.local_export_entries
|
||||
.iter()
|
||||
.filter_map(|e| e.local_name.name())
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
|
||||
let semantic = SemanticBuilder::new().build(&ret.program).semantic;
|
||||
let scoping = semantic.scoping();
|
||||
let root = scoping.root_scope_id();
|
||||
scoping
|
||||
.get_bindings(root)
|
||||
.iter()
|
||||
.filter_map(|(ident, &symbol_id)| {
|
||||
let name = ident.as_str();
|
||||
// Skip import bindings and exported bindings.
|
||||
let flags = scoping.symbol_flags(symbol_id);
|
||||
if flags.is_import() || exported.contains(name) {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Replace every whole-word occurrence of `old` with `new` in `source`.
|
||||
///
|
||||
/// "Whole word" means the characters immediately before and after the match
|
||||
/// are not JavaScript identifier characters (`[a-zA-Z0-9_$]`). This prevents
|
||||
/// `LOAD_MORE_ID` from being accidentally renamed when the source contains
|
||||
/// `LOAD_MORE_ID_EXTRA`.
|
||||
fn rename_binding(source: &str, old: &str, new: &str) -> String {
|
||||
let old_len = old.len();
|
||||
let mut out = String::with_capacity(source.len());
|
||||
let mut start = 0;
|
||||
|
||||
while let Some(rel) = source[start..].find(old) {
|
||||
let pos = start + rel;
|
||||
let after = pos + old_len;
|
||||
|
||||
let boundary_before = pos == 0
|
||||
|| source[..pos]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_none_or(|c| !is_js_ident_char(c));
|
||||
|
||||
let boundary_after = after >= source.len()
|
||||
|| source[after..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|c| !is_js_ident_char(c));
|
||||
|
||||
out.push_str(&source[start..pos]);
|
||||
if boundary_before && boundary_after {
|
||||
out.push_str(new);
|
||||
} else {
|
||||
out.push_str(old);
|
||||
}
|
||||
start = after;
|
||||
}
|
||||
out.push_str(&source[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_js_ident_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '$'
|
||||
}
|
||||
|
||||
/// DFS post-order: push `file` to `order` after all its imports.
|
||||
/// Marks files as seen before recursing to break circular dependencies.
|
||||
fn collect_module_deps(
|
||||
@@ -666,6 +851,27 @@ fn collect_import_aliases(stmt: &str, declared: &mut std::collections::HashSet<S
|
||||
// JS minification
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Parse the JS bundle with OXC and hard-fail the build on any error.
|
||||
///
|
||||
/// Called on the **raw** (pre-minification) bundle so error messages still
|
||||
/// reference readable source. Uses `cargo:error=` so Cargo surfaces the
|
||||
/// problem immediately and stops the build — no silent fallback.
|
||||
fn js_bundle_validate(source: &str) {
|
||||
use oxc_allocator::Allocator;
|
||||
use oxc_parser::Parser;
|
||||
use oxc_span::SourceType;
|
||||
|
||||
let allocator = Allocator::default();
|
||||
// The bundle is a classic IIFE script, not an ES module.
|
||||
let ret = Parser::new(&allocator, source, SourceType::cjs()).parse();
|
||||
if !ret.errors.is_empty() {
|
||||
for e in &ret.errors {
|
||||
println!("cargo:error=JS bundle parse error: {e}");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minify an ES-module file (contains import/export) — returns original on failure.
|
||||
fn js_minify_safe(source: &str) -> String {
|
||||
js_minify_inner(source, true)
|
||||
|
||||
@@ -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 favorites item, enriched with item metadata via SQL JOIN
|
||||
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
||||
@@ -104,6 +108,120 @@ pub struct BatchFavoritesResult {
|
||||
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)]
|
||||
pub struct BatchFavoritesStats {
|
||||
/// How many items were requested
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// DTO for folder creation requests
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -145,3 +150,138 @@ impl Default for FolderDto {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Cursor-paginated folder resources (GET /api/folders/{id}/resources)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Raw row returned by the UNION ALL query that combines `storage.folders` and
|
||||
/// `storage.files` for a given parent folder. Used internally between the
|
||||
/// repository and service/handler layers — never serialised directly.
|
||||
pub struct FolderResourceRow {
|
||||
pub resource_type: String, // "folder" | "file"
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// Parent folder UUID (for both resource types).
|
||||
pub parent_id: Option<Uuid>,
|
||||
/// `None` for folders.
|
||||
pub mime_type: Option<String>,
|
||||
/// `-1` sentinel for folders (no physical size).
|
||||
pub size: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
// Pre-computed sort fields — returned by the SQL for cursor construction.
|
||||
/// `LOWER(name)` used by `name`/`type` sorts.
|
||||
pub sort_str: String,
|
||||
/// `category_order` for files, `0` for folders.
|
||||
pub type_order: i64,
|
||||
/// `0` for folders, `1` for files (used by `name` sort to keep folders first).
|
||||
pub folder_first: i32,
|
||||
}
|
||||
|
||||
/// Opaque keyset-pagination cursor for `/api/folders/{id}/resources`.
|
||||
///
|
||||
/// Encoded as base64url-JSON (same scheme as [`GrantCursor`]).
|
||||
/// Fields are sparse: only the sort-relevant ones are serialised.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FolderResourceCursor {
|
||||
/// Sort dimension active when this cursor was produced.
|
||||
#[serde(default = "FolderResourceCursor::default_order")]
|
||||
pub order_by: String,
|
||||
/// UUID of the last item on the previous page (tie-breaker).
|
||||
pub resource_id: Uuid,
|
||||
/// `LOWER(name)` for `name`/`type` sorts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_str: Option<String>,
|
||||
/// Multipurpose integer sort key:
|
||||
/// - `name`: `folder_first` (0 = folder, 1 = file)
|
||||
/// - `type`: `category_order` (0 = Folder, 100 = Image …)
|
||||
/// - `size`: file size in bytes, -1 for folders
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_int: Option<i64>,
|
||||
/// Timestamp for `modified_at` / `created_at` sorts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_ts: Option<DateTime<Utc>>,
|
||||
/// Whether the result set was reversed when this cursor was produced.
|
||||
/// Must be passed unchanged on subsequent page requests.
|
||||
#[serde(default)]
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
impl FolderResourceCursor {
|
||||
fn default_order() -> String {
|
||||
"name".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl PageCursor for FolderResourceCursor {}
|
||||
|
||||
/// Query parameters for `GET /api/folders/{id}/resources`.
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct FolderResourcesQuery {
|
||||
/// 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 top.
|
||||
pub cursor: Option<String>,
|
||||
/// Sort / group-by dimension. Supported: `"name"` (default), `"type"`,
|
||||
/// `"modified_at"`, `"created_at"`, `"size"`.
|
||||
pub order_by: Option<String>,
|
||||
/// Comma-separated resource types to include, e.g. `"file,folder"`.
|
||||
/// Omit to include both.
|
||||
pub resource_types: Option<String>,
|
||||
/// Reverse the sort order. Default `false` (normal order).
|
||||
/// Must be the same on all pages of the same result set — the cursor
|
||||
/// carries this flag so the server can validate consistency.
|
||||
#[serde(default)]
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
impl FolderResourcesQuery {
|
||||
/// Returns `limit` clamped to `[1, 200]`.
|
||||
pub fn limit_clamped(&self) -> usize {
|
||||
self.limit.clamp(1, 200) as usize
|
||||
}
|
||||
|
||||
/// Decode the optional cursor string. Invalid cursor → start from top.
|
||||
pub fn decode_cursor(&self) -> Option<FolderResourceCursor> {
|
||||
self.cursor
|
||||
.as_deref()
|
||||
.and_then(FolderResourceCursor::decode)
|
||||
}
|
||||
|
||||
/// Parse `resource_types` into a `Vec<ResourceKind>`.
|
||||
/// Returns `None` when the field is absent (= include all types).
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for [`FolderService::list_resources_paged_with_perms`].
|
||||
///
|
||||
/// Groups the optional parameters so the function stays within clippy's
|
||||
/// `too_many_arguments` limit while remaining easy to extend.
|
||||
pub struct ListResourcesOptions<'a> {
|
||||
pub limit: usize,
|
||||
pub cursor: Option<FolderResourceCursor>,
|
||||
pub order_by: &'a str,
|
||||
pub kinds: Option<&'a [ResourceKind]>,
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
/// One item in a `/resources` page — a file or folder with a `resource_type` tag.
|
||||
/// Re-uses [`ResourceContentDto`] so the shape is identical to `SharedWithMeItemDto.resource`.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct FolderResourceItemDto {
|
||||
pub resource_type: ResourceTypeDto,
|
||||
/// Full resource details. Shape is determined by `resource_type`.
|
||||
pub resource: ResourceContentDto,
|
||||
}
|
||||
|
||||
/// Response envelope for `GET /api/folders/{id}/resources`.
|
||||
pub type FolderResourcesDto = CursorListResponse<FolderResourceItemDto>;
|
||||
|
||||
@@ -252,6 +252,11 @@ pub struct SharedWithMeQuery {
|
||||
/// Comma-separated resource types to include, e.g. `file,folder`.
|
||||
/// Omit to return all known types.
|
||||
pub resource_types: Option<String>,
|
||||
/// Reverse the sort order. Default `false` (normal order).
|
||||
/// Must be the same on all pages of the same result set — the cursor
|
||||
/// carries this flag so the server can validate consistency.
|
||||
#[serde(default)]
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
impl SharedWithMeQuery {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -86,6 +86,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
limit: u32,
|
||||
cursor: Option<GrantCursor>,
|
||||
sort_by: &str,
|
||||
reverse: bool,
|
||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
||||
|
||||
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
||||
|
||||
@@ -2,8 +2,11 @@ use std::collections::HashSet;
|
||||
|
||||
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::domain::services::authorization::ResourceKind;
|
||||
|
||||
/// Defines operations for managing user favorites
|
||||
pub trait FavoritesUseCase: Send + Sync {
|
||||
@@ -74,4 +77,17 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
|
||||
user_id: Uuid,
|
||||
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
|
||||
) -> 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>>;
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::favorites_dto::{
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow,
|
||||
FavoritesCursor,
|
||||
};
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
|
||||
MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
@@ -582,3 +584,109 @@ impl FolderUseCase for FolderService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── FolderService — cursor-paginated resource listing ────────────────────────
|
||||
|
||||
impl FolderService {
|
||||
/// Cursor-paginated listing of sub-folders **and** files inside `parent_id`.
|
||||
///
|
||||
/// Enforces `Permission::Read` on the parent folder before querying.
|
||||
/// `order_by` controls both the SQL `ORDER BY` and the cursor encoding.
|
||||
/// `kinds` filters the result to only the specified resource types.
|
||||
pub async fn list_resources_paged_with_perms(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
caller_id: Uuid,
|
||||
opts: ListResourcesOptions<'_>,
|
||||
) -> Result<(Vec<FolderResourceRow>, Option<String>), DomainError> {
|
||||
// 1. AuthZ — same check as list_folders_with_perms
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Self::folder_resource(parent_id)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pid =
|
||||
Uuid::parse_str(parent_id).map_err(|_| DomainError::not_found("Folder", parent_id))?;
|
||||
|
||||
let ListResourcesOptions {
|
||||
limit,
|
||||
cursor,
|
||||
order_by,
|
||||
kinds,
|
||||
reverse,
|
||||
} = opts;
|
||||
|
||||
// 2. Fetch limit+1 rows so we can detect has_next
|
||||
let mut rows = self
|
||||
.folder_storage
|
||||
.list_resources_paged(pid, limit + 1, cursor.as_ref(), order_by, kinds, reverse)
|
||||
.await?;
|
||||
|
||||
// 3. Detect has_next, build encoded next cursor
|
||||
let next_cursor = if rows.len() > limit {
|
||||
let last = &rows[limit - 1];
|
||||
let c = build_folder_resource_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 order.
|
||||
fn build_folder_resource_cursor(
|
||||
row: &FolderResourceRow,
|
||||
order_by: &str,
|
||||
reverse: bool,
|
||||
) -> FolderResourceCursor {
|
||||
match order_by {
|
||||
"type" => FolderResourceCursor {
|
||||
order_by: "type".to_owned(),
|
||||
resource_id: row.id,
|
||||
sort_str: Some(row.sort_str.clone()),
|
||||
sort_int: Some(row.type_order),
|
||||
sort_ts: None,
|
||||
reverse,
|
||||
},
|
||||
"modified_at" => FolderResourceCursor {
|
||||
order_by: "modified_at".to_owned(),
|
||||
resource_id: row.id,
|
||||
sort_str: None,
|
||||
sort_int: None,
|
||||
sort_ts: Some(row.modified_at),
|
||||
reverse,
|
||||
},
|
||||
"created_at" => FolderResourceCursor {
|
||||
order_by: "created_at".to_owned(),
|
||||
resource_id: row.id,
|
||||
sort_str: None,
|
||||
sort_int: None,
|
||||
sort_ts: Some(row.created_at),
|
||||
reverse,
|
||||
},
|
||||
"size" => FolderResourceCursor {
|
||||
order_by: "size".to_owned(),
|
||||
resource_id: row.id,
|
||||
sort_str: None,
|
||||
sort_int: Some(row.size),
|
||||
sort_ts: None,
|
||||
reverse,
|
||||
},
|
||||
_ => FolderResourceCursor {
|
||||
// "name" (default): sort_int = folder_first (0 or 1)
|
||||
order_by: "name".to_owned(),
|
||||
resource_id: row.id,
|
||||
sort_str: Some(row.sort_str.clone()),
|
||||
sort_int: Some(i64::from(row.folder_first)),
|
||||
sort_ts: None,
|
||||
reverse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,10 @@ pub struct GrantCursor {
|
||||
/// - `"size"` — file size in bytes (-1 = Folder sentinel)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_int: Option<i64>,
|
||||
/// Whether the result set was reversed when this cursor was produced.
|
||||
/// Must be passed unchanged on subsequent page requests.
|
||||
#[serde(default)]
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
impl GrantCursor {
|
||||
|
||||
@@ -4,9 +4,12 @@ use std::sync::Arc;
|
||||
use tracing::error;
|
||||
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::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
|
||||
/// PostgreSQL implementation of the favorites persistence port.
|
||||
pub struct FavoritesPgRepository {
|
||||
@@ -279,4 +282,308 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::folder_dto::{FolderResourceCursor, FolderResourceRow};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
/// Type alias for folder metadata rows from SQL queries.
|
||||
@@ -1060,4 +1062,238 @@ impl FolderDbRepository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cursor-paginated combined listing of sub-folders and files inside
|
||||
/// `parent_id`, sorted by `order_by`.
|
||||
///
|
||||
/// **Authorization must be verified by the caller** before invoking this
|
||||
/// method — no ownership filter is applied here.
|
||||
///
|
||||
/// Fetches `limit` rows (caller should pass `desired_page_size + 1` to
|
||||
/// detect the existence of a next page). Returns raw [`FolderResourceRow`]
|
||||
/// values; the handler / service layer converts them to DTOs.
|
||||
pub async fn list_resources_paged(
|
||||
&self,
|
||||
parent_id: Uuid,
|
||||
limit: usize,
|
||||
cursor: Option<&FolderResourceCursor>,
|
||||
order_by: &str,
|
||||
kinds: Option<&[ResourceKind]>,
|
||||
reverse: bool,
|
||||
) -> Result<Vec<FolderResourceRow>, DomainError> {
|
||||
let include_folders = kinds.is_none_or(|k| k.contains(&ResourceKind::Folder));
|
||||
let include_files = kinds.is_none_or(|k| k.contains(&ResourceKind::File));
|
||||
|
||||
if !include_folders && !include_files {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// ── CTE branches ────────────────────────────────────────────────────
|
||||
let folder_branch = r#"
|
||||
SELECT
|
||||
'folder'::text AS resource_type,
|
||||
f.id,
|
||||
f.name,
|
||||
f.parent_id AS folder_id,
|
||||
NULL::text AS mime_type,
|
||||
-1::bigint AS size,
|
||||
f.created_at,
|
||||
f.updated_at AS modified_at,
|
||||
f.user_id,
|
||||
LOWER(f.name) AS sort_str,
|
||||
0::bigint AS type_order,
|
||||
0::int AS folder_first
|
||||
FROM storage.folders f
|
||||
WHERE f.parent_id = $1::uuid AND NOT f.is_trashed
|
||||
"#;
|
||||
|
||||
let file_branch = r#"
|
||||
SELECT
|
||||
'file'::text AS resource_type,
|
||||
fm.id,
|
||||
fm.name,
|
||||
fm.folder_id,
|
||||
fm.mime_type,
|
||||
fm.size::bigint,
|
||||
fm.created_at,
|
||||
fm.updated_at AS modified_at,
|
||||
fm.user_id,
|
||||
LOWER(fm.name) AS sort_str,
|
||||
fm.category_order::bigint AS type_order,
|
||||
1::int AS folder_first
|
||||
FROM storage.files fm
|
||||
WHERE fm.folder_id = $1::uuid AND NOT fm.is_trashed
|
||||
"#;
|
||||
|
||||
let cte_inner = match (include_folders, include_files) {
|
||||
(true, true) => format!("{folder_branch} UNION ALL {file_branch}"),
|
||||
(true, false) => folder_branch.to_owned(),
|
||||
(false, true) => file_branch.to_owned(),
|
||||
(false, false) => unreachable!(),
|
||||
};
|
||||
|
||||
// ── Cursor binds ─────────────────────────────────────────────────────
|
||||
// $1 = parent_id $2 = cursor_str $3 = cursor_int
|
||||
// $4 = cursor_ts $5 = cursor_id $6 = limit
|
||||
let cursor_str = cursor.and_then(|c| c.sort_str.clone());
|
||||
let cursor_int = cursor.and_then(|c| c.sort_int);
|
||||
let cursor_ts = cursor.and_then(|c| c.sort_ts);
|
||||
let cursor_id = cursor.map(|c| c.resource_id);
|
||||
|
||||
// ── Sort-specific WHERE + ORDER BY ───────────────────────────────────
|
||||
// Each arm produces two variants based on `reverse`.
|
||||
// For "name": folder_first stays ASC in both directions (folders always
|
||||
// precede files); only the alpha order within each group flips.
|
||||
let (where_clause, order_clause) = match order_by {
|
||||
"type" => {
|
||||
if reverse {
|
||||
(
|
||||
r#"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 id < $5::uuid)"#,
|
||||
"ORDER BY type_order DESC, sort_str DESC, id DESC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"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 id > $5::uuid)"#,
|
||||
"ORDER BY type_order ASC, sort_str ASC, id ASC",
|
||||
)
|
||||
}
|
||||
}
|
||||
"modified_at" => {
|
||||
if reverse {
|
||||
(
|
||||
r#"WHERE ($4::timestamptz IS NULL)
|
||||
OR (modified_at > $4)
|
||||
OR (modified_at = $4 AND id > $5::uuid)"#,
|
||||
"ORDER BY modified_at ASC, id ASC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"WHERE ($4::timestamptz IS NULL)
|
||||
OR (modified_at < $4)
|
||||
OR (modified_at = $4 AND id < $5::uuid)"#,
|
||||
"ORDER BY modified_at DESC, id DESC",
|
||||
)
|
||||
}
|
||||
}
|
||||
"created_at" => {
|
||||
if reverse {
|
||||
(
|
||||
r#"WHERE ($4::timestamptz IS NULL)
|
||||
OR (created_at > $4)
|
||||
OR (created_at = $4 AND id > $5::uuid)"#,
|
||||
"ORDER BY created_at ASC, id ASC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"WHERE ($4::timestamptz IS NULL)
|
||||
OR (created_at < $4)
|
||||
OR (created_at = $4 AND id < $5::uuid)"#,
|
||||
"ORDER BY created_at DESC, id DESC",
|
||||
)
|
||||
}
|
||||
}
|
||||
"size" => {
|
||||
if reverse {
|
||||
(
|
||||
r#"WHERE ($3::bigint IS NULL)
|
||||
OR (size < $3)
|
||||
OR (size = $3 AND id < $5::uuid)"#,
|
||||
"ORDER BY size DESC, id DESC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"WHERE ($3::bigint IS NULL)
|
||||
OR (size > $3)
|
||||
OR (size = $3 AND id > $5::uuid)"#,
|
||||
"ORDER BY size ASC, id ASC",
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// "name" (default): folder_first stays ASC so folders always precede
|
||||
// files; only the alpha order within each group flips when reversed.
|
||||
if reverse {
|
||||
(
|
||||
r#"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 id < $5::uuid)"#,
|
||||
"ORDER BY folder_first ASC, sort_str DESC, id DESC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"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 id > $5::uuid)"#,
|
||||
"ORDER BY folder_first ASC, sort_str ASC, id ASC",
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"WITH resources AS ({cte_inner}) \
|
||||
SELECT resource_type, id, name, folder_id, mime_type, size, \
|
||||
created_at, modified_at, user_id, sort_str, type_order, folder_first \
|
||||
FROM resources \
|
||||
{where_clause} \
|
||||
{order_clause} \
|
||||
LIMIT $6"
|
||||
);
|
||||
|
||||
// Row: (resource_type, id, name, folder_id, mime_type, size,
|
||||
// created_at, modified_at, user_id, sort_str, type_order, folder_first)
|
||||
type Row = (
|
||||
String,
|
||||
Uuid,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<String>,
|
||||
i64,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Uuid,
|
||||
String,
|
||||
i64,
|
||||
i32,
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(&sql)
|
||||
.bind(parent_id)
|
||||
.bind(cursor_str)
|
||||
.bind(cursor_int)
|
||||
.bind(cursor_ts)
|
||||
.bind(cursor_id)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| FolderResourceRow {
|
||||
resource_type: r.0,
|
||||
id: r.1,
|
||||
name: r.2,
|
||||
parent_id: r.3,
|
||||
mime_type: r.4,
|
||||
size: r.5,
|
||||
created_at: r.6,
|
||||
modified_at: r.7,
|
||||
owner_id: r.8,
|
||||
sort_str: r.9,
|
||||
type_order: r.10,
|
||||
folder_first: r.11,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
limit: u32,
|
||||
cursor: Option<GrantCursor>,
|
||||
sort_by: &str,
|
||||
reverse: bool,
|
||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
|
||||
// ── Common setup ──────────────────────────────────────────────────────
|
||||
let kind_strs: Option<Vec<&str>> = if kinds.is_empty() {
|
||||
@@ -358,6 +359,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// ── Build sort-specific SQL fragments ─────────────────────────────────
|
||||
// "name" and "type" share the same LEFT JOINs; only sort_int_expr,
|
||||
// the cursor WHERE condition, and ORDER BY differ.
|
||||
// Each branch emits two variants selected by `reverse`.
|
||||
let sql = match sort_by {
|
||||
"name" | "type" => {
|
||||
let sort_int_expr = if sort_by == "type" {
|
||||
@@ -365,20 +367,39 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
} else {
|
||||
"NULL::bigint"
|
||||
};
|
||||
let where_clause = if sort_by == "type" {
|
||||
r#"( $5::integer IS NULL
|
||||
OR sort_int > $5
|
||||
OR (sort_int = $5 AND LOWER(sort_str) > $4)
|
||||
OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#
|
||||
// Normal vs reversed keyset + ORDER BY.
|
||||
let (where_clause, order_clause) = if sort_by == "type" {
|
||||
if reverse {
|
||||
(
|
||||
r#"( $5::integer IS NULL
|
||||
OR sort_int < $5
|
||||
OR (sort_int = $5 AND LOWER(sort_str) < $4)
|
||||
OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id < $7::uuid))"#,
|
||||
"sort_int DESC, LOWER(sort_str) DESC, resource_id DESC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"( $5::integer IS NULL
|
||||
OR sort_int > $5
|
||||
OR (sort_int = $5 AND LOWER(sort_str) > $4)
|
||||
OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#,
|
||||
"sort_int ASC, LOWER(sort_str) ASC, resource_id ASC",
|
||||
)
|
||||
}
|
||||
} else if reverse {
|
||||
(
|
||||
r#"( $4::text IS NULL
|
||||
OR LOWER(sort_str) < $4
|
||||
OR (LOWER(sort_str) = $4 AND resource_id < $7::uuid))"#,
|
||||
"LOWER(sort_str) DESC, resource_id DESC",
|
||||
)
|
||||
} else {
|
||||
r#"( $4::text IS NULL
|
||||
OR LOWER(sort_str) > $4
|
||||
OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#
|
||||
};
|
||||
let order_clause = if sort_by == "type" {
|
||||
"sort_int ASC, LOWER(sort_str) ASC, resource_id ASC"
|
||||
} else {
|
||||
"LOWER(sort_str) ASC, resource_id ASC"
|
||||
(
|
||||
r#"( $4::text IS NULL
|
||||
OR LOWER(sort_str) > $4
|
||||
OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#,
|
||||
"LOWER(sort_str) ASC, resource_id ASC",
|
||||
)
|
||||
};
|
||||
format!(
|
||||
r#"WITH {AGG},
|
||||
@@ -400,64 +421,112 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
LIMIT $8"#
|
||||
)
|
||||
}
|
||||
"granted_by" => format!(
|
||||
"granted_by" => {
|
||||
// Joins auth.users to sort alphabetically by username.
|
||||
// Cursor encodes (owner_name=$4, granted_at=$6, resource_id=$7).
|
||||
r#"WITH {AGG},
|
||||
owner_named AS (
|
||||
SELECT agg.*,
|
||||
LOWER(u.username) AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
LEFT JOIN auth.users u ON u.id = agg.granted_by
|
||||
let (where_clause, order_clause) = if reverse {
|
||||
(
|
||||
r#"( $4::text IS NULL
|
||||
OR sort_str < $4
|
||||
OR (sort_str = $4 AND (
|
||||
$6::timestamptz IS NULL
|
||||
OR granted_at > $6
|
||||
OR (granted_at = $6 AND resource_id > $7::uuid))))"#,
|
||||
"sort_str DESC, granted_at ASC, resource_id ASC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"( $4::text IS NULL
|
||||
OR sort_str > $4
|
||||
OR (sort_str = $4 AND (
|
||||
$6::timestamptz IS NULL
|
||||
OR granted_at < $6
|
||||
OR (granted_at = $6 AND resource_id < $7::uuid))))"#,
|
||||
"sort_str ASC, granted_at DESC, resource_id DESC",
|
||||
)
|
||||
};
|
||||
format!(
|
||||
r#"WITH {AGG},
|
||||
owner_named AS (
|
||||
SELECT agg.*,
|
||||
LOWER(u.username) AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
LEFT JOIN auth.users u ON u.id = agg.granted_by
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
FROM owner_named
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $8"#
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
FROM owner_named
|
||||
WHERE ( $4::text IS NULL
|
||||
OR sort_str > $4
|
||||
OR (sort_str = $4 AND (
|
||||
$6::timestamptz IS NULL
|
||||
OR granted_at < $6
|
||||
OR (granted_at = $6 AND resource_id < $7::uuid))))
|
||||
ORDER BY sort_str ASC, granted_at DESC, resource_id DESC
|
||||
LIMIT $8"#
|
||||
),
|
||||
"size" => format!(
|
||||
// Folders have no size — they sort first with a sentinel of -1.
|
||||
// Files sort by size ASC; resource_id breaks ties.
|
||||
}
|
||||
"size" => {
|
||||
// Folders have no size — sentinel -1 (sorts first ASC, last DESC).
|
||||
// Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused.
|
||||
r#"WITH {AGG},
|
||||
sized AS (
|
||||
SELECT agg.*,
|
||||
NULL::text AS sort_str,
|
||||
CASE WHEN agg.resource_type = 'folder' THEN -1
|
||||
ELSE fi.size
|
||||
END AS sort_int
|
||||
FROM agg
|
||||
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
|
||||
let (where_clause, order_clause) = if reverse {
|
||||
(
|
||||
r#"( $5::bigint IS NULL
|
||||
OR sort_int < $5
|
||||
OR (sort_int = $5 AND resource_id < $7::uuid))"#,
|
||||
"sort_int DESC, resource_id DESC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"( $5::bigint IS NULL
|
||||
OR sort_int > $5
|
||||
OR (sort_int = $5 AND resource_id > $7::uuid))"#,
|
||||
"sort_int ASC, resource_id ASC",
|
||||
)
|
||||
};
|
||||
format!(
|
||||
r#"WITH {AGG},
|
||||
sized AS (
|
||||
SELECT agg.*,
|
||||
NULL::text AS sort_str,
|
||||
CASE WHEN agg.resource_type = 'folder' THEN -1
|
||||
ELSE fi.size
|
||||
END AS sort_int
|
||||
FROM agg
|
||||
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
FROM sized
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $8"#
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
FROM sized
|
||||
WHERE ( $5::bigint IS NULL
|
||||
OR sort_int > $5
|
||||
OR (sort_int = $5 AND resource_id > $7::uuid))
|
||||
ORDER BY sort_int ASC, resource_id ASC
|
||||
LIMIT $8"#
|
||||
),
|
||||
_ => format!(
|
||||
// Default: sort by grant date DESC (newest first).
|
||||
}
|
||||
_ => {
|
||||
// Default: sort by grant date.
|
||||
// Normal = DESC (newest first); reversed = ASC (oldest first).
|
||||
// Cursor encodes (granted_at=$6, resource_id=$7); $4/$5 unused.
|
||||
r#"WITH {AGG}
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
|
||||
NULL::text AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
WHERE ( $6::timestamptz IS NULL
|
||||
OR granted_at < $6
|
||||
OR (granted_at = $6 AND resource_id < $7::uuid))
|
||||
ORDER BY granted_at DESC, resource_id DESC
|
||||
LIMIT $8"#
|
||||
),
|
||||
let (where_clause, order_clause) = if reverse {
|
||||
(
|
||||
r#"( $6::timestamptz IS NULL
|
||||
OR granted_at > $6
|
||||
OR (granted_at = $6 AND resource_id > $7::uuid))"#,
|
||||
"granted_at ASC, resource_id ASC",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"( $6::timestamptz IS NULL
|
||||
OR granted_at < $6
|
||||
OR (granted_at = $6 AND resource_id < $7::uuid))"#,
|
||||
"granted_at DESC, resource_id DESC",
|
||||
)
|
||||
};
|
||||
format!(
|
||||
r#"WITH {AGG}
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
|
||||
NULL::text AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $8"#
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// ── Execute — uniform 8 binds for every sort mode ─────────────────────
|
||||
@@ -493,6 +562,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
resource_id: r.1,
|
||||
resource_name: sort_str_lc,
|
||||
sort_int: None,
|
||||
reverse,
|
||||
},
|
||||
"type" => GrantCursor {
|
||||
sort_by: "type".to_owned(),
|
||||
@@ -500,6 +570,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
resource_id: r.1,
|
||||
resource_name: sort_str_lc,
|
||||
sort_int: r.6,
|
||||
reverse,
|
||||
},
|
||||
"granted_by" => GrantCursor {
|
||||
sort_by: "granted_by".to_owned(),
|
||||
@@ -507,6 +578,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
resource_id: r.1,
|
||||
resource_name: r.5.clone(), // already lowercased by SQL
|
||||
sort_int: None,
|
||||
reverse,
|
||||
},
|
||||
"size" => GrantCursor {
|
||||
sort_by: "size".to_owned(),
|
||||
@@ -514,6 +586,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
resource_id: r.1,
|
||||
resource_name: None,
|
||||
sort_int: r.6,
|
||||
reverse,
|
||||
},
|
||||
_ => GrantCursor {
|
||||
sort_by: "granted_at".to_owned(),
|
||||
@@ -521,6 +594,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
resource_id: r.1,
|
||||
resource_name: None,
|
||||
sort_int: None,
|
||||
reverse,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
@@ -9,8 +9,18 @@ use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
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::services::favorites_service::FavoritesService;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Single item in a batch-add-favorites request.
|
||||
@@ -27,11 +37,16 @@ pub struct BatchFavoritesRequest {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
get,
|
||||
path = "/api/favorites",
|
||||
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" = [])),
|
||||
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.
|
||||
/// POST /api/favorites/batch
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -10,10 +10,16 @@ use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
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::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery,
|
||||
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::folder_listing_dto::FolderListingDto;
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
@@ -466,6 +472,7 @@ pub async fn list_root_folders(
|
||||
FolderHandler::list_root_folders_impl(state, auth_user).await
|
||||
}
|
||||
|
||||
#[deprecated = "Use /api/folders/{id}/resources instead"]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/contents",
|
||||
@@ -477,6 +484,7 @@ pub async fn list_root_folders(
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "folders"
|
||||
)]
|
||||
#[allow(deprecated)]
|
||||
pub async fn list_folder_contents(
|
||||
state: State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
@@ -503,6 +511,7 @@ pub async fn list_root_folders_paginated(
|
||||
FolderHandler::list_root_folders_paginated_impl(state, auth_user, pagination).await
|
||||
}
|
||||
|
||||
#[deprecated = "Use /api/folders/{id}/resources instead"]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/contents/paginated",
|
||||
@@ -517,6 +526,7 @@ pub async fn list_root_folders_paginated(
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "folders"
|
||||
)]
|
||||
#[allow(deprecated)]
|
||||
pub async fn list_folder_contents_paginated(
|
||||
state: State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
@@ -526,6 +536,7 @@ pub async fn list_folder_contents_paginated(
|
||||
FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await
|
||||
}
|
||||
|
||||
#[deprecated = "Use /api/folders/{id}/resources instead"]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/listing",
|
||||
@@ -538,6 +549,7 @@ pub async fn list_folder_contents_paginated(
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "folders"
|
||||
)]
|
||||
#[allow(deprecated)]
|
||||
pub async fn list_folder_listing(
|
||||
state: State<Arc<GlobalAppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -628,3 +640,105 @@ pub async fn download_folder_zip(
|
||||
) -> impl IntoResponse {
|
||||
FolderHandler::download_folder_zip_impl(state, auth_user, path, query).await
|
||||
}
|
||||
|
||||
// ── GET /api/folders/{id}/resources ─────────────────────────────────────────
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/resources",
|
||||
params(
|
||||
("id" = String, Path, description = "Folder ID"),
|
||||
FolderResourcesQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200,
|
||||
description = "Cursor-paginated files and folders inside the requested folder. \
|
||||
Items arrive in `order_by` order (folders first when order_by=name). \
|
||||
`next_cursor` is absent on the last page.",
|
||||
body = FolderResourcesDto),
|
||||
(status = 404, description = "Folder not found or access denied"),
|
||||
),
|
||||
tag = "folders"
|
||||
)]
|
||||
pub async fn list_folder_resources(
|
||||
State(service): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<FolderResourcesQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let order_by = q.order_by.clone().unwrap_or_else(|| "name".to_owned());
|
||||
let kinds = q.resource_kinds();
|
||||
let opts = ListResourcesOptions {
|
||||
limit: q.limit_clamped(),
|
||||
cursor: q.decode_cursor(),
|
||||
order_by: &order_by,
|
||||
kinds: kinds.as_deref(),
|
||||
reverse: q.reverse,
|
||||
};
|
||||
|
||||
match service
|
||||
.list_resources_paged_with_perms(&id, auth_user.id, opts)
|
||||
.await
|
||||
{
|
||||
Ok((rows, next_cursor)) => {
|
||||
let items: Vec<FolderResourceItemDto> = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
if row.resource_type == "folder" {
|
||||
let dto = FolderDto {
|
||||
id: row.id.to_string(),
|
||||
name: row.name.clone(),
|
||||
path: String::new(), // cleared — share recipients must not see hierarchy
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
created_at: row.created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
};
|
||||
FolderResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
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.id.to_string(),
|
||||
name: row.name.clone(),
|
||||
path: String::new(),
|
||||
size: size_bytes,
|
||||
mime_type: Arc::from(mime),
|
||||
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||
created_at: row.created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
icon_class: Arc::from(icon_class_for(&row.name, mime)),
|
||||
icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)),
|
||||
category: 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(),
|
||||
};
|
||||
FolderResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
resource: ResourceContentDto::File(dto),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(FolderResourcesDto::with_cursor(items, next_cursor)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,16 +341,18 @@ pub async fn list_shared_with_me(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Decode cursor — discard it when the sort dimension changed to avoid
|
||||
// keyset confusion across sort modes.
|
||||
let reverse = q.reverse;
|
||||
|
||||
// Decode cursor — discard it when the sort dimension or direction changed
|
||||
// to avoid keyset confusion across sort modes.
|
||||
let cursor = q
|
||||
.decode_cursor::<GrantCursor>()
|
||||
.filter(|c| c.sort_by == sort_by);
|
||||
.filter(|c| c.sort_by == sort_by && c.reverse == reverse);
|
||||
|
||||
// Fetch paged summaries from the ACL engine.
|
||||
let (summaries, next_cursor) = match state
|
||||
.authorization
|
||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by)
|
||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by, reverse)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +58,10 @@ use crate::interfaces::api::handlers::file_handler::{
|
||||
delete_file, download_file, get_file_metadata, get_thumbnail, list_files_query,
|
||||
move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
use crate::interfaces::api::handlers::folder_handler::{
|
||||
create_folder, delete_folder_with_trash, download_folder_zip, get_folder, list_folder_contents,
|
||||
list_folder_contents_paginated, list_folder_listing, list_root_folders,
|
||||
list_folder_contents_paginated, list_folder_listing, list_folder_resources, list_root_folders,
|
||||
list_root_folders_paginated, move_folder, rename_folder,
|
||||
};
|
||||
use crate::interfaces::api::handlers::i18n_handler::{
|
||||
@@ -155,6 +156,9 @@ pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppStat
|
||||
/// These routes require authentication when auth is enabled.
|
||||
/// Receives the fully-assembled `AppState` and extracts all needed services
|
||||
/// from it, avoiding a long parameter list.
|
||||
// Legacy folder endpoints (contents, listing) are kept for backward-compat;
|
||||
// they are marked #[deprecated] so the OpenAPI spec shows them as deprecated.
|
||||
#[allow(deprecated)]
|
||||
pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Extract services from the pre-built AppState
|
||||
let folder_service = app_state.applications.folder_service_concrete.clone();
|
||||
@@ -195,6 +199,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
"/{id}/contents/paginated",
|
||||
get(list_folder_contents_paginated),
|
||||
)
|
||||
.route("/{id}/resources", get(list_folder_resources))
|
||||
.route("/{id}/rename", put(rename_folder))
|
||||
.route("/{id}/move", put(move_folder))
|
||||
.with_state(folder_service.clone());
|
||||
@@ -326,10 +331,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Create a router without the i18n routes
|
||||
// Create routes for favorites if the service is available
|
||||
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()
|
||||
.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(
|
||||
"/{item_type}/{item_id}",
|
||||
@@ -346,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),
|
||||
|
||||
@@ -121,6 +121,8 @@
|
||||
}
|
||||
|
||||
.group-by-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -132,6 +134,15 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Sort direction button — rotate the SVG icon when order is reversed */
|
||||
.sort-dir-btn .oxi-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sort-dir-btn.active .oxi-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Active label shown inline next to the icon */
|
||||
.group-by-label {
|
||||
display: none;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* ── Item tooltip — "technical sheet" ─────────────────────────────────────── */
|
||||
/* */
|
||||
/* Overlays the search bar area in the top-bar while hovering a file item. */
|
||||
/* Spans from the sidebar right-edge to just before .user-controls. */
|
||||
/* */
|
||||
/* 3-column CSS grid: [icon] [label] [value] */
|
||||
/* All values start at the same x position regardless of label width. */
|
||||
|
||||
.path-tooltip {
|
||||
position: fixed;
|
||||
|
||||
/* Fill most of the top-bar (topbar height 70px, 8px margin top/bottom) */
|
||||
top: 8px;
|
||||
|
||||
/* Desktop: starts at sidebar right edge + top-bar padding */
|
||||
left: calc(var(--sidebar-width) + 30px);
|
||||
|
||||
/*
|
||||
* Right edge stops just before .user-controls:
|
||||
* 30px (top-bar right padding) + ~90px (notif bell + gap + avatar btn) + 4px gap
|
||||
* Using left + right instead of width so the browser computes it dynamically.
|
||||
*/
|
||||
right: 124px;
|
||||
|
||||
/* High enough to sit above the top-bar content */
|
||||
z-index: 200;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1em max-content 1fr;
|
||||
column-gap: 0.6em;
|
||||
row-gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
/* Fill the top-bar height minus an 8px margin on each side */
|
||||
height: 54px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--color-border-medium);
|
||||
|
||||
background-color: var(--color-bg-input);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
/* avoid blinking when pointer moves between items */
|
||||
transition: display 0.2s allow-discrete;
|
||||
}
|
||||
|
||||
/* Icon column */
|
||||
.path-tooltip__icon {
|
||||
color: var(--color-text-faint);
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
/* Label column */
|
||||
.path-tooltip__label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-tooltip__label::after {
|
||||
content: ":";
|
||||
}
|
||||
|
||||
/* Value column */
|
||||
.path-tooltip__value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Path value uses monospace to read folder separators clearly */
|
||||
.path-tooltip__value--path {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* "?" placeholder when data is absent */
|
||||
.path-tooltip__value--unknown {
|
||||
color: var(--color-text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Narrow screens (sidebar hidden) ────────────────────────────────────── */
|
||||
/* On mobile the sidebar slides off-screen; align after the #sidebar-toggle */
|
||||
/* button (≈44px) that takes its place in the top-bar. */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.path-tooltip {
|
||||
/* 16px topbar padding + ~44px sidebar-toggle + 8px gap */
|
||||
left: 68px;
|
||||
|
||||
/*
|
||||
* Right edge stops before search-toggle-btn + user-controls:
|
||||
* ~40px (search-toggle) + 12px (gap) + ~90px (user-controls) + 16px (padding)
|
||||
*/
|
||||
right: 158px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Vignette inside tooltip ─────────────────────────────────────────────── */
|
||||
/* The vignette name hard-codes 14px; scale it down to match the tooltip. */
|
||||
|
||||
.path-tooltip .user-vignette {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-tooltip .user-vignette__name {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.path-tooltip {
|
||||
position: fixed;
|
||||
bottom: 8px;
|
||||
left: calc(var(--sidebar-width) + 8px);
|
||||
z-index: 5;
|
||||
|
||||
max-width: 120ch;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
|
||||
background-color: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-faint);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
/* avoid blinking when pointer change items */
|
||||
transition: display 0.2s allow-discrete;
|
||||
}
|
||||
@@ -574,6 +574,20 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* When the header contains a rich DOM node (e.g. a user vignette for the
|
||||
"owner" group-by), reset the typographic overrides that only make sense
|
||||
for plain-text labels, and lay the node out inline. */
|
||||
.resource-list__swimlane-header--node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ── Swimlane group card (list view only) ────────────────── */
|
||||
|
||||
/* When swimlane groups are present, dissolve the outer container into the
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
@import url("./components/search.css");
|
||||
@import url("./components/icons.css");
|
||||
@import url("./components/csp-utilities.css");
|
||||
@import url("./components/pathTooltip.css");
|
||||
@import url("./components/itemTooltip.css");
|
||||
|
||||
/* Theme */
|
||||
@import url("./themes/dark.css");
|
||||
|
||||
@@ -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>
|
||||
|
||||
+285
-29
@@ -4,21 +4,24 @@
|
||||
* OxiCloud – Files section view.
|
||||
*
|
||||
* Orchestrates the main Files section:
|
||||
* - Data fetching via `filesModel`
|
||||
* - Rendering via a `ResourceListComponent` instance
|
||||
* - Data fetching via `filesModel` (cursor-paginated `/api/folders/{id}/resources`)
|
||||
* - Rendering via a `ResourceListComponent` instance with optional swimlane grouping
|
||||
* - Drag-and-drop initialisation (delegated to `ui.initDragDrop`)
|
||||
*
|
||||
* Exports `loadFiles` (navigation & deep-link entry-point) and `addItem`
|
||||
* (post-upload / post-create optimistic UI updates used by fileOperations
|
||||
* and search).
|
||||
* Exports:
|
||||
* - `loadFiles` – navigation & deep-link entry-point
|
||||
* - `addItem` – post-upload / post-create optimistic UI updates
|
||||
* - `filesView` – group-by controller consumed by `navigation.js` / `main.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 { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { fetchListing, rebuildBreadCrumb } from '../model/filesModel.js';
|
||||
import { fetchResourcesPage, rebuildBreadCrumb } from '../model/filesModel.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { resolveHomeFolder } from './authSession.js';
|
||||
import { updateHistory } from './main.js';
|
||||
@@ -28,12 +31,162 @@ import { uiNotifications } from './uiNotifications.js';
|
||||
|
||||
/** @import {FileItem, FolderItem} 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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Group-by dimension definitions for the Files section.
|
||||
* Mirrors the same shape used by `sharedWithMeView.groupByDefs` so `main.js`
|
||||
* can drive the group-by dropdown generically.
|
||||
*
|
||||
* @type {GroupByDef[]}
|
||||
*/
|
||||
const GROUP_BY_DEFS = [
|
||||
{
|
||||
key: 'type',
|
||||
get label() {
|
||||
return i18n.t('groupby.type', 'Type');
|
||||
},
|
||||
orderBy: 'type',
|
||||
// Folders → 'Folder'; files → their pre-computed category string.
|
||||
keyFn: (item) => ('mime_type' in item ? /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
|
||||
labelFn: (key) => {
|
||||
// biome-ignore format: keep indentation
|
||||
/** @type {Record<string, string>} */
|
||||
const labels = {
|
||||
Folder: i18n.t('groupby.type.folders', 'Folders'),
|
||||
Image: i18n.t('category.images', 'Images'),
|
||||
Video: i18n.t('category.videos', 'Videos'),
|
||||
Audio: i18n.t('category.audio', 'Audio'),
|
||||
PDF: 'PDF',
|
||||
Document: i18n.t('category.documents', 'Documents'),
|
||||
Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'),
|
||||
Presentation: i18n.t('category.presentations', 'Presentations'),
|
||||
Archive: i18n.t('category.archives', 'Archives'),
|
||||
Code: i18n.t('category.code', 'Code'),
|
||||
Markdown: i18n.t('category.markdown', 'Markdown'),
|
||||
Text: i18n.t('category.text', 'Text'),
|
||||
Installer: i18n.t('category.installers', 'Installers')
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
get label() {
|
||||
return i18n.t('groupby.size', 'Size');
|
||||
},
|
||||
orderBy: 'size',
|
||||
// sizeBucket(-1) → "Folders" sentinel; no labelFn needed.
|
||||
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: 'modifiedAt',
|
||||
get label() {
|
||||
return i18n.t('groupby.modifiedAt', 'Modified date');
|
||||
},
|
||||
orderBy: 'modified_at',
|
||||
// keyFn returns the human-readable bucket; the bucket IS the key.
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string, number>} */ (/** @type {unknown} */ (item));
|
||||
return r.modified_at ? normalizeDateBucket(r.modified_at) : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
get label() {
|
||||
return i18n.t('groupby.createdAt', 'Created date');
|
||||
},
|
||||
orderBy: 'created_at',
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string, number>} */ (/** @type {unknown} */ (item));
|
||||
return r.created_at ? normalizeDateBucket(r.created_at) : null;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ── Module-level state ────────────────────────────────────────────────────────
|
||||
|
||||
/** ID of the "Load more" wrapper injected below `.files-container`. */
|
||||
const LOAD_MORE_ID = 'files-load-more-wrapper';
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
let _component = null;
|
||||
|
||||
/** Guard against concurrent `loadFiles` calls. */
|
||||
/** Guard against concurrent `_loadPage` calls. */
|
||||
let _loading = false;
|
||||
|
||||
/** Opaque cursor for the next page; `null` on first page or when exhausted. */
|
||||
let _nextCursor = /** @type {string|null} */ (null);
|
||||
|
||||
/**
|
||||
* Active group-by key: '' = no grouping (name order), or one of the keys
|
||||
* from GROUP_BY_DEFS.
|
||||
* @type {string}
|
||||
*/
|
||||
let _groupBy = '';
|
||||
|
||||
/** Whether the current sort order is reversed. */
|
||||
let _reversed = false;
|
||||
|
||||
// ── Group-by controller (public API, consumed by navigation.js / main.js) ───
|
||||
|
||||
/**
|
||||
* Controller object registered with `setGroupByView()` by navigation.js when
|
||||
* the Files section is active. Exposes the same interface as
|
||||
* `sharedWithMeView` so the generic group-by infrastructure in `main.js`
|
||||
* drives both sections identically.
|
||||
*/
|
||||
const filesView = {
|
||||
/**
|
||||
* 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 '' | 'type' | 'modifiedAt' | 'createdAt' | 'size'
|
||||
*/
|
||||
setGroupBy(key) {
|
||||
if (_groupBy === key) return;
|
||||
_groupBy = key;
|
||||
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
|
||||
_nextCursor = null;
|
||||
_component?.clear();
|
||||
_loadPage({ isFirstPage: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the sort direction and reload from page 1.
|
||||
* Calling with the current value is a no-op.
|
||||
* @param {boolean} reversed
|
||||
*/
|
||||
setDirection(reversed) {
|
||||
if (_reversed === reversed) return;
|
||||
_reversed = reversed;
|
||||
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
|
||||
_nextCursor = null;
|
||||
_component?.clear();
|
||||
_loadPage({ isFirstPage: true });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Component factory ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return (creating on first call) the `ResourceListComponent` bound to
|
||||
* `#files-list`. The element must already be in the DOM.
|
||||
@@ -58,7 +211,7 @@ function _ensureComponent() {
|
||||
const isFile = 'mime_type' in item;
|
||||
const type = isFile ? 'file' : 'folder';
|
||||
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);
|
||||
} else {
|
||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||
@@ -85,9 +238,99 @@ function _ensureComponent() {
|
||||
ui.initDragDrop(/** @type {HTMLElement} */ (filesList));
|
||||
}
|
||||
|
||||
_ensureLoadMoreButton();
|
||||
|
||||
return _component;
|
||||
}
|
||||
|
||||
// ── "Load more" button ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the "Load more" wrapper once and attach it below `.files-container`.
|
||||
* Subsequent calls are no-ops.
|
||||
*/
|
||||
function _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 = 'files-load-more';
|
||||
btn.className = 'button secondary';
|
||||
btn.textContent = i18n.t('files.loadMore', 'Load more');
|
||||
btn.addEventListener('click', () => {
|
||||
_loadPage({ isFirstPage: false });
|
||||
});
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
filesContainer.after(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
function _setLoadMoreVisible(visible) {
|
||||
const w = document.getElementById(LOAD_MORE_ID);
|
||||
if (w) w.classList.toggle('hidden', !visible);
|
||||
}
|
||||
|
||||
// ── Page loader ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch one cursor page and render it.
|
||||
* @param {{ isFirstPage?: boolean }} [opts]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function _loadPage({ isFirstPage = false } = {}) {
|
||||
if (_loading) return;
|
||||
_loading = true;
|
||||
|
||||
try {
|
||||
const def = GROUP_BY_DEFS.find((d) => d.key === _groupBy);
|
||||
const orderBy = def?.orderBy ?? 'name';
|
||||
|
||||
const { items, nextCursor } = await fetchResourcesPage(app.currentPath, {
|
||||
cursor: _nextCursor,
|
||||
orderBy,
|
||||
limit: 50,
|
||||
reverse: _reversed
|
||||
});
|
||||
|
||||
_nextCursor = nextCursor;
|
||||
|
||||
if (items.length === 0 && isFirstPage) {
|
||||
ui.showEmptyList();
|
||||
_setLoadMoreVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirstPage) {
|
||||
_component?.render(items, def?.keyFn, def?.labelFn);
|
||||
} else {
|
||||
_component?.append(items, def?.keyFn, def?.labelFn);
|
||||
}
|
||||
|
||||
await _component?.resolveOwnerCells();
|
||||
_setLoadMoreVisible(!!nextCursor);
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.status === 403) {
|
||||
ui.showError(`<p>${i18n.t('errors.forbidden', 'Could not load files')}</p>`);
|
||||
} else {
|
||||
console.error('filesView: load error', err);
|
||||
uiNotifications.show('Error', 'Could not load files and folders');
|
||||
}
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append a single item to the current view (post-upload / post-create
|
||||
* optimistic update). No-op when the Files section is not active or the
|
||||
@@ -111,14 +354,19 @@ function addItem(item) {
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.insertHistory=true]
|
||||
* @param {boolean} [options.forceRefresh=false]
|
||||
* @param {boolean} [options.forceRefresh=false] (legacy — kept for callers; ignored internally)
|
||||
*/
|
||||
async function loadFiles(options = { insertHistory: true }) {
|
||||
if (_loading) {
|
||||
console.log('A file load is already in progress, ignoring request');
|
||||
return;
|
||||
}
|
||||
_loading = true;
|
||||
|
||||
// Reset cursor on navigation; restore saved group-by/direction preferences.
|
||||
_nextCursor = null;
|
||||
const _savedPrefs = viewPrefs.load('files');
|
||||
_groupBy = _savedPrefs.groupBy;
|
||||
_reversed = _savedPrefs.reversed;
|
||||
|
||||
// Delay spinner so fast loads avoid the flash
|
||||
const spinnerTimeout = setTimeout(() => {
|
||||
@@ -130,6 +378,10 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
`);
|
||||
}, 100);
|
||||
|
||||
// A temporary guard: _loadPage sets _loading itself, but we need to
|
||||
// block re-entrant loadFiles() calls during the setup below.
|
||||
_loading = true;
|
||||
|
||||
try {
|
||||
if (!app.userHomeFolderId) await resolveHomeFolder();
|
||||
|
||||
@@ -148,10 +400,6 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
ui.updateBreadcrumb();
|
||||
updateHistory(options.insertHistory ?? true);
|
||||
|
||||
const { folders, files } = await fetchListing(app.currentPath, {
|
||||
forceRefresh: options.forceRefresh ?? false
|
||||
});
|
||||
|
||||
clearTimeout(spinnerTimeout);
|
||||
|
||||
// Prepare the container (shows #files-list, hides error panel)
|
||||
@@ -164,23 +412,31 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
batchToolbar.init();
|
||||
batchToolbar.setActiveComponent(component);
|
||||
|
||||
if (folders.length === 0 && files.length === 0) {
|
||||
ui.showEmptyList();
|
||||
} else {
|
||||
component.render([...folders, ...files]);
|
||||
await component.resolveOwnerCells();
|
||||
}
|
||||
// Hand off to _loadPage (re-use cursor/groupBy state just reset above).
|
||||
_loading = false; // _loadPage sets its own guard
|
||||
await _loadPage({ isFirstPage: true });
|
||||
|
||||
console.log(`Loaded ${folders.length} folders and ${files.length} files`);
|
||||
|
||||
// Deep-link: open a specific file if requested via app.viewFile
|
||||
// Deep-link: open a specific file if requested via app.viewFile.
|
||||
// We don't have a flat file list anymore (cursor pages), so only try
|
||||
// to open it if it was already rendered (first page).
|
||||
if (app.viewFile) {
|
||||
const fileFound = files.find((f) => f.id === app.viewFile) ?? null;
|
||||
if (fileFound) {
|
||||
console.log(`file ${app.viewFile} found, calling viewer`);
|
||||
await inlineViewer.openFile(fileFound);
|
||||
// Find the item among all rendered cards via the DOM attribute.
|
||||
const rendered = document.querySelector(`[data-id="${app.viewFile}"][data-type="file"]`);
|
||||
if (rendered) {
|
||||
// The component's item list may be sparse; ask for a fresh fetch.
|
||||
const fileRes = await fetch(`/api/files/${app.viewFile}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (fileRes.ok) {
|
||||
const fileFound = /** @type {FileItem} */ (await fileRes.json());
|
||||
await inlineViewer.openFile(fileFound);
|
||||
} else {
|
||||
app.viewFile = null;
|
||||
updateHistory(false);
|
||||
}
|
||||
} else {
|
||||
console.log(`file ${app.viewFile} not found`);
|
||||
console.log(`file ${app.viewFile} not in first page — skipping auto-open`);
|
||||
app.viewFile = null;
|
||||
updateHistory(false);
|
||||
}
|
||||
@@ -198,4 +454,4 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
}
|
||||
}
|
||||
|
||||
export { addItem, loadFiles };
|
||||
export { addItem, filesView, loadFiles };
|
||||
|
||||
+20
-4
@@ -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';
|
||||
@@ -89,6 +90,10 @@ const _toggleButtons = `
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span class="group-by-label"></span>
|
||||
</button>
|
||||
<button class="toggle-btn sort-dir-btn" id="sort-dir-btn"
|
||||
title="Sort direction" data-i18n-title="sortdir.title">
|
||||
<i class="fas fa-arrow-up" id="sort-dir-icon"></i>
|
||||
</button>
|
||||
<div class="group-by-menu hidden" id="group-by-menu"></div>
|
||||
</div>
|
||||
<span class="view-toggle-separator hidden" id="group-by-separator"></span>
|
||||
@@ -206,14 +211,14 @@ function setActionsBarMode(mode, force = false) {
|
||||
/**
|
||||
* The view that currently owns the group-by selector, or `null` when no
|
||||
* section supports grouping. Set by `setGroupByView()` from navigation.js.
|
||||
* @type {{ setGroupBy: (key: string) => void } | null}
|
||||
* @type {{ setGroupBy: (key: string) => void, setDirection: (reversed: boolean) => void } | null}
|
||||
*/
|
||||
let _groupByView = null;
|
||||
|
||||
/**
|
||||
* Update the reference to the view that handles group-by changes.
|
||||
* Called by navigation.js when the active section changes.
|
||||
* @param {{ setGroupBy: (key: string) => void } | null} view
|
||||
* @param {{ setGroupBy: (key: string) => void, setDirection: (reversed: boolean) => void } | null} view
|
||||
*/
|
||||
function setGroupByView(view) {
|
||||
_groupByView = view;
|
||||
@@ -247,6 +252,8 @@ function syncGroupByMenu(defs = []) {
|
||||
btn?.classList.remove('active');
|
||||
const lbl = btn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = '';
|
||||
// Reset direction button to ascending (↑)
|
||||
document.getElementById('sort-dir-btn')?.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,10 +296,19 @@ function setupActionsBarDelegation() {
|
||||
groupByBtn?.classList.toggle('active', key !== '');
|
||||
const lbl = groupByBtn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : '';
|
||||
// Changing order-by dimension resets direction to ascending
|
||||
_groupByView?.setDirection(false);
|
||||
document.getElementById('sort-dir-btn')?.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (btn.id) {
|
||||
case 'sort-dir-btn': {
|
||||
const nowReversed = !btn.classList.contains('active');
|
||||
_groupByView?.setDirection(nowReversed);
|
||||
btn.classList.toggle('active', nowReversed);
|
||||
return;
|
||||
}
|
||||
case 'group-by-btn':
|
||||
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
||||
return;
|
||||
@@ -330,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;
|
||||
|
||||
+64
-41
@@ -3,15 +3,18 @@
|
||||
* Extracted from main.js to keep navigation concerns isolated.
|
||||
*/
|
||||
|
||||
import { applyGroupByMenuState } from '../core/groupBySync.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import * as viewPrefs from '../core/viewPrefs.js';
|
||||
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 { loadFiles } from './filesView.js';
|
||||
import { filesView, loadFiles } from './filesView.js';
|
||||
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||
import { app, appElements } from './state.js';
|
||||
import { loadTrashItems } from './trashView.js';
|
||||
@@ -21,6 +24,14 @@ import { ui } from './ui.js';
|
||||
* Sync the hidden class and inline display for the grid/list containers
|
||||
* based on the current view preference.
|
||||
*/
|
||||
/**
|
||||
* Restore the grid/list view preference for a section before rendering.
|
||||
* @param {string} section Matches `app.currentSection` values.
|
||||
*/
|
||||
function restoreView(section) {
|
||||
app.currentView = viewPrefs.resolveView(section);
|
||||
}
|
||||
|
||||
function syncViewContainers() {
|
||||
const filesList = document.getElementById('files-list');
|
||||
const gridViewBtn = document.getElementById('grid-view-btn');
|
||||
@@ -164,6 +175,16 @@ function setCurrentSection(section) {
|
||||
sharedWithMeView.hide();
|
||||
}
|
||||
|
||||
// Hide favoritesView "Load more" button when leaving the favorites section
|
||||
if (section !== 'favorites' && favoritesView) {
|
||||
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);
|
||||
|
||||
@@ -219,11 +240,16 @@ function switchToSharedWithMeSection() {
|
||||
setGroupByView(sharedWithMeView);
|
||||
syncGroupByMenu(sharedWithMeView.groupByDefs);
|
||||
|
||||
// Restore the saved group-by selection in the dropdown.
|
||||
const swmPrefs = viewPrefs.load('sharedwithme');
|
||||
applyGroupByMenuState(swmPrefs.groupBy, swmPrefs.reversed);
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
|
||||
// Show the standard files container and respect grid/list preference
|
||||
toggleFileContainer(true);
|
||||
restoreView('sharedwithme');
|
||||
syncViewContainers();
|
||||
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
@@ -237,8 +263,12 @@ function switchToFilesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
setGroupByView(filesView);
|
||||
syncGroupByMenu(filesView.groupByDefs);
|
||||
|
||||
// Restore the saved group-by selection in the dropdown.
|
||||
const filesPrefs = viewPrefs.load('files');
|
||||
applyGroupByMenuState(filesPrefs.groupBy, filesPrefs.reversed);
|
||||
|
||||
// Show owner column in the Files section
|
||||
ui.setOwnerColumnVisible(true);
|
||||
@@ -251,6 +281,7 @@ function switchToFilesSection() {
|
||||
toggleFileContainer(true);
|
||||
|
||||
// ensure correct view
|
||||
restoreView('files');
|
||||
syncViewContainers();
|
||||
|
||||
//reset files view + remove any error
|
||||
@@ -273,8 +304,12 @@ function switchToFavoritesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('favorites');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
setGroupByView(favoritesView);
|
||||
syncGroupByMenu(favoritesView.groupByDefs);
|
||||
|
||||
// Restore the saved group-by selection in the dropdown.
|
||||
const favPrefs = viewPrefs.load('favorites');
|
||||
applyGroupByMenuState(favPrefs.groupBy, favPrefs.reversed);
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
@@ -287,34 +322,32 @@ function switchToFavoritesSection() {
|
||||
toggleFileContainer(true);
|
||||
|
||||
// ensure correct view
|
||||
restoreView('favorites');
|
||||
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();
|
||||
|
||||
// Prefetch isFavorite cache in background (non-blocking)
|
||||
favorites.init();
|
||||
|
||||
// Load and render via the cursor-paginated view
|
||||
favoritesView.init();
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -324,23 +357,12 @@ function switchToRecentFilesSection() {
|
||||
toggleFileContainer(true);
|
||||
|
||||
// ensure correct view
|
||||
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() {
|
||||
@@ -385,6 +407,7 @@ function switchToTrashSection() {
|
||||
ui.resetFilesList();
|
||||
|
||||
//ensure buttons match the current view
|
||||
restoreView('trash');
|
||||
syncViewContainers();
|
||||
|
||||
// Load trash items
|
||||
@@ -432,8 +455,8 @@ function switchToMusicSection() {
|
||||
function activateFilesUI() {
|
||||
setCurrentSection('files');
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
setGroupByView(filesView);
|
||||
syncGroupByMenu(filesView.groupByDefs);
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.remove('hidden');
|
||||
toggleFileContainer(true);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import * as pathTooltip from '../features/pathTooltip.js';
|
||||
import * as itemTooltip from '../features/itemTooltip.js';
|
||||
import { appElements } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
|
||||
@@ -23,7 +23,7 @@ async function loadTrashItems() {
|
||||
|
||||
try {
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
pathTooltip.destroy(elements.filesList);
|
||||
itemTooltip.destroy(elements.filesList);
|
||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
||||
elements.filesList.innerHTML = `
|
||||
<div class="list-header trash-header">
|
||||
@@ -50,7 +50,7 @@ async function loadTrashItems() {
|
||||
trashItems.forEach((item) => {
|
||||
addTrashItemToView(item);
|
||||
});
|
||||
pathTooltip.init(elements.filesList);
|
||||
itemTooltip.init(elements.filesList);
|
||||
} catch (error) {
|
||||
console.error('Error loading trash items:', error);
|
||||
ui.showNotification('Error', 'Error loading trash items');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { OxiIcons } from '../core/icons.js';
|
||||
import * as viewPrefs from '../core/viewPrefs.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { contextMenus } from '../features/files/contextMenus.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
@@ -419,6 +420,7 @@ const ui = {
|
||||
switchToGridView() {
|
||||
app.currentView = 'grid';
|
||||
localStorage.setItem('oxicloud-view', 'grid');
|
||||
if (app.currentSection) viewPrefs.saveView(app.currentSection, 'grid');
|
||||
|
||||
syncViewContainers();
|
||||
},
|
||||
@@ -429,6 +431,7 @@ const ui = {
|
||||
switchToListView() {
|
||||
app.currentView = 'list';
|
||||
localStorage.setItem('oxicloud-view', 'list');
|
||||
if (app.currentSection) viewPrefs.saveView(app.currentSection, 'list');
|
||||
|
||||
syncViewContainers();
|
||||
},
|
||||
|
||||
@@ -115,6 +115,14 @@ export class ResourceListComponent {
|
||||
*/
|
||||
this._groupLabelFn = undefined;
|
||||
|
||||
/**
|
||||
* Optional node-builder stored between `render()` and `append()` calls.
|
||||
* When set, the swimlane header renders a DOM node instead of plain text
|
||||
* (e.g. a user vignette for the "owner" group-by dimension).
|
||||
* @type {((key: string) => HTMLElement) | undefined}
|
||||
*/
|
||||
this._headerNodeFn = undefined;
|
||||
|
||||
this._ownerVisible = this._cfg.showOwner;
|
||||
|
||||
this._initDelegation();
|
||||
@@ -138,8 +146,12 @@ export class ResourceListComponent {
|
||||
* @param {((key: string) => string)=} groupLabelFn
|
||||
* Optional: converts the raw grouping key to a human-readable header
|
||||
* label. When omitted the key itself is used.
|
||||
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
||||
* Optional: builds a rich DOM node for the swimlane header (e.g. a user
|
||||
* vignette for the "owner" group-by). When provided, `groupLabelFn` is
|
||||
* ignored for the header and the returned node is appended instead.
|
||||
*/
|
||||
render(items, groupFn, groupLabelFn) {
|
||||
render(items, groupFn, groupLabelFn, headerNodeFn) {
|
||||
const header = this._container.querySelector('.list-header');
|
||||
this._container.innerHTML = '';
|
||||
if (header) this._container.appendChild(header);
|
||||
@@ -151,11 +163,12 @@ export class ResourceListComponent {
|
||||
this._lastGroupKey = undefined;
|
||||
this._lastGroupEl = null;
|
||||
this._groupLabelFn = groupLabelFn;
|
||||
this._headerNodeFn = headerNodeFn;
|
||||
|
||||
// Prevent ui.js global delegation from firing on this container
|
||||
this._container.dataset.managedBy = 'resource-list';
|
||||
|
||||
this._appendItems(items, groupFn, groupLabelFn);
|
||||
this._appendItems(items, groupFn, groupLabelFn, headerNodeFn);
|
||||
this._wireSelectAll();
|
||||
}
|
||||
|
||||
@@ -167,9 +180,10 @@ export class ResourceListComponent {
|
||||
* @param {Array<FileItem|FolderItem>} items
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
* @param {((key: string) => string)=} groupLabelFn
|
||||
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
||||
*/
|
||||
append(items, groupFn, groupLabelFn) {
|
||||
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn);
|
||||
append(items, groupFn, groupLabelFn, headerNodeFn) {
|
||||
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
|
||||
}
|
||||
|
||||
/** Remove all items (but keep `.list-header` if present). */
|
||||
@@ -328,8 +342,9 @@ export class ResourceListComponent {
|
||||
* @param {Array<FileItem|FolderItem>} items
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
* @param {((key: string) => string)=} groupLabelFn
|
||||
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
||||
*/
|
||||
_appendItems(items, groupFn, groupLabelFn) {
|
||||
_appendItems(items, groupFn, groupLabelFn, headerNodeFn) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Start from the persisted key so load-more pages continue seamlessly.
|
||||
@@ -354,7 +369,7 @@ export class ResourceListComponent {
|
||||
if (key !== null) {
|
||||
fragmentGroup = document.createElement('div');
|
||||
fragmentGroup.className = 'resource-list__swimlane-group';
|
||||
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn));
|
||||
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn));
|
||||
fragment.appendChild(fragmentGroup);
|
||||
}
|
||||
}
|
||||
@@ -382,14 +397,25 @@ export class ResourceListComponent {
|
||||
|
||||
/**
|
||||
* Create a swimlane divider element.
|
||||
* @param {string} key - Raw grouping key (e.g. UUID or bucket name).
|
||||
* @param {((key: string) => string)=} labelFn - Optional human-readable resolver.
|
||||
*
|
||||
* When `headerNodeFn` is supplied the header renders a rich DOM node
|
||||
* (e.g. a user vignette) instead of plain text; the `--node` CSS modifier
|
||||
* is added to suppress the small-caps / uppercase text styles.
|
||||
*
|
||||
* @param {string} key - Raw grouping key (e.g. UUID or bucket name).
|
||||
* @param {((key: string) => string)=} labelFn - Optional plain-text resolver.
|
||||
* @param {((key: string) => HTMLElement)=} headerNodeFn - Optional rich-node builder.
|
||||
*/
|
||||
_createGroupHeader(key, labelFn) {
|
||||
_createGroupHeader(key, labelFn, headerNodeFn) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'resource-list__swimlane-header';
|
||||
el.dataset.swimlaneHeader = 'true';
|
||||
el.textContent = labelFn ? labelFn(key) : key;
|
||||
if (headerNodeFn) {
|
||||
el.classList.add('resource-list__swimlane-header--node');
|
||||
el.appendChild(headerNodeFn(key));
|
||||
} else {
|
||||
el.textContent = labelFn ? labelFn(key) : key;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -407,6 +433,7 @@ export class ResourceListComponent {
|
||||
el.dataset.folderName = folder.name;
|
||||
el.dataset.parentId = folder.parent_id || '';
|
||||
if (folder.path) el.dataset.path = folder.path;
|
||||
if (folder.owner_id) el.dataset.ownerId = folder.owner_id;
|
||||
if (cfg.draggable) el.setAttribute('draggable', 'true');
|
||||
|
||||
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
|
||||
@@ -463,6 +490,7 @@ export class ResourceListComponent {
|
||||
el.dataset.fileName = file.name;
|
||||
el.dataset.folderId = file.folder_id || '';
|
||||
if (file.path) el.dataset.path = file.path;
|
||||
if (file.owner_id) el.dataset.ownerId = file.owner_id;
|
||||
if (cfg.draggable) el.setAttribute('draggable', 'true');
|
||||
|
||||
el.innerHTML = `
|
||||
@@ -489,6 +517,7 @@ export class ResourceListComponent {
|
||||
const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb'));
|
||||
if (thumb) {
|
||||
thumb.addEventListener('error', () => {
|
||||
console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`);
|
||||
thumb.classList.add('hidden');
|
||||
thumbnail?.queueGenerate(file, (dataUrl) => {
|
||||
thumb.src = dataUrl;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* groupBySync — apply groupBy + sort-direction state to the group-by menu UI.
|
||||
*
|
||||
* Pure DOM helper with no module imports so it can be safely imported by any
|
||||
* view without creating circular dependencies.
|
||||
*
|
||||
* Call after `syncGroupByMenu()` has built the option list, e.g. when
|
||||
* restoring saved preferences on section entry.
|
||||
*
|
||||
* Usage:
|
||||
* import { applyGroupByMenuState } from '../core/groupBySync.js';
|
||||
* applyGroupByMenuState('type', true);
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reflect `groupBy` and `reversed` in the group-by menu DOM:
|
||||
* - marks the matching `.group-by-option` as active
|
||||
* - updates the group-by button label and active class
|
||||
* - toggles the sort-direction button active class
|
||||
*
|
||||
* No-op when the menu elements are not in the DOM (e.g. before initApp).
|
||||
*
|
||||
* @param {string} groupBy Active group-by key, or `''` for "None".
|
||||
* @param {boolean} reversed Whether sort direction is reversed.
|
||||
*/
|
||||
function applyGroupByMenuState(groupBy, reversed) {
|
||||
// Mark the matching option as active; clear all others.
|
||||
for (const b of document.querySelectorAll('.group-by-option')) {
|
||||
const btn = /** @type {HTMLElement} */ (b);
|
||||
btn.classList.toggle('active', (btn.dataset.groupBy ?? '') === groupBy);
|
||||
}
|
||||
|
||||
// Group-by button: active class + label text.
|
||||
const groupByBtn = document.getElementById('group-by-btn');
|
||||
groupByBtn?.classList.toggle('active', groupBy !== '');
|
||||
|
||||
const lbl = groupByBtn?.querySelector('.group-by-label');
|
||||
if (lbl) {
|
||||
if (groupBy === '') {
|
||||
lbl.textContent = '';
|
||||
} else {
|
||||
const activeOpt = /** @type {HTMLElement|null} */ (document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`));
|
||||
lbl.textContent = activeOpt?.textContent ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Sort-direction button: active = reversed.
|
||||
document.getElementById('sort-dir-btn')?.classList.toggle('active', reversed);
|
||||
}
|
||||
|
||||
export { applyGroupByMenuState };
|
||||
@@ -25,6 +25,23 @@ const OxiIcons = {
|
||||
512,
|
||||
'M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 96-96 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6l0-32 96 0 0 96-32 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0 0-96 96 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 32-96 0 0-96 32 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64z'
|
||||
],
|
||||
'arrow-up': [
|
||||
512,
|
||||
'M214.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 109.3 160 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-370.7 105.4 105.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z'
|
||||
],
|
||||
'arrow-down': [
|
||||
512,
|
||||
'M169.4 502.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 402.7 224 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 370.7-105.4-105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z'
|
||||
],
|
||||
'arrow-down-short-wide': [
|
||||
576,
|
||||
'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z'
|
||||
],
|
||||
'arrow-down-wide-short': [
|
||||
576,
|
||||
'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L320 96z'
|
||||
],
|
||||
|
||||
ban: [
|
||||
512,
|
||||
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z'
|
||||
@@ -246,6 +263,10 @@ const OxiIcons = {
|
||||
512,
|
||||
'M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z'
|
||||
],
|
||||
'location-crosshairs': [
|
||||
576,
|
||||
'M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM128 256a160 160 0 1 0 320 0 160 160 0 1 0 -320 0zm160-96a96 96 0 1 1 0 192 96 96 0 1 1 0-192z'
|
||||
],
|
||||
lock: [
|
||||
448,
|
||||
'M144 144l0 48 160 0 0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192l0-48C80 64.5 144.5 0 224 0s144 64.5 144 144l0 48 16 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0z'
|
||||
|
||||
@@ -333,6 +333,22 @@
|
||||
* @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
|
||||
* @property {string} email
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* View preferences — persist groupBy, sort-direction, and grid/list view
|
||||
* per section in localStorage.
|
||||
*
|
||||
* Each section has its own key (`oxicloud.view.<section>`) so that, for
|
||||
* example, Favorites can be in list view with "Type" grouping while Files
|
||||
* is in grid view with no grouping.
|
||||
*
|
||||
* Section keys match `app.currentSection` values:
|
||||
* 'files' | 'favorites' | 'sharedwithme' | 'recent' | 'trash' |
|
||||
* 'photos' | 'music' | 'shared'
|
||||
*
|
||||
* All errors (quota, private browsing, JSON parse) are silently swallowed.
|
||||
*
|
||||
* Usage:
|
||||
* import * as viewPrefs from '../core/viewPrefs.js';
|
||||
*
|
||||
* // Read
|
||||
* const { groupBy, reversed, view } = viewPrefs.load('files');
|
||||
*
|
||||
* // Write all fields at once
|
||||
* viewPrefs.save('files', 'type', true, 'list');
|
||||
*
|
||||
* // Write only the view (grid/list) toggle, keeping stored groupBy/reversed
|
||||
* viewPrefs.saveView('favorites', 'grid');
|
||||
*
|
||||
* // Resolve which view (grid/list) to apply on section entry
|
||||
* const v = viewPrefs.resolveView('sharedwithme'); // 'grid' | 'list'
|
||||
*/
|
||||
|
||||
const _PREFIX = 'oxicloud.view.';
|
||||
|
||||
/**
|
||||
* @typedef {'grid'|'list'|''} ViewMode
|
||||
* @typedef {{ groupBy: string, reversed: boolean, view: ViewMode }} ViewPrefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load saved preferences for a section.
|
||||
* Returns safe defaults when nothing is stored or storage is unavailable.
|
||||
* @param {string} section
|
||||
* @returns {ViewPrefs}
|
||||
*/
|
||||
function load(section) {
|
||||
try {
|
||||
const raw = localStorage.getItem(_PREFIX + section);
|
||||
if (!raw) return { groupBy: '', reversed: false, view: '' };
|
||||
const p = JSON.parse(raw);
|
||||
return {
|
||||
groupBy: typeof p.groupBy === 'string' ? p.groupBy : '',
|
||||
reversed: Boolean(p.reversed),
|
||||
view: p.view === 'grid' || p.view === 'list' ? p.view : ''
|
||||
};
|
||||
} catch {
|
||||
return { groupBy: '', reversed: false, view: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist all preferences for a section.
|
||||
* @param {string} section
|
||||
* @param {string} groupBy
|
||||
* @param {boolean} reversed
|
||||
* @param {ViewMode} view
|
||||
*/
|
||||
function save(section, groupBy, reversed, view) {
|
||||
try {
|
||||
localStorage.setItem(_PREFIX + section, JSON.stringify({ groupBy, reversed, view }));
|
||||
} catch {
|
||||
// Silently ignore quota errors or restricted environments.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the grid/list view for a section, preserving groupBy and reversed.
|
||||
* @param {string} section
|
||||
* @param {ViewMode} view
|
||||
*/
|
||||
function saveView(section, view) {
|
||||
const current = load(section);
|
||||
save(section, current.groupBy, current.reversed, view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the view mode to apply when entering a section.
|
||||
* Priority: section-specific pref → legacy global `oxicloud-view` key → `'grid'`.
|
||||
* @param {string} section
|
||||
* @returns {'grid'|'list'}
|
||||
*/
|
||||
function resolveView(section) {
|
||||
const prefs = load(section);
|
||||
if (prefs.view) return prefs.view;
|
||||
// Fall back to the pre-existing global key (backward compatibility).
|
||||
const global = localStorage.getItem('oxicloud-view');
|
||||
return global === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
export { load, resolveView, save, saveView };
|
||||
@@ -508,12 +508,8 @@ const batchToolbar = {
|
||||
const data = await response.json();
|
||||
const inserted = data.stats?.inserted || 0;
|
||||
|
||||
// Replace cache directly from response (no extra GET)
|
||||
if (data.favorites && favorites._replaceCacheFromResponse) {
|
||||
favorites._replaceCacheFromResponse(data.favorites);
|
||||
} else {
|
||||
await favorites._fetchFromServer();
|
||||
}
|
||||
// Re-fetch the isFavorite cache from the server.
|
||||
await favorites._fetchFromServer();
|
||||
|
||||
this.clear();
|
||||
loadFiles();
|
||||
|
||||
@@ -122,7 +122,7 @@ const contextMenus = {
|
||||
// Check if folder is already in favorites to toggle
|
||||
if (favorites?.isFavorite(folder.id, 'folder')) {
|
||||
// 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') {
|
||||
ui.setFavoriteVisualState(folder.id, 'folder', false);
|
||||
}
|
||||
@@ -244,7 +244,7 @@ const contextMenus = {
|
||||
// Check if file is already in favorites to toggle
|
||||
if (favorites?.isFavorite(file.id, 'file')) {
|
||||
// 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') {
|
||||
ui.setFavoriteVisualState(file.id, 'file', false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Item tooltip — unified hover tooltip showing a stable "technical sheet" for
|
||||
* a hovered `.file-item`.
|
||||
*
|
||||
* Both rows are always rendered so the layout never shifts between items.
|
||||
* A "?" placeholder is shown when data is absent for a given row.
|
||||
*
|
||||
* data-owner-id → 👤 Owner [userVignette] (avatar + name, async)
|
||||
* data-path → ⊕ Path Documents/Work (monospace)
|
||||
*
|
||||
* The tooltip is shown only when at least one of the two attributes is present.
|
||||
* Lines are laid out in a 3-column CSS grid (icon | label | value) so values
|
||||
* are always left-aligned at the same x position.
|
||||
*
|
||||
* Replaces the former `pathTooltip` and `ownerTooltip` modules.
|
||||
*
|
||||
* Usage:
|
||||
* import * as itemTooltip from '../features/itemTooltip.js';
|
||||
* itemTooltip.init(containerEl) — call after rendering items
|
||||
* itemTooltip.destroy(containerEl) — call when leaving the section
|
||||
*/
|
||||
|
||||
import { createUserVignette } from '../components/userVignette.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
|
||||
// ── Tooltip DOM ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** @returns {HTMLElement} */
|
||||
function _getOrCreateTooltip() {
|
||||
let el = document.getElementById('path-tooltip');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'path-tooltip';
|
||||
el.className = 'path-tooltip hidden';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function _hide() {
|
||||
const el = document.getElementById('path-tooltip');
|
||||
if (el) el.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Row builder ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append one grid row (icon | label | value) to the tooltip container.
|
||||
* The three cells are direct children of the CSS grid — column assignment
|
||||
* is automatic.
|
||||
*
|
||||
* @param {HTMLElement} tooltip
|
||||
* @param {string} iconClass FontAwesome class string, e.g. `"fas fa-user"`
|
||||
* @param {string} labelText
|
||||
* @param {(el: HTMLElement) => void} populate Fills the value cell.
|
||||
* @returns {HTMLElement} The value cell.
|
||||
*/
|
||||
function _addRow(tooltip, iconClass, labelText, populate) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = `${iconClass} path-tooltip__icon`;
|
||||
tooltip.appendChild(icon);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'path-tooltip__label';
|
||||
label.textContent = labelText;
|
||||
tooltip.appendChild(label);
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.className = 'path-tooltip__value';
|
||||
populate(value);
|
||||
tooltip.appendChild(value);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a "?" placeholder cell (used when data is unavailable).
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
function _setUnknown(el) {
|
||||
el.classList.add('path-tooltip__value--unknown');
|
||||
el.textContent = '?';
|
||||
}
|
||||
|
||||
// ── Event handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function _onEnter(e) {
|
||||
const item = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const ownerId = item.dataset.ownerId;
|
||||
const path = item.dataset.path;
|
||||
|
||||
// Nothing to show — don't display an all-? tooltip.
|
||||
if (!ownerId && !path) return;
|
||||
|
||||
const tooltip = _getOrCreateTooltip();
|
||||
|
||||
// Clear previous content.
|
||||
while (tooltip.firstChild) tooltip.removeChild(tooltip.firstChild);
|
||||
|
||||
// ── Owner row (always rendered) ───────────────────────────────────────────
|
||||
_addRow(tooltip, 'fas fa-user', i18n.t('files.owner', 'Owner'), (el) => {
|
||||
if (ownerId && systemUsers.isAvailable()) {
|
||||
el.appendChild(createUserVignette(ownerId, 'xs'));
|
||||
} else {
|
||||
_setUnknown(el);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Path row (always rendered) ────────────────────────────────────────────
|
||||
_addRow(tooltip, 'fas fa-location-crosshairs', i18n.t('tooltip.path', 'Path'), (el) => {
|
||||
if (path) {
|
||||
el.classList.add('path-tooltip__value--path');
|
||||
el.textContent = path;
|
||||
} else {
|
||||
_setUnknown(el);
|
||||
}
|
||||
});
|
||||
|
||||
tooltip.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function _onLeave() {
|
||||
_hide();
|
||||
}
|
||||
|
||||
// ── Listener registry (WeakMap for leak-free cleanup) ────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers
|
||||
*/
|
||||
|
||||
/** @type {WeakMap<HTMLElement, Handlers>} */
|
||||
const _registry = new WeakMap();
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach tooltip listeners to every `.file-item` inside `container`.
|
||||
* Items with neither `data-owner-id` nor `data-path` will not trigger the
|
||||
* tooltip. Safe to call repeatedly — already-wired elements are skipped.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function init(container) {
|
||||
for (const item of container.querySelectorAll('.file-item')) {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
if (_registry.has(el)) continue; // already wired
|
||||
|
||||
const enter = (/** @type {MouseEvent} */ ev) => _onEnter(ev);
|
||||
const leave = () => _onLeave();
|
||||
|
||||
el.addEventListener('mouseenter', enter);
|
||||
el.addEventListener('mouseleave', leave);
|
||||
_registry.set(el, { enter, leave });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tooltip listeners from all `.file-item` elements inside `container`
|
||||
* and hide any visible tooltip.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function destroy(container) {
|
||||
for (const item of container.querySelectorAll('.file-item')) {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
const h = _registry.get(el);
|
||||
if (h) {
|
||||
el.removeEventListener('mouseenter', h.enter);
|
||||
el.removeEventListener('mouseleave', h.leave);
|
||||
_registry.delete(el);
|
||||
}
|
||||
}
|
||||
_hide();
|
||||
}
|
||||
|
||||
export { destroy, init };
|
||||
@@ -1,30 +1,28 @@
|
||||
/**
|
||||
* OxiCloud - Favorites Module (server-authoritative)
|
||||
*
|
||||
* Source of truth: GET /api/favorites (enriched with name/size/mime via SQL JOIN).
|
||||
* Local in-memory cache (`_cache`) keeps `isFavorite()` synchronous for the
|
||||
* rendering path so star icons can be painted without a round-trip.
|
||||
* Source of truth: GET /api/favorites/resources (cursor-paginated).
|
||||
* The in-memory cache (`_cache`) is a Set of "type:id" keys that keeps
|
||||
* `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 { 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 pathTooltip from '../pathTooltip.js';
|
||||
|
||||
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
||||
import { fetchFavoritesPage } from '../../model/favoritesModel.js';
|
||||
|
||||
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,
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
_component: null,
|
||||
|
||||
// ───────────────────── helpers ─────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
@@ -34,56 +32,43 @@ const favorites = {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} type
|
||||
* @returns {string}
|
||||
*/
|
||||
_cacheKey(id, type) {
|
||||
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 ─────────────────────
|
||||
|
||||
/**
|
||||
* Initialise the module: fetch the full list from the server and populate
|
||||
* the in-memory cache. Called once from app.js on startup.
|
||||
* Initialise the module: fetch the full favorites list from the server and
|
||||
* 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() {
|
||||
console.log('Initializing favorites module (server-authoritative)');
|
||||
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() {
|
||||
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();
|
||||
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;
|
||||
@@ -96,20 +81,22 @@ const favorites = {
|
||||
// ───────────────────── 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} type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isFavorite(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} name
|
||||
* @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) {
|
||||
try {
|
||||
@@ -122,10 +109,9 @@ const favorites = {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
|
||||
// Refresh cache from server to get enriched data
|
||||
await this._fetchFromServer();
|
||||
// Optimistically update local cache without a full re-fetch.
|
||||
this._cache.add(this._cacheKey(id, type));
|
||||
|
||||
// Notify user
|
||||
if (ui?.showNotification) {
|
||||
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} 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 {
|
||||
// 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}`, {
|
||||
method: 'DELETE',
|
||||
headers: this._authHeaders()
|
||||
@@ -157,11 +141,10 @@ const favorites = {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
|
||||
// Remove from local cache
|
||||
this._cache.delete(this._cacheKey(id, type));
|
||||
|
||||
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;
|
||||
@@ -171,124 +154,32 @@ const favorites = {
|
||||
}
|
||||
},
|
||||
|
||||
// ───────────────────── display ─────────────────────
|
||||
|
||||
/**
|
||||
* Render the favourites view. All data comes from the in-memory cache
|
||||
* (which was populated from the enriched backend response — zero extra
|
||||
* fetches).
|
||||
* Batch-add multiple items to favourites in a single server call.
|
||||
* Re-fetches the cache after success to stay consistent.
|
||||
*
|
||||
* @param {Array<{item_id: string, item_type: string}>} items
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async displayFavorites() {
|
||||
async batchAdd(items) {
|
||||
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();
|
||||
|
||||
ui.resetFilesList();
|
||||
batchToolbar.init();
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
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');
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error in batchAdd:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 pathTooltip from '../pathTooltip.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);
|
||||
pathTooltip.init(filesList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error displaying recent files:', error);
|
||||
if (ui?.showNotification) {
|
||||
ui.showNotification('Error', 'Error loading recent files');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Owner tooltip — shows "Shared by: <display name>" when hovering a
|
||||
* `.file-item[data-owner-id]` element.
|
||||
*
|
||||
* Reuses the existing `#path-tooltip` DOM element (same position and style)
|
||||
* so no extra CSS is needed. The tooltip is hidden immediately on mouseleave
|
||||
* and the display-name resolution is async-but-usually-instant because
|
||||
* `systemUsers` is pre-fetched when the Shared-with-me section is entered.
|
||||
*
|
||||
* Usage:
|
||||
* ownerTooltip.init(containerEl) — call after rendering items
|
||||
* ownerTooltip.destroy(containerEl) — call when leaving the section
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
|
||||
// ── Tooltip DOM ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** @returns {HTMLElement} */
|
||||
function _getOrCreateTooltip() {
|
||||
let el = document.getElementById('path-tooltip');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'path-tooltip';
|
||||
el.className = 'path-tooltip hidden';
|
||||
document.querySelector('.main-content')?.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function _hide() {
|
||||
document.getElementById('path-tooltip')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Event handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
async function _onEnter(e) {
|
||||
const item = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const ownerId = item.dataset.ownerId;
|
||||
if (!ownerId) return;
|
||||
|
||||
if (!systemUsers.isAvailable()) return;
|
||||
|
||||
const tooltip = _getOrCreateTooltip();
|
||||
|
||||
// Show immediately with a placeholder so the tooltip appears without lag.
|
||||
const label = i18n.t('sharedwithme_sharedBy', 'Shared by');
|
||||
tooltip.textContent = `${label}: …`;
|
||||
tooltip.classList.remove('hidden');
|
||||
|
||||
// Resolve the name (usually instant from the pre-fetched cache).
|
||||
const name = await systemUsers.getDisplayName(ownerId);
|
||||
|
||||
// Guard: don't update if the user already moved away.
|
||||
if (!tooltip.classList.contains('hidden')) {
|
||||
tooltip.textContent = `${label}: ${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
function _onLeave() {
|
||||
_hide();
|
||||
}
|
||||
|
||||
// ── Listener registry (WeakMap for leak-free cleanup) ────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers
|
||||
*/
|
||||
|
||||
/** @type {WeakMap<HTMLElement, Handlers>} */
|
||||
const _registry = new WeakMap();
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach owner-tooltip listeners to every `.file-item[data-owner-id]`
|
||||
* inside `container`.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function init(container) {
|
||||
for (const item of container.querySelectorAll('.file-item[data-owner-id]')) {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
if (_registry.has(el)) continue; // already wired
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const enter = (e) => {
|
||||
_onEnter(e);
|
||||
}; // intentionally discard the Promise
|
||||
const leave = () => _onLeave();
|
||||
|
||||
el.addEventListener('mouseenter', enter);
|
||||
el.addEventListener('mouseleave', leave);
|
||||
_registry.set(el, { enter, leave });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove owner-tooltip listeners from all `.file-item` elements inside
|
||||
* `container` and hide any visible tooltip.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function destroy(container) {
|
||||
for (const item of container.querySelectorAll('.file-item')) {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
const h = _registry.get(el);
|
||||
if (h) {
|
||||
el.removeEventListener('mouseenter', h.enter);
|
||||
el.removeEventListener('mouseleave', h.leave);
|
||||
_registry.delete(el);
|
||||
}
|
||||
}
|
||||
_hide();
|
||||
}
|
||||
|
||||
export const ownerTooltip = { init, destroy };
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Path tooltip — shows the full path of a hovered file/folder item
|
||||
* in an overlay at the bottom-left of the content area.
|
||||
*
|
||||
* Usage: call init(container) after rendering items, destroy(container) on teardown.
|
||||
* Only file-item elements with a data-path attribute trigger the tooltip.
|
||||
*/
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
let _tooltip = null;
|
||||
|
||||
function _getOrCreateTooltip() {
|
||||
if (_tooltip) return _tooltip;
|
||||
_tooltip = document.getElementById('path-tooltip');
|
||||
if (!_tooltip) {
|
||||
_tooltip = document.createElement('div');
|
||||
_tooltip.id = 'path-tooltip';
|
||||
_tooltip.className = 'path-tooltip hidden';
|
||||
document.querySelector('.main-content')?.appendChild(_tooltip);
|
||||
}
|
||||
return _tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function _onEnter(e) {
|
||||
const item = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const path = item.dataset.path;
|
||||
if (!path) return;
|
||||
|
||||
const tooltip = _getOrCreateTooltip();
|
||||
tooltip.textContent = path;
|
||||
tooltip.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function _onLeave() {
|
||||
_tooltip?.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} EnterLeaveF
|
||||
* @property {(e: MouseEvent) => void} enter
|
||||
* @property {(e: MouseEvent) => void} leave
|
||||
*
|
||||
|
||||
/** @type {WeakMap<HTMLElement, EnterLeaveF>} */
|
||||
const _listeners = new WeakMap();
|
||||
|
||||
/**
|
||||
* Attach path tooltip listeners to all file-item elements inside container.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function init(container) {
|
||||
const items = container.querySelectorAll('.file-item[data-path]');
|
||||
items.forEach((item) => {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const enter = (e) => _onEnter(e);
|
||||
el.addEventListener('mouseenter', enter);
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const leave = (_e) => _onLeave();
|
||||
el.addEventListener('mouseleave', leave);
|
||||
|
||||
_listeners.set(el, { enter, leave });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove path tooltip listeners from all file-item elements inside container.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function destroy(container) {
|
||||
const items = container.querySelectorAll('.file-item');
|
||||
items.forEach((item) => {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
const fns = _listeners.get(el);
|
||||
if (fns) {
|
||||
el.removeEventListener('mouseenter', fns.enter);
|
||||
el.removeEventListener('mouseleave', fns.leave);
|
||||
_listeners.delete(el);
|
||||
}
|
||||
});
|
||||
_onLeave();
|
||||
}
|
||||
|
||||
export { destroy, init };
|
||||
@@ -17,8 +17,11 @@ let _pdfjsLib = null;
|
||||
*/
|
||||
async function getPdfjsLib() {
|
||||
if (_pdfjsLib) return _pdfjsLib;
|
||||
// IMPORTANT: this hack (const lib=...) so tsc will not load vendors library
|
||||
const lib = '../vendors/pdf.min.mjs';
|
||||
// IMPORTANT: use an absolute path so the import resolves correctly both in
|
||||
// dev mode (native ESM, module at /js/features/thumbnail.js) and in release
|
||||
// mode (IIFE bundle at /js/app.{hash}.js — relative '../vendors/…' would
|
||||
// incorrectly resolve to /vendors/… instead of /js/vendors/…).
|
||||
const lib = '/js/vendors/pdf.min.mjs';
|
||||
_pdfjsLib = /** @type {any} */ (await import(lib));
|
||||
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
|
||||
return _pdfjsLib;
|
||||
|
||||
@@ -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 };
|
||||
@@ -107,4 +107,72 @@ async function fetchListing(folderId, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export { fetchListing, getFolder, rebuildBreadCrumb };
|
||||
/**
|
||||
* Map one tagged resource item from `/api/folders/{id}/resources` into the
|
||||
* canonical `FileItem` / `FolderItem` shape used by `ResourceListComponent`.
|
||||
*
|
||||
* @param {{ resource_type: string, resource: Record<string, unknown> }} tagged
|
||||
* @returns {FileItem|FolderItem}
|
||||
*/
|
||||
function _mapResourceItem(tagged) {
|
||||
const r = tagged.resource;
|
||||
if (tagged.resource_type === 'folder') {
|
||||
return /** @type {FolderItem} */ ({
|
||||
id: String(r.id ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
path: String(r.path ?? ''),
|
||||
parent_id: r.parent_id != null ? String(r.parent_id) : '',
|
||||
owner_id: r.owner_id != null ? String(r.owner_id) : '',
|
||||
created_at: /** @type {number} */ (r.created_at),
|
||||
modified_at: /** @type {number} */ (r.modified_at),
|
||||
is_root: Boolean(r.is_root),
|
||||
icon_class: String(r.icon_class ?? 'fas fa-folder'),
|
||||
icon_special_class: String(r.icon_special_class ?? 'folder-icon'),
|
||||
category: String(r.category ?? 'Folder')
|
||||
});
|
||||
}
|
||||
return /** @type {FileItem} */ ({
|
||||
id: String(r.id ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
path: String(r.path ?? ''),
|
||||
folder_id: r.folder_id != null ? String(r.folder_id) : '',
|
||||
owner_id: r.owner_id != null ? String(r.owner_id) : '',
|
||||
mime_type: String(r.mime_type ?? ''),
|
||||
size: /** @type {number} */ (r.size),
|
||||
size_formatted: String(r.size_formatted ?? ''),
|
||||
created_at: /** @type {number} */ (r.created_at),
|
||||
modified_at: /** @type {number} */ (r.modified_at),
|
||||
icon_class: String(r.icon_class ?? ''),
|
||||
icon_special_class: String(r.icon_special_class ?? ''),
|
||||
category: String(r.category ?? '')
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one cursor page from `GET /api/folders/{id}/resources`.
|
||||
*
|
||||
* @param {string} folderId
|
||||
* @param {{ cursor?: string|null, orderBy?: string, limit?: number, reverse?: boolean }} [opts]
|
||||
* @returns {Promise<{ items: Array<FileItem|FolderItem>, nextCursor: string|null }>}
|
||||
*/
|
||||
async function fetchResourcesPage(folderId, { cursor = null, orderBy = 'name', limit = 50, reverse = false } = {}) {
|
||||
const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (reverse) params.set('reverse', 'true');
|
||||
|
||||
const res = await fetch(`/api/folders/${folderId}/resources?${params}`, NO_CACHE);
|
||||
if (!res.ok) {
|
||||
const err = /** @type {any} */ (new Error(`fetchResourcesPage: ${res.status}`));
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const items = /** @type {Array<{ resource_type: string, resource: Record<string, unknown> }>} */ (Array.isArray(data.items) ? data.items : []).map(
|
||||
_mapResourceItem
|
||||
);
|
||||
|
||||
return { items, nextCursor: data.next_cursor ?? null };
|
||||
}
|
||||
|
||||
export { fetchListing, fetchResourcesPage, getFolder, rebuildBreadCrumb };
|
||||
|
||||
@@ -89,15 +89,17 @@ const grants = {
|
||||
* @param {number} [opts.limit] - Max items per page (1–200, default 50).
|
||||
* @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page.
|
||||
* @param {string} [opts.orderBy] - Sort dimension: 'granted_at' | 'granted_by' (default: 'granted_at').
|
||||
* @param {boolean} [opts.reverse] - Reverse the sort order (default: false).
|
||||
* @returns {Promise<SharedWithMeResponse>}
|
||||
*/
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) {
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy, reverse = false } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
resource_types: resourceTypes.join(',')
|
||||
});
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (orderBy) params.set('sort_by', orderBy);
|
||||
if (reverse) params.set('reverse', 'true');
|
||||
|
||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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 { createUserVignette } from '../../components/userVignette.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 { 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,
|
||||
* headerNodeFn?: (key: string) => HTMLElement }} 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: '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),
|
||||
headerNodeFn: (id) => createUserVignette(id, 'sm')
|
||||
},
|
||||
{
|
||||
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: '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: '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;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/** 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;
|
||||
viewPrefs.save('favorites', this._groupBy, this._reversed, viewPrefs.load('favorites').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('favorites', this._groupBy, this._reversed, viewPrefs.load('favorites').view);
|
||||
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;
|
||||
const _savedPrefs = viewPrefs.load('favorites');
|
||||
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);
|
||||
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, def?.headerNodeFn);
|
||||
} else {
|
||||
this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
|
||||
}
|
||||
|
||||
// Wire unified item tooltip (owner + path) after items are in the DOM
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) itemTooltip.init(filesList);
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,436 @@
|
||||
// @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 { createUserVignette } from '../../components/userVignette.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,
|
||||
* headerNodeFn?: (key: string) => HTMLElement }} 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: '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),
|
||||
headerNodeFn: (id) => createUserVignette(id, 'sm')
|
||||
},
|
||||
{
|
||||
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: '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: '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;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/** 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, def?.headerNodeFn);
|
||||
} else {
|
||||
this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
|
||||
}
|
||||
|
||||
// Wire unified item tooltip (owner + path) after items are in the DOM.
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) itemTooltip.init(filesList);
|
||||
|
||||
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 };
|
||||
@@ -12,11 +12,13 @@
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { createUserVignette } from '../../components/userVignette.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 { ownerTooltip } from '../../features/ownerTooltip.js';
|
||||
import { grants } from '../../model/grants.js';
|
||||
import { systemUsers } from '../../model/systemUsers.js';
|
||||
|
||||
@@ -25,7 +27,8 @@ import { systemUsers } from '../../model/systemUsers.js';
|
||||
/**
|
||||
* @typedef {{ key: string, label: string, orderBy: string,
|
||||
* keyFn: (item: FileItem|FolderItem) => string|null,
|
||||
* labelFn?: (key: string) => string }} GroupByDef
|
||||
* labelFn?: (key: string) => string,
|
||||
* headerNodeFn?: (key: string) => HTMLElement }} GroupByDef
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -41,6 +44,24 @@ import { systemUsers } from '../../model/systemUsers.js';
|
||||
* @type {GroupByDef[]}
|
||||
*/
|
||||
const GROUP_BY_DEFS = [
|
||||
{
|
||||
key: 'owner',
|
||||
// label is accessed via syncGroupByMenu → read at section-switch time,
|
||||
// when translations are guaranteed to be loaded.
|
||||
get label() {
|
||||
return i18n.t('groupby.owner', 'Owner');
|
||||
},
|
||||
orderBy: 'granted_by',
|
||||
// keyFn groups by UUID — stable and unique, avoids collisions between
|
||||
// users with the same display name.
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
|
||||
return r.owner_id || null;
|
||||
},
|
||||
// labelFn resolves UUID → display name from the pre-fetched cache.
|
||||
labelFn: (id) => systemUsers.getDisplayNameSync(id),
|
||||
headerNodeFn: (id) => createUserVignette(id, 'sm')
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
get label() {
|
||||
@@ -73,23 +94,6 @@ const GROUP_BY_DEFS = [
|
||||
return labels[key] ?? key;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
// label is accessed via syncGroupByMenu → read at section-switch time,
|
||||
// when translations are guaranteed to be loaded.
|
||||
get label() {
|
||||
return i18n.t('groupby.owner', 'Owner');
|
||||
},
|
||||
orderBy: 'granted_by',
|
||||
// keyFn groups by UUID — stable and unique, avoids collisions between
|
||||
// users with the same display name.
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
|
||||
return r.owner_id || null;
|
||||
},
|
||||
// labelFn resolves UUID → display name from the pre-fetched cache.
|
||||
labelFn: (id) => systemUsers.getDisplayNameSync(id)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
get label() {
|
||||
@@ -143,6 +147,9 @@ const sharedWithMeView = {
|
||||
*/
|
||||
_groupBy: '',
|
||||
|
||||
/** Whether the current sort order is reversed. */
|
||||
_reversed: false,
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -162,11 +169,26 @@ const sharedWithMeView = {
|
||||
setGroupBy(key) {
|
||||
if (this._groupBy === key) return;
|
||||
this._groupBy = key;
|
||||
viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view);
|
||||
this._nextCursor = null; // restart from first page
|
||||
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('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view);
|
||||
this._nextCursor = null;
|
||||
this._component?.clear();
|
||||
this._loadPage();
|
||||
},
|
||||
|
||||
/**
|
||||
* (Re-)load from page 1 and render into the existing files container.
|
||||
* Called every time the user switches to this section.
|
||||
@@ -174,7 +196,9 @@ const sharedWithMeView = {
|
||||
async init() {
|
||||
this._nextCursor = null;
|
||||
this._loading = false;
|
||||
this._groupBy = '';
|
||||
const _savedPrefs = viewPrefs.load('sharedwithme');
|
||||
this._groupBy = _savedPrefs.groupBy;
|
||||
this._reversed = _savedPrefs.reversed;
|
||||
|
||||
this._ensureLoadMoreButton();
|
||||
|
||||
@@ -205,7 +229,7 @@ const sharedWithMeView = {
|
||||
const isFile = 'mime_type' in item;
|
||||
const type = isFile ? 'file' : 'folder';
|
||||
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);
|
||||
} else {
|
||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||
@@ -245,7 +269,7 @@ const sharedWithMeView = {
|
||||
batchToolbar.setActiveComponent(null);
|
||||
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) ownerTooltip.destroy(filesList);
|
||||
if (filesList) itemTooltip.destroy(filesList);
|
||||
},
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||
@@ -275,7 +299,8 @@ const sharedWithMeView = {
|
||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||
limit: 50,
|
||||
cursor: this._nextCursor ?? undefined,
|
||||
orderBy
|
||||
orderBy,
|
||||
reverse: this._reversed
|
||||
});
|
||||
|
||||
this._nextCursor = data.next_cursor ?? null;
|
||||
@@ -294,14 +319,14 @@ const sharedWithMeView = {
|
||||
const items = this._mapItems(data.items);
|
||||
|
||||
if (isFirstPage) {
|
||||
this._component?.render(items, def?.keyFn, def?.labelFn);
|
||||
this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
|
||||
} else {
|
||||
this._component?.append(items, def?.keyFn, def?.labelFn);
|
||||
this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
|
||||
}
|
||||
|
||||
// Wire owner tooltips after items are in the DOM
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) ownerTooltip.init(filesList);
|
||||
if (filesList) itemTooltip.init(filesList);
|
||||
|
||||
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
||||
await this._component?.resolveOwnerCells();
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "مسح الأخيرة",
|
||||
"accessed": "تم الوصول",
|
||||
"empty_state": "لا توجد ملفات حديثة",
|
||||
"empty_hint": "الملفات التي تفتحها ستظهر هنا"
|
||||
"empty_hint": "الملفات التي تفتحها ستظهر هنا",
|
||||
"loadMore": "تحميل المزيد"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "تمت إعادة تسمية الملف",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "لا شيء",
|
||||
"title": "التجميع حسب",
|
||||
"owner": "المالك",
|
||||
"shareDate": "تاريخ المشاركة"
|
||||
"shareDate": "تاريخ المشاركة",
|
||||
"type": "النوع",
|
||||
"type.folders": "المجلدات",
|
||||
"accessedAt": "تاريخ الوصول",
|
||||
"modifiedAt": "تاريخ التعديل",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"size": "الحجم",
|
||||
"favoriteDate": "تاريخ المفضلة"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "اليوم",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "Zuletzt verwendete löschen",
|
||||
"accessed": "Zugegriffen",
|
||||
"empty_state": "Keine zuletzt verwendeten Dateien",
|
||||
"empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt"
|
||||
"empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt",
|
||||
"loadMore": "Mehr laden"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Datei umbenannt",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "Keine",
|
||||
"title": "Gruppieren nach",
|
||||
"owner": "Eigentümer",
|
||||
"shareDate": "Freigabedatum"
|
||||
"shareDate": "Freigabedatum",
|
||||
"type": "Typ",
|
||||
"type.folders": "Ordner",
|
||||
"accessedAt": "Zugriffsdatum",
|
||||
"modifiedAt": "Änderungsdatum",
|
||||
"createdAt": "Erstellungsdatum",
|
||||
"size": "Größe",
|
||||
"favoriteDate": "Datum der Markierung"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Heute",
|
||||
|
||||
+10
-2
@@ -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
@@ -430,7 +430,8 @@
|
||||
"clear": "Limpiar recientes",
|
||||
"accessed": "Accedido",
|
||||
"empty_state": "No hay archivos recientes",
|
||||
"empty_hint": "Los archivos que abras aparecerán aquí"
|
||||
"empty_hint": "Los archivos que abras aparecerán aquí",
|
||||
"loadMore": "Cargar más"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Archivo renombrado",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "Ninguno",
|
||||
"title": "Agrupar por",
|
||||
"owner": "Propietario",
|
||||
"shareDate": "Fecha de compartición"
|
||||
"shareDate": "Fecha de compartición",
|
||||
"type": "Tipo",
|
||||
"type.folders": "Carpetas",
|
||||
"accessedAt": "Fecha de acceso",
|
||||
"modifiedAt": "Fecha de modificación",
|
||||
"createdAt": "Fecha de creación",
|
||||
"size": "Tamaño",
|
||||
"favoriteDate": "Fecha de favorito"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Hoy",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "پاک کردن اخیر",
|
||||
"accessed": "دسترسی یافته",
|
||||
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
|
||||
"empty_hint": "پروندههایی که باز میکنید اینجا ظاهر میشوند"
|
||||
"empty_hint": "پروندههایی که باز میکنید اینجا ظاهر میشوند",
|
||||
"loadMore": "بارگذاری بیشتر"
|
||||
},
|
||||
"batch": {
|
||||
"one_selected": "۱ مورد انتخاب شده",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "هیچ",
|
||||
"title": "گروهبندی بر اساس",
|
||||
"owner": "مالک",
|
||||
"shareDate": "تاریخ اشتراک"
|
||||
"shareDate": "تاریخ اشتراک",
|
||||
"type": "نوع",
|
||||
"type.folders": "پوشهها",
|
||||
"accessedAt": "تاریخ دسترسی",
|
||||
"modifiedAt": "تاریخ تغییر",
|
||||
"createdAt": "تاریخ ایجاد",
|
||||
"size": "اندازه",
|
||||
"favoriteDate": "تاریخ مورد علاقه"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "امروز",
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "हाल ही का साफ़ करें",
|
||||
"accessed": "एक्सेस किया",
|
||||
"empty_state": "कोई हाल की फ़ाइलें नहीं",
|
||||
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी"
|
||||
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी",
|
||||
"loadMore": "और लोड करें"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "फ़ाइल का नाम बदला गया",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "कोई नहीं",
|
||||
"title": "इसके अनुसार समूहीकृत करें",
|
||||
"owner": "स्वामी",
|
||||
"shareDate": "साझा तिथि"
|
||||
"shareDate": "साझा तिथि",
|
||||
"type": "प्रकार",
|
||||
"type.folders": "फ़ोल्डर",
|
||||
"accessedAt": "पहुँच की तारीख",
|
||||
"modifiedAt": "संशोधन की तारीख",
|
||||
"createdAt": "बनाने की तारीख",
|
||||
"size": "आकार",
|
||||
"favoriteDate": "पसंदीदा की तारीख"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "आज",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "Cancella recenti",
|
||||
"accessed": "Accesso",
|
||||
"empty_state": "Nessun file recente",
|
||||
"empty_hint": "I file che apri appariranno qui"
|
||||
"empty_hint": "I file che apri appariranno qui",
|
||||
"loadMore": "Carica altri"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "File rinominato",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "Nessuno",
|
||||
"title": "Raggruppa per",
|
||||
"owner": "Proprietario",
|
||||
"shareDate": "Data condivisione"
|
||||
"shareDate": "Data condivisione",
|
||||
"type": "Tipo",
|
||||
"type.folders": "Cartelle",
|
||||
"accessedAt": "Data di accesso",
|
||||
"modifiedAt": "Data di modifica",
|
||||
"createdAt": "Data di creazione",
|
||||
"size": "Dimensione",
|
||||
"favoriteDate": "Data preferito"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Oggi",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "最近をクリア",
|
||||
"accessed": "アクセス日",
|
||||
"empty_state": "最近のファイルはありません",
|
||||
"empty_hint": "開いたファイルがここに表示されます"
|
||||
"empty_hint": "開いたファイルがここに表示されます",
|
||||
"loadMore": "さらに読み込む"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "ファイル名を変更しました",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "なし",
|
||||
"title": "グループ化",
|
||||
"owner": "オーナー",
|
||||
"shareDate": "共有日"
|
||||
"shareDate": "共有日",
|
||||
"type": "種類",
|
||||
"type.folders": "フォルダー",
|
||||
"accessedAt": "アクセス日",
|
||||
"modifiedAt": "更新日",
|
||||
"createdAt": "作成日",
|
||||
"size": "サイズ",
|
||||
"favoriteDate": "お気に入り登録日"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今日",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "최근 항목 지우기",
|
||||
"accessed": "접근일",
|
||||
"empty_state": "최근 파일이 없습니다",
|
||||
"empty_hint": "열어본 파일이 여기에 표시됩니다"
|
||||
"empty_hint": "열어본 파일이 여기에 표시됩니다",
|
||||
"loadMore": "더 불러오기"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "파일 이름이 변경되었습니다",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "없음",
|
||||
"title": "그룹화 기준",
|
||||
"owner": "소유자",
|
||||
"shareDate": "공유 날짜"
|
||||
"shareDate": "공유 날짜",
|
||||
"type": "유형",
|
||||
"type.folders": "폴더",
|
||||
"accessedAt": "접근 날짜",
|
||||
"modifiedAt": "수정 날짜",
|
||||
"createdAt": "생성 날짜",
|
||||
"size": "크기",
|
||||
"favoriteDate": "즐겨찾기 날짜"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "오늘",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "Recente wissen",
|
||||
"accessed": "Geopend",
|
||||
"empty_state": "Geen recente bestanden",
|
||||
"empty_hint": "Bestanden die je opent verschijnen hier"
|
||||
"empty_hint": "Bestanden die je opent verschijnen hier",
|
||||
"loadMore": "Meer laden"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Bestand hernoemd",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "Geen",
|
||||
"title": "Groeperen op",
|
||||
"owner": "Eigenaar",
|
||||
"shareDate": "Deeldatum"
|
||||
"shareDate": "Deeldatum",
|
||||
"type": "Type",
|
||||
"type.folders": "Mappen",
|
||||
"accessedAt": "Toegangsdatum",
|
||||
"modifiedAt": "Wijzigingsdatum",
|
||||
"createdAt": "Aanmaakdatum",
|
||||
"size": "Grootte",
|
||||
"favoriteDate": "Favoritendatum"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Vandaag",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "Wyczyść ostatnie",
|
||||
"accessed": "Otwarte",
|
||||
"empty_state": "Brak ostatnich plików",
|
||||
"empty_hint": "Otwarte pliki pojawią się tutaj"
|
||||
"empty_hint": "Otwarte pliki pojawią się tutaj",
|
||||
"loadMore": "Załaduj więcej"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Zmieniono nazwę pliku",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "Brak",
|
||||
"title": "Grupuj według",
|
||||
"owner": "Właściciel",
|
||||
"shareDate": "Data udostępnienia"
|
||||
"shareDate": "Data udostępnienia",
|
||||
"type": "Typ",
|
||||
"type.folders": "Foldery",
|
||||
"accessedAt": "Data dostępu",
|
||||
"modifiedAt": "Data modyfikacji",
|
||||
"createdAt": "Data utworzenia",
|
||||
"size": "Rozmiar",
|
||||
"favoriteDate": "Data dodania do ulubionych"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Dzisiaj",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "Limpar recentes",
|
||||
"accessed": "Acessado",
|
||||
"empty_state": "Nenhum arquivo recente",
|
||||
"empty_hint": "Os arquivos que você abrir aparecerão aqui"
|
||||
"empty_hint": "Os arquivos que você abrir aparecerão aqui",
|
||||
"loadMore": "Carregar mais"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Arquivo renomeado",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "Nenhum",
|
||||
"title": "Agrupar por",
|
||||
"owner": "Proprietário",
|
||||
"shareDate": "Data de partilha"
|
||||
"shareDate": "Data de partilha",
|
||||
"type": "Tipo",
|
||||
"type.folders": "Pastas",
|
||||
"accessedAt": "Data de acesso",
|
||||
"modifiedAt": "Data de modificação",
|
||||
"createdAt": "Data de criação",
|
||||
"size": "Tamanho",
|
||||
"favoriteDate": "Data de favorito"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Hoje",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "Очистить недавние",
|
||||
"accessed": "Открыт",
|
||||
"empty_state": "Нет недавних файлов",
|
||||
"empty_hint": "Открытые вами файлы будут отображаться здесь"
|
||||
"empty_hint": "Открытые вами файлы будут отображаться здесь",
|
||||
"loadMore": "Загрузить ещё"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Файл переименован",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "Нет",
|
||||
"title": "Группировать по",
|
||||
"owner": "Владелец",
|
||||
"shareDate": "Дата общего доступа"
|
||||
"shareDate": "Дата общего доступа",
|
||||
"type": "Тип",
|
||||
"type.folders": "Папки",
|
||||
"accessedAt": "Дата доступа",
|
||||
"modifiedAt": "Дата изменения",
|
||||
"createdAt": "Дата создания",
|
||||
"size": "Размер",
|
||||
"favoriteDate": "Дата добавления в избранное"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Сегодня",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
"clear": "清除最近",
|
||||
"accessed": "訪問於",
|
||||
"empty_state": "沒有最近檔案",
|
||||
"empty_hint": "您開啟的檔案將顯示在這裡"
|
||||
"empty_hint": "您開啟的檔案將顯示在這裡",
|
||||
"loadMore": "載入更多"
|
||||
},
|
||||
"batch": {
|
||||
"one_selected": "已選擇 1 個專案",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "無",
|
||||
"title": "分組方式",
|
||||
"owner": "擁有者",
|
||||
"shareDate": "分享日期"
|
||||
"shareDate": "分享日期",
|
||||
"type": "類型",
|
||||
"type.folders": "資料夾",
|
||||
"accessedAt": "存取日期",
|
||||
"modifiedAt": "修改日期",
|
||||
"createdAt": "建立日期",
|
||||
"size": "大小",
|
||||
"favoriteDate": "收藏日期"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今天",
|
||||
|
||||
+10
-2
@@ -430,7 +430,8 @@
|
||||
"clear": "清除最近",
|
||||
"accessed": "访问于",
|
||||
"empty_state": "没有最近文件",
|
||||
"empty_hint": "您打开的文件将显示在这里"
|
||||
"empty_hint": "您打开的文件将显示在这里",
|
||||
"loadMore": "加载更多"
|
||||
},
|
||||
"batch": {
|
||||
"one_selected": "已选择 1 个项目",
|
||||
@@ -715,7 +716,14 @@
|
||||
"none": "无",
|
||||
"title": "分组方式",
|
||||
"owner": "所有者",
|
||||
"shareDate": "分享日期"
|
||||
"shareDate": "分享日期",
|
||||
"type": "类型",
|
||||
"type.folders": "文件夹",
|
||||
"accessedAt": "访问日期",
|
||||
"modifiedAt": "修改日期",
|
||||
"createdAt": "创建日期",
|
||||
"size": "大小",
|
||||
"favoriteDate": "收藏日期"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今天",
|
||||
|
||||
Reference in New Issue
Block a user