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: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cargo build --release
|
- 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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: oxicloud-release
|
name: oxicloud-release
|
||||||
|
|||||||
Generated
+1
@@ -3692,6 +3692,7 @@ dependencies = [
|
|||||||
"oxc_codegen",
|
"oxc_codegen",
|
||||||
"oxc_minifier",
|
"oxc_minifier",
|
||||||
"oxc_parser",
|
"oxc_parser",
|
||||||
|
"oxc_semantic",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quick-xml 0.39.2",
|
"quick-xml 0.39.2",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ path = "src/bin/generate-openapi.rs"
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
oxc_allocator = "0.125.0"
|
oxc_allocator = "0.125.0"
|
||||||
oxc_parser = "0.125.0"
|
oxc_parser = "0.125.0"
|
||||||
|
oxc_semantic = "0.125.0"
|
||||||
oxc_span = "0.125.0"
|
oxc_span = "0.125.0"
|
||||||
oxc_codegen = "0.125.0"
|
oxc_codegen = "0.125.0"
|
||||||
oxc_minifier = "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 index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html");
|
||||||
let module_scripts = extract_module_scripts(&index_html);
|
let module_scripts = extract_module_scripts(&index_html);
|
||||||
let js_raw = build_js_module_bundle(&module_scripts, static_dir);
|
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_bundle = js_minify_script_safe(&js_raw);
|
||||||
let js_hash = fnv_hash(js_bundle.as_bytes());
|
let js_hash = fnv_hash(js_bundle.as_bytes());
|
||||||
let js_name = format!("app.{js_hash}.js");
|
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.
|
/// 1. DFS from each entry point, following `import … from '…'` edges.
|
||||||
/// 2. Post-order traversal ensures every dependency is emitted before its importer.
|
/// 2. Post-order traversal ensures every dependency is emitted before its importer.
|
||||||
/// 3. Cycles are broken by marking files as visited before recursing.
|
/// 3. Cycles are broken by marking files as visited before recursing.
|
||||||
/// 4. Each file has its import/export syntax stripped before being appended.
|
/// 4. **Deconflict pass**: any top-level binding that is private (not exported)
|
||||||
/// 5. The result is wrapped in `(function(){"use strict"; …})();`.
|
/// 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 {
|
fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String {
|
||||||
use std::collections::HashSet;
|
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:",
|
"cargo:warning=bundle: {} files in dependency order:",
|
||||||
order.len()
|
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);
|
let mut bundle = String::with_capacity(2 * 1024 * 1024);
|
||||||
bundle.push_str("(function(){\n\"use strict\";\n");
|
bundle.push_str("(function(){\n\"use strict\";\n");
|
||||||
let mut declared_namespaces = std::collections::HashSet::new();
|
let mut declared_namespaces = HashSet::new();
|
||||||
for (i, file) in order.iter().enumerate() {
|
for (i, (file, src)) in order.iter().zip(sources.iter()).enumerate() {
|
||||||
println!(
|
println!(
|
||||||
"cargo:warning=bundle [{:>3}/{}] {}",
|
"cargo:warning=bundle [{:>3}/{}] {}",
|
||||||
i + 1,
|
i + 1,
|
||||||
order.len(),
|
order.len(),
|
||||||
file.display()
|
file.display()
|
||||||
);
|
);
|
||||||
match fs::read_to_string(file) {
|
bundle.push_str(&strip_esm_syntax(src, file, &mut declared_namespaces));
|
||||||
Ok(src) => {
|
|
||||||
bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces));
|
|
||||||
bundle.push('\n');
|
bundle.push('\n');
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bundle.push_str("})();\n");
|
bundle.push_str("})();\n");
|
||||||
bundle
|
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.
|
/// DFS post-order: push `file` to `order` after all its imports.
|
||||||
/// Marks files as seen before recursing to break circular dependencies.
|
/// Marks files as seen before recursing to break circular dependencies.
|
||||||
fn collect_module_deps(
|
fn collect_module_deps(
|
||||||
@@ -666,6 +851,27 @@ fn collect_import_aliases(stmt: &str, declared: &mut std::collections::HashSet<S
|
|||||||
// JS minification
|
// 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.
|
/// Minify an ES-module file (contains import/export) — returns original on failure.
|
||||||
fn js_minify_safe(source: &str) -> String {
|
fn js_minify_safe(source: &str) -> String {
|
||||||
js_minify_inner(source, true)
|
js_minify_inner(source, true)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||||
use super::display_helpers::{
|
use super::display_helpers::{
|
||||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||||
};
|
};
|
||||||
|
use super::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// DTO for favorites item, enriched with item metadata via SQL JOIN
|
/// DTO for favorites item, enriched with item metadata via SQL JOIN
|
||||||
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
||||||
@@ -104,6 +108,120 @@ pub struct BatchFavoritesResult {
|
|||||||
pub favorites: Vec<FavoriteItemDto>,
|
pub favorites: Vec<FavoriteItemDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Cursor-paginated favorites resources (GET /api/favorites/resources)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Raw row returned by the UNION ALL query that joins `auth.user_favorites`
|
||||||
|
/// with `storage.files` / `storage.folders`. Never serialised directly.
|
||||||
|
pub struct FavoriteResourceRow {
|
||||||
|
pub resource_type: String, // "file" | "folder"
|
||||||
|
pub resource_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
/// `-1` for folders, actual byte-count for files.
|
||||||
|
pub size: i64,
|
||||||
|
pub resource_created_at: DateTime<Utc>,
|
||||||
|
pub modified_at: DateTime<Utc>,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
/// `true` when `owner_id == requesting user_id`.
|
||||||
|
pub is_owner: bool,
|
||||||
|
pub favorited_at: DateTime<Utc>,
|
||||||
|
/// Human-readable path (e.g. `Documents/Work` for a folder,
|
||||||
|
/// `Documents/Work/report.pdf` for a file). Always populated; the
|
||||||
|
/// handler clears it to `""` when `is_owner` is false.
|
||||||
|
pub path: Option<String>,
|
||||||
|
// Pre-computed sort fields for cursor construction.
|
||||||
|
pub sort_str: Option<String>,
|
||||||
|
pub sort_int: Option<i64>,
|
||||||
|
pub sort_ts: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque keyset-pagination cursor for `GET /api/favorites/resources`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FavoritesCursor {
|
||||||
|
/// Sort dimension active when this cursor was produced.
|
||||||
|
/// Values: `"name"` (default), `"type"`, `"favorited_at"`, `"modified_at"`,
|
||||||
|
/// `"size"`, `"owner"`.
|
||||||
|
#[serde(default = "FavoritesCursor::default_order")]
|
||||||
|
pub order_by: String,
|
||||||
|
/// UUID of the last item on the previous page (tie-breaker).
|
||||||
|
pub resource_id: Uuid,
|
||||||
|
/// `LOWER(name)` for `name`/`type` sorts; `LOWER(username)` for `owner`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sort_str: Option<String>,
|
||||||
|
/// Multipurpose integer: `folder_first` for `name`, `type_order` for `type`,
|
||||||
|
/// size in bytes for `size`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sort_int: Option<i64>,
|
||||||
|
/// Timestamp for `favorited_at` and `modified_at` sorts.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sort_ts: Option<DateTime<Utc>>,
|
||||||
|
/// Whether the result set was reversed — must match on every page.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reverse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FavoritesCursor {
|
||||||
|
fn default_order() -> String {
|
||||||
|
"name".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageCursor for FavoritesCursor {}
|
||||||
|
|
||||||
|
/// Query parameters for `GET /api/favorites/resources`.
|
||||||
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
|
pub struct FavoritesResourcesQuery {
|
||||||
|
/// Maximum items per page (1–200, default 50).
|
||||||
|
#[serde(default = "CursorQuery::default_limit")]
|
||||||
|
pub limit: u32,
|
||||||
|
/// Opaque cursor from a previous response. Omit to start from the first page.
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
/// Sort / group-by dimension. Supported: `"name"` (default), `"type"`,
|
||||||
|
/// `"favorited_at"`, `"modified_at"`, `"size"`, `"owner"`.
|
||||||
|
pub order_by: Option<String>,
|
||||||
|
/// Comma-separated resource types to include, e.g. `"file,folder"`.
|
||||||
|
/// Omit to include both.
|
||||||
|
pub resource_types: Option<String>,
|
||||||
|
/// Reverse the sort order. Default `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reverse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FavoritesResourcesQuery {
|
||||||
|
pub fn limit_clamped(&self) -> usize {
|
||||||
|
self.limit.clamp(1, 200) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_cursor(&self) -> Option<FavoritesCursor> {
|
||||||
|
self.cursor.as_deref().and_then(FavoritesCursor::decode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `None` when `resource_types` is absent (= include all).
|
||||||
|
pub fn resource_kinds(&self) -> Option<Vec<ResourceKind>> {
|
||||||
|
self.resource_types.as_deref().map(|s| {
|
||||||
|
s.split(',')
|
||||||
|
.filter_map(|t| ResourceKind::parse(t.trim()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One item in a `GET /api/favorites/resources` page.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct FavoritesResourceItemDto {
|
||||||
|
pub resource_type: ResourceTypeDto,
|
||||||
|
/// When the resource was added to the user's favorites.
|
||||||
|
pub favorited_at: DateTime<Utc>,
|
||||||
|
/// Full resource details — shape determined by `resource_type`.
|
||||||
|
pub resource: ResourceContentDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response envelope for `GET /api/favorites/resources`.
|
||||||
|
pub type FavoritesResourcesDto = CursorListResponse<FavoritesResourceItemDto>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct BatchFavoritesStats {
|
pub struct BatchFavoritesStats {
|
||||||
/// How many items were requested
|
/// How many items were requested
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
use std::sync::Arc;
|
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::entities::folder::Folder;
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// DTO for folder creation requests
|
/// DTO for folder creation requests
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
@@ -145,3 +150,138 @@ impl Default for FolderDto {
|
|||||||
Self::empty()
|
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`.
|
/// Comma-separated resource types to include, e.g. `file,folder`.
|
||||||
/// Omit to return all known types.
|
/// Omit to return all known types.
|
||||||
pub resource_types: Option<String>,
|
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 {
|
impl SharedWithMeQuery {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||||
use super::display_helpers::{
|
use super::display_helpers::{
|
||||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||||
};
|
};
|
||||||
|
use super::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// DTO for recent items, enriched with item metadata via SQL JOIN
|
/// DTO for recent items, enriched with item metadata via SQL JOIN
|
||||||
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
||||||
@@ -84,3 +88,109 @@ impl RecentItemDto {
|
|||||||
self
|
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,
|
limit: u32,
|
||||||
cursor: Option<GrantCursor>,
|
cursor: Option<GrantCursor>,
|
||||||
sort_by: &str,
|
sort_by: &str,
|
||||||
|
reverse: bool,
|
||||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
||||||
|
|
||||||
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
|
use crate::application::dtos::favorites_dto::{
|
||||||
|
BatchFavoritesResult, FavoriteItemDto, FavoriteResourceRow, FavoritesCursor,
|
||||||
|
};
|
||||||
use crate::common::errors::Result;
|
use crate::common::errors::Result;
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// Defines operations for managing user favorites
|
/// Defines operations for managing user favorites
|
||||||
pub trait FavoritesUseCase: Send + Sync {
|
pub trait FavoritesUseCase: Send + Sync {
|
||||||
@@ -74,4 +77,17 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
|
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
|
||||||
) -> Result<HashSet<String>>;
|
) -> Result<HashSet<String>>;
|
||||||
|
|
||||||
|
/// Cursor-paginated list of a user's favorited resources.
|
||||||
|
/// Items that no longer exist (deleted/trashed) are silently excluded.
|
||||||
|
/// `kinds = None` → both files and folders.
|
||||||
|
async fn list_resources_paged(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&FavoritesCursor>,
|
||||||
|
order_by: &str,
|
||||||
|
kinds: Option<&[ResourceKind]>,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<Vec<FavoriteResourceRow>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,4 +51,15 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static {
|
|||||||
|
|
||||||
/// Removes items exceeding `max_items` (the oldest ones).
|
/// Removes items exceeding `max_items` (the oldest ones).
|
||||||
async fn prune(&self, user_id: Uuid, max_items: i32) -> Result<()>;
|
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 tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::cursor::PageCursor;
|
||||||
use crate::application::dtos::favorites_dto::{
|
use crate::application::dtos::favorites_dto::{
|
||||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow,
|
||||||
|
FavoritesCursor,
|
||||||
};
|
};
|
||||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||||
|
|
||||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||||
@@ -155,3 +158,105 @@ impl FavoritesUseCase for FavoritesService {
|
|||||||
self.repo.batch_check_favorites(user_id, item_ids).await
|
self.repo.batch_check_favorites(user_id, item_ids).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FavoritesService {
|
||||||
|
/// Cursor-paginated list of the user's favorited resources.
|
||||||
|
///
|
||||||
|
/// No authz needed — favorites are strictly user-scoped; the repository
|
||||||
|
/// enforces `WHERE user_id = $1` so users can only see their own entries.
|
||||||
|
///
|
||||||
|
/// Returns `(rows, next_cursor_encoded)`.
|
||||||
|
pub async fn list_resources_paged(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<FavoritesCursor>,
|
||||||
|
order_by: &str,
|
||||||
|
kinds: Option<&[ResourceKind]>,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<(Vec<FavoriteResourceRow>, Option<String>)> {
|
||||||
|
// Fetch one extra row to detect whether a next page exists.
|
||||||
|
let mut rows = self
|
||||||
|
.repo
|
||||||
|
.list_resources_paged(
|
||||||
|
user_id,
|
||||||
|
limit + 1,
|
||||||
|
cursor.as_ref(),
|
||||||
|
order_by,
|
||||||
|
kinds,
|
||||||
|
reverse,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let next_cursor = if rows.len() > limit {
|
||||||
|
let last = &rows[limit - 1];
|
||||||
|
let c = build_favorites_cursor(last, order_by, reverse);
|
||||||
|
rows.truncate(limit);
|
||||||
|
Some(c.encode())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((rows, next_cursor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the next-page cursor from the last row of the current page.
|
||||||
|
/// `reverse` is stored in the cursor so subsequent pages use the same direction.
|
||||||
|
fn build_favorites_cursor(
|
||||||
|
row: &FavoriteResourceRow,
|
||||||
|
order_by: &str,
|
||||||
|
reverse: bool,
|
||||||
|
) -> FavoritesCursor {
|
||||||
|
match order_by {
|
||||||
|
"type" => FavoritesCursor {
|
||||||
|
order_by: "type".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: row.sort_str.clone(), // LOWER(name)
|
||||||
|
sort_int: row.sort_int, // type_order
|
||||||
|
sort_ts: None,
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"favorited_at" => FavoritesCursor {
|
||||||
|
order_by: "favorited_at".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: None,
|
||||||
|
sort_int: None,
|
||||||
|
sort_ts: row.sort_ts, // favorited_at timestamp
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"modified_at" => FavoritesCursor {
|
||||||
|
order_by: "modified_at".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: None,
|
||||||
|
sort_int: None,
|
||||||
|
sort_ts: row.sort_ts, // modified_at timestamp
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"size" => FavoritesCursor {
|
||||||
|
order_by: "size".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: None,
|
||||||
|
sort_int: row.sort_int, // file size in bytes
|
||||||
|
sort_ts: None,
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
"owner" => FavoritesCursor {
|
||||||
|
order_by: "owner".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: row.sort_str.clone(), // LOWER(username)
|
||||||
|
sort_int: None,
|
||||||
|
sort_ts: row.sort_ts, // favorited_at (secondary sort)
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
_ => FavoritesCursor {
|
||||||
|
// "name" (default): sort_str = LOWER(name), sort_int = folder_first (0 = folder, 1 = file)
|
||||||
|
order_by: "name".to_owned(),
|
||||||
|
resource_id: row.resource_id,
|
||||||
|
sort_str: row.sort_str.clone(),
|
||||||
|
sort_int: row.sort_int, // folder_first
|
||||||
|
sort_ts: None,
|
||||||
|
reverse,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
use crate::application::dtos::cursor::PageCursor;
|
||||||
use crate::application::dtos::folder_dto::{
|
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::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
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::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
|
||||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
|
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -109,3 +111,99 @@ impl RecentItemsUseCase for RecentService {
|
|||||||
Ok(())
|
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)
|
/// - `"size"` — file size in bytes (-1 = Folder sentinel)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub sort_int: Option<i64>,
|
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 {
|
impl GrantCursor {
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ use std::sync::Arc;
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::dtos::favorites_dto::FavoriteItemDto;
|
use crate::application::dtos::favorites_dto::{
|
||||||
|
FavoriteItemDto, FavoriteResourceRow, FavoritesCursor,
|
||||||
|
};
|
||||||
use crate::application::ports::favorites_ports::FavoritesRepositoryPort;
|
use crate::application::ports::favorites_ports::FavoritesRepositoryPort;
|
||||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// PostgreSQL implementation of the favorites persistence port.
|
/// PostgreSQL implementation of the favorites persistence port.
|
||||||
pub struct FavoritesPgRepository {
|
pub struct FavoritesPgRepository {
|
||||||
@@ -279,4 +282,308 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
|
|
||||||
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
|
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_resources_paged(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&FavoritesCursor>,
|
||||||
|
order_by: &str,
|
||||||
|
kinds: Option<&[ResourceKind]>,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<Vec<FavoriteResourceRow>> {
|
||||||
|
let include_folders =
|
||||||
|
kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder)));
|
||||||
|
let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File)));
|
||||||
|
|
||||||
|
// ── Build the UNION ALL CTE ─────────────────────────────────────────
|
||||||
|
let mut cte_branches: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
let folder_branch = r#"
|
||||||
|
SELECT
|
||||||
|
'folder'::text AS resource_type,
|
||||||
|
fld.id AS resource_id,
|
||||||
|
fld.name,
|
||||||
|
fld.parent_id,
|
||||||
|
NULL::text AS mime_type,
|
||||||
|
-1::bigint AS size,
|
||||||
|
fld.created_at AS resource_created_at,
|
||||||
|
fld.updated_at AS modified_at,
|
||||||
|
fld.user_id AS owner_id,
|
||||||
|
(fld.user_id = $1::uuid) AS is_owner,
|
||||||
|
uf.created_at AS favorited_at,
|
||||||
|
fld.path::text AS resource_path,
|
||||||
|
LOWER(fld.name) AS sort_str,
|
||||||
|
0::bigint AS type_order,
|
||||||
|
0::int AS folder_first
|
||||||
|
FROM auth.user_favorites uf
|
||||||
|
INNER JOIN storage.folders fld
|
||||||
|
ON fld.id = uf.item_id::UUID AND NOT fld.is_trashed
|
||||||
|
WHERE uf.user_id = $1::uuid AND uf.item_type = 'folder'"#;
|
||||||
|
|
||||||
|
let file_branch = r#"
|
||||||
|
SELECT
|
||||||
|
'file'::text AS resource_type,
|
||||||
|
f.id AS resource_id,
|
||||||
|
f.name,
|
||||||
|
f.folder_id AS parent_id,
|
||||||
|
f.mime_type,
|
||||||
|
f.size::bigint,
|
||||||
|
f.created_at AS resource_created_at,
|
||||||
|
f.updated_at AS modified_at,
|
||||||
|
f.user_id AS owner_id,
|
||||||
|
(f.user_id = $1::uuid) AS is_owner,
|
||||||
|
uf.created_at AS favorited_at,
|
||||||
|
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
|
||||||
|
LOWER(f.name) AS sort_str,
|
||||||
|
f.category_order::bigint AS type_order,
|
||||||
|
1::int AS folder_first
|
||||||
|
FROM auth.user_favorites uf
|
||||||
|
INNER JOIN storage.files f
|
||||||
|
ON f.id = uf.item_id::UUID AND NOT f.is_trashed
|
||||||
|
LEFT JOIN storage.folders pfld
|
||||||
|
ON pfld.id = f.folder_id
|
||||||
|
WHERE uf.user_id = $1::uuid AND uf.item_type = 'file'"#;
|
||||||
|
|
||||||
|
if include_folders {
|
||||||
|
cte_branches.push(folder_branch);
|
||||||
|
}
|
||||||
|
if include_files {
|
||||||
|
cte_branches.push(file_branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cte_branches.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let union_sql = cte_branches.join("\n UNION ALL\n");
|
||||||
|
let cte = format!("WITH resources AS ({union_sql}\n)");
|
||||||
|
|
||||||
|
// ── Cursor values ───────────────────────────────────────────────────
|
||||||
|
let cur_str: Option<&str> = cursor.and_then(|c| c.sort_str.as_deref());
|
||||||
|
let cur_int: Option<i64> = cursor.and_then(|c| c.sort_int);
|
||||||
|
let cur_ts: Option<chrono::DateTime<chrono::Utc>> = cursor.and_then(|c| c.sort_ts);
|
||||||
|
let cur_id: Option<Uuid> = cursor.map(|c| c.resource_id);
|
||||||
|
|
||||||
|
// ── Per-dimension keyset WHERE + ORDER BY ───────────────────────────
|
||||||
|
// Binds: $1=user_id (in CTE), $2=cur_str, $3=cur_int, $4=cur_ts,
|
||||||
|
// $5=cur_id, $6=limit (for "owner" sort: JOIN uses no extra binds)
|
||||||
|
let (keyset, order_by_clause, need_user_join) = match (order_by, reverse) {
|
||||||
|
// ── name ────────────────────────────────────────────────────────
|
||||||
|
("name", false) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (folder_first::bigint > $3)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str > $2)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY folder_first ASC, sort_str ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("name", true) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (folder_first::bigint > $3)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str < $2)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY folder_first ASC, sort_str DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── type ────────────────────────────────────────────────────────
|
||||||
|
("type", false) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (type_order > $3)
|
||||||
|
OR (type_order = $3 AND sort_str > $2)
|
||||||
|
OR (type_order = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY type_order ASC, sort_str ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("type", true) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (type_order < $3)
|
||||||
|
OR (type_order = $3 AND sort_str < $2)
|
||||||
|
OR (type_order = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY type_order DESC, sort_str DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── favorited_at ─────────────────────────────────────────────────
|
||||||
|
("favorited_at", false) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (favorited_at < $4)
|
||||||
|
OR (favorited_at = $4 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY favorited_at DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("favorited_at", true) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (favorited_at > $4)
|
||||||
|
OR (favorited_at = $4 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY favorited_at ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── modified_at ──────────────────────────────────────────────────
|
||||||
|
("modified_at", false) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (modified_at < $4)
|
||||||
|
OR (modified_at = $4 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY modified_at DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("modified_at", true) => (
|
||||||
|
"WHERE ($4::timestamptz IS NULL)
|
||||||
|
OR (modified_at > $4)
|
||||||
|
OR (modified_at = $4 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY modified_at ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── size ─────────────────────────────────────────────────────────
|
||||||
|
("size", false) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (size > $3)
|
||||||
|
OR (size = $3 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY size ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("size", true) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (size < $3)
|
||||||
|
OR (size = $3 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY size DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// ── owner ────────────────────────────────────────────────────────
|
||||||
|
("owner", false) => (
|
||||||
|
"WHERE ($2::text IS NULL)
|
||||||
|
OR (LOWER(u.username) > $2)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at < $4)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY LOWER(u.username) ASC, favorited_at DESC, resource_id DESC",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("owner", true) => (
|
||||||
|
"WHERE ($2::text IS NULL)
|
||||||
|
OR (LOWER(u.username) < $2)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at > $4)
|
||||||
|
OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY LOWER(u.username) DESC, favorited_at ASC, resource_id ASC",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
// ── default: same as name, ascending ─────────────────────────────
|
||||||
|
(_, false) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (folder_first::bigint > $3)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str > $2)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
|
||||||
|
"ORDER BY folder_first ASC, sort_str ASC, resource_id ASC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(_, true) => (
|
||||||
|
"WHERE ($3::bigint IS NULL)
|
||||||
|
OR (folder_first::bigint > $3)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str < $2)
|
||||||
|
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
|
||||||
|
"ORDER BY folder_first ASC, sort_str DESC, resource_id DESC",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_join = if need_user_join {
|
||||||
|
"LEFT JOIN auth.users u ON u.id = r.owner_id"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
// For "owner" sort the JOIN makes LOWER(u.username) available; add it to SELECT
|
||||||
|
// so the cursor can carry the correct sort key.
|
||||||
|
let username_col = if need_user_join {
|
||||||
|
",\n LOWER(u.username) AS username_lower"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"{cte}
|
||||||
|
SELECT
|
||||||
|
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||||
|
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||||
|
r.owner_id, r.is_owner, r.favorited_at, r.resource_path,
|
||||||
|
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||||
|
FROM resources r
|
||||||
|
{user_join}
|
||||||
|
{keyset}
|
||||||
|
{order_by_clause}
|
||||||
|
LIMIT $6"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.bind(user_id) // $1 (in CTE + outer)
|
||||||
|
.bind(cur_str) // $2
|
||||||
|
.bind(cur_int) // $3
|
||||||
|
.bind(cur_ts) // $4
|
||||||
|
.bind(cur_id) // $5
|
||||||
|
.bind(limit as i64) // $6
|
||||||
|
.fetch_all(&*self.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Database error listing favorite resources: {e}");
|
||||||
|
DomainError::new(
|
||||||
|
ErrorKind::InternalError,
|
||||||
|
"Favorites",
|
||||||
|
format!("Failed to list favorite resources: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let resource_type: String = row.get("resource_type");
|
||||||
|
let sort_str_val: Option<String> = row.try_get("sort_str").ok();
|
||||||
|
let type_order: i64 = row.try_get("type_order").unwrap_or(0);
|
||||||
|
let folder_first: i32 = row.try_get("folder_first").unwrap_or(0);
|
||||||
|
let size: i64 = row.get("size");
|
||||||
|
|
||||||
|
// Pre-compute the cursor sort fields based on order_by
|
||||||
|
let (c_sort_str, c_sort_int, c_sort_ts) = match order_by {
|
||||||
|
"name" => (sort_str_val, Some(folder_first as i64), None),
|
||||||
|
"type" => (sort_str_val, Some(type_order), None),
|
||||||
|
"size" => (None, Some(size), None),
|
||||||
|
"favorited_at" => {
|
||||||
|
let ts: Option<chrono::DateTime<chrono::Utc>> =
|
||||||
|
row.try_get("favorited_at").ok();
|
||||||
|
(None, None, ts)
|
||||||
|
}
|
||||||
|
"modified_at" => {
|
||||||
|
let ts: Option<chrono::DateTime<chrono::Utc>> =
|
||||||
|
row.try_get("modified_at").ok();
|
||||||
|
(None, None, ts)
|
||||||
|
}
|
||||||
|
"owner" => {
|
||||||
|
// For "owner" sort the JOIN added LOWER(u.username) AS username_lower.
|
||||||
|
// The cursor's sort_str must carry the username (not the file name).
|
||||||
|
let username: Option<String> = row.try_get("username_lower").ok();
|
||||||
|
let ts: Option<chrono::DateTime<chrono::Utc>> =
|
||||||
|
row.try_get("favorited_at").ok();
|
||||||
|
(username, None, ts)
|
||||||
|
}
|
||||||
|
_ => (sort_str_val, Some(folder_first as i64), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
FavoriteResourceRow {
|
||||||
|
resource_type,
|
||||||
|
resource_id: row.get("resource_id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
parent_id: row.try_get("parent_id").ok(),
|
||||||
|
mime_type: row.try_get("mime_type").ok(),
|
||||||
|
size,
|
||||||
|
resource_created_at: row.get("resource_created_at"),
|
||||||
|
modified_at: row.get("modified_at"),
|
||||||
|
owner_id: row.get("owner_id"),
|
||||||
|
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||||
|
favorited_at: row.get("favorited_at"),
|
||||||
|
path: row.try_get("resource_path").ok(),
|
||||||
|
sort_str: c_sort_str,
|
||||||
|
sort_int: c_sort_int,
|
||||||
|
sort_ts: c_sort_ts,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ use sqlx::PgPool;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::folder_dto::{FolderResourceCursor, FolderResourceRow};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::folder::Folder;
|
use crate::domain::entities::folder::Folder;
|
||||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
use crate::domain::services::path_service::StoragePath;
|
use crate::domain::services::path_service::StoragePath;
|
||||||
|
|
||||||
/// Type alias for folder metadata rows from SQL queries.
|
/// Type alias for folder metadata rows from SQL queries.
|
||||||
@@ -1060,4 +1062,238 @@ impl FolderDbRepository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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 tracing::error;
|
||||||
use uuid::Uuid;
|
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::application::ports::recent_ports::RecentItemsRepositoryPort;
|
||||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
|
|
||||||
/// PostgreSQL implementation of the recent items persistence port.
|
/// PostgreSQL implementation of the recent items persistence port.
|
||||||
pub struct RecentItemsPgRepository {
|
pub struct RecentItemsPgRepository {
|
||||||
@@ -188,4 +189,310 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
|||||||
|
|
||||||
Ok(())
|
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,
|
limit: u32,
|
||||||
cursor: Option<GrantCursor>,
|
cursor: Option<GrantCursor>,
|
||||||
sort_by: &str,
|
sort_by: &str,
|
||||||
|
reverse: bool,
|
||||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
|
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
|
||||||
// ── Common setup ──────────────────────────────────────────────────────
|
// ── Common setup ──────────────────────────────────────────────────────
|
||||||
let kind_strs: Option<Vec<&str>> = if kinds.is_empty() {
|
let kind_strs: Option<Vec<&str>> = if kinds.is_empty() {
|
||||||
@@ -358,6 +359,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
// ── Build sort-specific SQL fragments ─────────────────────────────────
|
// ── Build sort-specific SQL fragments ─────────────────────────────────
|
||||||
// "name" and "type" share the same LEFT JOINs; only sort_int_expr,
|
// "name" and "type" share the same LEFT JOINs; only sort_int_expr,
|
||||||
// the cursor WHERE condition, and ORDER BY differ.
|
// the cursor WHERE condition, and ORDER BY differ.
|
||||||
|
// Each branch emits two variants selected by `reverse`.
|
||||||
let sql = match sort_by {
|
let sql = match sort_by {
|
||||||
"name" | "type" => {
|
"name" | "type" => {
|
||||||
let sort_int_expr = if sort_by == "type" {
|
let sort_int_expr = if sort_by == "type" {
|
||||||
@@ -365,20 +367,39 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
} else {
|
} else {
|
||||||
"NULL::bigint"
|
"NULL::bigint"
|
||||||
};
|
};
|
||||||
let where_clause = if sort_by == "type" {
|
// 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
|
r#"( $5::integer IS NULL
|
||||||
OR sort_int > $5
|
OR sort_int > $5
|
||||||
OR (sort_int = $5 AND LOWER(sort_str) > $4)
|
OR (sort_int = $5 AND LOWER(sort_str) > $4)
|
||||||
OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#
|
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 {
|
} else {
|
||||||
|
(
|
||||||
r#"( $4::text IS NULL
|
r#"( $4::text IS NULL
|
||||||
OR LOWER(sort_str) > $4
|
OR LOWER(sort_str) > $4
|
||||||
OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#
|
OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#,
|
||||||
};
|
"LOWER(sort_str) ASC, resource_id ASC",
|
||||||
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"
|
|
||||||
};
|
};
|
||||||
format!(
|
format!(
|
||||||
r#"WITH {AGG},
|
r#"WITH {AGG},
|
||||||
@@ -400,9 +421,31 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
LIMIT $8"#
|
LIMIT $8"#
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"granted_by" => format!(
|
"granted_by" => {
|
||||||
// Joins auth.users to sort alphabetically by username.
|
// Joins auth.users to sort alphabetically by username.
|
||||||
// Cursor encodes (owner_name=$4, granted_at=$6, resource_id=$7).
|
// Cursor encodes (owner_name=$4, granted_at=$6, resource_id=$7).
|
||||||
|
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},
|
r#"WITH {AGG},
|
||||||
owner_named AS (
|
owner_named AS (
|
||||||
SELECT agg.*,
|
SELECT agg.*,
|
||||||
@@ -413,19 +456,30 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
)
|
)
|
||||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||||
FROM owner_named
|
FROM owner_named
|
||||||
WHERE ( $4::text IS NULL
|
WHERE {where_clause}
|
||||||
OR sort_str > $4
|
ORDER BY {order_clause}
|
||||||
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"#
|
LIMIT $8"#
|
||||||
),
|
)
|
||||||
"size" => format!(
|
}
|
||||||
// Folders have no size — they sort first with a sentinel of -1.
|
"size" => {
|
||||||
// Files sort by size ASC; resource_id breaks ties.
|
// Folders have no size — sentinel -1 (sorts first ASC, last DESC).
|
||||||
// Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused.
|
// Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused.
|
||||||
|
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},
|
r#"WITH {AGG},
|
||||||
sized AS (
|
sized AS (
|
||||||
SELECT agg.*,
|
SELECT agg.*,
|
||||||
@@ -438,26 +492,41 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
)
|
)
|
||||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||||
FROM sized
|
FROM sized
|
||||||
WHERE ( $5::bigint IS NULL
|
WHERE {where_clause}
|
||||||
OR sort_int > $5
|
ORDER BY {order_clause}
|
||||||
OR (sort_int = $5 AND resource_id > $7::uuid))
|
|
||||||
ORDER BY sort_int ASC, resource_id ASC
|
|
||||||
LIMIT $8"#
|
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.
|
// Cursor encodes (granted_at=$6, resource_id=$7); $4/$5 unused.
|
||||||
|
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}
|
r#"WITH {AGG}
|
||||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
|
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
|
||||||
NULL::text AS sort_str,
|
NULL::text AS sort_str,
|
||||||
NULL::bigint AS sort_int
|
NULL::bigint AS sort_int
|
||||||
FROM agg
|
FROM agg
|
||||||
WHERE ( $6::timestamptz IS NULL
|
WHERE {where_clause}
|
||||||
OR granted_at < $6
|
ORDER BY {order_clause}
|
||||||
OR (granted_at = $6 AND resource_id < $7::uuid))
|
|
||||||
ORDER BY granted_at DESC, resource_id DESC
|
|
||||||
LIMIT $8"#
|
LIMIT $8"#
|
||||||
),
|
)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Execute — uniform 8 binds for every sort mode ─────────────────────
|
// ── Execute — uniform 8 binds for every sort mode ─────────────────────
|
||||||
@@ -493,6 +562,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
resource_id: r.1,
|
resource_id: r.1,
|
||||||
resource_name: sort_str_lc,
|
resource_name: sort_str_lc,
|
||||||
sort_int: None,
|
sort_int: None,
|
||||||
|
reverse,
|
||||||
},
|
},
|
||||||
"type" => GrantCursor {
|
"type" => GrantCursor {
|
||||||
sort_by: "type".to_owned(),
|
sort_by: "type".to_owned(),
|
||||||
@@ -500,6 +570,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
resource_id: r.1,
|
resource_id: r.1,
|
||||||
resource_name: sort_str_lc,
|
resource_name: sort_str_lc,
|
||||||
sort_int: r.6,
|
sort_int: r.6,
|
||||||
|
reverse,
|
||||||
},
|
},
|
||||||
"granted_by" => GrantCursor {
|
"granted_by" => GrantCursor {
|
||||||
sort_by: "granted_by".to_owned(),
|
sort_by: "granted_by".to_owned(),
|
||||||
@@ -507,6 +578,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
resource_id: r.1,
|
resource_id: r.1,
|
||||||
resource_name: r.5.clone(), // already lowercased by SQL
|
resource_name: r.5.clone(), // already lowercased by SQL
|
||||||
sort_int: None,
|
sort_int: None,
|
||||||
|
reverse,
|
||||||
},
|
},
|
||||||
"size" => GrantCursor {
|
"size" => GrantCursor {
|
||||||
sort_by: "size".to_owned(),
|
sort_by: "size".to_owned(),
|
||||||
@@ -514,6 +586,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
resource_id: r.1,
|
resource_id: r.1,
|
||||||
resource_name: None,
|
resource_name: None,
|
||||||
sort_int: r.6,
|
sort_int: r.6,
|
||||||
|
reverse,
|
||||||
},
|
},
|
||||||
_ => GrantCursor {
|
_ => GrantCursor {
|
||||||
sort_by: "granted_at".to_owned(),
|
sort_by: "granted_at".to_owned(),
|
||||||
@@ -521,6 +594,7 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
resource_id: r.1,
|
resource_id: r.1,
|
||||||
resource_name: None,
|
resource_name: None,
|
||||||
sort_int: None,
|
sort_int: None,
|
||||||
|
reverse,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
@@ -9,8 +9,18 @@ use std::sync::Arc;
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::application::dtos::display_helpers::{
|
||||||
|
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||||
|
};
|
||||||
|
use crate::application::dtos::favorites_dto::{
|
||||||
|
FavoritesResourceItemDto, FavoritesResourcesDto, FavoritesResourcesQuery,
|
||||||
|
};
|
||||||
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
|
use crate::application::dtos::folder_dto::FolderDto;
|
||||||
|
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||||
use crate::application::services::favorites_service::FavoritesService;
|
use crate::application::services::favorites_service::FavoritesService;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
/// Single item in a batch-add-favorites request.
|
/// Single item in a batch-add-favorites request.
|
||||||
@@ -27,11 +37,16 @@ pub struct BatchFavoritesRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for favorite-related API endpoints
|
/// Handler for favorite-related API endpoints
|
||||||
|
///
|
||||||
|
/// # Deprecated
|
||||||
|
/// Use `GET /api/favorites/resources` instead. This endpoint is kept for
|
||||||
|
/// backwards compatibility but will be removed in a future release.
|
||||||
|
#[deprecated = "Use GET /api/favorites/resources instead"]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/favorites",
|
path = "/api/favorites",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "List of favorites", body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
|
(status = 200, description = "List of favorites (deprecated — use /api/favorites/resources)", body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
|
||||||
),
|
),
|
||||||
security(("bearerAuth" = [])),
|
security(("bearerAuth" = [])),
|
||||||
tag = "favorites"
|
tag = "favorites"
|
||||||
@@ -178,6 +193,125 @@ pub async fn remove_favorite(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cursor-paginated list of a user's favorited resources.
|
||||||
|
///
|
||||||
|
/// Supports sorting by `name`, `type`, `favorited_at`, `modified_at`, `size`, or `owner`.
|
||||||
|
/// Items that have been deleted/trashed are silently excluded.
|
||||||
|
/// `path` is cleared when the resource is not owned by the requesting user.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/favorites/resources",
|
||||||
|
params(FavoritesResourcesQuery),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Paginated list of favorited resources",
|
||||||
|
body = crate::application::dtos::favorites_dto::FavoritesResourcesDto),
|
||||||
|
(status = 400, description = "Invalid cursor or query parameters"),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "favorites"
|
||||||
|
)]
|
||||||
|
pub async fn list_favorites_resources(
|
||||||
|
State(favorites_service): State<Arc<FavoritesService>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Query(q): Query<FavoritesResourcesQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_id = auth_user.id;
|
||||||
|
|
||||||
|
let order_by = q.order_by.as_deref().unwrap_or("name").to_owned();
|
||||||
|
|
||||||
|
// If a cursor exists, validate that it matches the requested sort/direction.
|
||||||
|
let cursor = q
|
||||||
|
.decode_cursor()
|
||||||
|
.filter(|c| c.order_by == order_by && c.reverse == q.reverse);
|
||||||
|
|
||||||
|
let kinds = q.resource_kinds();
|
||||||
|
|
||||||
|
match favorites_service
|
||||||
|
.list_resources_paged(
|
||||||
|
user_id,
|
||||||
|
q.limit_clamped(),
|
||||||
|
cursor,
|
||||||
|
&order_by,
|
||||||
|
kinds.as_deref(),
|
||||||
|
q.reverse,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((rows, next_cursor)) => {
|
||||||
|
let items: Vec<FavoritesResourceItemDto> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
// Path is only shown to the owner; non-owners see ""
|
||||||
|
// to avoid leaking another user's folder hierarchy.
|
||||||
|
let path = if row.is_owner {
|
||||||
|
row.path.clone().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
if row.resource_type == "folder" {
|
||||||
|
let dto = FolderDto {
|
||||||
|
id: row.resource_id.to_string(),
|
||||||
|
name: row.name.clone(),
|
||||||
|
path,
|
||||||
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
|
created_at: row.resource_created_at.timestamp() as u64,
|
||||||
|
modified_at: row.modified_at.timestamp() as u64,
|
||||||
|
is_root: false,
|
||||||
|
icon_class: std::sync::Arc::from("fas fa-folder"),
|
||||||
|
icon_special_class: std::sync::Arc::from("folder-icon"),
|
||||||
|
category: std::sync::Arc::from("Folder"),
|
||||||
|
};
|
||||||
|
FavoritesResourceItemDto {
|
||||||
|
resource_type: ResourceTypeDto::Folder,
|
||||||
|
favorited_at: row.favorited_at,
|
||||||
|
resource: ResourceContentDto::Folder(dto),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mime = row
|
||||||
|
.mime_type
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("application/octet-stream");
|
||||||
|
let size_bytes = row.size.max(0) as u64;
|
||||||
|
let dto = FileDto {
|
||||||
|
id: row.resource_id.to_string(),
|
||||||
|
name: row.name.clone(),
|
||||||
|
path,
|
||||||
|
size: size_bytes,
|
||||||
|
mime_type: std::sync::Arc::from(mime),
|
||||||
|
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||||
|
created_at: row.resource_created_at.timestamp() as u64,
|
||||||
|
modified_at: row.modified_at.timestamp() as u64,
|
||||||
|
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
|
||||||
|
icon_special_class: std::sync::Arc::from(icon_special_class_for(
|
||||||
|
&row.name, mime,
|
||||||
|
)),
|
||||||
|
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
||||||
|
size_formatted: format_file_size(size_bytes),
|
||||||
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
|
sort_date: None,
|
||||||
|
etag: String::new(),
|
||||||
|
};
|
||||||
|
FavoritesResourceItemDto {
|
||||||
|
resource_type: ResourceTypeDto::File,
|
||||||
|
favorited_at: row.favorited_at,
|
||||||
|
resource: ResourceContentDto::File(dto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(FavoritesResourcesDto::with_cursor(items, next_cursor)),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => AppError::from(e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Add multiple items to favourites in a single transaction.
|
/// Add multiple items to favourites in a single transaction.
|
||||||
/// POST /api/favorites/batch
|
/// POST /api/favorites/batch
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ use std::hash::{Hash, Hasher};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio_util::io::ReaderStream;
|
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::{
|
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::folder_listing_dto::FolderListingDto;
|
||||||
|
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
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
|
FolderHandler::list_root_folders_impl(state, auth_user).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[deprecated = "Use /api/folders/{id}/resources instead"]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/folders/{id}/contents",
|
path = "/api/folders/{id}/contents",
|
||||||
@@ -477,6 +484,7 @@ pub async fn list_root_folders(
|
|||||||
security(("bearerAuth" = [])),
|
security(("bearerAuth" = [])),
|
||||||
tag = "folders"
|
tag = "folders"
|
||||||
)]
|
)]
|
||||||
|
#[allow(deprecated)]
|
||||||
pub async fn list_folder_contents(
|
pub async fn list_folder_contents(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
auth_user: AuthUser,
|
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
|
FolderHandler::list_root_folders_paginated_impl(state, auth_user, pagination).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[deprecated = "Use /api/folders/{id}/resources instead"]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/folders/{id}/contents/paginated",
|
path = "/api/folders/{id}/contents/paginated",
|
||||||
@@ -517,6 +526,7 @@ pub async fn list_root_folders_paginated(
|
|||||||
security(("bearerAuth" = [])),
|
security(("bearerAuth" = [])),
|
||||||
tag = "folders"
|
tag = "folders"
|
||||||
)]
|
)]
|
||||||
|
#[allow(deprecated)]
|
||||||
pub async fn list_folder_contents_paginated(
|
pub async fn list_folder_contents_paginated(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
auth_user: AuthUser,
|
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
|
FolderHandler::list_folder_contents_paginated_impl(state, auth_user, path, pagination).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[deprecated = "Use /api/folders/{id}/resources instead"]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/folders/{id}/listing",
|
path = "/api/folders/{id}/listing",
|
||||||
@@ -538,6 +549,7 @@ pub async fn list_folder_contents_paginated(
|
|||||||
security(("bearerAuth" = [])),
|
security(("bearerAuth" = [])),
|
||||||
tag = "folders"
|
tag = "folders"
|
||||||
)]
|
)]
|
||||||
|
#[allow(deprecated)]
|
||||||
pub async fn list_folder_listing(
|
pub async fn list_folder_listing(
|
||||||
state: State<Arc<GlobalAppState>>,
|
state: State<Arc<GlobalAppState>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
@@ -628,3 +640,105 @@ pub async fn download_folder_zip(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
FolderHandler::download_folder_zip_impl(state, auth_user, path, query).await
|
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();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode cursor — discard it when the sort dimension changed to avoid
|
let reverse = q.reverse;
|
||||||
// keyset confusion across sort modes.
|
|
||||||
|
// Decode cursor — discard it when the sort dimension or direction changed
|
||||||
|
// to avoid keyset confusion across sort modes.
|
||||||
let cursor = q
|
let cursor = q
|
||||||
.decode_cursor::<GrantCursor>()
|
.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.
|
// Fetch paged summaries from the ACL engine.
|
||||||
let (summaries, next_cursor) = match state
|
let (summaries, next_cursor) = match state
|
||||||
.authorization
|
.authorization
|
||||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by)
|
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by, reverse)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ use serde::Deserialize;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
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::ports::recent_ports::RecentItemsUseCase;
|
||||||
use crate::application::services::recent_service::RecentService;
|
use crate::application::services::recent_service::RecentService;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
/// Query parameters for getting recent items
|
/// Query parameters for getting recent items
|
||||||
@@ -19,7 +29,8 @@ pub struct GetRecentParams {
|
|||||||
limit: Option<i32>,
|
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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/recent",
|
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,
|
delete_file, download_file, get_file_metadata, get_thumbnail, list_files_query,
|
||||||
move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
|
move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
|
||||||
};
|
};
|
||||||
|
#[allow(deprecated)]
|
||||||
use crate::interfaces::api::handlers::folder_handler::{
|
use crate::interfaces::api::handlers::folder_handler::{
|
||||||
create_folder, delete_folder_with_trash, download_folder_zip, get_folder, list_folder_contents,
|
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,
|
list_root_folders_paginated, move_folder, rename_folder,
|
||||||
};
|
};
|
||||||
use crate::interfaces::api::handlers::i18n_handler::{
|
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.
|
/// These routes require authentication when auth is enabled.
|
||||||
/// Receives the fully-assembled `AppState` and extracts all needed services
|
/// Receives the fully-assembled `AppState` and extracts all needed services
|
||||||
/// from it, avoiding a long parameter list.
|
/// 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>> {
|
pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||||
// Extract services from the pre-built AppState
|
// Extract services from the pre-built AppState
|
||||||
let folder_service = app_state.applications.folder_service_concrete.clone();
|
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",
|
"/{id}/contents/paginated",
|
||||||
get(list_folder_contents_paginated),
|
get(list_folder_contents_paginated),
|
||||||
)
|
)
|
||||||
|
.route("/{id}/resources", get(list_folder_resources))
|
||||||
.route("/{id}/rename", put(rename_folder))
|
.route("/{id}/rename", put(rename_folder))
|
||||||
.route("/{id}/move", put(move_folder))
|
.route("/{id}/move", put(move_folder))
|
||||||
.with_state(folder_service.clone());
|
.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 a router without the i18n routes
|
||||||
// Create routes for favorites if the service is available
|
// Create routes for favorites if the service is available
|
||||||
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
|
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
|
||||||
use crate::interfaces::api::handlers::favorites_handler;
|
#[allow(deprecated)]
|
||||||
|
use crate::interfaces::api::handlers::favorites_handler::{
|
||||||
|
self, get_favorites, list_favorites_resources,
|
||||||
|
};
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(favorites_handler::get_favorites))
|
.route("/", get(get_favorites)) // deprecated, kept for compat
|
||||||
|
.route("/resources", get(list_favorites_resources)) // new cursor-paginated endpoint
|
||||||
.route("/batch", post(favorites_handler::batch_add_favorites))
|
.route("/batch", post(favorites_handler::batch_add_favorites))
|
||||||
.route(
|
.route(
|
||||||
"/{item_type}/{item_id}",
|
"/{item_type}/{item_id}",
|
||||||
@@ -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
|
// Create routes for recent items if the service is available
|
||||||
let recent_router = if let Some(recent_service) = recent_service.clone() {
|
let recent_router = if let Some(recent_service) = recent_service.clone() {
|
||||||
|
#[allow(deprecated)]
|
||||||
use crate::interfaces::api::handlers::recent_handler;
|
use crate::interfaces::api::handlers::recent_handler;
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(recent_handler::get_recent_items))
|
.route("/", get(recent_handler::get_recent_items))
|
||||||
|
.route("/resources", get(recent_handler::list_recent_resources))
|
||||||
.route(
|
.route(
|
||||||
"/{item_type}/{item_id}",
|
"/{item_type}/{item_id}",
|
||||||
post(recent_handler::record_item_access),
|
post(recent_handler::record_item_access),
|
||||||
|
|||||||
@@ -121,6 +121,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-by-selector {
|
.group-by-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +134,15 @@
|
|||||||
color: var(--color-accent);
|
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 */
|
/* Active label shown inline next to the icon */
|
||||||
.group-by-label {
|
.group-by-label {
|
||||||
display: none;
|
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;
|
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) ────────────────── */
|
/* ── Swimlane group card (list view only) ────────────────── */
|
||||||
|
|
||||||
/* When swimlane groups are present, dissolve the outer container into the
|
/* 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/search.css");
|
||||||
@import url("./components/icons.css");
|
@import url("./components/icons.css");
|
||||||
@import url("./components/csp-utilities.css");
|
@import url("./components/csp-utilities.css");
|
||||||
@import url("./components/pathTooltip.css");
|
@import url("./components/itemTooltip.css");
|
||||||
|
|
||||||
/* Theme */
|
/* Theme */
|
||||||
@import url("./themes/dark.css");
|
@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/library/music.js"></script>
|
||||||
<script defer type="module" src="/js/features/sharing/fileSharing.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/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/inlineViewer.js"></script>
|
||||||
<script defer type="module" src="/js/features/files/wopiEditor.js"></script>
|
<script defer type="module" src="/js/features/files/wopiEditor.js"></script>
|
||||||
<script defer type="module" src="/js/core/icons.js"></script>
|
<script defer type="module" src="/js/core/icons.js"></script>
|
||||||
|
|||||||
+284
-28
@@ -4,21 +4,24 @@
|
|||||||
* OxiCloud – Files section view.
|
* OxiCloud – Files section view.
|
||||||
*
|
*
|
||||||
* Orchestrates the main Files section:
|
* Orchestrates the main Files section:
|
||||||
* - Data fetching via `filesModel`
|
* - Data fetching via `filesModel` (cursor-paginated `/api/folders/{id}/resources`)
|
||||||
* - Rendering via a `ResourceListComponent` instance
|
* - Rendering via a `ResourceListComponent` instance with optional swimlane grouping
|
||||||
* - Drag-and-drop initialisation (delegated to `ui.initDragDrop`)
|
* - Drag-and-drop initialisation (delegated to `ui.initDragDrop`)
|
||||||
*
|
*
|
||||||
* Exports `loadFiles` (navigation & deep-link entry-point) and `addItem`
|
* Exports:
|
||||||
* (post-upload / post-create optimistic UI updates used by fileOperations
|
* - `loadFiles` – navigation & deep-link entry-point
|
||||||
* and search).
|
* - `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 { ResourceListComponent } from '../components/resourceList.js';
|
||||||
|
import { normalizeDateBucket, sizeBucket } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
|
import * as viewPrefs from '../core/viewPrefs.js';
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||||
import { favorites } from '../features/library/favorites.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 { grants } from '../model/grants.js';
|
||||||
import { resolveHomeFolder } from './authSession.js';
|
import { resolveHomeFolder } from './authSession.js';
|
||||||
import { updateHistory } from './main.js';
|
import { updateHistory } from './main.js';
|
||||||
@@ -28,12 +31,162 @@ import { uiNotifications } from './uiNotifications.js';
|
|||||||
|
|
||||||
/** @import {FileItem, FolderItem} from '../core/types.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} */
|
/** @type {ResourceListComponent|null} */
|
||||||
let _component = null;
|
let _component = null;
|
||||||
|
|
||||||
/** Guard against concurrent `loadFiles` calls. */
|
/** Guard against concurrent `_loadPage` calls. */
|
||||||
let _loading = false;
|
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
|
* Return (creating on first call) the `ResourceListComponent` bound to
|
||||||
* `#files-list`. The element must already be in the DOM.
|
* `#files-list`. The element must already be in the DOM.
|
||||||
@@ -58,7 +211,7 @@ function _ensureComponent() {
|
|||||||
const isFile = 'mime_type' in item;
|
const isFile = 'mime_type' in item;
|
||||||
const type = isFile ? 'file' : 'folder';
|
const type = isFile ? 'file' : 'folder';
|
||||||
if (favorites.isFavorite(item.id, type)) {
|
if (favorites.isFavorite(item.id, type)) {
|
||||||
await favorites.removeFromFavorites(item.id, type);
|
await favorites.removeFromFavorites(item.id, type, item.name);
|
||||||
_component?.setFavoriteVisualState(item.id, type, false);
|
_component?.setFavoriteVisualState(item.id, type, false);
|
||||||
} else {
|
} else {
|
||||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
@@ -85,9 +238,99 @@ function _ensureComponent() {
|
|||||||
ui.initDragDrop(/** @type {HTMLElement} */ (filesList));
|
ui.initDragDrop(/** @type {HTMLElement} */ (filesList));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ensureLoadMoreButton();
|
||||||
|
|
||||||
return _component;
|
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
|
* 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
|
* optimistic update). No-op when the Files section is not active or the
|
||||||
@@ -111,14 +354,19 @@ function addItem(item) {
|
|||||||
*
|
*
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {boolean} [options.insertHistory=true]
|
* @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 }) {
|
async function loadFiles(options = { insertHistory: true }) {
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
console.log('A file load is already in progress, ignoring request');
|
console.log('A file load is already in progress, ignoring request');
|
||||||
return;
|
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
|
// Delay spinner so fast loads avoid the flash
|
||||||
const spinnerTimeout = setTimeout(() => {
|
const spinnerTimeout = setTimeout(() => {
|
||||||
@@ -130,6 +378,10 @@ async function loadFiles(options = { insertHistory: true }) {
|
|||||||
`);
|
`);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
// A temporary guard: _loadPage sets _loading itself, but we need to
|
||||||
|
// block re-entrant loadFiles() calls during the setup below.
|
||||||
|
_loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!app.userHomeFolderId) await resolveHomeFolder();
|
if (!app.userHomeFolderId) await resolveHomeFolder();
|
||||||
|
|
||||||
@@ -148,10 +400,6 @@ async function loadFiles(options = { insertHistory: true }) {
|
|||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
updateHistory(options.insertHistory ?? true);
|
updateHistory(options.insertHistory ?? true);
|
||||||
|
|
||||||
const { folders, files } = await fetchListing(app.currentPath, {
|
|
||||||
forceRefresh: options.forceRefresh ?? false
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(spinnerTimeout);
|
clearTimeout(spinnerTimeout);
|
||||||
|
|
||||||
// Prepare the container (shows #files-list, hides error panel)
|
// Prepare the container (shows #files-list, hides error panel)
|
||||||
@@ -164,23 +412,31 @@ async function loadFiles(options = { insertHistory: true }) {
|
|||||||
batchToolbar.init();
|
batchToolbar.init();
|
||||||
batchToolbar.setActiveComponent(component);
|
batchToolbar.setActiveComponent(component);
|
||||||
|
|
||||||
if (folders.length === 0 && files.length === 0) {
|
// Hand off to _loadPage (re-use cursor/groupBy state just reset above).
|
||||||
ui.showEmptyList();
|
_loading = false; // _loadPage sets its own guard
|
||||||
} else {
|
await _loadPage({ isFirstPage: true });
|
||||||
component.render([...folders, ...files]);
|
|
||||||
await component.resolveOwnerCells();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Loaded ${folders.length} folders and ${files.length} files`);
|
// 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
|
||||||
// Deep-link: open a specific file if requested via app.viewFile
|
// to open it if it was already rendered (first page).
|
||||||
if (app.viewFile) {
|
if (app.viewFile) {
|
||||||
const fileFound = files.find((f) => f.id === app.viewFile) ?? null;
|
// Find the item among all rendered cards via the DOM attribute.
|
||||||
if (fileFound) {
|
const rendered = document.querySelector(`[data-id="${app.viewFile}"][data-type="file"]`);
|
||||||
console.log(`file ${app.viewFile} found, calling viewer`);
|
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);
|
await inlineViewer.openFile(fileFound);
|
||||||
} else {
|
} else {
|
||||||
console.log(`file ${app.viewFile} not found`);
|
app.viewFile = null;
|
||||||
|
updateHistory(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`file ${app.viewFile} not in first page — skipping auto-open`);
|
||||||
app.viewFile = null;
|
app.viewFile = null;
|
||||||
updateHistory(false);
|
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 { recent } from '../features/library/recent.js';
|
||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
import { grants } from '../model/grants.js';
|
import { grants } from '../model/grants.js';
|
||||||
|
import { recentView } from '../views/recent/recentView.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
import { sharedView } from '../views/shared/sharedView.js';
|
||||||
import { checkAuthentication } from './authSession.js';
|
import { checkAuthentication } from './authSession.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
@@ -89,6 +90,10 @@ const _toggleButtons = `
|
|||||||
<i class="fas fa-layer-group"></i>
|
<i class="fas fa-layer-group"></i>
|
||||||
<span class="group-by-label"></span>
|
<span class="group-by-label"></span>
|
||||||
</button>
|
</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 class="group-by-menu hidden" id="group-by-menu"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="view-toggle-separator hidden" id="group-by-separator"></span>
|
<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
|
* The view that currently owns the group-by selector, or `null` when no
|
||||||
* section supports grouping. Set by `setGroupByView()` from navigation.js.
|
* 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;
|
let _groupByView = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the reference to the view that handles group-by changes.
|
* Update the reference to the view that handles group-by changes.
|
||||||
* Called by navigation.js when the active section 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) {
|
function setGroupByView(view) {
|
||||||
_groupByView = view;
|
_groupByView = view;
|
||||||
@@ -247,6 +252,8 @@ function syncGroupByMenu(defs = []) {
|
|||||||
btn?.classList.remove('active');
|
btn?.classList.remove('active');
|
||||||
const lbl = btn?.querySelector('.group-by-label');
|
const lbl = btn?.querySelector('.group-by-label');
|
||||||
if (lbl) lbl.textContent = '';
|
if (lbl) lbl.textContent = '';
|
||||||
|
// Reset direction button to ascending (↑)
|
||||||
|
document.getElementById('sort-dir-btn')?.classList.remove('active');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,10 +296,19 @@ function setupActionsBarDelegation() {
|
|||||||
groupByBtn?.classList.toggle('active', key !== '');
|
groupByBtn?.classList.toggle('active', key !== '');
|
||||||
const lbl = groupByBtn?.querySelector('.group-by-label');
|
const lbl = groupByBtn?.querySelector('.group-by-label');
|
||||||
if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : '';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (btn.id) {
|
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':
|
case 'group-by-btn':
|
||||||
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
||||||
return;
|
return;
|
||||||
@@ -330,8 +346,8 @@ function setupActionsBarDelegation() {
|
|||||||
break;
|
break;
|
||||||
case 'clear-recent-btn':
|
case 'clear-recent-btn':
|
||||||
if (recent) {
|
if (recent) {
|
||||||
recent.clearRecentFiles();
|
await recent.clearRecentFiles();
|
||||||
recent.displayRecentFiles();
|
await recentView.init();
|
||||||
ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
|
ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
+64
-41
@@ -3,15 +3,18 @@
|
|||||||
* Extracted from main.js to keep navigation concerns isolated.
|
* Extracted from main.js to keep navigation concerns isolated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { applyGroupByMenuState } from '../core/groupBySync.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
|
import * as viewPrefs from '../core/viewPrefs.js';
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { musicView } from '../features/library/music.js';
|
import { musicView } from '../features/library/music.js';
|
||||||
import { photosView } from '../features/library/photos.js';
|
import { photosView } from '../features/library/photos.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { favoritesView } from '../views/favorites/favoritesView.js';
|
||||||
|
import { recentView } from '../views/recent/recentView.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
import { sharedView } from '../views/shared/sharedView.js';
|
||||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { filesView, loadFiles } from './filesView.js';
|
||||||
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||||
import { app, appElements } from './state.js';
|
import { app, appElements } from './state.js';
|
||||||
import { loadTrashItems } from './trashView.js';
|
import { 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
|
* Sync the hidden class and inline display for the grid/list containers
|
||||||
* based on the current view preference.
|
* 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() {
|
function syncViewContainers() {
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
const gridViewBtn = document.getElementById('grid-view-btn');
|
const gridViewBtn = document.getElementById('grid-view-btn');
|
||||||
@@ -164,6 +175,16 @@ function setCurrentSection(section) {
|
|||||||
sharedWithMeView.hide();
|
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.
|
// Reset owner column — sections that need it re-enable it explicitly below.
|
||||||
ui.setOwnerColumnVisible(false);
|
ui.setOwnerColumnVisible(false);
|
||||||
|
|
||||||
@@ -219,11 +240,16 @@ function switchToSharedWithMeSection() {
|
|||||||
setGroupByView(sharedWithMeView);
|
setGroupByView(sharedWithMeView);
|
||||||
syncGroupByMenu(sharedWithMeView.groupByDefs);
|
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.
|
// Show the Owner column — names are resolved async after render.
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
|
|
||||||
// Show the standard files container and respect grid/list preference
|
// Show the standard files container and respect grid/list preference
|
||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
restoreView('sharedwithme');
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
|
|
||||||
if (batchToolbar) batchToolbar.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
@@ -237,8 +263,12 @@ function switchToFilesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('files', true);
|
setActionsBarMode('files', true);
|
||||||
setGroupByView(null);
|
setGroupByView(filesView);
|
||||||
syncGroupByMenu([]);
|
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
|
// Show owner column in the Files section
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
@@ -251,6 +281,7 @@ function switchToFilesSection() {
|
|||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|
||||||
// ensure correct view
|
// ensure correct view
|
||||||
|
restoreView('files');
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
|
|
||||||
//reset files view + remove any error
|
//reset files view + remove any error
|
||||||
@@ -273,8 +304,12 @@ function switchToFavoritesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('favorites');
|
setActionsBarMode('favorites');
|
||||||
setGroupByView(null);
|
setGroupByView(favoritesView);
|
||||||
syncGroupByMenu([]);
|
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.
|
// Show the Owner column — names are resolved async after render.
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
@@ -287,34 +322,32 @@ function switchToFavoritesSection() {
|
|||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|
||||||
// ensure correct view
|
// ensure correct view
|
||||||
|
restoreView('favorites');
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
|
|
||||||
//reset files view + remove any error
|
|
||||||
ui.resetFilesList();
|
|
||||||
|
|
||||||
if (favorites) {
|
|
||||||
// temp solution
|
|
||||||
sharedView.loadItems().then(() => {
|
|
||||||
favorites.displayFavorites();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Favorites module not loaded or initialized');
|
|
||||||
ui.showError(`
|
|
||||||
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
|
|
||||||
<p>Error loading the favorites module</p>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batchToolbar) batchToolbar.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
|
|
||||||
|
// Prefetch isFavorite cache in background (non-blocking)
|
||||||
|
favorites.init();
|
||||||
|
|
||||||
|
// Load and render via the cursor-paginated view
|
||||||
|
favoritesView.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToRecentFilesSection() {
|
function switchToRecentFilesSection() {
|
||||||
if (!setCurrentSection('recent')) return;
|
if (!setCurrentSection('recent')) return;
|
||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode with group-by support
|
||||||
setActionsBarMode('recent');
|
setActionsBarMode('recent');
|
||||||
setGroupByView(null);
|
setGroupByView(recentView);
|
||||||
syncGroupByMenu([]);
|
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)
|
// Hide breadcrumb (only shown in Files view)
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
@@ -324,23 +357,12 @@ function switchToRecentFilesSection() {
|
|||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|
||||||
// ensure correct view
|
// ensure correct view
|
||||||
|
restoreView('recent');
|
||||||
syncViewContainers();
|
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();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
|
|
||||||
|
recentView.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToPhotosSection() {
|
function switchToPhotosSection() {
|
||||||
@@ -385,6 +407,7 @@ function switchToTrashSection() {
|
|||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
|
|
||||||
//ensure buttons match the current view
|
//ensure buttons match the current view
|
||||||
|
restoreView('trash');
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
|
|
||||||
// Load trash items
|
// Load trash items
|
||||||
@@ -432,8 +455,8 @@ function switchToMusicSection() {
|
|||||||
function activateFilesUI() {
|
function activateFilesUI() {
|
||||||
setCurrentSection('files');
|
setCurrentSection('files');
|
||||||
setActionsBarMode('files', true);
|
setActionsBarMode('files', true);
|
||||||
setGroupByView(null);
|
setGroupByView(filesView);
|
||||||
syncGroupByMenu([]);
|
syncGroupByMenu(filesView.groupByDefs);
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
breadcrumb?.classList.remove('hidden');
|
breadcrumb?.classList.remove('hidden');
|
||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
|||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { fileOps } from '../features/files/fileOperations.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 { appElements } from './state.js';
|
||||||
import { ui } from './ui.js';
|
import { ui } from './ui.js';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ async function loadTrashItems() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (batchToolbar) batchToolbar.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
pathTooltip.destroy(elements.filesList);
|
itemTooltip.destroy(elements.filesList);
|
||||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
ui.resetFilesList(); // ensure also list visible & error hidden
|
||||||
elements.filesList.innerHTML = `
|
elements.filesList.innerHTML = `
|
||||||
<div class="list-header trash-header">
|
<div class="list-header trash-header">
|
||||||
@@ -50,7 +50,7 @@ async function loadTrashItems() {
|
|||||||
trashItems.forEach((item) => {
|
trashItems.forEach((item) => {
|
||||||
addTrashItemToView(item);
|
addTrashItemToView(item);
|
||||||
});
|
});
|
||||||
pathTooltip.init(elements.filesList);
|
itemTooltip.init(elements.filesList);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading trash items:', error);
|
console.error('Error loading trash items:', error);
|
||||||
ui.showNotification('Error', 'Error loading trash items');
|
ui.showNotification('Error', 'Error loading trash items');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { OxiIcons } from '../core/icons.js';
|
import { OxiIcons } from '../core/icons.js';
|
||||||
|
import * as viewPrefs from '../core/viewPrefs.js';
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { contextMenus } from '../features/files/contextMenus.js';
|
import { contextMenus } from '../features/files/contextMenus.js';
|
||||||
import { fileOps } from '../features/files/fileOperations.js';
|
import { fileOps } from '../features/files/fileOperations.js';
|
||||||
@@ -419,6 +420,7 @@ const ui = {
|
|||||||
switchToGridView() {
|
switchToGridView() {
|
||||||
app.currentView = 'grid';
|
app.currentView = 'grid';
|
||||||
localStorage.setItem('oxicloud-view', 'grid');
|
localStorage.setItem('oxicloud-view', 'grid');
|
||||||
|
if (app.currentSection) viewPrefs.saveView(app.currentSection, 'grid');
|
||||||
|
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
},
|
},
|
||||||
@@ -429,6 +431,7 @@ const ui = {
|
|||||||
switchToListView() {
|
switchToListView() {
|
||||||
app.currentView = 'list';
|
app.currentView = 'list';
|
||||||
localStorage.setItem('oxicloud-view', 'list');
|
localStorage.setItem('oxicloud-view', 'list');
|
||||||
|
if (app.currentSection) viewPrefs.saveView(app.currentSection, 'list');
|
||||||
|
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ export class ResourceListComponent {
|
|||||||
*/
|
*/
|
||||||
this._groupLabelFn = undefined;
|
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._ownerVisible = this._cfg.showOwner;
|
||||||
|
|
||||||
this._initDelegation();
|
this._initDelegation();
|
||||||
@@ -138,8 +146,12 @@ export class ResourceListComponent {
|
|||||||
* @param {((key: string) => string)=} groupLabelFn
|
* @param {((key: string) => string)=} groupLabelFn
|
||||||
* Optional: converts the raw grouping key to a human-readable header
|
* Optional: converts the raw grouping key to a human-readable header
|
||||||
* label. When omitted the key itself is used.
|
* 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');
|
const header = this._container.querySelector('.list-header');
|
||||||
this._container.innerHTML = '';
|
this._container.innerHTML = '';
|
||||||
if (header) this._container.appendChild(header);
|
if (header) this._container.appendChild(header);
|
||||||
@@ -151,11 +163,12 @@ export class ResourceListComponent {
|
|||||||
this._lastGroupKey = undefined;
|
this._lastGroupKey = undefined;
|
||||||
this._lastGroupEl = null;
|
this._lastGroupEl = null;
|
||||||
this._groupLabelFn = groupLabelFn;
|
this._groupLabelFn = groupLabelFn;
|
||||||
|
this._headerNodeFn = headerNodeFn;
|
||||||
|
|
||||||
// Prevent ui.js global delegation from firing on this container
|
// Prevent ui.js global delegation from firing on this container
|
||||||
this._container.dataset.managedBy = 'resource-list';
|
this._container.dataset.managedBy = 'resource-list';
|
||||||
|
|
||||||
this._appendItems(items, groupFn, groupLabelFn);
|
this._appendItems(items, groupFn, groupLabelFn, headerNodeFn);
|
||||||
this._wireSelectAll();
|
this._wireSelectAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +180,10 @@ export class ResourceListComponent {
|
|||||||
* @param {Array<FileItem|FolderItem>} items
|
* @param {Array<FileItem|FolderItem>} items
|
||||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
* @param {((key: string) => string)=} groupLabelFn
|
* @param {((key: string) => string)=} groupLabelFn
|
||||||
|
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
||||||
*/
|
*/
|
||||||
append(items, groupFn, groupLabelFn) {
|
append(items, groupFn, groupLabelFn, headerNodeFn) {
|
||||||
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn);
|
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove all items (but keep `.list-header` if present). */
|
/** Remove all items (but keep `.list-header` if present). */
|
||||||
@@ -328,8 +342,9 @@ export class ResourceListComponent {
|
|||||||
* @param {Array<FileItem|FolderItem>} items
|
* @param {Array<FileItem|FolderItem>} items
|
||||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
* @param {((key: string) => string)=} groupLabelFn
|
* @param {((key: string) => string)=} groupLabelFn
|
||||||
|
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
||||||
*/
|
*/
|
||||||
_appendItems(items, groupFn, groupLabelFn) {
|
_appendItems(items, groupFn, groupLabelFn, headerNodeFn) {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
// Start from the persisted key so load-more pages continue seamlessly.
|
// Start from the persisted key so load-more pages continue seamlessly.
|
||||||
@@ -354,7 +369,7 @@ export class ResourceListComponent {
|
|||||||
if (key !== null) {
|
if (key !== null) {
|
||||||
fragmentGroup = document.createElement('div');
|
fragmentGroup = document.createElement('div');
|
||||||
fragmentGroup.className = 'resource-list__swimlane-group';
|
fragmentGroup.className = 'resource-list__swimlane-group';
|
||||||
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn));
|
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn));
|
||||||
fragment.appendChild(fragmentGroup);
|
fragment.appendChild(fragmentGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,14 +397,25 @@ export class ResourceListComponent {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a swimlane divider element.
|
* Create a swimlane divider element.
|
||||||
|
*
|
||||||
|
* 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 {string} key - Raw grouping key (e.g. UUID or bucket name).
|
||||||
* @param {((key: string) => string)=} labelFn - Optional human-readable resolver.
|
* @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');
|
const el = document.createElement('div');
|
||||||
el.className = 'resource-list__swimlane-header';
|
el.className = 'resource-list__swimlane-header';
|
||||||
el.dataset.swimlaneHeader = 'true';
|
el.dataset.swimlaneHeader = 'true';
|
||||||
|
if (headerNodeFn) {
|
||||||
|
el.classList.add('resource-list__swimlane-header--node');
|
||||||
|
el.appendChild(headerNodeFn(key));
|
||||||
|
} else {
|
||||||
el.textContent = labelFn ? labelFn(key) : key;
|
el.textContent = labelFn ? labelFn(key) : key;
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +433,7 @@ export class ResourceListComponent {
|
|||||||
el.dataset.folderName = folder.name;
|
el.dataset.folderName = folder.name;
|
||||||
el.dataset.parentId = folder.parent_id || '';
|
el.dataset.parentId = folder.parent_id || '';
|
||||||
if (folder.path) el.dataset.path = folder.path;
|
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');
|
if (cfg.draggable) el.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
|
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
|
||||||
@@ -463,6 +490,7 @@ export class ResourceListComponent {
|
|||||||
el.dataset.fileName = file.name;
|
el.dataset.fileName = file.name;
|
||||||
el.dataset.folderId = file.folder_id || '';
|
el.dataset.folderId = file.folder_id || '';
|
||||||
if (file.path) el.dataset.path = file.path;
|
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');
|
if (cfg.draggable) el.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
@@ -489,6 +517,7 @@ export class ResourceListComponent {
|
|||||||
const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb'));
|
const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb'));
|
||||||
if (thumb) {
|
if (thumb) {
|
||||||
thumb.addEventListener('error', () => {
|
thumb.addEventListener('error', () => {
|
||||||
|
console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`);
|
||||||
thumb.classList.add('hidden');
|
thumb.classList.add('hidden');
|
||||||
thumbnail?.queueGenerate(file, (dataUrl) => {
|
thumbnail?.queueGenerate(file, (dataUrl) => {
|
||||||
thumb.src = 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,
|
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'
|
'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: [
|
ban: [
|
||||||
512,
|
512,
|
||||||
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z'
|
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z'
|
||||||
@@ -246,6 +263,10 @@ const OxiIcons = {
|
|||||||
512,
|
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'
|
'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: [
|
lock: [
|
||||||
448,
|
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'
|
'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.
|
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One item returned by `GET /api/favorites/resources`.
|
||||||
|
* `resource_type` discriminates the shape of `resource`.
|
||||||
|
* @typedef {Object} FavoritesResourceItem
|
||||||
|
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
|
||||||
|
* @property {string} favorited_at - ISO-8601 timestamp when the item was starred.
|
||||||
|
* @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for `GET /api/favorites/resources`.
|
||||||
|
* @typedef {Object} FavoritesResourcesResponse
|
||||||
|
* @property {FavoritesResourceItem[]} items
|
||||||
|
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ContactEmail
|
* @typedef {Object} ContactEmail
|
||||||
* @property {string} email
|
* @property {string} email
|
||||||
|
|||||||
@@ -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 data = await response.json();
|
||||||
const inserted = data.stats?.inserted || 0;
|
const inserted = data.stats?.inserted || 0;
|
||||||
|
|
||||||
// Replace cache directly from response (no extra GET)
|
// Re-fetch the isFavorite cache from the server.
|
||||||
if (data.favorites && favorites._replaceCacheFromResponse) {
|
|
||||||
favorites._replaceCacheFromResponse(data.favorites);
|
|
||||||
} else {
|
|
||||||
await favorites._fetchFromServer();
|
await favorites._fetchFromServer();
|
||||||
}
|
|
||||||
|
|
||||||
this.clear();
|
this.clear();
|
||||||
loadFiles();
|
loadFiles();
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const contextMenus = {
|
|||||||
// Check if folder is already in favorites to toggle
|
// Check if folder is already in favorites to toggle
|
||||||
if (favorites?.isFavorite(folder.id, 'folder')) {
|
if (favorites?.isFavorite(folder.id, 'folder')) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
const ok = await favorites.removeFromFavorites(folder.id, 'folder');
|
const ok = await favorites.removeFromFavorites(folder.id, 'folder', folder.name);
|
||||||
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
||||||
ui.setFavoriteVisualState(folder.id, 'folder', false);
|
ui.setFavoriteVisualState(folder.id, 'folder', false);
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ const contextMenus = {
|
|||||||
// Check if file is already in favorites to toggle
|
// Check if file is already in favorites to toggle
|
||||||
if (favorites?.isFavorite(file.id, 'file')) {
|
if (favorites?.isFavorite(file.id, 'file')) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
const ok = await favorites.removeFromFavorites(file.id, 'file');
|
const ok = await favorites.removeFromFavorites(file.id, 'file', file.name);
|
||||||
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
|
||||||
ui.setFavoriteVisualState(file.id, 'file', false);
|
ui.setFavoriteVisualState(file.id, 'file', false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
* OxiCloud - Favorites Module (server-authoritative)
|
||||||
*
|
*
|
||||||
* Source of truth: GET /api/favorites (enriched with name/size/mime via SQL JOIN).
|
* Source of truth: GET /api/favorites/resources (cursor-paginated).
|
||||||
* Local in-memory cache (`_cache`) keeps `isFavorite()` synchronous for the
|
* The in-memory cache (`_cache`) is a Set of "type:id" keys that keeps
|
||||||
* rendering path so star icons can be painted without a round-trip.
|
* `isFavorite()` synchronous so star icons are painted without a round-trip.
|
||||||
|
*
|
||||||
|
* Display is handled by `views/favorites/favoritesView.js`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { batchToolbar } from '../files/batchToolbar.js';
|
import { fetchFavoritesPage } from '../../model/favoritesModel.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
|
||||||
|
|
||||||
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
|
||||||
|
|
||||||
const favorites = {
|
const favorites = {
|
||||||
/** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
|
/**
|
||||||
_cache: new Map(),
|
* Set of "type:id" cache keys. A Set is enough — we only need O(1) lookups.
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
_cache: new Set(),
|
||||||
|
|
||||||
/** Whether the initial fetch from the server has completed */
|
/** Whether the initial fetch from the server has completed. */
|
||||||
_ready: false,
|
_ready: false,
|
||||||
|
|
||||||
/** @type {ResourceListComponent|null} */
|
|
||||||
_component: null,
|
|
||||||
|
|
||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
@@ -34,56 +32,43 @@ const favorites = {
|
|||||||
/**
|
/**
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
_cacheKey(id, type) {
|
_cacheKey(id, type) {
|
||||||
return `${type}:${id}`;
|
return `${type}:${id}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the entire in-memory cache from an array of FavoriteItemDto
|
|
||||||
* objects (as returned by the batch endpoint). Avoids an extra
|
|
||||||
* GET /api/favorites round-trip.
|
|
||||||
* @param {any[]} items
|
|
||||||
*/
|
|
||||||
_replaceCacheFromResponse(items) {
|
|
||||||
this._cache.clear();
|
|
||||||
for (const item of items) {
|
|
||||||
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
|
|
||||||
}
|
|
||||||
this._ready = true;
|
|
||||||
console.log(`Favorites cache replaced from response: ${this._cache.size} items`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ───────────────────── lifecycle ─────────────────────
|
// ───────────────────── lifecycle ─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise the module: fetch the full list from the server and populate
|
* Initialise the module: fetch the full favorites list from the server and
|
||||||
* the in-memory cache. Called once from app.js on startup.
|
* populate the in-memory cache. Called from navigation.js every time the
|
||||||
|
* Favorites section is entered (non-blocking — the view loads in parallel).
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
console.log('Initializing favorites module (server-authoritative)');
|
|
||||||
await this._fetchFromServer();
|
await this._fetchFromServer();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch favourites from the backend and rebuild the cache.
|
* Fetch all favorited resource IDs from the server and rebuild the cache.
|
||||||
|
* Paginates through `GET /api/favorites/resources` until exhausted.
|
||||||
*/
|
*/
|
||||||
async _fetchFromServer() {
|
async _fetchFromServer() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/favorites', {
|
|
||||||
headers: this._authHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(`Favorites API returned ${response.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {FavoriteItem[]} */
|
|
||||||
const items = await response.json();
|
|
||||||
this._cache.clear();
|
this._cache.clear();
|
||||||
for (const item of items) {
|
|
||||||
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
|
let cursor = /** @type {string|undefined} */ (undefined);
|
||||||
|
|
||||||
|
// Paginate with the max page size so most users need only one request.
|
||||||
|
while (true) {
|
||||||
|
const data = await fetchFavoritesPage({ limit: 200, cursor, orderBy: 'name' });
|
||||||
|
for (const item of data.items) {
|
||||||
|
// `item.resource.id` works for both FileItem (id) and FolderItem (id).
|
||||||
|
const r = /** @type {Record<string, string>} */ (/** @type {unknown} */ (item.resource));
|
||||||
|
this._cache.add(this._cacheKey(r.id, item.resource_type));
|
||||||
|
}
|
||||||
|
if (!data.next_cursor) break;
|
||||||
|
cursor = data.next_cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._ready = true;
|
this._ready = true;
|
||||||
@@ -96,20 +81,22 @@ const favorites = {
|
|||||||
// ───────────────────── public API ─────────────────────
|
// ───────────────────── public API ─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous check used by ui.js to paint star icons.
|
* Synchronous check used by the rendering layer to paint star icons.
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
isFavorite(id, type) {
|
isFavorite(id, type) {
|
||||||
return this._cache.has(this._cacheKey(id, type));
|
return this._cache.has(this._cacheKey(id, type));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an item to favourites (server-first).
|
* Add an item to favourites (server-first, then update local cache).
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
* @param {string | null} _parentId
|
* @param {string | null} _parentId - unused, kept for call-site compatibility
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async addToFavorites(id, name, type, _parentId) {
|
async addToFavorites(id, name, type, _parentId) {
|
||||||
try {
|
try {
|
||||||
@@ -122,10 +109,9 @@ const favorites = {
|
|||||||
throw new Error(`Server returned ${response.status}`);
|
throw new Error(`Server returned ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh cache from server to get enriched data
|
// Optimistically update local cache without a full re-fetch.
|
||||||
await this._fetchFromServer();
|
this._cache.add(this._cacheKey(id, type));
|
||||||
|
|
||||||
// Notify user
|
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
ui.showNotification(i18n.t('favorites.added_title'), `"${name}" ${i18n.t('favorites.added_msg')}`);
|
ui.showNotification(i18n.t('favorites.added_title'), `"${name}" ${i18n.t('favorites.added_msg')}`);
|
||||||
}
|
}
|
||||||
@@ -138,16 +124,14 @@ const favorites = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an item from favourites (server-first).
|
* Remove an item from favourites (server-first, then update local cache).
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
* @param {string} [name] - Display name for the notification; falls back to `id`.
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async removeFromFavorites(id, type) {
|
async removeFromFavorites(id, type, name = id) {
|
||||||
try {
|
try {
|
||||||
// Remember name for notification before removing from cache
|
|
||||||
const cached = this._cache.get(this._cacheKey(id, type));
|
|
||||||
const itemName = cached?.item_name || id;
|
|
||||||
|
|
||||||
const response = await fetch(`/api/favorites/${type}/${id}`, {
|
const response = await fetch(`/api/favorites/${type}/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: this._authHeaders()
|
headers: this._authHeaders()
|
||||||
@@ -157,11 +141,10 @@ const favorites = {
|
|||||||
throw new Error(`Server returned ${response.status}`);
|
throw new Error(`Server returned ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from local cache
|
|
||||||
this._cache.delete(this._cacheKey(id, type));
|
this._cache.delete(this._cacheKey(id, type));
|
||||||
|
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
ui.showNotification(i18n.t('favorites.removed_title'), `"${itemName}" ${i18n.t('favorites.removed_msg')}`);
|
ui.showNotification(i18n.t('favorites.removed_title'), `"${name}" ${i18n.t('favorites.removed_msg')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -171,124 +154,32 @@ const favorites = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ───────────────────── display ─────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the favourites view. All data comes from the in-memory cache
|
* Batch-add multiple items to favourites in a single server call.
|
||||||
* (which was populated from the enriched backend response — zero extra
|
* Re-fetches the cache after success to stay consistent.
|
||||||
* fetches).
|
*
|
||||||
|
* @param {Array<{item_id: string, item_type: string}>} items
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async displayFavorites() {
|
async batchAdd(items) {
|
||||||
try {
|
try {
|
||||||
|
const response = await fetch('/api/favorites/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this._authHeaders() },
|
||||||
|
body: JSON.stringify({ items })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Server returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the full cache so the Set reflects the latest server state.
|
||||||
await this._fetchFromServer();
|
await this._fetchFromServer();
|
||||||
|
|
||||||
ui.resetFilesList();
|
return true;
|
||||||
batchToolbar.init();
|
} catch (err) {
|
||||||
ui.updateBreadcrumb();
|
console.error('Error in batchAdd:', err);
|
||||||
|
return false;
|
||||||
if (this._cache.size === 0) {
|
|
||||||
ui.showError(`
|
|
||||||
<i class="fas fa-star empty-state-icon"></i>
|
|
||||||
<p>${i18n.t('favorites.empty_state')}</p>
|
|
||||||
<p>${i18n.t('favorites.empty_hint')}</p>
|
|
||||||
`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {Array<FileItem|FolderItem>} */
|
|
||||||
const items = [];
|
|
||||||
|
|
||||||
for (const item of this._cache.values()) {
|
|
||||||
// owner_id comes from the backend JOIN (actual file/folder owner)
|
|
||||||
if (item.item_type === 'folder') {
|
|
||||||
items.push(
|
|
||||||
/** @type {FolderItem} */ ({
|
|
||||||
id: item.item_id,
|
|
||||||
name: item.item_name || item.item_id,
|
|
||||||
parent_id: item.parent_id || '',
|
|
||||||
modified_at: item.modified_at || item.created_at,
|
|
||||||
path: item.item_path || '',
|
|
||||||
category: 'folder',
|
|
||||||
created_at: item.created_at,
|
|
||||||
icon_class: item.icon_class,
|
|
||||||
icon_special_class: item.icon_special_class,
|
|
||||||
owner_id: item.owner_id ?? '',
|
|
||||||
is_root: false
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
items.push(
|
|
||||||
/** @type {FileItem} */ ({
|
|
||||||
id: item.item_id,
|
|
||||||
name: item.item_name || item.item_id,
|
|
||||||
folder_id: item.parent_id || '',
|
|
||||||
mime_type: item.item_mime_type,
|
|
||||||
icon_class: item.icon_class,
|
|
||||||
icon_special_class: item.icon_special_class,
|
|
||||||
category: item.category,
|
|
||||||
size: item.item_size || 0,
|
|
||||||
size_formatted: item.size_formatted,
|
|
||||||
modified_at: item.modified_at || item.created_at,
|
|
||||||
path: item.item_path || '',
|
|
||||||
owner_id: item.owner_id ?? '',
|
|
||||||
created_at: item.created_at,
|
|
||||||
sort_date: item.created_at
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
|
||||||
if (filesList) {
|
|
||||||
if (!this._component) {
|
|
||||||
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
|
||||||
selectable: true,
|
|
||||||
showFavorite: true,
|
|
||||||
showOwner: true,
|
|
||||||
showShareBadge: true,
|
|
||||||
draggable: false,
|
|
||||||
showContextMenu: true,
|
|
||||||
itemModifierClass: 'favorite-item',
|
|
||||||
isFavorite: (id, type) => this.isFavorite(id, type),
|
|
||||||
onOpen: (item) => ui.openItem(item),
|
|
||||||
onFavoriteToggle: async (item) => {
|
|
||||||
const isFile = 'mime_type' in item;
|
|
||||||
const type = isFile ? 'file' : 'folder';
|
|
||||||
if (this.isFavorite(item.id, type)) {
|
|
||||||
await this.removeFromFavorites(item.id, type);
|
|
||||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
|
||||||
} else {
|
|
||||||
await this.addToFavorites(item.id, item.name, type, null);
|
|
||||||
this._component?.setFavoriteVisualState(item.id, type, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
|
||||||
onSelectionChange: (selectedItems) => {
|
|
||||||
batchToolbar._selected.clear();
|
|
||||||
for (const sel of selectedItems) {
|
|
||||||
const isFile = 'mime_type' in sel;
|
|
||||||
batchToolbar._selected.set(sel.id, {
|
|
||||||
id: sel.id,
|
|
||||||
name: sel.name,
|
|
||||||
type: isFile ? 'file' : 'folder',
|
|
||||||
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
batchToolbar._syncUI();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
batchToolbar.setActiveComponent(this._component);
|
|
||||||
this._component.render(items);
|
|
||||||
pathTooltip.init(filesList);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this._component?.resolveOwnerCells();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error displaying favorites:', error);
|
|
||||||
if (ui?.showNotification) {
|
|
||||||
ui.showNotification('Error', 'Error loading favorite items');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OxiCloud - Recent Files Module (server-authoritative)
|
* OxiCloud - Recent Files Module (server-authoritative)
|
||||||
*
|
*
|
||||||
* Source of truth: GET /api/recent (enriched with name/size/mime via SQL JOIN).
|
* Records file-access events via POST /api/recent/{type}/{id} and exposes
|
||||||
* File-access events are forwarded to the backend with POST /api/recent/{type}/{id}.
|
* `clearRecentFiles()` for the clear-all action.
|
||||||
* No localStorage usage — the server persists and prunes recent items.
|
*
|
||||||
|
* 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 { 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 = {
|
const recent = {
|
||||||
/** Maximum items to request from the server */
|
|
||||||
MAX_RECENT_FILES: 20,
|
|
||||||
|
|
||||||
/** @type {ResourceListComponent|null} */
|
|
||||||
_component: null,
|
|
||||||
|
|
||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
@@ -34,7 +27,6 @@ const recent = {
|
|||||||
* Initialise the module. Called once from app.js on startup.
|
* Initialise the module. Called once from app.js on startup.
|
||||||
*/
|
*/
|
||||||
init() {
|
init() {
|
||||||
console.log('Initializing recent files module (server-authoritative)');
|
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -83,139 +75,6 @@ const recent = {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error clearing recent files:', 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() {
|
async function getPdfjsLib() {
|
||||||
if (_pdfjsLib) return _pdfjsLib;
|
if (_pdfjsLib) return _pdfjsLib;
|
||||||
// IMPORTANT: this hack (const lib=...) so tsc will not load vendors library
|
// IMPORTANT: use an absolute path so the import resolves correctly both in
|
||||||
const lib = '../vendors/pdf.min.mjs';
|
// 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 = /** @type {any} */ (await import(lib));
|
||||||
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
|
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
|
||||||
return _pdfjsLib;
|
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 {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.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 {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>}
|
* @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({
|
const params = new URLSearchParams({
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
resource_types: resourceTypes.join(',')
|
resource_types: resourceTypes.join(',')
|
||||||
});
|
});
|
||||||
if (cursor) params.set('cursor', cursor);
|
if (cursor) params.set('cursor', cursor);
|
||||||
if (orderBy) params.set('sort_by', orderBy);
|
if (orderBy) params.set('sort_by', orderBy);
|
||||||
|
if (reverse) params.set('reverse', 'true');
|
||||||
|
|
||||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
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 { ui } from '../../app/ui.js';
|
||||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
|
import { createUserVignette } from '../../components/userVignette.js';
|
||||||
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
|
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import * as viewPrefs from '../../core/viewPrefs.js';
|
||||||
import { batchToolbar } from '../../features/files/batchToolbar.js';
|
import { batchToolbar } from '../../features/files/batchToolbar.js';
|
||||||
|
import * as itemTooltip from '../../features/itemTooltip.js';
|
||||||
import { favorites } from '../../features/library/favorites.js';
|
import { favorites } from '../../features/library/favorites.js';
|
||||||
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
|
||||||
import { grants } from '../../model/grants.js';
|
import { grants } from '../../model/grants.js';
|
||||||
import { systemUsers } from '../../model/systemUsers.js';
|
import { systemUsers } from '../../model/systemUsers.js';
|
||||||
|
|
||||||
@@ -25,7 +27,8 @@ import { systemUsers } from '../../model/systemUsers.js';
|
|||||||
/**
|
/**
|
||||||
* @typedef {{ key: string, label: string, orderBy: string,
|
* @typedef {{ key: string, label: string, orderBy: string,
|
||||||
* keyFn: (item: FileItem|FolderItem) => string|null,
|
* 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[]}
|
* @type {GroupByDef[]}
|
||||||
*/
|
*/
|
||||||
const GROUP_BY_DEFS = [
|
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',
|
key: 'type',
|
||||||
get label() {
|
get label() {
|
||||||
@@ -73,23 +94,6 @@ const GROUP_BY_DEFS = [
|
|||||||
return labels[key] ?? key;
|
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',
|
key: 'size',
|
||||||
get label() {
|
get label() {
|
||||||
@@ -143,6 +147,9 @@ const sharedWithMeView = {
|
|||||||
*/
|
*/
|
||||||
_groupBy: '',
|
_groupBy: '',
|
||||||
|
|
||||||
|
/** Whether the current sort order is reversed. */
|
||||||
|
_reversed: false,
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,11 +169,26 @@ const sharedWithMeView = {
|
|||||||
setGroupBy(key) {
|
setGroupBy(key) {
|
||||||
if (this._groupBy === key) return;
|
if (this._groupBy === key) return;
|
||||||
this._groupBy = key;
|
this._groupBy = key;
|
||||||
|
viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view);
|
||||||
this._nextCursor = null; // restart from first page
|
this._nextCursor = null; // restart from first page
|
||||||
this._component?.clear();
|
this._component?.clear();
|
||||||
this._loadPage();
|
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.
|
* (Re-)load from page 1 and render into the existing files container.
|
||||||
* Called every time the user switches to this section.
|
* Called every time the user switches to this section.
|
||||||
@@ -174,7 +196,9 @@ const sharedWithMeView = {
|
|||||||
async init() {
|
async init() {
|
||||||
this._nextCursor = null;
|
this._nextCursor = null;
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
this._groupBy = '';
|
const _savedPrefs = viewPrefs.load('sharedwithme');
|
||||||
|
this._groupBy = _savedPrefs.groupBy;
|
||||||
|
this._reversed = _savedPrefs.reversed;
|
||||||
|
|
||||||
this._ensureLoadMoreButton();
|
this._ensureLoadMoreButton();
|
||||||
|
|
||||||
@@ -205,7 +229,7 @@ const sharedWithMeView = {
|
|||||||
const isFile = 'mime_type' in item;
|
const isFile = 'mime_type' in item;
|
||||||
const type = isFile ? 'file' : 'folder';
|
const type = isFile ? 'file' : 'folder';
|
||||||
if (favorites.isFavorite(item.id, type)) {
|
if (favorites.isFavorite(item.id, type)) {
|
||||||
await favorites.removeFromFavorites(item.id, type);
|
await favorites.removeFromFavorites(item.id, type, item.name);
|
||||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
} else {
|
} else {
|
||||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
@@ -245,7 +269,7 @@ const sharedWithMeView = {
|
|||||||
batchToolbar.setActiveComponent(null);
|
batchToolbar.setActiveComponent(null);
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) ownerTooltip.destroy(filesList);
|
if (filesList) itemTooltip.destroy(filesList);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||||
@@ -275,7 +299,8 @@ const sharedWithMeView = {
|
|||||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||||
limit: 50,
|
limit: 50,
|
||||||
cursor: this._nextCursor ?? undefined,
|
cursor: this._nextCursor ?? undefined,
|
||||||
orderBy
|
orderBy,
|
||||||
|
reverse: this._reversed
|
||||||
});
|
});
|
||||||
|
|
||||||
this._nextCursor = data.next_cursor ?? null;
|
this._nextCursor = data.next_cursor ?? null;
|
||||||
@@ -294,14 +319,14 @@ const sharedWithMeView = {
|
|||||||
const items = this._mapItems(data.items);
|
const items = this._mapItems(data.items);
|
||||||
|
|
||||||
if (isFirstPage) {
|
if (isFirstPage) {
|
||||||
this._component?.render(items, def?.keyFn, def?.labelFn);
|
this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
|
||||||
} else {
|
} 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
|
// Wire owner tooltips after items are in the DOM
|
||||||
const filesList = document.getElementById('files-list');
|
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).
|
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
||||||
await this._component?.resolveOwnerCells();
|
await this._component?.resolveOwnerCells();
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "مسح الأخيرة",
|
"clear": "مسح الأخيرة",
|
||||||
"accessed": "تم الوصول",
|
"accessed": "تم الوصول",
|
||||||
"empty_state": "لا توجد ملفات حديثة",
|
"empty_state": "لا توجد ملفات حديثة",
|
||||||
"empty_hint": "الملفات التي تفتحها ستظهر هنا"
|
"empty_hint": "الملفات التي تفتحها ستظهر هنا",
|
||||||
|
"loadMore": "تحميل المزيد"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"file_renamed": "تمت إعادة تسمية الملف",
|
"file_renamed": "تمت إعادة تسمية الملف",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"title": "التجميع حسب",
|
"title": "التجميع حسب",
|
||||||
"owner": "المالك",
|
"owner": "المالك",
|
||||||
"shareDate": "تاريخ المشاركة"
|
"shareDate": "تاريخ المشاركة",
|
||||||
|
"type": "النوع",
|
||||||
|
"type.folders": "المجلدات",
|
||||||
|
"accessedAt": "تاريخ الوصول",
|
||||||
|
"modifiedAt": "تاريخ التعديل",
|
||||||
|
"createdAt": "تاريخ الإنشاء",
|
||||||
|
"size": "الحجم",
|
||||||
|
"favoriteDate": "تاريخ المفضلة"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "اليوم",
|
"today": "اليوم",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Zuletzt verwendete löschen",
|
"clear": "Zuletzt verwendete löschen",
|
||||||
"accessed": "Zugegriffen",
|
"accessed": "Zugegriffen",
|
||||||
"empty_state": "Keine zuletzt verwendeten Dateien",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "Datei umbenannt",
|
"file_renamed": "Datei umbenannt",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"title": "Gruppieren nach",
|
"title": "Gruppieren nach",
|
||||||
"owner": "Eigentümer",
|
"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": {
|
"dateBucket": {
|
||||||
"today": "Heute",
|
"today": "Heute",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Clear recent",
|
"clear": "Clear recent",
|
||||||
"accessed": "Accessed",
|
"accessed": "Accessed",
|
||||||
"empty_state": "No recent files",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "File renamed",
|
"file_renamed": "File renamed",
|
||||||
@@ -714,8 +715,15 @@
|
|||||||
"groupby": {
|
"groupby": {
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"title": "Group by",
|
"title": "Group by",
|
||||||
|
"type": "Type",
|
||||||
|
"type.folders": "Folders",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
"shareDate": "Share date"
|
"shareDate": "Share date",
|
||||||
|
"favoriteDate": "Favorite date",
|
||||||
|
"accessedAt": "Accessed date",
|
||||||
|
"modifiedAt": "Modified date",
|
||||||
|
"createdAt": "Created date",
|
||||||
|
"size": "Size"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Limpiar recientes",
|
"clear": "Limpiar recientes",
|
||||||
"accessed": "Accedido",
|
"accessed": "Accedido",
|
||||||
"empty_state": "No hay archivos recientes",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "Archivo renombrado",
|
"file_renamed": "Archivo renombrado",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "Ninguno",
|
"none": "Ninguno",
|
||||||
"title": "Agrupar por",
|
"title": "Agrupar por",
|
||||||
"owner": "Propietario",
|
"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": {
|
"dateBucket": {
|
||||||
"today": "Hoy",
|
"today": "Hoy",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "پاک کردن اخیر",
|
"clear": "پاک کردن اخیر",
|
||||||
"accessed": "دسترسی یافته",
|
"accessed": "دسترسی یافته",
|
||||||
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
|
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
|
||||||
"empty_hint": "پروندههایی که باز میکنید اینجا ظاهر میشوند"
|
"empty_hint": "پروندههایی که باز میکنید اینجا ظاهر میشوند",
|
||||||
|
"loadMore": "بارگذاری بیشتر"
|
||||||
},
|
},
|
||||||
"batch": {
|
"batch": {
|
||||||
"one_selected": "۱ مورد انتخاب شده",
|
"one_selected": "۱ مورد انتخاب شده",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "هیچ",
|
"none": "هیچ",
|
||||||
"title": "گروهبندی بر اساس",
|
"title": "گروهبندی بر اساس",
|
||||||
"owner": "مالک",
|
"owner": "مالک",
|
||||||
"shareDate": "تاریخ اشتراک"
|
"shareDate": "تاریخ اشتراک",
|
||||||
|
"type": "نوع",
|
||||||
|
"type.folders": "پوشهها",
|
||||||
|
"accessedAt": "تاریخ دسترسی",
|
||||||
|
"modifiedAt": "تاریخ تغییر",
|
||||||
|
"createdAt": "تاریخ ایجاد",
|
||||||
|
"size": "اندازه",
|
||||||
|
"favoriteDate": "تاریخ مورد علاقه"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "امروز",
|
"today": "امروز",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Effacer les récents",
|
"clear": "Effacer les récents",
|
||||||
"accessed": "Consulté",
|
"accessed": "Consulté",
|
||||||
"empty_state": "Aucun fichier récent",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "Fichier renommé",
|
"file_renamed": "Fichier renommé",
|
||||||
@@ -714,8 +715,15 @@
|
|||||||
"groupby": {
|
"groupby": {
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"title": "Grouper par",
|
"title": "Grouper par",
|
||||||
|
"type": "Type",
|
||||||
|
"type.folders": "Dossiers",
|
||||||
"owner": "Propriétaire",
|
"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": {
|
"dateBucket": {
|
||||||
"today": "Aujourd'hui",
|
"today": "Aujourd'hui",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "हाल ही का साफ़ करें",
|
"clear": "हाल ही का साफ़ करें",
|
||||||
"accessed": "एक्सेस किया",
|
"accessed": "एक्सेस किया",
|
||||||
"empty_state": "कोई हाल की फ़ाइलें नहीं",
|
"empty_state": "कोई हाल की फ़ाइलें नहीं",
|
||||||
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी"
|
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी",
|
||||||
|
"loadMore": "और लोड करें"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"file_renamed": "फ़ाइल का नाम बदला गया",
|
"file_renamed": "फ़ाइल का नाम बदला गया",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "कोई नहीं",
|
"none": "कोई नहीं",
|
||||||
"title": "इसके अनुसार समूहीकृत करें",
|
"title": "इसके अनुसार समूहीकृत करें",
|
||||||
"owner": "स्वामी",
|
"owner": "स्वामी",
|
||||||
"shareDate": "साझा तिथि"
|
"shareDate": "साझा तिथि",
|
||||||
|
"type": "प्रकार",
|
||||||
|
"type.folders": "फ़ोल्डर",
|
||||||
|
"accessedAt": "पहुँच की तारीख",
|
||||||
|
"modifiedAt": "संशोधन की तारीख",
|
||||||
|
"createdAt": "बनाने की तारीख",
|
||||||
|
"size": "आकार",
|
||||||
|
"favoriteDate": "पसंदीदा की तारीख"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "आज",
|
"today": "आज",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Cancella recenti",
|
"clear": "Cancella recenti",
|
||||||
"accessed": "Accesso",
|
"accessed": "Accesso",
|
||||||
"empty_state": "Nessun file recente",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "File rinominato",
|
"file_renamed": "File rinominato",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "Nessuno",
|
"none": "Nessuno",
|
||||||
"title": "Raggruppa per",
|
"title": "Raggruppa per",
|
||||||
"owner": "Proprietario",
|
"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": {
|
"dateBucket": {
|
||||||
"today": "Oggi",
|
"today": "Oggi",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "最近をクリア",
|
"clear": "最近をクリア",
|
||||||
"accessed": "アクセス日",
|
"accessed": "アクセス日",
|
||||||
"empty_state": "最近のファイルはありません",
|
"empty_state": "最近のファイルはありません",
|
||||||
"empty_hint": "開いたファイルがここに表示されます"
|
"empty_hint": "開いたファイルがここに表示されます",
|
||||||
|
"loadMore": "さらに読み込む"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"file_renamed": "ファイル名を変更しました",
|
"file_renamed": "ファイル名を変更しました",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "なし",
|
"none": "なし",
|
||||||
"title": "グループ化",
|
"title": "グループ化",
|
||||||
"owner": "オーナー",
|
"owner": "オーナー",
|
||||||
"shareDate": "共有日"
|
"shareDate": "共有日",
|
||||||
|
"type": "種類",
|
||||||
|
"type.folders": "フォルダー",
|
||||||
|
"accessedAt": "アクセス日",
|
||||||
|
"modifiedAt": "更新日",
|
||||||
|
"createdAt": "作成日",
|
||||||
|
"size": "サイズ",
|
||||||
|
"favoriteDate": "お気に入り登録日"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "최근 항목 지우기",
|
"clear": "최근 항목 지우기",
|
||||||
"accessed": "접근일",
|
"accessed": "접근일",
|
||||||
"empty_state": "최근 파일이 없습니다",
|
"empty_state": "최근 파일이 없습니다",
|
||||||
"empty_hint": "열어본 파일이 여기에 표시됩니다"
|
"empty_hint": "열어본 파일이 여기에 표시됩니다",
|
||||||
|
"loadMore": "더 불러오기"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"file_renamed": "파일 이름이 변경되었습니다",
|
"file_renamed": "파일 이름이 변경되었습니다",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "없음",
|
"none": "없음",
|
||||||
"title": "그룹화 기준",
|
"title": "그룹화 기준",
|
||||||
"owner": "소유자",
|
"owner": "소유자",
|
||||||
"shareDate": "공유 날짜"
|
"shareDate": "공유 날짜",
|
||||||
|
"type": "유형",
|
||||||
|
"type.folders": "폴더",
|
||||||
|
"accessedAt": "접근 날짜",
|
||||||
|
"modifiedAt": "수정 날짜",
|
||||||
|
"createdAt": "생성 날짜",
|
||||||
|
"size": "크기",
|
||||||
|
"favoriteDate": "즐겨찾기 날짜"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "오늘",
|
"today": "오늘",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Recente wissen",
|
"clear": "Recente wissen",
|
||||||
"accessed": "Geopend",
|
"accessed": "Geopend",
|
||||||
"empty_state": "Geen recente bestanden",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "Bestand hernoemd",
|
"file_renamed": "Bestand hernoemd",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "Geen",
|
"none": "Geen",
|
||||||
"title": "Groeperen op",
|
"title": "Groeperen op",
|
||||||
"owner": "Eigenaar",
|
"owner": "Eigenaar",
|
||||||
"shareDate": "Deeldatum"
|
"shareDate": "Deeldatum",
|
||||||
|
"type": "Type",
|
||||||
|
"type.folders": "Mappen",
|
||||||
|
"accessedAt": "Toegangsdatum",
|
||||||
|
"modifiedAt": "Wijzigingsdatum",
|
||||||
|
"createdAt": "Aanmaakdatum",
|
||||||
|
"size": "Grootte",
|
||||||
|
"favoriteDate": "Favoritendatum"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Vandaag",
|
"today": "Vandaag",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Wyczyść ostatnie",
|
"clear": "Wyczyść ostatnie",
|
||||||
"accessed": "Otwarte",
|
"accessed": "Otwarte",
|
||||||
"empty_state": "Brak ostatnich plików",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "Zmieniono nazwę pliku",
|
"file_renamed": "Zmieniono nazwę pliku",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "Brak",
|
"none": "Brak",
|
||||||
"title": "Grupuj według",
|
"title": "Grupuj według",
|
||||||
"owner": "Właściciel",
|
"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": {
|
"dateBucket": {
|
||||||
"today": "Dzisiaj",
|
"today": "Dzisiaj",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Limpar recentes",
|
"clear": "Limpar recentes",
|
||||||
"accessed": "Acessado",
|
"accessed": "Acessado",
|
||||||
"empty_state": "Nenhum arquivo recente",
|
"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": {
|
"notifications": {
|
||||||
"file_renamed": "Arquivo renomeado",
|
"file_renamed": "Arquivo renomeado",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "Nenhum",
|
"none": "Nenhum",
|
||||||
"title": "Agrupar por",
|
"title": "Agrupar por",
|
||||||
"owner": "Proprietário",
|
"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": {
|
"dateBucket": {
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "Очистить недавние",
|
"clear": "Очистить недавние",
|
||||||
"accessed": "Открыт",
|
"accessed": "Открыт",
|
||||||
"empty_state": "Нет недавних файлов",
|
"empty_state": "Нет недавних файлов",
|
||||||
"empty_hint": "Открытые вами файлы будут отображаться здесь"
|
"empty_hint": "Открытые вами файлы будут отображаться здесь",
|
||||||
|
"loadMore": "Загрузить ещё"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"file_renamed": "Файл переименован",
|
"file_renamed": "Файл переименован",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "Нет",
|
"none": "Нет",
|
||||||
"title": "Группировать по",
|
"title": "Группировать по",
|
||||||
"owner": "Владелец",
|
"owner": "Владелец",
|
||||||
"shareDate": "Дата общего доступа"
|
"shareDate": "Дата общего доступа",
|
||||||
|
"type": "Тип",
|
||||||
|
"type.folders": "Папки",
|
||||||
|
"accessedAt": "Дата доступа",
|
||||||
|
"modifiedAt": "Дата изменения",
|
||||||
|
"createdAt": "Дата создания",
|
||||||
|
"size": "Размер",
|
||||||
|
"favoriteDate": "Дата добавления в избранное"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "Сегодня",
|
"today": "Сегодня",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
"clear": "清除最近",
|
"clear": "清除最近",
|
||||||
"accessed": "訪問於",
|
"accessed": "訪問於",
|
||||||
"empty_state": "沒有最近檔案",
|
"empty_state": "沒有最近檔案",
|
||||||
"empty_hint": "您開啟的檔案將顯示在這裡"
|
"empty_hint": "您開啟的檔案將顯示在這裡",
|
||||||
|
"loadMore": "載入更多"
|
||||||
},
|
},
|
||||||
"batch": {
|
"batch": {
|
||||||
"one_selected": "已選擇 1 個專案",
|
"one_selected": "已選擇 1 個專案",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "無",
|
"none": "無",
|
||||||
"title": "分組方式",
|
"title": "分組方式",
|
||||||
"owner": "擁有者",
|
"owner": "擁有者",
|
||||||
"shareDate": "分享日期"
|
"shareDate": "分享日期",
|
||||||
|
"type": "類型",
|
||||||
|
"type.folders": "資料夾",
|
||||||
|
"accessedAt": "存取日期",
|
||||||
|
"modifiedAt": "修改日期",
|
||||||
|
"createdAt": "建立日期",
|
||||||
|
"size": "大小",
|
||||||
|
"favoriteDate": "收藏日期"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
|
|||||||
+10
-2
@@ -430,7 +430,8 @@
|
|||||||
"clear": "清除最近",
|
"clear": "清除最近",
|
||||||
"accessed": "访问于",
|
"accessed": "访问于",
|
||||||
"empty_state": "没有最近文件",
|
"empty_state": "没有最近文件",
|
||||||
"empty_hint": "您打开的文件将显示在这里"
|
"empty_hint": "您打开的文件将显示在这里",
|
||||||
|
"loadMore": "加载更多"
|
||||||
},
|
},
|
||||||
"batch": {
|
"batch": {
|
||||||
"one_selected": "已选择 1 个项目",
|
"one_selected": "已选择 1 个项目",
|
||||||
@@ -715,7 +716,14 @@
|
|||||||
"none": "无",
|
"none": "无",
|
||||||
"title": "分组方式",
|
"title": "分组方式",
|
||||||
"owner": "所有者",
|
"owner": "所有者",
|
||||||
"shareDate": "分享日期"
|
"shareDate": "分享日期",
|
||||||
|
"type": "类型",
|
||||||
|
"type.folders": "文件夹",
|
||||||
|
"accessedAt": "访问日期",
|
||||||
|
"modifiedAt": "修改日期",
|
||||||
|
"createdAt": "创建日期",
|
||||||
|
"size": "大小",
|
||||||
|
"favoriteDate": "收藏日期"
|
||||||
},
|
},
|
||||||
"dateBucket": {
|
"dateBucket": {
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
|
|||||||
Reference in New Issue
Block a user