Merge pull request #399 from EdouardVanbelle/feat/implement-cursor-on-main-lists

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