From c65f2b538507746b8ed99e8598ec04d0645ab74c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 26 May 2026 17:52:28 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(api):=20cursor=20listing=20contract=20?= =?UTF-8?q?=E2=80=94=20PageCursor=20trait=20+=20resource=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/application/dtos/cursor.rs with three shared types: · PageCursor trait — default base64url+JSON encode/decode; one bare impl line per cursor struct · CursorQuery struct — standard limit/cursor/sort_by query params with limit_clamped() and decode_cursor() helpers; compose via flatten · CursorListResponse — standard {items, next_cursor?} envelope with from_oversized() and with_cursor() builders - Migrate GrantCursor to impl PageCursor (remove duplicate encode/decode) - Update GET /api/grants/incoming/resources: · SharedWithMeQuery now embeds CursorQuery via #[serde(flatten)] · Replace file/folder nullable pair with ResourceContentDto (untagged enum) under a single always-present 'resource' field · SharedWithMeDto is now a type alias for CursorListResponse · Handler uses q.paging.limit_clamped() and decode_cursor() - Add docs/architecture/resource-listing.md — authoritative contract for all listing endpoints (cursor design, SQL keyset WHERE, sort_by naming, Rust + JS skeletons, compliance table, migration guide) - Register doc in VitePress sidebar and architecture index Co-Authored-By: Claude Sonnet 4.6 --- docs/.vitepress/config.mts | 2 + docs/architecture/index.md | 1 + docs/architecture/resource-listing.md | 331 ++++++++++++++++++ src/application/dtos/cursor.rs | 161 +++++++++ src/application/dtos/grant_dto.rs | 65 ++-- src/application/dtos/mod.rs | 2 + src/domain/services/authorization.rs | 18 +- src/interfaces/api/handlers/grant_handler.rs | 25 +- static/js/core/types.js | 5 +- .../js/views/sharedWithMe/sharedWithMeView.js | 8 +- 10 files changed, 565 insertions(+), 53 deletions(-) create mode 100644 docs/architecture/resource-listing.md create mode 100644 src/application/dtos/cursor.rs diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 30f2171e..a3e6a9fb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -96,10 +96,12 @@ export default defineConfig({ items: [ { text: "Internal Architecture", link: "/architecture/" }, { text: "Caching", link: "/architecture/caching" }, + { text: "Resource Listing API", link: "/architecture/resource-listing" }, { text: "Storage Safety", link: "/architecture/file-system-safety" }, { text: "Database Transactions", link: "/architecture/database-transactions" }, { text: "Share Integration", link: "/architecture/share-integration" }, { text: "Storage Quotas", link: "/architecture/storage-quotas" }, + { text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" }, ], }, { text: "FAQ", link: "/faq" }, diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 26bc07ae..b5cd0c6f 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -71,4 +71,5 @@ src/ ## Further Reading - [Caching Architecture →](/architecture/caching) +- [Resource Listing API →](/architecture/resource-listing) - [Storage Quotas →](/architecture/storage-quotas) diff --git a/docs/architecture/resource-listing.md b/docs/architecture/resource-listing.md new file mode 100644 index 00000000..7aaf7497 --- /dev/null +++ b/docs/architecture/resource-listing.md @@ -0,0 +1,331 @@ +# Resource Listing API Contract + +Every OxiCloud endpoint that returns a **collection** of items must follow the conventions in +this document. Consistency makes the REST API predictable for clients and keeps server-side +code easy to audit and extend. + +## TL;DR checklist + +- [ ] Response is `CursorListResponse` → `{ items: T[], next_cursor?: string }` +- [ ] Query embeds `CursorQuery` via `#[serde(flatten)]` +- [ ] `limit` is clamped with `q.paging.limit_clamped()` — never trust the raw value +- [ ] Cursor is decoded with `q.paging.decode_cursor::()` — invalid cursor → first page +- [ ] Cursor struct implements `PageCursor` (one bare `impl` line) +- [ ] Cursor includes **every column** in `ORDER BY` plus a unique tiebreaker (`id`) +- [ ] `sort_by` param is present even if only one sort value is meaningful today +- [ ] SQL fetches `limit + 1` rows to detect whether a next page exists + +--- + +## Response envelope — `CursorListResponse` + +All listing endpoints return the same wrapper (defined in +`src/application/dtos/cursor.rs`): + +```json +{ + "items": [ … ], + "next_cursor": "eyJncmFudGVkX2F0IjoiMjAyNi…" +} +``` + +| Field | Type | Rules | +|---|---|---| +| `items` | `T[]` | The page of results. Length ≤ `limit`. | +| `next_cursor` | `string` | **Omitted** (not `null`) when this is the last page. | + +**Never** include `total`, `page`, or `offset` — computing a total requires a `COUNT(*)` that +does not scale. + +--- + +## Resource content field + +When an item can be a **file or a folder** (or any future resource type), use a single +`resource` field rather than nullable `file`/`folder` siblings. The existing +`resource_type` discriminator tells the client which shape to expect. + +```json +{ + "resource_type": "file", + "resource": { "id": "…", "name": "photo.jpg", "size": 204800, … }, + … +} +``` + +Adding a third resource type in the future only requires a new `resource_type` variant — +the wrapper shape stays the same, so older clients that don't know the new variant simply +skip the item. + +### Rust — `ResourceContentDto` + +```rust +#[derive(Debug, Serialize, ToSchema)] +#[serde(untagged)] // ← serialises as the inner object; no wrapper key +pub enum ResourceContentDto { + File(FileDto), + Folder(FolderDto), + // Playlist(PlaylistDto), ← add future variants here +} +``` + +### JavaScript / JSDoc + +```js +/** + * @typedef {Object} MyListItem + * @property {'file'|'folder'} resource_type + * @property {FileItem|FolderItem} resource // always present; shape follows resource_type + */ + +const f = item.resource; +if (item.resource_type === 'folder') { /* FolderItem fields */ } +else { /* FileItem fields */ } +``` + +--- + +## Standard query parameters — `CursorQuery` + +`CursorQuery` (in `src/application/dtos/cursor.rs`) carries the three fields every listing +endpoint needs. Use it directly as `Query` when there are no extra filters. +When extra params are needed, **repeat the three fields** in your endpoint-specific struct +— Axum's query extractor uses `serde_urlencoded` which does not support +`#[serde(flatten)]`: + +```rust +#[derive(Debug, Deserialize, IntoParams)] +pub struct MyQuery { + // Standard cursor fields — repeated (not flattened) due to serde_urlencoded limitation + #[serde(default = "CursorQuery::default_limit")] + pub limit: u32, + pub cursor: Option, + pub sort_by: Option, + // Endpoint-specific extra + pub status: Option, +} +``` + +| Parameter | Type | Default | Constraints | +|---|---|---|---| +| `limit` | integer | 50 | 1–200; use `q.paging.limit_clamped()` | +| `cursor` | string | — | Opaque; absent on first page | +| `sort_by` | string | endpoint-defined | See §Sort values below | + +### Naming conventions + +- Use `sort_by`, **not** `order`, `orderBy`, or `sort`. +- Values are **snake_case**: `granted_at`, `name`, `size`, `granted_by`. +- Append `_desc` for descending: `name_desc`, `size_desc`. No separate `direction` param. +- Default sort is the most natural recency order (usually `created_at DESC`). +- Return **HTTP 400** for unknown `sort_by` values. + +--- + +## Cursor design — `PageCursor` trait + +Use **keyset (seek) pagination** — never offset-based pagination. + +### Why not offset? + +`OFFSET N` forces the database to scan and discard N rows on every page load, which becomes +unacceptably slow for large collections. Keyset pagination skips directly to the right row +via an index seek, regardless of page depth. + +### Implementing a cursor + +`PageCursor` (in `src/application/dtos/cursor.rs`) provides `encode`/`decode` as default +methods. A cursor struct needs only a bare `impl` line: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MyCursor { + pub created_at: DateTime, + pub id: Uuid, // tiebreaker — must be unique +} + +impl PageCursor for MyCursor {} // encode/decode for free +``` + +**The cursor must include every column in `ORDER BY`** plus a unique tiebreaker so that two +rows with identical sort values never cause items to be skipped or repeated. + +| Sort | Cursor fields | +|---|---| +| `created_at DESC` (default) | `created_at`, `id` | +| `granted_by ASC` | `granted_by`, `created_at`, `id` | +| `name ASC` | `name`, `id` | + +Encoding is URL-safe base64url (no padding) over a JSON payload — opaque to API callers. +An undecodable cursor is treated as "start from the top" (never an error). + +--- + +## SQL implementation + +Fetch **`limit + 1`** rows. If more than `limit` rows are returned, a next page exists: +truncate to `limit` and encode the last kept item as the next cursor. + +```sql +-- Default: ORDER BY created_at DESC, id DESC +WHERE ( + $cursor_created_at IS NULL -- first page + OR created_at < $cursor_created_at + OR (created_at = $cursor_created_at AND id < $cursor_id::uuid) +) +ORDER BY created_at DESC, id DESC +LIMIT $limit + 1 +``` + +For an additional sort dimension (e.g. `sort_by = "granted_by"`): + +```sql +-- ORDER BY granted_by ASC, created_at DESC, id DESC +WHERE ( + $cursor_granted_by IS NULL + OR granted_by > $cursor_granted_by + OR (granted_by = $cursor_granted_by AND created_at < $cursor_created_at) + OR (granted_by = $cursor_granted_by AND created_at = $cursor_created_at + AND id < $cursor_id::uuid) +) +ORDER BY granted_by ASC, created_at DESC, id DESC +LIMIT $limit + 1 +``` + +--- + +## Rust implementation skeleton + +```rust +// ── DTO layer ──────────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +pub struct ThingCursor { pub created_at: DateTime, pub id: Uuid } +impl PageCursor for ThingCursor {} + +#[derive(Deserialize, IntoParams)] +pub struct ThingQuery { + #[serde(flatten)] + pub paging: CursorQuery, + pub status: Option, +} + +// ── Handler ────────────────────────────────────────────────────────────────── + +pub async fn list_things( + Query(q): Query, + State(state): State, + auth_user: AuthUser, +) -> impl IntoResponse { + let limit = q.paging.limit_clamped(); + let cursor = q.paging.decode_cursor::(); + let sort = q.paging.sort_by.as_deref().unwrap_or("created_at"); + + // Service returns limit+1 rows; the cursor comes from the service layer + // (it knows which columns to include based on the sort). + let (rows, next_cursor) = state.service + .list_things(auth_user.id, limit + 1, cursor, sort) + .await?; + + Json(CursorListResponse::with_cursor( + rows.into_iter().take(limit).map(ThingDto::from).collect(), + next_cursor.map(|c| c.encode()), + )) +} +``` + +--- + +## JavaScript consumption pattern + +```js +// static/js/core/types.js +/** + * @template T + * @typedef {Object} CursorListResponse + * @property {T[]} items + * @property {string} [next_cursor] // absent on last page + */ + +// View module (e.g. sharedWithMeView.js) +let _cursor = null; +let _loading = false; + +async function loadPage() { + if (_loading) return; + _loading = true; + try { + const params = new URLSearchParams({ limit: '50' }); + if (_sortBy) params.set('sort_by', _sortBy); + if (_cursor) params.set('cursor', _cursor); + + /** @type {CursorListResponse} */ + const data = await fetch(`/api/things?${params}`).then(r => r.json()); + renderItems(data.items); + _cursor = data.next_cursor ?? null; + loadMoreBtn.hidden = _cursor === null; + } finally { + _loading = false; + } +} + +// Reset on section entry or sort change: +function reset() { _cursor = null; clearList(); loadPage(); } +``` + +--- + +## Sort values reference + +| Value | SQL ORDER BY | Typical use | +|---|---|---| +| `created_at` (default) | `created_at DESC, id DESC` | Newest first | +| `created_at_asc` | `created_at ASC, id ASC` | Oldest first | +| `granted_by` | `granted_by ASC, created_at DESC, id DESC` | Swimlane grouping | +| `name` | `lower(name) ASC, id ASC` | Case-insensitive alpha | +| `name_desc` | `lower(name) DESC, id DESC` | Reverse alpha | +| `size` | `size_bytes ASC, id ASC` | Smallest first | +| `size_desc` | `size_bytes DESC, id DESC` | Largest first | + +Only expose sort values that are meaningful for the resource type. The `sort_by` param +must always exist in the query struct, even if only one value is supported today — this +avoids a breaking API change when a second sort is added later. + +--- + +## Endpoint compliance + +| Endpoint | Cursor | `sort_by` | Status | +|---|---|---|---| +| `GET /api/grants/incoming/resources` | ✅ | 🔜 planned | **Reference implementation** | +| `GET /api/photos` | ⚠️ `before` header | ❌ | Non-standard — migrate to body cursor | +| `GET /api/search` | ❌ offset | ✅ | Migrate cursor | +| `GET /api/folders/paginated` | ❌ page | ❌ | Migrate cursor | +| `GET /api/folders/{id}/contents/paginated` | ❌ page | ❌ | Migrate cursor | +| `GET /api/admin/users` | ❌ offset | ❌ | Migrate cursor | +| `GET /api/address-books/{id}/contacts` | ❌ offset | ❌ | Migrate cursor | +| `GET /api/shares` | ❌ page | ❌ | Migrate cursor | +| `GET /api/playlists` | ❌ offset | ❌ | Migrate cursor | +| `GET /api/recent` | ❌ limit only | ❌ | Migrate cursor | +| `GET /api/files` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/folders` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/folders/{id}/listing` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/favorites` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/trash` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/grants/incoming` | ❌ **none** | ❌ | Unbounded | +| `GET /api/grants/outgoing` | ❌ **none** | ❌ | Unbounded | + +--- + +## Migration guide — offset/none → cursor + +1. **Add `CursorQuery`** via `#[serde(flatten)]` to the query struct; remove `page`, + `offset`, `per_page`. +2. **Define a cursor struct** with the `ORDER BY` columns + `id`; add `impl PageCursor`. +3. **Adjust SQL** to the keyset `WHERE` pattern; fetch `limit + 1`. +4. **Return `CursorListResponse`** built with `from_oversized` or `with_cursor`. +5. **Remove** `total`, `total_pages`, `has_next`, `has_prev` from the response. +6. **Update the JS caller**: remove page tracking, add `_cursor` state, pass it on + "Load more", reset to `null` on section entry. +7. **Update `types.js`**: remove old pagination typedef fields, add + `next_cursor?: string` to the response typedef. diff --git a/src/application/dtos/cursor.rs b/src/application/dtos/cursor.rs new file mode 100644 index 00000000..74f2a79e --- /dev/null +++ b/src/application/dtos/cursor.rs @@ -0,0 +1,161 @@ +//! Standard types for cursor-based, sortable listing endpoints. +//! +//! All `GET` endpoints that return a collection **must** use these types so +//! that every listing is consistent for API consumers. +//! +//! # Quick start +//! +//! ```rust,ignore +//! // 1. Define a cursor for your endpoint +//! #[derive(Serialize, Deserialize)] +//! pub struct MyCursor { pub created_at: DateTime, pub id: Uuid } +//! impl PageCursor for MyCursor {} // encode/decode for free +//! +//! // 2. Compose the standard query params +//! #[derive(Deserialize, IntoParams)] +//! pub struct MyQuery { +//! #[serde(flatten)] +//! pub paging: CursorQuery, +//! pub my_filter: Option, // endpoint-specific extras +//! } +//! +//! // 3. Return the standard envelope +//! async fn list_things(Query(q): Query, …) -> Json> { +//! let limit = q.paging.limit_clamped(); +//! let cursor = q.paging.decode_cursor::(); +//! // fetch limit+1 rows … +//! Json(CursorListResponse::from_oversized(rows, limit, |r| MyCursor { … })) +//! } +//! ``` + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +// ToSchema is used on CursorQuery so it can be flattened into IntoParams structs + +// ════════════════════════════════════════════════════════════════════════════ +// PageCursor trait +// ════════════════════════════════════════════════════════════════════════════ + +/// Marker trait for opaque keyset-pagination cursors. +/// +/// The default `encode` / `decode` implementations use +/// URL-safe base64url (no padding) over a JSON serialisation of `Self`. +/// Any struct that derives `Serialize + Deserialize` can implement this +/// with a bare `impl PageCursor for MyCursor {}`. +/// +/// The encoding is intentionally opaque to API callers. Treat an +/// undecodable cursor as "start from the top" — never return an error. +pub trait PageCursor: Sized + Serialize + for<'de> Deserialize<'de> { + /// Encode `self` as a URL-safe, no-padding base64url string. + fn encode(&self) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).unwrap_or_default()) + } + + /// Decode from a base64url string. Returns `None` on any parse failure. + fn decode(s: &str) -> Option { + let bytes = URL_SAFE_NO_PAD.decode(s).ok()?; + serde_json::from_slice(&bytes).ok() + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// CursorQuery — standard query params +// ════════════════════════════════════════════════════════════════════════════ + +/// Standard query parameters for cursor-based listing endpoints. +/// +/// Use `CursorQuery` directly as the `Query` extractor when an +/// endpoint has no extra filter params. When extra params are needed, declare +/// them in an endpoint-specific struct and **repeat** the three fields — Axum's +/// query extractor uses `serde_urlencoded` which does not support +/// `#[serde(flatten)]`. Use `CursorQuery::default_limit()` for the default +/// and the helpers `limit_clamped()` / `decode_cursor()` by either calling +/// them on `CursorQuery` directly or re-implementing them inline: +/// +/// ```rust,ignore +/// #[derive(Deserialize, IntoParams)] +/// pub struct MyQuery { +/// #[serde(default = "CursorQuery::default_limit")] +/// pub limit: u32, +/// pub cursor: Option, +/// pub sort_by: Option, +/// pub status: Option, // endpoint-specific +/// } +/// ``` +#[derive(Debug, Deserialize, IntoParams, ToSchema)] +pub struct CursorQuery { + /// Maximum items per page (1–200, default 50). + #[serde(default = "CursorQuery::default_limit")] + pub limit: u32, + /// Opaque cursor from a previous response. Absent on the first page. + pub cursor: Option, + /// Sort dimension. Valid values are endpoint-defined (e.g. `"granted_at"`, + /// `"name"`, `"granted_by"`). Unknown values should return HTTP 400. + pub sort_by: Option, +} + +impl CursorQuery { + /// Default value for the `limit` field — exposed `pub` so endpoint-specific + /// query structs can reference it in `#[serde(default = "CursorQuery::default_limit")]`. + pub fn default_limit() -> u32 { + 50 + } + + /// Returns `limit` clamped to `[1, 200]`. + pub fn limit_clamped(&self) -> usize { + self.limit.clamp(1, 200) as usize + } + + /// Decode the optional cursor string into type `C`. + /// Returns `None` when no cursor is present or when decoding fails + /// (invalid cursor → start from the top). + pub fn decode_cursor(&self) -> Option { + self.cursor.as_deref().and_then(C::decode) + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// CursorListResponse — standard response envelope +// ════════════════════════════════════════════════════════════════════════════ + +/// Standard response envelope for cursor-paginated listing endpoints. +/// +/// `next_cursor` is omitted from the JSON when `None` (i.e. last page). +/// Callers must treat a missing `next_cursor` as end-of-results — never +/// include a `total` count (that would require an expensive `COUNT(*)`). +#[derive(Debug, Serialize, ToSchema)] +pub struct CursorListResponse { + pub items: Vec, + /// Opaque cursor for the next page. Absent when this is the last page. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl CursorListResponse { + /// Build a response from an over-fetched slice (fetch `limit + 1` rows). + /// + /// If `items.len() > limit` a next page exists: `items` is truncated to + /// `limit` and `cursor_fn` is called on the **last kept item** to produce + /// the next cursor. Otherwise `next_cursor` is `None`. + pub fn from_oversized( + mut items: Vec, + limit: usize, + cursor_fn: impl FnOnce(&T) -> C, + ) -> Self { + let next_cursor = if items.len() > limit { + let c = cursor_fn(&items[limit - 1]); + items.truncate(limit); + Some(c.encode()) + } else { + None + }; + Self { items, next_cursor } + } + + /// Build a response when the next cursor is already known (e.g. returned + /// by a service layer that handles the `limit+1` logic internally). + pub fn with_cursor(items: Vec, next_cursor: Option) -> Self { + Self { items, next_cursor } + } +} diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 23553a0c..d6b1030d 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; +use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; @@ -232,26 +233,58 @@ impl From for GrantDto { // ════════════════════════════════════════════════════════════════════════════ /// Query parameters for `GET /api/grants/incoming/resources`. +/// +/// `limit`, `cursor`, and `sort_by` follow the standard [`CursorQuery`] +/// contract. They are declared directly here rather than via +/// `#[serde(flatten)]` because `serde_urlencoded` (Axum's query extractor) +/// does not support flattening. #[derive(Debug, Deserialize, IntoParams)] pub struct SharedWithMeQuery { /// Maximum number of items to return (1–200, default 50). - #[serde(default = "shared_with_me_default_limit")] + #[serde(default = "CursorQuery::default_limit")] pub limit: u32, + /// Opaque cursor from a previous response. Omit to start from the + /// most-recently-granted item. + pub cursor: Option, + /// Sort dimension. Supported values: `"granted_at"` (default), + /// `"granted_by"` (for swimlane grouping). + pub sort_by: Option, /// Comma-separated resource types to include, e.g. `file,folder`. /// Omit to return all known types. pub resource_types: Option, - /// Opaque cursor returned by a previous call. Omit to start from the - /// most-recently-granted item. - pub cursor: Option, } -fn shared_with_me_default_limit() -> u32 { - 50 +impl SharedWithMeQuery { + /// 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 { + self.cursor.as_deref().and_then(C::decode) + } } -/// One item in the shared-with-me list. Exactly one of `file` / `folder` is -/// populated, indicated by `resource_type`. Additional optional fields for -/// future resource types (playlist, addressbook, …) will be added here. +/// The resource payload for one item in the shared-with-me list. +/// +/// The variant is discriminated by `resource_type` on the parent +/// [`SharedWithMeItemDto`]. Serialised as the inner object (no wrapper key) +/// via `#[serde(untagged)]`, so consumers see the file/folder fields directly +/// under the `resource` key. +#[derive(Debug, Serialize, ToSchema)] +#[serde(untagged)] +pub enum ResourceContentDto { + File(FileDto), + Folder(FolderDto), +} + +/// One item in the shared-with-me list. +/// +/// `resource_type` indicates whether `resource` contains a file or a folder. +/// Using a single `resource` field (instead of nullable `file`/`folder` pairs) +/// makes adding new resource types backward-compatible — only `resource_type` +/// gains a new variant; the wrapper shape stays the same. #[derive(Debug, Serialize, ToSchema)] pub struct SharedWithMeItemDto { pub resource_type: ResourceTypeDto, @@ -261,17 +294,9 @@ pub struct SharedWithMeItemDto { pub granted_at: chrono::DateTime, /// UUID of the user who created the (earliest) grant. pub granted_by: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub folder: Option, + /// Full resource details. Shape is determined by `resource_type`. + pub resource: ResourceContentDto, } /// Response for `GET /api/grants/incoming/resources`. -#[derive(Debug, Serialize, ToSchema)] -pub struct SharedWithMeDto { - pub items: Vec, - /// Opaque cursor for the next page. Absent when the last page is reached. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, -} +pub type SharedWithMeDto = CursorListResponse; diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 52cb3135..68400c05 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -1,4 +1,6 @@ pub mod address_book_dto; +pub mod cursor; +pub use cursor::{CursorListResponse, CursorQuery, PageCursor}; pub mod app_password_dto; pub mod calendar_dto; pub mod contact_dto; diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 939dfdeb..bdfcbb22 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -5,7 +5,7 @@ //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation //! maps them to / from `storage.access_grants` rows. -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use crate::application::dtos::cursor::PageCursor; use std::fmt; use uuid::Uuid; @@ -265,20 +265,8 @@ pub struct GrantCursor { pub resource_id: Uuid, } -impl GrantCursor { - /// Encode as a URL-safe base64 JSON string (no padding). - pub fn encode(&self) -> String { - let json = serde_json::to_vec(self).unwrap_or_default(); - URL_SAFE_NO_PAD.encode(&json) - } - - /// Decode from a URL-safe base64 JSON string. Returns `None` on any - /// parse failure — callers treat a bad cursor as "start from the top". - pub fn decode(s: &str) -> Option { - let bytes = URL_SAFE_NO_PAD.decode(s).ok()?; - serde_json::from_slice(&bytes).ok() - } -} +/// Delegate encode/decode to the shared [`PageCursor`] trait. +impl PageCursor for GrantCursor {} #[cfg(test)] mod tests { diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index eb813ccb..12c34128 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -18,9 +18,10 @@ use tracing::{error, info, warn}; use utoipa::IntoParams; use uuid::Uuid; +use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto, - SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, + CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, + SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::FileRetrievalUseCase; @@ -320,10 +321,10 @@ pub async fn list_shared_with_me( .unwrap_or_default(); // Clamp limit to 1–200. - let limit = q.limit.clamp(1, 200); + let limit = q.limit_clamped() as u32; // Decode cursor (treat invalid cursor as "start from top"). - let cursor = q.cursor.as_deref().and_then(GrantCursor::decode); + let cursor = q.decode_cursor::(); // Fetch paged summaries from the ACL engine. let (summaries, next_cursor) = match state @@ -384,8 +385,9 @@ pub async fn list_shared_with_me( permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), granted_at: summary.granted_at, granted_by: summary.granted_by, - file: Some(file_dto.clone().without_hierarchy_info()), - folder: None, + resource: ResourceContentDto::File( + file_dto.clone().without_hierarchy_info(), + ), }); } Err(e) if e.kind == ErrorKind::NotFound => { @@ -414,8 +416,9 @@ pub async fn list_shared_with_me( permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), granted_at: summary.granted_at, granted_by: summary.granted_by, - file: None, - folder: Some(folder_dto.clone().without_hierarchy_info()), + resource: ResourceContentDto::Folder( + folder_dto.clone().without_hierarchy_info(), + ), }); } Err(e) if e.kind == ErrorKind::NotFound => { @@ -438,10 +441,10 @@ pub async fn list_shared_with_me( ( StatusCode::OK, - Json(SharedWithMeDto { + Json(SharedWithMeDto::with_cursor( items, - next_cursor: next_cursor.map(|c| c.encode()), - }), + next_cursor.map(|c| c.encode()), + )), ) .into_response() } diff --git a/static/js/core/types.js b/static/js/core/types.js index 2aafe956..51a561b8 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -297,14 +297,13 @@ /** * One item returned by `GET /api/grants/incoming/resources`. - * Exactly one of `file` / `folder` is populated (indicated by `resource_type`). + * `resource_type` discriminates the shape of `resource`. * @typedef {Object} SharedWithMeItem * @property {ResourceTypeEnum} resource_type * @property {PermissionTypeEnum[]} permissions - All permissions the caller holds on this resource. * @property {string} granted_at - ISO-8601 timestamp of the earliest grant. * @property {string} granted_by - UUID of the user who created the grant. - * @property {FileItem|undefined} [file] - Populated when resource_type === 'file'. - * @property {FolderItem|undefined} [folder] - Populated when resource_type === 'folder'. + * @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type. */ /** diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 6cc68ad6..4e3f8f9e 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -150,8 +150,8 @@ const sharedWithMeView = { const ownerMap = new Map(); for (const item of items) { - if (item.resource_type === 'folder' && item.folder) { - const f = item.folder; + if (item.resource_type === 'folder') { + const f = /** @type {FolderItem} */ (item.resource); folders.push( /** @type {FolderItem} */ ({ id: f.id, @@ -168,8 +168,8 @@ const sharedWithMeView = { }) ); ownerMap.set(f.id, item.granted_by); - } else if (item.resource_type === 'file' && item.file) { - const f = item.file; + } else if (item.resource_type === 'file') { + const f = /** @type {FileItem} */ (item.resource); files.push( /** @type {FileItem} */ ({ id: f.id, From 1cd934d5949f5a343a634b3a3322eaf94e775a68 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 26 May 2026 19:51:40 +0200 Subject: [PATCH 2/9] refactor(resourceList): move grid/list view into a resourceList component, purpose normalize on all sections views --- static/css/components/fileManager.css | 17 + static/css/components/multiSelect.css | 30 +- .../{filesView.css => resourceList.css} | 137 ++++- static/css/main.css | 3 +- static/css/views/favorites.css | 28 +- static/css/views/recent.css | 28 +- static/css/views/trash.css | 4 +- static/js/components/resourceList.js | 486 ++++++++++++++++++ static/js/core/types.js | 20 + 9 files changed, 646 insertions(+), 107 deletions(-) create mode 100644 static/css/components/fileManager.css rename static/css/components/{filesView.css => resourceList.css} (76%) create mode 100644 static/js/components/resourceList.js diff --git a/static/css/components/fileManager.css b/static/css/components/fileManager.css new file mode 100644 index 00000000..dec439b4 --- /dev/null +++ b/static/css/components/fileManager.css @@ -0,0 +1,17 @@ +/* File-manager page shell — layout concerns specific to the file-manager section. + * Item rendering (grid cards, list rows, drag ghost) lives in resourceList.css. */ + +.files-container { + padding-top: 3px; /* cards animate on hover; sticky header has positive z-index */ +} + +/* Rubber band / lasso selection rectangle */ +.selection-rect { + position: fixed; + border: 1.5px solid var(--primary-color, var(--color-card-drop-border)); + background-color: var(--color-card-drop-tint); + pointer-events: none; + z-index: 1000; + border-radius: 3px; + display: none; +} diff --git a/static/css/components/multiSelect.css b/static/css/components/multiSelect.css index ed54c193..ee782373 100644 --- a/static/css/components/multiSelect.css +++ b/static/css/components/multiSelect.css @@ -1,30 +1,6 @@ -/* Multi-Select – checkboxes & batch action bar */ -.list-header-checkbox, -.file-item .checkbox-cell { - display: flex; - align-items: center; - justify-content: center; -} - -.list-header-checkbox input[type="checkbox"], -.file-item .checkbox-cell input[type="checkbox"] { - width: 17px; - height: 17px; - cursor: pointer; - accent-color: var(--color-accent); - border-radius: 4px; -} - -.list-header.selection-mode { - grid-template-columns: 36px 1fr; - background-color: var(--color-multiselect-bg); - color: var(--color-multiselect-text); - border-bottom-color: var(--color-multiselect-border); -} - -.list-header.selection-mode .list-header-checkbox input[type="checkbox"] { - accent-color: var(--color-accent); -} +/* Multi-Select – batch action toolbar + * Per-item checkbox styles (.file-item .checkbox-cell, .list-header.selection-mode) + * live in resourceList.css alongside the item renderer. */ .batch-selection-info { display: flex; diff --git a/static/css/components/filesView.css b/static/css/components/resourceList.css similarity index 76% rename from static/css/components/filesView.css rename to static/css/components/resourceList.css index 3a129f6d..ad57be34 100644 --- a/static/css/components/filesView.css +++ b/static/css/components/resourceList.css @@ -1,17 +1,17 @@ -.files-container { - padding-top: 3px; /* due to cards animation on mouse hover and page-sticky-header with a positive z-index */ -} +/* ============================================================ + * ResourceList component styles + * + * All styles for .file-item (both grid cards and list rows), + * the list header, drag ghost, per-item checkboxes, and + * section-specific item modifiers (.favorite-item, .recent-item, + * .trash-item). + * + * Page-level shell (.files-container, .selection-rect) → fileManager.css + * Batch-action toolbar (.batch-selection-bar, .batch-btn …) → multiSelect.css + * Section page headers (.list-header.favorites-header …) → views/*.css + * ============================================================ */ -/* Rubber band / lasso selection rectangle */ -.selection-rect { - position: fixed; - border: 1.5px solid var(--primary-color, var(--color-card-drop-border)); - background-color: var(--color-card-drop-tint); - pointer-events: none; - z-index: 1000; - border-radius: 3px; - display: none; -} +/* ── Base icon container ─────────────────────────────────── */ .file-icon { width: 100px; @@ -35,6 +35,8 @@ z-index: 1; } +/* ── Item states ─────────────────────────────────────────── */ + .file-item { background-color: var(--color-item); } @@ -73,7 +75,36 @@ justify-content: center; } -/* ------------------File list View --------------------- */ +/* ── Per-item checkboxes (list + grid) ───────────────────── */ + +.list-header-checkbox, +.file-item .checkbox-cell { + display: flex; + align-items: center; + justify-content: center; +} + +.list-header-checkbox input[type="checkbox"], +.file-item .checkbox-cell input[type="checkbox"] { + width: 17px; + height: 17px; + cursor: pointer; + accent-color: var(--color-accent); + border-radius: 4px; +} + +.list-header.selection-mode { + grid-template-columns: 36px 1fr; + background-color: var(--color-multiselect-bg); + color: var(--color-multiselect-text); + border-bottom-color: var(--color-multiselect-border); +} + +.list-header.selection-mode .list-header-checkbox input[type="checkbox"] { + accent-color: var(--color-accent); +} + +/* ── List view ───────────────────────────────────────────── */ .list-header { display: grid; @@ -108,7 +139,7 @@ text-align: right; } -/* ── Owner column ─────────────────────────────────────────── */ +/* ── Owner column ────────────────────────────────────────── */ /* Styles applied whenever the cell is visible (hidden class absent). The .hidden utility class (display:none !important) keeps it invisible @@ -183,6 +214,7 @@ justify-self: center; text-align: center; } + .files-list-view .file-item .date-cell { color: var(--color-text-muted); font-size: 14px; @@ -236,7 +268,7 @@ display: inline; } -/* --------------------- Files grid view --------------------------- */ +/* ── Grid view ───────────────────────────────────────────── */ .files-grid-view { display: grid; @@ -289,12 +321,13 @@ 0 6px 18px var(--color-accent-ring-dark); } -/* element hidden on grid view */ +/* elements hidden in grid view */ .files-grid-view .file-item .date-cell, .files-grid-view .file-item .size-cell, .files-grid-view .file-item .owner-cell { display: none; } + /* Selection checkbox */ .files-grid-view .file-item .checkbox-cell { position: absolute; @@ -373,7 +406,6 @@ display: flex; align-items: center; justify-content: center; - z-index: 12; font-size: 15px; padding: 0; @@ -461,7 +493,7 @@ height: 60px; } -/* ----------------------- dragged items -----------*/ +/* ── Drag ghost ──────────────────────────────────────────── */ .dragged-items { --dragged-files-list-columns: 36px minmax(200px, 1fr); @@ -484,7 +516,7 @@ color: var(--color-text); } -/* file name, text is wrapped */ +/* file name text is truncated */ .dragged-items .file-item div:nth-child(2) { white-space: nowrap; overflow: hidden; @@ -505,22 +537,75 @@ position: absolute; top: 0; right: 0; - transform: translate(50%, -50%); - background: var(--color-content-debug-bg); color: var(--color-content-debug-text); - border-radius: 50%; min-width: 20px; height: 20px; - display: flex; align-items: center; justify-content: center; - font-size: 12px; font-weight: bold; - padding: 2px; } + +/* ── Section item modifiers ──────────────────────────────── */ + +/* — Favorites — */ + +.file-item.favorite-item { + border-left: 3px solid var(--color-warning-border); + + [dir="rtl"] & { + border-right: 3px solid var(--color-warning-border); + border-left: unset; + } +} + +/* list-view column override */ +.file-item.favorite-item { + position: relative; + grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; +} + +.file-item.favorite-item .favorite-indicator { + position: relative; + top: 0; + right: 0; + width: 30px; + height: 30px; +} + +/* — Recent — */ + +.files-grid-view .file-item.recent-item { + border-left: 3px solid var(--color-recent-border); + + [dir="rtl"] & { + border-right: 3px solid var(--color-recent-border); + border-left: unset; + } +} + +/* list-view column override */ +.file-item.recent-item { + position: relative; + grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; +} + +.file-item.recent-item .recent-indicator { + position: relative; + top: 0; + right: 0; + width: 30px; + height: 30px; +} + +/* — Trash — */ + +/* path-cell is shown in trashView; hide it in non-trash contexts */ +.file-item.trash-item > .path-cell { + display: none; +} diff --git a/static/css/main.css b/static/css/main.css index de5d36bc..380b2f4b 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -13,7 +13,8 @@ @import url("./components/breadcrumb.css"); @import url("./components/buttons.css"); @import url("./components/fileType.css"); -@import url("./components/filesView.css"); +@import url("./components/fileManager.css"); +@import url("./components/resourceList.css"); @import url("./components/contextMenu.css"); @import url("./components/dialogs.css"); @import url("./components/modals.css"); diff --git a/static/css/views/favorites.css b/static/css/views/favorites.css index 8b09a670..e2ee4917 100644 --- a/static/css/views/favorites.css +++ b/static/css/views/favorites.css @@ -31,39 +31,17 @@ text-shadow: 0 0 5px var(--color-warning-shadow); } -/* Styles for the favorites view items */ +/* Item modifier rules (.file-item.favorite-item) → resourceList.css */ + .favorite-item { position: relative; } -/* Adjustments for grid view */ -.file-item.favorite-item { - border-left: 3px solid var(--color-warning-border); - - [dir="rtl"] & { - border-right: 3px solid var(--color-warning-border); - border-left: unset; - } -} - -/* Adjustments for list view */ +/* Adjustments for list view header (page-level, not item-level) */ .list-header.favorites-header { grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; } -.file-item.favorite-item { - position: relative; - grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; -} - -.file-item.favorite-item .favorite-indicator { - position: relative; - top: 0; - right: 0; - width: 30px; - height: 30px; -} - /* Styles for the favorites-specific empty state */ .favorites-empty-state { display: flex; diff --git a/static/css/views/recent.css b/static/css/views/recent.css index a1d2b557..79ecea9c 100644 --- a/static/css/views/recent.css +++ b/static/css/views/recent.css @@ -20,39 +20,17 @@ } } -/* Styles for the recent view items */ +/* Item modifier rules (.file-item.recent-item) → resourceList.css */ + .recent-item { position: relative; } -/* Adjustments for grid view */ -.files-grid-view .file-item.recent-item { - border-left: 3px solid var(--color-recent-border); - - [dir="rtl"] & { - border-right: 3px solid var(--color-recent-border); - border-left: unset; - } -} - -/* Adjustments for list view */ +/* Adjustments for list view header (page-level, not item-level) */ .list-header.recent-header { grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; } -.file-item.recent-item { - position: relative; - grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; -} - -.file-item.recent-item .recent-indicator { - position: relative; - top: 0; - right: 0; - width: 30px; - height: 30px; -} - /* Styles for the recent-specific empty state */ .recents-empty-state { display: flex; diff --git a/static/css/views/trash.css b/static/css/views/trash.css index bfed0a15..53899cfb 100644 --- a/static/css/views/trash.css +++ b/static/css/views/trash.css @@ -43,9 +43,7 @@ color: var(--color-trash-delete); } -.file-item.trash-item > .path-cell { - display: none; -} +/* .file-item.trash-item > .path-cell → resourceList.css */ .actions-cell { display: flex; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js new file mode 100644 index 00000000..7d4af5b0 --- /dev/null +++ b/static/js/components/resourceList.js @@ -0,0 +1,486 @@ +/** + * ResourceListComponent — generic grid / list renderer for files and folders. + * + * Each view that shows a list of resources (SharedWithMe, Favorites, Recent, + * and the main file manager) creates its own component instance with a config + * that enables only the features the view needs. + * + * The component is responsible for: + * - Creating .file-item DOM nodes (folders first, then files) + * - Injecting optional swimlane dividers via a `groupFn` + * - Scoped event delegation (one listener per instance, never global) + * - Reporting events back to the view through callbacks + * + * The component does NOT own: context menus, multi-select toolbar, navigation + * state, or thumbnail generation queues — those remain in the calling module + * and are reached through the config callbacks. + */ + +// @ts-check + +import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; +import { i18n } from '../core/i18n.js'; +import { thumbnail } from '../features/thumbnail.js'; + +/** + * @import {FileItem, FolderItem} from '../core/types.js' + */ + +/** + * @typedef {Object} ResourceListConfig + * + * Feature flags + * @property {boolean} [selectable=true] - Show per-item checkboxes and enable selection. + * @property {boolean} [showFavorite=true] - Show the favorite-star button on each item. + * @property {boolean} [showOwner=false] - Show the owner column initially. + * @property {boolean} [showShareBadge=true] - Show the shared-resource badge on items. + * @property {boolean} [draggable=false] - Mark items as draggable (HTML attribute). + * @property {boolean} [showContextMenu=true] - Enable the three-dots button and right-click menu. + * + * Appearance + * @property {string} [itemModifierClass] - Extra CSS class applied to every .file-item + * (e.g. 'favorite-item', 'recent-item'). + * @property {string} [dateField='modified_at'] - Which date field to display in the date column. + * @property {string} [dateLabel] - Column header label for the date column (i18n key). + * + * State providers (called at item-creation time) + * @property {(id: string, type: 'file'|'folder') => boolean} [isFavorite] + * @property {(id: string, type: 'file'|'folder') => boolean} [isShared] + * + * Callbacks (all optional; the component silently skips missing ones) + * @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onOpen] + * Called when the user clicks an item (not a button inside it). + * @property {(item: FileItem|FolderItem) => Promise} [onFavoriteToggle] + * Called when the user clicks the favorite-star button. + * @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu] + * Called for the three-dots button click, right-click, and shared-badge click. + * @property {(selected: Array) => void} [onSelectionChange] + * Called whenever the selection set changes. + */ + +export class ResourceListComponent { + /** + * @param {HTMLElement} container - The element that will contain .file-item nodes. + * @param {ResourceListConfig} config + */ + constructor(container, config) { + this._container = container; + + /** @type {Required> & ResourceListConfig} */ + this._cfg = { + selectable: true, + showFavorite: true, + showOwner: false, + showShareBadge: true, + draggable: false, + showContextMenu: true, + dateField: 'modified_at', + ...config + }; + + /** Items registered with this instance, keyed by id. */ + /** @type {Map} */ + this._items = new Map(); + + /** IDs of currently selected items. */ + /** @type {Set} */ + this._selected = new Set(); + + this._ownerVisible = this._cfg.showOwner; + + this._initDelegation(); + } + + // ── Public API ────────────────────────────────────────────────────────── + + /** + * Replace the current item list. Preserves an existing `.list-header` + * at the start of the container. + * + * @param {FolderItem[]} folders + * @param {FileItem[]} files + * @param {((item: FileItem|FolderItem) => string|null)=} groupFn + * When provided, a swimlane divider is injected whenever the returned + * label changes. Return `null` to suppress the divider for that item. + */ + render(folders, files, groupFn) { + const header = this._container.querySelector('.list-header'); + this._container.innerHTML = ''; + if (header) this._container.appendChild(header); + + this._selected.clear(); + this._items.clear(); + + this._appendItems(folders, files, groupFn); + } + + /** + * Append additional items without clearing the existing ones (load-more). + * + * @param {FolderItem[]} folders + * @param {FileItem[]} files + * @param {((item: FileItem|FolderItem) => string|null)=} groupFn + */ + append(folders, files, groupFn) { + this._appendItems(folders, files, groupFn); + } + + /** Remove all items (but keep `.list-header` if present). */ + clear() { + const header = this._container.querySelector('.list-header'); + this._container.innerHTML = ''; + if (header) this._container.appendChild(header); + this._selected.clear(); + this._items.clear(); + } + + /** + * Switch between grid and list rendering mode. + * @param {'grid'|'list'} mode + */ + setViewMode(mode) { + this._container.classList.toggle('files-grid-view', mode === 'grid'); + this._container.classList.toggle('files-list-view', mode === 'list'); + } + + /** + * Show or hide the owner column on all current and future items. + * @param {boolean} visible + */ + setOwnerVisible(visible) { + this._ownerVisible = visible; + this._container.querySelectorAll('.owner-cell').forEach((cell) => { + cell.classList.toggle('hidden', !visible); + }); + } + + /** + * Update the favorite-star visual on a specific item without re-rendering. + * @param {string} id + * @param {'file'|'folder'} type + * @param {boolean} isFavorite + */ + setFavoriteVisualState(id, type, isFavorite) { + const selector = type === 'folder' ? `.file-item[data-folder-id="${id}"]` : `.file-item[data-file-id="${id}"]`; + const item = this._container.querySelector(selector); + if (!item) return; + + const star = item.querySelector('.favorite-star'); + if (star) { + star.classList.toggle('active', isFavorite); + const i = star.querySelector('i'); + if (i) { + i.classList.toggle('fas', isFavorite); + i.classList.toggle('far', !isFavorite); + } + } + + const badge = item.querySelector('.file-badge-favorite'); + badge?.classList.toggle('hidden', !isFavorite); + } + + /** + * Update the shared-badge visual on a specific item without re-rendering. + * @param {string} id + * @param {'file'|'folder'} type + * @param {boolean} isShared + */ + setSharedVisualState(id, type, isShared) { + const selector = type === 'folder' ? `.file-item[data-folder-id="${id}"]` : `.file-item[data-file-id="${id}"]`; + const item = this._container.querySelector(selector); + if (!item) return; + item.querySelector('.file-badge-shared')?.classList.toggle('hidden', !isShared); + } + + // ── Private helpers ───────────────────────────────────────────────────── + + /** + * @param {FolderItem[]} folders + * @param {FileItem[]} files + * @param {((item: FileItem|FolderItem) => string|null)=} groupFn + */ + _appendItems(folders, files, groupFn) { + const fragment = document.createDocumentFragment(); + let lastGroupKey = /** @type {string|null|undefined} */ (undefined); + + for (const folder of folders) { + this._items.set(folder.id, folder); + if (groupFn) { + const key = groupFn(folder); + if (key !== lastGroupKey) { + lastGroupKey = key; + if (key !== null) fragment.appendChild(this._createGroupHeader(key)); + } + } + fragment.appendChild(this._createFolderItem(folder)); + } + + for (const file of files) { + this._items.set(file.id, file); + if (groupFn) { + const key = groupFn(file); + if (key !== lastGroupKey) { + lastGroupKey = key; + if (key !== null) fragment.appendChild(this._createGroupHeader(key)); + } + } + fragment.appendChild(this._createFileItem(file)); + } + + this._container.appendChild(fragment); + } + + /** + * Create a swimlane divider element. + * @param {string} label + */ + _createGroupHeader(label) { + const el = document.createElement('div'); + el.className = 'resource-list__swimlane-header'; + el.dataset.swimlaneHeader = 'true'; + el.textContent = label; + return el; + } + + /** + * Build a .file-item DOM element for a folder. + * @param {FolderItem} folder + * @returns {HTMLElement} + */ + _createFolderItem(folder) { + const cfg = this._cfg; + const el = document.createElement('div'); + const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : ''; + el.className = `file-item${modClass}`; + el.dataset.folderId = folder.id; + el.dataset.folderName = folder.name; + el.dataset.parentId = folder.parent_id || ''; + if (folder.path) el.dataset.path = folder.path; + if (cfg.draggable) el.setAttribute('draggable', 'true'); + + const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false; + const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false; + const dateVal = /** @type {Record} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at; + const formattedDate = formatDateTime(new Date(dateVal)); + + el.innerHTML = ` + ${cfg.selectable ? '
' : ''} +
+
+ +
+ ${escapeHtml(folder.name)} + ${cfg.showFavorite ? `
` : ''} + ${cfg.showShareBadge ? `
` : ''} +
+
+
${i18n.t('files.file_types.folder')}
+
--
+
${formattedDate}
+
+ ${cfg.showFavorite ? `` : ''} + ${cfg.showContextMenu ? '' : ''} +
+ `; + + this._bindItemEvents(el, folder); + return el; + } + + /** + * Build a .file-item DOM element for a file. + * @param {FileItem} file + * @returns {HTMLElement} + */ + _createFileItem(file) { + const cfg = this._cfg; + const iconClass = file.icon_class || 'fas fa-file'; + const iconSpecialClass = file.icon_special_class || ''; + const cat = file.category || ''; + const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document'); + const fileSize = file.size_formatted || formatFileSize(file.size); + const dateVal = /** @type {Record} */ (/** @type {unknown} */ (file))[cfg.dateField] ?? file.modified_at; + const formattedDate = formatDateTime(new Date(dateVal)); + const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false; + const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false; + const canThumbnail = thumbnail?.canHandle(file) ?? false; + + const el = document.createElement('div'); + const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : ''; + el.className = `file-item${modClass}`; + el.dataset.fileId = file.id; + el.dataset.fileName = file.name; + el.dataset.folderId = file.folder_id || ''; + if (file.path) el.dataset.path = file.path; + if (cfg.draggable) el.setAttribute('draggable', 'true'); + + el.innerHTML = ` + ${cfg.selectable ? '
' : ''} +
+
+ ${canThumbnail ? `` : ''} + +
+ ${escapeHtml(file.name)} + ${cfg.showFavorite ? `
` : ''} + ${cfg.showShareBadge ? `
` : ''} +
+
+
${typeLabel}
+
${fileSize}
+
${formattedDate}
+
+ ${cfg.showFavorite ? `` : ''} + ${cfg.showContextMenu ? '' : ''} +
+ `; + + const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb')); + if (thumb) { + thumb.addEventListener('error', () => { + thumb.classList.add('hidden'); + thumbnail?.queueGenerate(file, (dataUrl) => { + thumb.src = dataUrl; + thumb.classList.remove('hidden'); + }); + }); + } + + this._bindItemEvents(el, file); + return el; + } + + /** + * Attach direct event listeners to interactive elements inside a .file-item. + * This covers buttons that must stop propagation before the delegated listener runs. + * @param {HTMLElement} el + * @param {FileItem|FolderItem} item + */ + _bindItemEvents(el, item) { + const cfg = this._cfg; + + // Favorite-star — direct click, stopPropagation so the card open doesn't fire + if (cfg.showFavorite && cfg.onFavoriteToggle) { + const star = el.querySelector('.favorite-star'); + star?.addEventListener('click', (e) => { + e.stopPropagation(); + e.stopImmediatePropagation(); + e.preventDefault(); + cfg.onFavoriteToggle?.(item); + }); + } + + // Shared-badge click → treat as context-menu trigger (e.g. open share modal) + if (cfg.showShareBadge && cfg.onContextMenu) { + const badge = el.querySelector('.file-badge-shared'); + badge?.addEventListener('click', (e) => { + e.stopPropagation(); + e.stopImmediatePropagation(); + e.preventDefault(); + cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e)); + }); + } + } + + /** Wire one delegated listener for all pointer events in this container. */ + _initDelegation() { + const container = this._container; + const cfg = this._cfg; + + // ── click ────────────────────────────────────────────────────────── + container.addEventListener('click', (e) => { + const target = /** @type {HTMLElement} */ (e.target); + + // Swimlane dividers are not interactive + if (target.dataset.swimlaneHeader) return; + + const card = /** @type {HTMLElement | null} */ (target.closest('.file-item')); + if (!card) return; + + // Three-dots button → context menu + if (target.closest('.file-actions')) { + e.stopPropagation(); + e.preventDefault(); + const item = this._itemFromCard(card); + if (item && cfg.onContextMenu) cfg.onContextMenu(item, /** @type {MouseEvent} */ (e)); + return; + } + + // Checkbox cell → selection + if (cfg.selectable && target.closest('.checkbox-cell')) { + this._toggleSelection(card, /** @type {MouseEvent} */ (e)); + return; + } + + // Favorite star is handled by the direct listener in _bindItemEvents + if (target.closest('.favorite-star')) return; + + // Modifier-key click → selection toggle + if (e.metaKey || e.altKey || e.ctrlKey) { + if (cfg.selectable) this._toggleSelection(card, /** @type {MouseEvent} */ (e)); + return; + } + + // Plain click → open or navigate + const item = this._itemFromCard(card); + if (item && cfg.onOpen) cfg.onOpen(item, /** @type {MouseEvent} */ (e)); + }); + + // ── contextmenu ──────────────────────────────────────────────────── + if (cfg.showContextMenu) { + container.addEventListener('contextmenu', (e) => { + const target = /** @type {HTMLElement} */ (e.target); + if (target.dataset.swimlaneHeader) return; + const card = /** @type {HTMLElement | null} */ (target.closest('.file-item')); + if (!card) return; + e.preventDefault(); + const item = this._itemFromCard(card); + if (item && cfg.onContextMenu) cfg.onContextMenu(item, /** @type {MouseEvent} */ (e)); + }); + } + + // ── dblclick — prevent double-fire of open on rapid clicks ───────── + container.addEventListener('dblclick', (e) => e.preventDefault()); + } + + /** + * Return the registered item object for a given card element. + * @param {HTMLElement} card + * @returns {FileItem|FolderItem|undefined} + */ + _itemFromCard(card) { + const id = card.dataset.fileId || card.dataset.folderId || ''; + return this._items.get(id); + } + + /** + * Toggle selection state on a card and notify via `onSelectionChange`. + * @param {HTMLElement} card + * @param {MouseEvent} _e - Reserved for future shift-click range selection. + */ + _toggleSelection(card, _e) { + const id = card.dataset.fileId || card.dataset.folderId || ''; + if (!id) return; + + const nowSelected = !card.classList.contains('selected'); + card.classList.toggle('selected', nowSelected); + + const checkbox = /** @type {HTMLInputElement | null} */ (card.querySelector('.item-checkbox')); + if (checkbox) checkbox.checked = nowSelected; + + if (nowSelected) { + this._selected.add(id); + } else { + this._selected.delete(id); + } + + if (this._cfg.onSelectionChange) { + /** @type {Array} */ + const selectedItems = [...this._selected].flatMap((sid) => { + const item = this._items.get(sid); + return item ? [item] : []; + }); + this._cfg.onSelectionChange(selectedItems); + } + } +} diff --git a/static/js/core/types.js b/static/js/core/types.js index 51a561b8..343e8091 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -295,6 +295,26 @@ * Roles: `viewer`, `commenter`, `editor`, `manager`, `admin` */ +/** + * Configuration for `ResourceListComponent`. + * @typedef {Object} ResourceListConfig + * @property {boolean} [selectable=true] - Show per-item checkboxes and enable selection. + * @property {boolean} [showFavorite=true] - Show the favorite-star button on each item. + * @property {boolean} [showOwner=false] - Show the owner column initially. + * @property {boolean} [showShareBadge=true] - Show the shared-resource badge on items. + * @property {boolean} [draggable=false] - Mark items as draggable. + * @property {boolean} [showContextMenu=true] - Enable the three-dots button and right-click menu. + * @property {string} [itemModifierClass] - Extra CSS class on every .file-item (e.g. 'favorite-item'). + * @property {string} [dateField='modified_at'] - Which date field to display in the date column. + * @property {string} [dateLabel] - Column header label for the date column. + * @property {(id: string, type: 'file'|'folder') => boolean} [isFavorite] - State provider for favorite badge. + * @property {(id: string, type: 'file'|'folder') => boolean} [isShared] - State provider for share badge. + * @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onOpen] - Item open/navigate callback. + * @property {(item: FileItem|FolderItem) => Promise} [onFavoriteToggle] - Favorite-star click callback. + * @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu] - Context menu callback. + * @property {(selected: Array) => void} [onSelectionChange] - Selection change callback. + */ + /** * One item returned by `GET /api/grants/incoming/resources`. * `resource_type` discriminates the shape of `resource`. From 6ac4e6177c6fd366661ff29758631aa38c7c1861 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 26 May 2026 22:59:43 +0200 Subject: [PATCH 3/9] refactor(js): move multiSelect into batchToolbar + move residual method into relevant components --- .../{multiSelect.css => batchToolbar.css} | 0 static/css/main.css | 2 +- static/js/app/filesView.js | 8 +- static/js/app/main.js | 14 +- static/js/app/navigation.js | 20 +-- static/js/app/trashView.js | 4 +- static/js/app/ui.js | 36 ++-- static/js/components/resourceList.js | 158 ++++++++++++++++-- .../files/{multiSelect.js => batchToolbar.js} | 20 ++- static/js/features/files/contextMenus.js | 18 +- static/js/features/library/favorites.js | 4 +- static/js/features/library/recent.js | 8 +- .../js/views/sharedWithMe/sharedWithMeView.js | 4 +- 13 files changed, 215 insertions(+), 81 deletions(-) rename static/css/components/{multiSelect.css => batchToolbar.css} (100%) rename static/js/features/files/{multiSelect.js => batchToolbar.js} (97%) diff --git a/static/css/components/multiSelect.css b/static/css/components/batchToolbar.css similarity index 100% rename from static/css/components/multiSelect.css rename to static/css/components/batchToolbar.css diff --git a/static/css/main.css b/static/css/main.css index 380b2f4b..83aa67a1 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -25,7 +25,7 @@ @import url("./components/notifications.css"); @import url("./components/userMenu.css"); @import url("./components/languageSelector.css"); -@import url("./components/multiSelect.css"); +@import url("./components/batchToolbar.css"); @import url("./components/spinner.css"); @import url("./components/search.css"); @import url("./components/icons.css"); diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index ddff0609..12be1db0 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -2,7 +2,7 @@ import { i18n } from '../core/i18n.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; import { app } from './state.js'; @@ -217,9 +217,9 @@ async function loadFiles(options = { insertHistory: true }) { ui._items.clear(); ui.resetFilesList(); - if (multiSelect) { - multiSelect.clear(); - multiSelect.init(); // this will wire buttons & select-all-checkbox + if (batchToolbar) { + batchToolbar.clear(); + batchToolbar.init(); // this will wire buttons & select-all-checkbox } /** @type {FolderItem[]} */ diff --git a/static/js/app/main.js b/static/js/app/main.js index 037712ac..e6c8768d 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -12,7 +12,7 @@ import { formatFileSize, formatQuotaSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { oxiIconsInit } from '../core/icons.js'; import { fileOps } from '../features/files/fileOperations.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; @@ -50,7 +50,7 @@ let uploadDropdownDocumentClickHandler = null; let uploadDropdownBindingsController = null; let actionsBarDelegationBound = false; -const _multiSelectButons = ` +const _batchToolbarButons = ` - ${_multiSelectButons} + ${_batchToolbarButons} ${_toggleButtons} `, sharedwithme: ` @@ -386,9 +386,9 @@ function initApp() { } // Initialize multi-select / batch actions - if (multiSelect?.init) { + if (batchToolbar?.init) { console.log('Initializing multi-select module'); - multiSelect.init(); + batchToolbar.init(); } window.addEventListener('authenticationDone', async () => { diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 08ac0f33..3be34396 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -4,7 +4,7 @@ */ import { i18n } from '../core/i18n.js'; -import { multiSelect } from '../features/files/multiSelect.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'; @@ -201,7 +201,7 @@ function switchToSharedSection() { sharedView.show(); }); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToSharedWithMeSection() { @@ -221,7 +221,7 @@ function switchToSharedWithMeSection() { toggleFileContainer(true); syncViewContainers(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); // Load and render items into the files container sharedWithMeView.init(); @@ -253,7 +253,7 @@ function switchToFilesSection() { app.currentPath = app.userHomeFolderId || ''; app.breadcrumbPath = []; ui.updateBreadcrumb(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); // temp solution sharedView.loadItems().then(() => { @@ -296,7 +296,7 @@ function switchToFavoritesSection() { `); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToRecentFilesSection() { @@ -329,7 +329,7 @@ function switchToRecentFilesSection() {

Error loading the recent module

`); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToPhotosSection() { @@ -352,7 +352,7 @@ function switchToPhotosSection() { if (photosView) { photosView.show(); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToTrashSection() { @@ -377,7 +377,7 @@ function switchToTrashSection() { // Load trash items loadTrashItems(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToMusicSection() { @@ -404,7 +404,7 @@ function switchToMusicSection() { if (musicView) { musicView.show(); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } /** @@ -423,7 +423,7 @@ function activateFilesUI() { breadcrumb?.classList.remove('hidden'); toggleFileContainer(true); syncViewContainers(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } export { diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index b7066906..c9cb2d33 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -5,7 +5,7 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { fileOps } from '../features/files/fileOperations.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import * as pathTooltip from '../features/pathTooltip.js'; import { appElements } from './state.js'; import { ui } from './ui.js'; @@ -22,7 +22,7 @@ async function loadTrashItems() { const elements = appElements; try { - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); pathTooltip.destroy(elements.filesList); ui.resetFilesList(); // ensure also list visible & error hidden elements.filesList.innerHTML = ` diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 7862e0dd..9daa7624 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -13,7 +13,7 @@ import { OxiIcons } from '../core/icons.js'; import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; @@ -727,9 +727,9 @@ const ui = { * @param {any} dataTransfer fallback if nothing is selected */ async _dropToFolder(action, targetFolderId, dataTransfer) { - const selection = multiSelect.getSelection(targetFolderId); + const selection = batchToolbar.getSelection(targetFolderId); - multiSelect.clear(); + batchToolbar.clear(); if (selection.fileIds.length === 0 && selection.folderIds.length === 0) { // try to use dataTransfer (direct move without selection) @@ -774,7 +774,7 @@ const ui = { console.error(`drag and drop: action ${action} unknown`); return; } - multiSelect.showBatchResult(action, result); + batchToolbar.showBatchResult(action, result); console.log(result); }, @@ -941,8 +941,8 @@ const ui = { } // shiftkey is used to complete selection - if (e.shiftKey && multiSelect) { - multiSelect.handleToggleItem(card, e); + if (e.shiftKey && batchToolbar) { + batchToolbar.handleToggleItem(card, e); return; } @@ -1512,13 +1512,13 @@ const ui = { /** * Toggle selection state of a file/folder card. - * Routes through the multiSelect module so batch actions know about selected items. + * Routes through the batchToolbar module so batch actions know about selected items. * @param {HTMLDivElement} card * @param {MouseEvent} event */ function toggleCardSelection(card, event) { - if (multiSelect) { - multiSelect.handleToggleItem(card, event); + if (batchToolbar) { + batchToolbar.handleToggleItem(card, event); } else { card.classList.toggle('selected'); } @@ -1641,17 +1641,17 @@ function initRubberBandSelection() { if (intersects) { card.classList.add('selected'); - // Sync with multiSelect module - if (multiSelect) { - const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card)); - if (info) multiSelect.select(info.id, info.name, info.type, info.parentId); + // Sync with batchToolbar module + if (batchToolbar) { + const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card)); + if (info) batchToolbar.select(info.id, info.name, info.type, info.parentId); } } else { card.classList.remove('selected'); - // Deselect from multiSelect module - if (multiSelect) { - const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card)); - if (info) multiSelect.deselect(info.id); + // Deselect from batchToolbar module + if (batchToolbar) { + const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card)); + if (info) batchToolbar.deselect(info.id); } } }); @@ -1663,7 +1663,7 @@ function initRubberBandSelection() { const hadSelection = selRect.style.display === 'block'; selRect.style.display = 'none'; // Update the batch bar after rubber band selection completes - if (multiSelect) multiSelect._syncUI(); + if (batchToolbar) batchToolbar._syncUI(); // Suppress the click event that follows mouseup so the global // deselect handler doesn't immediately clear the selection. if (hadSelection) { diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 7d4af5b0..f2ddb286 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -86,6 +86,9 @@ export class ResourceListComponent { /** @type {Set} */ this._selected = new Set(); + /** Index of the last clicked item — used for shift-click range selection. */ + this._lastClickedIndex = -1; + this._ownerVisible = this._cfg.showOwner; this._initDelegation(); @@ -110,8 +113,10 @@ export class ResourceListComponent { this._selected.clear(); this._items.clear(); + this._lastClickedIndex = -1; this._appendItems(folders, files, groupFn); + this._wireSelectAll(); } /** @@ -132,6 +137,40 @@ export class ResourceListComponent { if (header) this._container.appendChild(header); this._selected.clear(); this._items.clear(); + this._lastClickedIndex = -1; + } + + /** + * Deselect all items without removing them from the DOM. + * Used by the batch toolbar after an operation completes. + */ + clearSelection() { + this._selected.clear(); + this._lastClickedIndex = -1; + this._container.querySelectorAll('.file-item.selected').forEach((card) => { + card.classList.remove('selected'); + const cb = /** @type {HTMLInputElement | null} */ (card.querySelector('.item-checkbox')); + if (cb) cb.checked = false; + }); + this._syncSelectAllCheckbox(); + this._cfg.onSelectionChange?.([]); + } + + /** + * Select all visible items in the container. + */ + selectAll() { + this._container.querySelectorAll('.file-item').forEach((card) => { + const el = /** @type {HTMLElement} */ (card); + const id = el.dataset.fileId || el.dataset.folderId || ''; + if (!id) return; + el.classList.add('selected'); + const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox')); + if (cb) cb.checked = true; + this._selected.add(id); + }); + this._syncSelectAllCheckbox(); + this._notifySelectionChange(); } /** @@ -406,9 +445,13 @@ export class ResourceListComponent { return; } - // Checkbox cell → selection + // Checkbox cell → selection (shift extends range) if (cfg.selectable && target.closest('.checkbox-cell')) { - this._toggleSelection(card, /** @type {MouseEvent} */ (e)); + if (e.shiftKey) { + this._handleShiftSelect(card); + } else { + this._toggleSelection(card); + } return; } @@ -417,7 +460,13 @@ export class ResourceListComponent { // Modifier-key click → selection toggle if (e.metaKey || e.altKey || e.ctrlKey) { - if (cfg.selectable) this._toggleSelection(card, /** @type {MouseEvent} */ (e)); + if (cfg.selectable) this._toggleSelection(card); + return; + } + + // Shift-click anywhere on the card → extend selection range + if (e.shiftKey && cfg.selectable) { + this._handleShiftSelect(card); return; } @@ -454,11 +503,11 @@ export class ResourceListComponent { } /** - * Toggle selection state on a card and notify via `onSelectionChange`. + * Toggle selection on a single card and notify. + * Tracks `_lastClickedIndex` for subsequent shift-clicks. * @param {HTMLElement} card - * @param {MouseEvent} _e - Reserved for future shift-click range selection. */ - _toggleSelection(card, _e) { + _toggleSelection(card) { const id = card.dataset.fileId || card.dataset.folderId || ''; if (!id) return; @@ -474,13 +523,96 @@ export class ResourceListComponent { this._selected.delete(id); } - if (this._cfg.onSelectionChange) { - /** @type {Array} */ - const selectedItems = [...this._selected].flatMap((sid) => { - const item = this._items.get(sid); - return item ? [item] : []; - }); - this._cfg.onSelectionChange(selectedItems); + // Record position for the next shift-click + const items = [...this._container.querySelectorAll('.file-item')]; + this._lastClickedIndex = items.indexOf(card); + + this._syncSelectAllCheckbox(); + this._notifySelectionChange(); + } + + /** + * Extend the selection from `_lastClickedIndex` to `card` (inclusive). + * If no previous click exists, falls back to a plain toggle. + * @param {HTMLElement} card + */ + _handleShiftSelect(card) { + const items = /** @type {HTMLElement[]} */ ([...this._container.querySelectorAll('.file-item')]); + const index = items.indexOf(card); + + if (this._lastClickedIndex >= 0 && index >= 0) { + const start = Math.min(this._lastClickedIndex, index); + const end = Math.max(this._lastClickedIndex, index); + for (let i = start; i <= end; i++) { + const el = items[i]; + const id = el.dataset.fileId || el.dataset.folderId || ''; + if (!id) continue; + el.classList.add('selected'); + const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox')); + if (cb) cb.checked = true; + this._selected.add(id); + } + } else { + this._toggleSelection(card); + return; + } + + this._lastClickedIndex = index; + this._syncSelectAllCheckbox(); + this._notifySelectionChange(); + } + + /** + * Find the select-all checkbox in the container header and wire its + * `change` event. Called after every `render()`. + */ + _wireSelectAll() { + if (!this._cfg.selectable) return; + const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox')); + if (!cb) return; + // Replace with a fresh listener to avoid duplicates across re-renders + const fresh = /** @type {HTMLInputElement} */ (cb.cloneNode(true)); + cb.parentNode?.replaceChild(fresh, cb); + fresh.addEventListener('change', () => { + if (fresh.checked) { + this.selectAll(); + } else { + this.clearSelection(); + } + }); + } + + /** + * Sync the three-state select-all checkbox in the list header. + * Checked = all selected, indeterminate = some selected, unchecked = none. + */ + _syncSelectAllCheckbox() { + const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox')); + if (!cb) return; + const total = this._container.querySelectorAll('.file-item').length; + if (total === 0) { + cb.checked = false; + cb.indeterminate = false; + } else if (this._selected.size >= total) { + cb.checked = true; + cb.indeterminate = false; + } else if (this._selected.size > 0) { + cb.checked = false; + cb.indeterminate = true; + } else { + cb.checked = false; + cb.indeterminate = false; } } + + /** Build the selected-items array and fire `onSelectionChange`. */ + _notifySelectionChange() { + if (!this._cfg.onSelectionChange) return; + /** @type {Array} */ + const selectedItems = [...this._selected].flatMap((id) => { + const item = this._items.get(id); + return item ? [item] : []; + }); + this._cfg.onSelectionChange(selectedItems); + } } diff --git a/static/js/features/files/multiSelect.js b/static/js/features/files/batchToolbar.js similarity index 97% rename from static/js/features/files/multiSelect.js rename to static/js/features/files/batchToolbar.js index de9c9525..ceaebecc 100644 --- a/static/js/features/files/multiSelect.js +++ b/static/js/features/files/batchToolbar.js @@ -1,14 +1,16 @@ /** - * OxiCloud - Multi-Select & Batch Actions Module + * OxiCloud — Batch Toolbar Module * - * Adds checkboxes to grid and list views, replaces the list-view header - * with a NextCloud-style selection bar when items are selected, and - * provides batch delete / move / download / favorites operations. + * Manages the floating selection bar that appears when items are selected, + * and executes batch operations (delete, move, download, favorites). + * + * Selection state (_selected, handleToggleItem, selectAll, …) is kept here + * while the main file manager still uses its own delegation (ui.js). + * Once ui.js is migrated to ResourceListComponent (plan step B5), all + * selection mechanics will live in the component and this module will + * shrink to only the toolbar UI and batch-operation API calls. */ -// TODO: rename into selection-bar ? -// TODO: merge with photo part - import { loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; @@ -22,7 +24,7 @@ import { getAuthHeaders } from './fileOperations.js'; * @import {BatchResult} from './fileOperations.js' */ -const multiSelect = { +const batchToolbar = { /** @type {Map} items: Map */ _selected: new Map(), @@ -536,4 +538,4 @@ const multiSelect = { } }; -export { multiSelect }; +export { batchToolbar }; diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index e4395fb1..de6c493d 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -18,7 +18,7 @@ import { musicView } from '../library/music.js'; import { fileSharing } from '../sharing/fileSharing.js'; import { fileOps } from './fileOperations.js'; import { inlineViewer } from './inlineViewer.js'; -import { multiSelect } from './multiSelect.js'; +import { batchToolbar } from './batchToolbar.js'; import { wopiEditor } from './wopiEditor.js'; /** @@ -330,8 +330,8 @@ const contextMenus = { // Copy button handler copyConfirmBtn.addEventListener('click', async () => { - // Batch copy mode (from multiSelect) - if (app.moveDialogMode === 'batch' && multiSelect) { + // Batch copy mode (from batchToolbar) + if (app.moveDialogMode === 'batch' && batchToolbar) { const targetId = app.selectedTargetFolderId; const items = app.batchMoveItems || []; @@ -341,10 +341,10 @@ const contextMenus = { const result = await fileOps.batchCopy(fileIds, folderIds, targetId); this.closeMoveDialog(); - multiSelect.clear(); + batchToolbar.clear(); loadFiles(); - multiSelect.showBatchResult('copy', result); + batchToolbar.showBatchResult('copy', result); return; } @@ -363,8 +363,8 @@ const contextMenus = { }); moveConfirmBtn.addEventListener('click', async () => { - // Batch move mode (from multiSelect) - if (app.moveDialogMode === 'batch' && multiSelect) { + // Batch move mode (from batchToolbar) + if (app.moveDialogMode === 'batch' && batchToolbar) { const targetId = app.selectedTargetFolderId; const items = app.batchMoveItems || []; @@ -374,9 +374,9 @@ const contextMenus = { const result = await fileOps.batchMove(fileIds, folderIds, targetId); this.closeMoveDialog(); - multiSelect.clear(); + batchToolbar.clear(); loadFiles(); - multiSelect.showBatchResult('move', result); + batchToolbar.showBatchResult('move', result); return; } diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 86939cc7..28fabf64 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -9,7 +9,7 @@ import { ui } from '../../app/ui.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; -import { multiSelect } from '../files/multiSelect.js'; +import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; /** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */ @@ -181,7 +181,7 @@ const favorites = { ui.resetFilesList(); // ensure also list visible & error hidden // wire buttons & select-all-checkbox as list header has changed in ui.resetFilesList() // FIXME: this case is not easy to understand, should apply better implementation - multiSelect.init(); + batchToolbar.init(); ui.updateBreadcrumb(); diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index f6e21494..08cec86f 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -9,7 +9,7 @@ import { ui } from '../../app/ui.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; -import { multiSelect } from '../files/multiSelect.js'; +import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; /** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */ @@ -111,9 +111,9 @@ const recent = { `; - if (multiSelect) { - multiSelect.clear(); - multiSelect.init(); // this will wire buttons & select-all-checkbox + if (batchToolbar) { + batchToolbar.clear(); + batchToolbar.init(); // this will wire buttons & select-all-checkbox } ui.updateBreadcrumb(); diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 4e3f8f9e..2b5dc371 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -16,7 +16,7 @@ import { ui } from '../../app/ui.js'; import { i18n } from '../../core/i18n.js'; -import { multiSelect } from '../../features/files/multiSelect.js'; +import { batchToolbar } from '../../features/files/batchToolbar.js'; import { ownerTooltip } from '../../features/ownerTooltip.js'; import { grants } from '../../model/grants.js'; import { systemUsers } from '../../model/systemUsers.js'; @@ -52,7 +52,7 @@ const sharedWithMeView = { // Standard files-view setup: clear list, show container, init multiselect ui.resetFilesList(); - multiSelect.init(); + batchToolbar.init(); ui.updateBreadcrumb(); await this._loadPage(); From 720bf11168e5c930f01421608aa815a2c5145ab2 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 26 May 2026 23:21:58 +0200 Subject: [PATCH 4/9] use of resourceList --- static/js/app/ui.js | 176 +++++++++++++----- static/js/components/resourceList.js | 5 + static/js/features/files/batchToolbar.js | 38 +++- static/js/features/library/favorites.js | 74 ++++++-- static/js/features/library/recent.js | 86 ++++++--- .../js/views/sharedWithMe/sharedWithMeView.js | 136 +++++++++----- 6 files changed, 371 insertions(+), 144 deletions(-) diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 9daa7624..c80b5a89 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -828,59 +828,10 @@ const ui = { }; /** @param {FileItem} file */ - const openFile = async (file) => { - if (!file) return; - if (recent) { - document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } })); - } - // WOPI editor intercept: open Office documents in the WOPI editor - // But NOT image files - those should be previewed in the inline viewer - const ext = (file.name || '').split('.').pop().toLowerCase(); - const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'heic', 'heif', 'avif', 'tiff']; - const isImage = file.mime_type?.startsWith('image/') || imageExts.includes(ext); - try { - if (!isImage && wopiEditor && (await wopiEditor.canEdit(file.name))) { - await wopiEditor.openInModal(file.id, file.name, 'edit'); - return; - } - } catch (e) { - console.warn(`WOPI Editor failed, falling bck to classic view `, e); - } - - if (this.isViewableFile(file) || isImage) { - if (inlineViewer) { - inlineViewer.openFile(file); - // update history - app.viewFile = file.id; - updateHistory(false); - } else { - fileOps.downloadFile(file.id, file.name); - } - } else { - fileOps.downloadFile(file.id, file.name); - } - }; + const openFile = async (file) => this._openFile(file); /** @param {HTMLElement} card */ - const navigateFolder = (card) => { - const folderId = card.dataset.folderId; - const folderName = card.dataset.folderName; - if (app.currentSection === 'favorites' || app.currentSection === 'recent') { - switchToFilesSection(); - app.currentPath = folderId; - loadFiles(); - return; - } - if (app.currentSection === 'sharedwithme') { - // Activate Files UI (nav, breadcrumb, actions bar) without - // resetting the path — the shared folder becomes the entry point. - activateFilesUI(); - } - app.breadcrumbPath.push({ id: folderId, name: folderName }); - app.currentPath = folderId; - this.updateBreadcrumb(); - loadFiles(); - }; + const navigateFolder = (card) => this._navigateToFolder(card.dataset.folderId, card.dataset.folderName); /** * @param {HTMLElement} card @@ -906,6 +857,8 @@ const ui = { // ── click (open / navigate; select only via checkbox) ── filesList.addEventListener('click', (e) => { + // ResourceListComponent manages its own delegation when mounted here + if (filesList.dataset.managedBy) return; const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card) return; @@ -963,6 +916,7 @@ const ui = { // ── shared events ────────────────────── filesList.addEventListener('contextmenu', (e) => { + if (filesList.dataset.managedBy) return; const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card) return; e.preventDefault(); @@ -1136,6 +1090,124 @@ const ui = { }); }, + /* ================================================================ + * Item open / navigate — shared by ui.js delegation and component + * callbacks so the same logic fires regardless of which view renders + * the items. + * ================================================================ */ + + /** + * Open a file: dispatch a recent-access event, try WOPI, fall back to + * inline viewer or download. + * @param {FileItem} file + */ + async _openFile(file) { + if (!file) return; + if (recent) { + document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } })); + } + // WOPI editor intercept: open Office documents in the WOPI editor + // But NOT image files - those should be previewed in the inline viewer + const ext = (file.name || '').split('.').pop().toLowerCase(); + const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'heic', 'heif', 'avif', 'tiff']; + const isImage = file.mime_type?.startsWith('image/') || imageExts.includes(ext); + try { + if (!isImage && wopiEditor && (await wopiEditor.canEdit(file.name))) { + await wopiEditor.openInModal(file.id, file.name, 'edit'); + return; + } + } catch (e) { + console.warn(`WOPI Editor failed, falling back to classic view`, e); + } + if (this.isViewableFile(file) || isImage) { + if (inlineViewer) { + inlineViewer.openFile(file); + app.viewFile = file.id; + updateHistory(false); + } else { + fileOps.downloadFile(file.id, file.name); + } + } else { + fileOps.downloadFile(file.id, file.name); + } + }, + + /** + * Navigate into a folder, handling section transitions (SharedWithMe, + * Favorites, Recent → Files). + * @param {string|undefined} folderId + * @param {string|undefined} folderName + */ + _navigateToFolder(folderId, folderName) { + if (!folderId) return; + if (app.currentSection === 'favorites' || app.currentSection === 'recent') { + switchToFilesSection(); + app.currentPath = folderId; + loadFiles(); + return; + } + if (app.currentSection === 'sharedwithme') { + // Activate Files UI (nav, breadcrumb, actions bar) without + // resetting the path — the shared folder becomes the entry point. + activateFilesUI(); + } + app.breadcrumbPath.push({ id: folderId, name: folderName || '' }); + app.currentPath = folderId; + this.updateBreadcrumb(); + loadFiles(); + }, + + /** + * Open a file or navigate into a folder. + * Used as the `onOpen` callback for `ResourceListComponent`. + * @param {FileItem|FolderItem} item + */ + async openItem(item) { + if ('mime_type' in item) { + await this._openFile(/** @type {FileItem} */ (item)); + } else { + const folder = /** @type {FolderItem} */ (item); + this._navigateToFolder(folder.id, folder.name); + } + }, + + /** + * Set the context-menu target and show the appropriate menu. + * Used as the `onContextMenu` callback for `ResourceListComponent`. + * @param {FileItem|FolderItem} item + * @param {MouseEvent} e + */ + showContextMenuForItem(item, e) { + const trigger = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-actions')); + if ('mime_type' in item) { + app.contextMenuTargetFile = /** @type {FileItem} */ (item); + if (trigger) { + showContextMenuAtElement(trigger, 'file-context-menu'); + } else { + const menu = document.getElementById('file-context-menu'); + if (menu) { + menu.style.left = `${e.pageX}px`; + menu.style.top = `${e.pageY}px`; + contextMenus.sync(); + menu.classList.remove('hidden'); + } + } + } else { + app.contextMenuTargetFolder = /** @type {FolderItem} */ (item); + if (trigger) { + showContextMenuAtElement(trigger, 'folder-context-menu'); + } else { + const menu = document.getElementById('folder-context-menu'); + if (menu) { + menu.style.left = `${e.pageX}px`; + menu.style.top = `${e.pageY}px`; + contextMenus.sync(); + menu.classList.remove('hidden'); + } + } + } + }, + /* ================================================================ * Favorite star helper – attaches a direct click handler to a * star + + + + + + +`; +``` + +**B. Update sharedwithme template:** Also add missing `_batchToolbarButons`: +```js +sharedwithme: ` +
+ ${_batchToolbarButons} + ${_toggleButtonsWithGroupBy} +` +``` + +**C. `setupActionsBarDelegation()` — add group-by handling before the switch:** +```js +// Group-by option selected +if (btn.classList.contains('group-by-option')) { + const key = btn.dataset.groupBy ?? ''; + sharedWithMeView.setGroupBy(key); + document.querySelectorAll('.group-by-option').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.getElementById('group-by-menu')?.classList.add('hidden'); + document.getElementById('group-by-btn')?.classList.toggle('active', !!key); + return; +} +switch (btn.id) { + case 'group-by-btn': + document.getElementById('group-by-menu')?.classList.toggle('hidden'); + break; + … +} +``` + +**D.** Add a `document.addEventListener('click', …)` (or reuse the existing upload-dropdown pattern) to close the group-by menu on outside clicks. + +### 8. CSS for group-by dropdown + +Add to `static/css/components/buttons.css` (already contains `.view-toggle` styles): + +```css +/* ── Group-by selector (inside .view-toggle) ─────────── */ +.view-toggle-separator { + width: 1px; + height: 20px; + background: var(--color-border); + align-self: center; + margin: 0 2px; +} + +.group-by-selector { + position: relative; +} + +.group-by-btn.active { color: var(--color-accent); } + +.group-by-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 200; + min-width: 140px; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 16px var(--color-shadow); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.group-by-menu.hidden { display: none; } + +.group-by-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + color: var(--color-text); + text-align: left; + width: 100%; +} + +.group-by-option:hover { background: var(--color-border); } +.group-by-option.active { color: var(--color-accent); font-weight: 600; } +``` + +--- + +## Changes — Backend + +### 9. `src/domain/services/authorization.rs` — update `GrantCursor` + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GrantCursor { + /// Sort dimension active when this cursor was issued. + /// Mis-match with the current `sort_by` param → cursor is discarded. + #[serde(default = "GrantCursor::default_sort")] + pub sort_by: String, + pub granted_at: chrono::DateTime, + pub resource_id: Uuid, + /// Present only when `sort_by == "granted_by"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub granted_by: Option, +} +impl GrantCursor { + fn default_sort() -> String { "granted_at".to_owned() } +} +impl PageCursor for GrantCursor {} +``` + +Old cursors (which lack `sort_by`) fail serde and are treated as "start from top" — the existing "undecodable cursor → restart" invariant applies. + +### 10. `src/application/ports/authorization_ports.rs` — add `sort_by` arg + +```rust +async fn list_incoming_resources_paged( + &self, + subject: Subject, + kinds: &[ResourceKind], + limit: u32, + cursor: Option, + sort_by: &str, // "granted_at" | "granted_by" +) -> Result<(Vec, Option), DomainError>; +``` + +### 11. `src/infrastructure/services/pg_acl_engine.rs` — branch SQL on sort_by + +Two separate `sqlx::query_as` calls, selected at runtime: + +**`sort_by = "granted_by"` SQL:** +```sql +WITH agg AS ( … same aggregation … ) +SELECT resource_type, resource_id, permissions, granted_at, granted_by +FROM agg +WHERE ( $4::uuid IS NULL -- cursor_by + OR granted_by > $4::uuid + OR (granted_by = $4::uuid AND ( + $5::timestamptz IS NULL -- cursor_at + OR granted_at < $5 + OR (granted_at = $5 AND resource_id < $6::uuid)))) +ORDER BY granted_by ASC, granted_at DESC, resource_id DESC +LIMIT $7 +``` +Cursor for next page: `GrantCursor { sort_by: "granted_by", granted_at: r.3, resource_id: r.1, granted_by: Some(r.4) }` + +**`sort_by = "granted_at"` SQL (existing, unchanged except cursor struct gains `sort_by` field):** +Cursor for next page: `GrantCursor { sort_by: "granted_at", granted_at: r.3, resource_id: r.1, granted_by: None }` + +### 12. `src/interfaces/api/handlers/grant_handler.rs` + +```rust +let sort_by = q.sort_by.as_deref().unwrap_or("granted_at"); +if !matches!(sort_by, "granted_at" | "granted_by") { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "invalid sort_by"}))).into_response(); +} +// Invalidate cursor when sort mode changed (prevents keyset confusion) +let cursor = q.decode_cursor::() + .filter(|c| c.sort_by == sort_by); + +let (summaries, next_cursor) = state.authorization + .list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by) + .await …; +``` + +--- + +## Files touched + +| File | Change | +|---|---| +| `static/js/components/resourceList.js` | Persist `_lastGroupKey`; add `groupLabelFn` param to `render`/`append` | +| `static/css/components/resourceList.css` | Add `.resource-list__swimlane-header` styles | +| `static/js/core/formatters.js` | Add `normalizeDateBucket()` | +| `static/js/model/systemUsers.js` | Add `getDisplayNameSync()` | +| `static/js/model/grants.js` | Add `orderBy` param | +| `static/js/views/sharedWithMe/sharedWithMeView.js` | Add `_groupBy`, `setGroupBy()`, `GROUP_BY_DEFS`; update `_mapItems`, `_loadPage` | +| `static/js/app/main.js` | Add `_toggleButtonsWithGroupBy`; add batch toolbar to sharedwithme; wire group-by delegation | +| `static/css/components/buttons.css` | Add group-by dropdown styles + `.view-toggle-separator` | +| `src/domain/services/authorization.rs` | Update `GrantCursor` struct | +| `src/application/ports/authorization_ports.rs` | Add `sort_by` to trait method signature | +| `src/infrastructure/services/pg_acl_engine.rs` | Branch SQL on `sort_by`; emit new cursor shape | +| `src/interfaces/api/handlers/grant_handler.rs` | Extract + validate `sort_by`; filter cursor on mismatch | + +--- + +## Known limitations (out of scope) + +- **Owner sort order is by UUID, not display name.** Items from the same owner are correctly grouped, but the ORDER of groups is UUID-lexicographic, not alphabetical by name. Alphabetical ordering would require a server-side join to the users table and a different cursor — deferred. +- **Batch operations from SharedWithMe navigate to Files** — `batchDelete()` calls `loadFiles()`. Pre-existing bug; separate PR. +- **Group-by is SharedWithMe-only** — the `GroupByDef` contract is extensible but no other view is wired in this PR. + +--- + +## Verification + +```bash +# Backend +cargo fmt --all +cargo clippy --all-features --all-targets -- -D warnings +cargo test --workspace + +# Frontend +biome lint static/js/ +stylelint static/css/ +tsc -p jsconfig.json --noEmit +``` + +Manual smoke tests: +1. SharedWithMe loads with no group-by → items appear, no swimlane headers +2. Select "Owner" → page reloads, swimlane headers show resolved display names grouped by granter +3. "Load more" appends without inserting a redundant header for a continuing group +4. Select "Share date" → swimlane headers: Today / Last 7 days / Last 30 days / year +5. Switch back to "None" → plain list, no headers +6. Cursor cursor changes don't bleed across sort modes (switching group-by resets to page 1) +7. Grid ↔ list toggle still works in all group-by states +8. Group-by menu closes when clicking outside diff --git a/migrations/20260527000001_files_category_order.sql b/migrations/20260527000001_files_category_order.sql new file mode 100644 index 00000000..c6ad5f30 --- /dev/null +++ b/migrations/20260527000001_files_category_order.sql @@ -0,0 +1,83 @@ +-- ── storage.files: pre-computed category_order ────────────────────────────── +-- Stores a numeric sort bucket derived from the file's mime_type so that +-- GROUP-BY-TYPE queries in the grants engine can ORDER BY an indexed integer +-- instead of evaluating a long CASE WHEN mime_type LIKE '…' chain at runtime. +-- +-- Values are sparse multiples of 100 so future categories can be inserted +-- between existing ones without renumbering (e.g. "RichText" = 550). +-- Folder rows live in storage.folders and are not affected; the SQL query +-- hard-codes 0 for them. +-- +-- Mapping (mirrors category_order_for() in display_helpers.rs): +-- 0 → Folder (SQL-only constant, not stored) +-- 100 → Image +-- 200 → Video +-- 300 → Audio +-- 400 → PDF +-- 500 → Document +-- 600 → Spreadsheet +-- 700 → Presentation +-- 800 → Archive +-- 900 → Code +-- 1000 → Markdown +-- 1100 → Text +-- 1200 → Installer +-- 9999 → Other (default) + +ALTER TABLE storage.files + ADD COLUMN IF NOT EXISTS category_order SMALLINT NOT NULL DEFAULT 9999; + +-- Backfill existing rows. The CASE mirrors category_for() + category_order_for(). +UPDATE storage.files +SET category_order = CASE + -- Image + WHEN mime_type LIKE 'image/%' THEN 100 + -- Video + WHEN mime_type LIKE 'video/%' THEN 200 + -- Audio + WHEN mime_type LIKE 'audio/%' THEN 300 + -- PDF + WHEN mime_type = 'application/pdf' THEN 400 + -- Document + WHEN mime_type IN ( + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'application/rtf') THEN 500 + -- Spreadsheet + WHEN mime_type IN ( + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + 'text/csv') THEN 600 + -- Presentation + WHEN mime_type IN ( + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation') THEN 700 + -- Archive + WHEN mime_type IN ( + 'application/zip', 'application/x-rar-compressed', 'application/vnd.rar', + 'application/x-7z-compressed', 'application/gzip', 'application/x-tar') THEN 800 + -- Code (application/* and text/x-* variants) + WHEN mime_type IN ( + 'application/json', 'application/javascript', 'application/typescript', + 'application/xml', 'application/sql', + 'application/x-sh', 'application/x-shellscript') + OR mime_type LIKE 'text/x-%' + OR mime_type LIKE 'text/html%' + OR mime_type = 'text/css' THEN 900 + -- Markdown + WHEN mime_type LIKE 'text/markdown%' THEN 1000 + -- Text (generic) + WHEN mime_type LIKE 'text/%' THEN 1100 + -- Installer / disk image + WHEN mime_type IN ( + 'application/x-apple-diskimage', 'application/x-ms-dos-executable', + 'application/x-msdownload', 'application/x-msi') THEN 1200 + -- Everything else → Other + ELSE 9999 +END; + +-- Index so ORDER BY category_order is a fast index scan, not a table sort. +CREATE INDEX IF NOT EXISTS idx_files_category_order ON storage.files (category_order); diff --git a/src/application/dtos/display_helpers.rs b/src/application/dtos/display_helpers.rs index b9590d2c..4ad0f059 100644 --- a/src/application/dtos/display_helpers.rs +++ b/src/application/dtos/display_helpers.rs @@ -344,6 +344,31 @@ pub fn category_for(name: &str, mime: &str) -> &'static str { "Document" } +/// Returns the sort order for a file category, stored as `category_order` in `storage.files`. +/// +/// Values are **sparse multiples of 100** so a future category can be slotted between two +/// existing ones (e.g. "RichText" = 550, between Document=500 and Spreadsheet=600) without +/// renumbering any rows. Folders are not handled here — the SQL query hard-codes 0 for them. +/// +/// This function delegates to [`category_for`] so the two are always in sync. +pub fn category_order_for(name: &str, mime: &str) -> i16 { + match category_for(name, mime) { + "Image" => 100, + "Video" => 200, + "Audio" => 300, + "PDF" => 400, + "Document" => 500, + "Spreadsheet" => 600, + "Presentation" => 700, + "Archive" => 800, + "Code" => 900, + "Markdown" => 1000, + "Text" => 1100, + "Installer" => 1200, + _ => 9999, // Other / unknown + } +} + /// Formats a byte count into a human-readable string (1024-based). /// /// Matches the JavaScript `formatFileSize()` output exactly so the frontend diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 2e46dddb..6e752f33 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -85,6 +85,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static { kinds: &[ResourceKind], limit: u32, cursor: Option, + sort_by: &str, ) -> Result<(Vec, Option), DomainError>; /// All grants on a specific resource (for "Manage sharing" UI). Caller diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index bdfcbb22..c062c273 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -258,11 +258,39 @@ pub struct IncomingGrantSummary { /// Encodes the position of the last seen item in a cursor-paginated grant /// listing. The encoding is opaque to API callers — only the backend -/// decodes it. Change the encoding algorithm in a major version bump. +/// decodes it. +/// +/// The `sort_by` field must match the active sort dimension — if the caller +/// switches sort order the handler discards any cursor whose `sort_by` does +/// not match, restarting from the first page. +/// +/// Sort-key fields populated per `sort_by` value: +/// - `"granted_at"` (default) — uses `granted_at` + `resource_id` +/// - `"name"` — uses `resource_name` (lowercased) + `resource_id` +/// - `"type"` — uses `type_order` + `resource_name` (lowercased) + `resource_id` +/// - `"granted_by"` — uses `resource_name` (owner display name, lowercased) + `granted_at` + `resource_id` #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GrantCursor { + /// Sort dimension that was active when this cursor was produced. + #[serde(default = "GrantCursor::default_sort")] + pub sort_by: String, pub granted_at: chrono::DateTime, pub resource_id: Uuid, + /// Lowercased sort string — resource name for `"name"`/`"type"`, + /// owner display name for `"granted_by"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_name: Option, + /// Generic integer sort key: + /// - `"type"` — category_order (0 = Folder, 100 = Image, …) + /// - `"size"` — file size in bytes (-1 = Folder sentinel) + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_int: Option, +} + +impl GrantCursor { + fn default_sort() -> String { + "granted_at".to_owned() + } } /// Delegate encode/decode to the shared [`PageCursor`] trait. diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 942f4615..3bc1a9bd 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use std::sync::Arc; use uuid::Uuid; +use crate::application::dtos::display_helpers::category_order_for; use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort}; use crate::common::errors::DomainError; use crate::domain::entities::file::File; @@ -226,8 +227,8 @@ impl FileBlobWriteRepository { let row = match sqlx::query_as::<_, (String, i64, i64)>( r#" - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) - VALUES ($1, $2::uuid, $3, $4, $5, $6) + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order) + VALUES ($1, $2::uuid, $3, $4, $5, $6, $7) RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -239,6 +240,7 @@ impl FileBlobWriteRepository { .bind(&blob_hash) .bind(size as i64) .bind(&content_type) + .bind(category_order_for(&name, &content_type)) .fetch_one(self.pool.as_ref()) .await { @@ -374,18 +376,19 @@ impl FileWritePort for FileBlobWriteRepository { >( r#" WITH src AS ( - SELECT name, folder_id, user_id, blob_hash, size, mime_type + SELECT name, folder_id, user_id, blob_hash, size, mime_type, category_order FROM storage.files WHERE id = $1::uuid AND NOT is_trashed ), new_file AS ( - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order) SELECT name, COALESCE($2::uuid, folder_id), user_id, blob_hash, size, - mime_type + mime_type, + category_order FROM src RETURNING id::text, name, folder_id::text, size, mime_type, EXTRACT(EPOCH FROM created_at)::bigint, @@ -537,8 +540,8 @@ impl FileWritePort for FileBlobWriteRepository { let row = sqlx::query_as::<_, (String, i64, i64)>( r#" - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) - VALUES ($1, $2::uuid, $3, $4, $5, $6) + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order) + VALUES ($1, $2::uuid, $3, $4, $5, $6, $7) RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -550,6 +553,7 @@ impl FileWritePort for FileBlobWriteRepository { .bind(placeholder_hash) .bind(size as i64) .bind(&content_type) + .bind(category_order_for(&name, &content_type)) .fetch_one(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?; diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 34d1be13..54df9614 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -299,87 +299,243 @@ impl AuthorizationEngine for PgAclEngine { kinds: &[ResourceKind], limit: u32, cursor: Option, + sort_by: &str, ) -> Result<(Vec, Option), DomainError> { - // Build kind filter array — NULL means "all kinds". + // ── Common setup ────────────────────────────────────────────────────── let kind_strs: Option> = if kinds.is_empty() { None } else { Some(kinds.iter().map(|k| k.as_str()).collect()) }; - - let cursor_at = cursor.as_ref().map(|c| c.granted_at); - let cursor_id = cursor.as_ref().map(|c| c.resource_id); - - // Fetch limit+1 rows so we can detect whether a next page exists. let fetch_limit = (limit as i64) + 1; - // Each row: (resource_type, resource_id, permissions_text_array, - // granted_at, granted_by) + // Unified row type — the last two columns carry the sort key when present, + // NULL otherwise. This lets every sort mode share a single query_as call. + // 0 resource_type String + // 1 resource_id Uuid + // 2 permissions Vec + // 3 granted_at DateTime + // 4 granted_by Uuid + // 5 sort_str Option — resource_name (name/type) or owner_name (granted_by) + // 6 sort_int Option — category_order (type) or file size in bytes (size) type Row = ( String, Uuid, Vec, chrono::DateTime, Uuid, + Option, + Option, ); - let rows: Vec = sqlx::query_as( - r#" - WITH agg AS ( - SELECT - resource_type, - resource_id, - array_agg(DISTINCT permission ORDER BY permission) AS permissions, - MIN(granted_at) AS granted_at, - (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by - FROM storage.access_grants - WHERE subject_type = $1 - AND subject_id = $2 - AND ($3::text[] IS NULL OR resource_type = ANY($3)) - GROUP BY resource_type, resource_id - ) - SELECT resource_type, resource_id, permissions, granted_at, granted_by - FROM agg - WHERE ( $4::timestamptz IS NULL - OR granted_at < $4 - OR (granted_at = $4 AND resource_id < $5::uuid)) - ORDER BY granted_at DESC, resource_id DESC - LIMIT $6 - "#, - ) - .bind(subject.type_str()) - .bind(subject.id()) - .bind(kind_strs) - .bind(cursor_at) - .bind(cursor_id) - .bind(fetch_limit) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("PgAcl", format!("list_incoming_resources_paged: {e}")) - })?; + // Extract all cursor fields up-front; each branch uses the subset it needs. + // Fixed parameter positions used in all SQL variants: + // $4 = cursor_str (resource_name / owner_name) + // $5 = cursor_int (type_order) + // $6 = cursor_at (granted_at) + // $7 = cursor_id (resource_id) + // $8 = fetch_limit + let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone()); + let cursor_int = cursor.as_ref().and_then(|c| c.sort_int); + let cursor_at = cursor.as_ref().map(|c| c.granted_at); + let cursor_id = cursor.as_ref().map(|c| c.resource_id); + // ── agg CTE (identical in all branches) ─────────────────────────────── + const AGG: &str = r#"agg AS ( + SELECT + resource_type, + resource_id, + array_agg(DISTINCT permission ORDER BY permission) AS permissions, + MIN(granted_at) AS granted_at, + (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by + FROM storage.access_grants + WHERE subject_type = $1 + AND subject_id = $2 + AND ($3::text[] IS NULL OR resource_type = ANY($3)) + GROUP BY resource_type, resource_id + )"#; + + // ── 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. + let sql = match sort_by { + "name" | "type" => { + let sort_int_expr = if sort_by == "type" { + "CASE WHEN agg.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END" + } 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))"# + } 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" + }; + format!( + r#"WITH {AGG}, + named AS ( + SELECT agg.*, + COALESCE( + CASE WHEN agg.resource_type = 'folder' THEN f.name END, + CASE WHEN agg.resource_type = 'file' THEN fi.name END + ) AS sort_str, + {sort_int_expr} AS sort_int + FROM agg + LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder' + 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 named + WHERE {where_clause} + ORDER BY {order_clause} + LIMIT $8"# + ) + } + "granted_by" => format!( + // 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 + ) + 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. + // 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' + ) + 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). + // 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"# + ), + }; + + // ── Execute — uniform 8 binds for every sort mode ───────────────────── + let mut rows: Vec = sqlx::query_as::<_, Row>(&sql) + .bind(subject.type_str()) // $1 + .bind(subject.id()) // $2 + .bind(&kind_strs) // $3 + .bind(&cursor_str) // $4 sort_str cursor + .bind(cursor_int) // $5 sort_int cursor + .bind(cursor_at) // $6 granted_at cursor + .bind(cursor_id) // $7 resource_id cursor + .bind(fetch_limit) // $8 + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error( + "PgAcl", + format!("list_incoming_resources_paged ({sort_by}): {e}"), + ) + })?; + + // ── Pagination ──────────────────────────────────────────────────────── let has_next = rows.len() > limit as usize; - let rows: Vec = rows.into_iter().take(limit as usize).collect(); + rows.truncate(limit as usize); - // Determine the next cursor from the last item we're actually returning. let next_cursor = if has_next { - rows.last().map(|r| GrantCursor { - granted_at: r.3, - resource_id: r.1, + rows.last().map(|r| { + let sort_str_lc = r.5.as_deref().map(str::to_lowercase); + match sort_by { + "name" => GrantCursor { + sort_by: "name".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: None, + }, + "type" => GrantCursor { + sort_by: "type".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: r.6, + }, + "granted_by" => GrantCursor { + sort_by: "granted_by".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: r.5.clone(), // already lowercased by SQL + sort_int: None, + }, + "size" => GrantCursor { + sort_by: "size".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: None, + sort_int: r.6, + }, + _ => GrantCursor { + sort_by: "granted_at".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: None, + sort_int: None, + }, + } }) } else { None }; - // Convert rows into domain summaries. + // ── Convert rows to domain summaries ────────────────────────────────── let summaries = rows .into_iter() - .filter_map(|(rt, rid, perms_str, granted_at, granted_by)| { + .filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| { let resource_type = ResourceKind::parse(&rt)?; let permissions = perms_str - .iter() - .filter_map(|s| Permission::parse(s)) + .into_iter() + .filter_map(|s| Permission::parse(&s)) .collect(); Some(IncomingGrantSummary { resource_type, diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 12c34128..4019d96c 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -323,13 +323,29 @@ pub async fn list_shared_with_me( // Clamp limit to 1–200. let limit = q.limit_clamped() as u32; - // Decode cursor (treat invalid cursor as "start from top"). - let cursor = q.decode_cursor::(); + // Validate sort_by (defaults to "granted_at"). + let sort_by = q.sort_by.as_deref().unwrap_or("granted_at"); + if !matches!( + sort_by, + "granted_at" | "granted_by" | "name" | "type" | "size" + ) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type, size"})), + ) + .into_response(); + } + + // Decode cursor — discard it when the sort dimension changed to avoid + // keyset confusion across sort modes. + let cursor = q + .decode_cursor::() + .filter(|c| c.sort_by == sort_by); // Fetch paged summaries from the ACL engine. let (summaries, next_cursor) = match state .authorization - .list_incoming_resources_paged(subject, &kinds, limit, cursor) + .list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by) .await { Ok(r) => r, diff --git a/static/css/components/buttons.css b/static/css/components/buttons.css index f9f7c398..82a690e2 100644 --- a/static/css/components/buttons.css +++ b/static/css/components/buttons.css @@ -105,3 +105,92 @@ .toggle-btn i { pointer-events: none; } + +/* ── Group-by selector (inside .view-toggle) ────────────── */ + +.view-toggle-separator { + width: 1px; + height: 20px; + background: var(--color-border-medium); + align-self: center; + margin: 0 2px; +} + +.view-toggle-separator.hidden { + display: none; +} + +.group-by-selector { + position: relative; +} + +.group-by-selector.hidden { + display: none; +} + +.group-by-btn.active { + color: var(--color-accent); +} + +/* Active label shown inline next to the icon */ +.group-by-label { + display: none; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} + +/* When a group-by is selected the label has text — expand the button to fit */ +.group-by-btn:has(.group-by-label:not(:empty)) { + width: auto; + padding: 0 8px; + gap: 5px; +} + +.group-by-btn:has(.group-by-label:not(:empty)) .group-by-label { + display: inline; +} + +.group-by-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 200; + min-width: 140px; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 16px var(--color-shadow); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.group-by-menu.hidden { + display: none; +} + +.group-by-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + color: var(--color-text); + text-align: left; + width: 100%; +} + +.group-by-option:hover { + background: var(--color-border); +} + +.group-by-option.active { + color: var(--color-accent); + font-weight: 600; +} diff --git a/static/css/components/resourceList.css b/static/css/components/resourceList.css index ad57be34..a26e0d90 100644 --- a/static/css/components/resourceList.css +++ b/static/css/components/resourceList.css @@ -551,6 +551,66 @@ padding: 2px; } +/* ── Swimlane group header ───────────────────────────────── */ + +/* Spans the full grid width in list view; no-op in grid view since + grid wraps it naturally. */ +.resource-list__swimlane-header { + grid-column: 1 / -1; + padding: 6px 12px 4px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-faint); + border-bottom: 1px solid var(--color-border); + margin-top: 8px; + cursor: default; + user-select: none; +} + +.resource-list__swimlane-header:first-child, +.list-header + .resource-list__swimlane-header { + margin-top: 0; +} + +/* ── Swimlane group card (list view only) ────────────────── */ + +/* When swimlane groups are present, dissolve the outer container into the + page background so each group card reads as its own panel. */ +.files-list-view:has(.resource-list__swimlane-group) { + background-color: transparent; + box-shadow: none; + border-radius: 0; + overflow: visible; + gap: 10px; +} + +/* Each group is a self-contained card */ +.files-list-view .resource-list__swimlane-group { + background-color: var(--color-item); + border-radius: 10px; + box-shadow: 0 1px 3px var(--color-shadow-xs); + overflow: hidden; +} + +/* The header inside a group card is always first — no extra top margin */ +.files-list-view .resource-list__swimlane-group .resource-list__swimlane-header { + margin-top: 0; +} + +/* ── Swimlane group wrapper (grid view) ──────────────────── */ + +/* The group wrapper must be transparent to the grid so .file-item children + continue to flow in the parent's columns (subgrid mirrors the column tracks). + The wrapper spans the full row width; items inside fill the columns naturally. */ +.files-grid-view .resource-list__swimlane-group { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + gap: 20px; +} + /* ── Section item modifiers ──────────────────────────────── */ /* — Favorites — */ diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 12be1db0..aa1f96e4 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -1,8 +1,8 @@ // @ts-check import { i18n } from '../core/i18n.js'; -import { inlineViewer } from '../features/files/inlineViewer.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; +import { inlineViewer } from '../features/files/inlineViewer.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; import { app } from './state.js'; diff --git a/static/js/app/main.js b/static/js/app/main.js index e6c8768d..6ca2fe07 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -8,11 +8,11 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js'; installFetchInterceptor(); import { Modal } from '../components/modal.js'; -import { formatFileSize, formatQuotaSize } from '../core/formatters.js'; +import { escapeHtml, formatFileSize, formatQuotaSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { oxiIconsInit } from '../core/icons.js'; -import { fileOps } from '../features/files/fileOperations.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; +import { fileOps } from '../features/files/fileOperations.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; @@ -83,6 +83,15 @@ const _batchToolbarButons = ` const _toggleButtons = `
+ + @@ -146,6 +155,7 @@ const ACTIONS_BAR_TEMPLATES = { `, sharedwithme: `
+ ${_batchToolbarButons} ${_toggleButtons} ` }; @@ -189,6 +199,75 @@ function setActionsBarMode(mode, force = false) { } } +/** + * @typedef {{ key: string, label: string, setGroupBy: (key: string) => void }} GroupByCapableView + */ + +/** + * 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} + */ +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 + */ +function setGroupByView(view) { + _groupByView = view; +} + +/** @type {((e: MouseEvent) => void) | null} */ +let _groupByDocumentClickHandler = null; + +/** + * Populate and show (or hide) the group-by dropdown based on the active + * section's `groupByDefs`. Pass an empty array (or omit) to hide the button. + * + * Must be called AFTER `setActionsBarMode()` so the selector elements exist + * in the DOM. + * + * @param {Array<{key: string, label: string}>} [defs] + */ +function syncGroupByMenu(defs = []) { + const selector = document.getElementById('group-by-selector'); + const separator = document.getElementById('group-by-separator'); + const menu = document.getElementById('group-by-menu'); + if (!selector || !menu) return; + + const hasDefs = defs.length > 0; + selector.classList.toggle('hidden', !hasDefs); + separator?.classList.toggle('hidden', !hasDefs); + + if (!hasDefs) { + // Reset active indicator when the section has no group-by support + const btn = document.getElementById('group-by-btn'); + btn?.classList.remove('active'); + const lbl = btn?.querySelector('.group-by-label'); + if (lbl) lbl.textContent = ''; + return; + } + + // Rebuild menu options — call i18n.t() directly so each label is resolved + // at call time (translations are loaded by the time any section switch runs). + menu.innerHTML = ``; + for (const def of defs) { + menu.insertAdjacentHTML('beforeend', ``); + } + + // One stable document-level handler to close the menu on outside clicks. + if (_groupByDocumentClickHandler) { + document.removeEventListener('click', _groupByDocumentClickHandler); + } + _groupByDocumentClickHandler = (e) => { + if (/** @type {HTMLElement} */ (e.target)?.closest('#group-by-selector')) return; + document.getElementById('group-by-menu')?.classList.add('hidden'); + }; + document.addEventListener('click', _groupByDocumentClickHandler); +} + function setupActionsBarDelegation() { if (actionsBarDelegationBound || !elements.actionsBar) return; actionsBarDelegationBound = true; @@ -197,7 +276,26 @@ function setupActionsBarDelegation() { const btn = /** @type {HTMLElement} */ (e.target)?.closest('button'); if (!btn) return; + // ── Group-by option selected ────────────────────────────────────────── + if (btn.classList.contains('group-by-option')) { + const key = btn.dataset.groupBy ?? ''; + _groupByView?.setGroupBy(key); + document.querySelectorAll('.group-by-option').forEach((b) => { + b.classList.remove('active'); + }); + btn.classList.add('active'); + document.getElementById('group-by-menu')?.classList.add('hidden'); + const groupByBtn = document.getElementById('group-by-btn'); + groupByBtn?.classList.toggle('active', key !== ''); + const lbl = groupByBtn?.querySelector('.group-by-label'); + if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : ''; + return; + } + switch (btn.id) { + case 'group-by-btn': + document.getElementById('group-by-menu')?.classList.toggle('hidden'); + return; case 'upload-files-btn': { e.stopPropagation(); const menu = document.getElementById('upload-dropdown-menu'); @@ -798,4 +896,4 @@ function updateStorageUsageDisplay(userData) { console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`); } -export { deserializeHash, initApp, setActionsBarMode, updateHistory, updateStorageUsageDisplay }; +export { deserializeHash, initApp, setActionsBarMode, setGroupByView, syncGroupByMenu, updateHistory, updateStorageUsageDisplay }; diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 3be34396..4f590fa2 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -12,7 +12,7 @@ import { recent } from '../features/library/recent.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; import { loadFiles } from './filesView.js'; -import { setActionsBarMode } from './main.js'; +import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js'; import { app, appElements } from './state.js'; import { loadTrashItems } from './trashView.js'; import { ui } from './ui.js'; @@ -214,6 +214,11 @@ function switchToSharedWithMeSection() { // Show actions-bar with view toggle (no upload / new-folder in this view) setActionsBarMode('sharedwithme'); + // Populate the group-by dropdown with this section's dimensions. + // Must be called AFTER setActionsBarMode() so the selector elements exist. + setGroupByView(sharedWithMeView); + syncGroupByMenu(sharedWithMeView.groupByDefs); + // Show the Owner column — names are resolved async after render. ui.setOwnerColumnVisible(true); @@ -232,6 +237,8 @@ function switchToFilesSection() { // Set actions bar mode setActionsBarMode('files', true); + setGroupByView(null); + syncGroupByMenu([]); // Show owner column in the Files section ui.setOwnerColumnVisible(true); @@ -266,6 +273,8 @@ function switchToFavoritesSection() { // Set actions bar mode setActionsBarMode('favorites'); + setGroupByView(null); + syncGroupByMenu([]); // Show the Owner column — names are resolved async after render. ui.setOwnerColumnVisible(true); @@ -304,6 +313,8 @@ function switchToRecentFilesSection() { // Set actions bar mode setActionsBarMode('recent'); + setGroupByView(null); + syncGroupByMenu([]); // Hide breadcrumb (only shown in Files view) const breadcrumb = document.querySelector('.breadcrumb'); @@ -367,6 +378,8 @@ function switchToTrashSection() { toggleFileContainer(true); setActionsBarMode('trash'); + setGroupByView(null); + syncGroupByMenu([]); //reset files view + remove any error ui.resetFilesList(); @@ -419,6 +432,8 @@ function switchToMusicSection() { function activateFilesUI() { setCurrentSection('files'); setActionsBarMode('files', true); + setGroupByView(null); + syncGroupByMenu([]); const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.remove('hidden'); toggleFileContainer(true); diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index c9cb2d33..2b4ceb1b 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -4,8 +4,8 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; -import { fileOps } from '../features/files/fileOperations.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; +import { fileOps } from '../features/files/fileOperations.js'; import * as pathTooltip from '../features/pathTooltip.js'; import { appElements } from './state.js'; import { ui } from './ui.js'; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index c80b5a89..27776151 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -10,10 +10,10 @@ import { createUserVignette } from '../components/userVignette.js'; import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { OxiIcons } from '../core/icons.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; -import { batchToolbar } from '../features/files/batchToolbar.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 277a4c78..557040d0 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -89,6 +89,30 @@ export class ResourceListComponent { /** Index of the last clicked item — used for shift-click range selection. */ this._lastClickedIndex = -1; + /** + * The grouping key of the last rendered item — persisted across + * `append()` calls so load-more pages don't insert a redundant header + * when the first item of the new page shares a group with the last + * item of the previous page. + * `undefined` means no items have been rendered yet (reset in `render()`). + * @type {string|null|undefined} + */ + this._lastGroupKey = undefined; + + /** + * The live DOM group wrapper of the last rendered swimlane — persisted + * across `append()` calls so load-more items that continue the same + * group are appended into the existing card rather than starting a new one. + * @type {HTMLElement|null} + */ + this._lastGroupEl = null; + + /** + * Optional label-resolver stored between `render()` and `append()` calls. + * @type {((key: string) => string) | undefined} + */ + this._groupLabelFn = undefined; + this._ownerVisible = this._cfg.showOwner; this._initDelegation(); @@ -100,13 +124,20 @@ export class ResourceListComponent { * Replace the current item list. Preserves an existing `.list-header` * at the start of the container. * - * @param {FolderItem[]} folders - * @param {FileItem[]} files + * Items are rendered **in the order supplied** — do not pre-sort or + * split them into folders/files; the caller (or server) owns ordering. + * Folders vs. files are distinguished at render time by whether the item + * has a `mime_type` property. + * + * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn * When provided, a swimlane divider is injected whenever the returned - * label changes. Return `null` to suppress the divider for that item. + * key changes. Return `null` to suppress the divider for that item. + * @param {((key: string) => string)=} groupLabelFn + * Optional: converts the raw grouping key to a human-readable header + * label. When omitted the key itself is used. */ - render(folders, files, groupFn) { + render(items, groupFn, groupLabelFn) { const header = this._container.querySelector('.list-header'); this._container.innerHTML = ''; if (header) this._container.appendChild(header); @@ -114,23 +145,29 @@ export class ResourceListComponent { this._selected.clear(); this._items.clear(); this._lastClickedIndex = -1; + // Reset group tracking for the fresh render + this._lastGroupKey = undefined; + this._lastGroupEl = null; + this._groupLabelFn = groupLabelFn; // Prevent ui.js global delegation from firing on this container this._container.dataset.managedBy = 'resource-list'; - this._appendItems(folders, files, groupFn); + this._appendItems(items, groupFn, groupLabelFn); this._wireSelectAll(); } /** * Append additional items without clearing the existing ones (load-more). + * Continues swimlane grouping from the last item of the previous page — + * no redundant header is inserted when the key is unchanged. * - * @param {FolderItem[]} folders - * @param {FileItem[]} files + * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn + * @param {((key: string) => string)=} groupLabelFn */ - append(folders, files, groupFn) { - this._appendItems(folders, files, groupFn); + append(items, groupFn, groupLabelFn) { + this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn); } /** Remove all items (but keep `.list-header` if present). */ @@ -141,6 +178,8 @@ export class ResourceListComponent { this._selected.clear(); this._items.clear(); this._lastClickedIndex = -1; + this._lastGroupKey = undefined; + this._lastGroupEl = null; // Hand delegation back to ui.js delete this._container.dataset.managedBy; } @@ -239,50 +278,74 @@ export class ResourceListComponent { // ── Private helpers ───────────────────────────────────────────────────── /** - * @param {FolderItem[]} folders - * @param {FileItem[]} files + * Internal: append items to the container in the order supplied. + * Files vs. folders are distinguished by presence of `mime_type`. + * + * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn + * @param {((key: string) => string)=} groupLabelFn */ - _appendItems(folders, files, groupFn) { + _appendItems(items, groupFn, groupLabelFn) { const fragment = document.createDocumentFragment(); - let lastGroupKey = /** @type {string|null|undefined} */ (undefined); - for (const folder of folders) { - this._items.set(folder.id, folder); + // Start from the persisted key so load-more pages continue seamlessly. + let lastGroupKey = this._lastGroupKey; + + // liveGroup: existing DOM group element from the previous page; items + // that continue its group are appended directly into it (not via fragment). + // fragmentGroup: the group wrapper currently being built in the fragment. + let liveGroup = groupFn ? this._lastGroupEl : null; + let fragmentGroup = /** @type {HTMLElement|null} */ (null); + + for (const item of items) { + this._items.set(item.id, item); + if (groupFn) { - const key = groupFn(folder); + const key = groupFn(item); if (key !== lastGroupKey) { lastGroupKey = key; - if (key !== null) fragment.appendChild(this._createGroupHeader(key)); - } - } - fragment.appendChild(this._createFolderItem(folder)); - } + liveGroup = null; // stop extending the previous page's group + fragmentGroup = null; - for (const file of files) { - this._items.set(file.id, file); - if (groupFn) { - const key = groupFn(file); - if (key !== lastGroupKey) { - lastGroupKey = key; - if (key !== null) fragment.appendChild(this._createGroupHeader(key)); + if (key !== null) { + fragmentGroup = document.createElement('div'); + fragmentGroup.className = 'resource-list__swimlane-group'; + fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn)); + fragment.appendChild(fragmentGroup); + } } } - fragment.appendChild(this._createFileItem(file)); + + // Dispatch to the correct renderer: files have mime_type, folders do not. + const isFile = 'mime_type' in item; + const itemEl = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item)); + + // Priority: live DOM group (load-more continuation) > current fragment group > bare container + const target = liveGroup ?? fragmentGroup; + if (target) { + target.appendChild(itemEl); + } else { + fragment.appendChild(itemEl); + } } this._container.appendChild(fragment); + // Persist for the next append() call (load-more continuity). + this._lastGroupKey = lastGroupKey; + // Track the last group element (live or freshly added) for the next page. + this._lastGroupEl = fragmentGroup ?? liveGroup; } /** * Create a swimlane divider element. - * @param {string} label + * @param {string} key - Raw grouping key (e.g. UUID or bucket name). + * @param {((key: string) => string)=} labelFn - Optional human-readable resolver. */ - _createGroupHeader(label) { + _createGroupHeader(key, labelFn) { const el = document.createElement('div'); el.className = 'resource-list__swimlane-header'; el.dataset.swimlaneHeader = 'true'; - el.textContent = label; + el.textContent = labelFn ? labelFn(key) : key; return el; } diff --git a/static/js/core/formatters.js b/static/js/core/formatters.js index 2263c436..84b069a3 100644 --- a/static/js/core/formatters.js +++ b/static/js/core/formatters.js @@ -4,6 +4,8 @@ * Contains also checkers */ +import { i18n } from './i18n.js'; + /** * * @param {string} str @@ -107,4 +109,62 @@ function isEmailValid(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } -export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable }; +/** + * Normalize a date value into a human-readable bucket label. + * Buckets (newest-first): Today | Last 7 days | Last 30 days | + * + * Accepts: + * - `string` — ISO-8601 date string (e.g. `granted_at` from the API) + * - `number` — Unix timestamp in **seconds** (e.g. `sort_date`, `modified_at`) + * Values < 1e12 are treated as seconds; larger values as milliseconds. + * - `Date` — JavaScript Date object + * + * @param {string | number | Date} value + * @returns {string} + */ +function normalizeDateBucket(value) { + let date; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number') { + date = new Date(value < 1e12 ? value * 1000 : value); + } else { + date = new Date(value); + } + const diffDays = Math.floor((Date.now() - date.getTime()) / 86_400_000); + if (diffDays === 0) return i18n.t('dateBucket.today', 'Today'); + if (diffDays <= 7) return i18n.t('dateBucket.last7days', 'Last 7 days'); + if (diffDays <= 30) return i18n.t('dateBucket.last30days', 'Last 30 days'); + return String(date.getFullYear()); +} + +/** + * Maps a file size in bytes to a coarse, human-readable bucket label. + * + * Pass `-1` for folders — they sort before all files on the server and + * receive the "Folders" label client-side. + * + * Buckets: + * -1 → Folders + * 0 → Empty (0 B) + * 1 – 1 048 575 → < 1 MB + * 1 048 576 – 104 857 599 → 1 – 100 MB + * 104 857 600 – 1 073 741 823 → 100 MB – 1 GB + * 1 073 741 824 – 5 368 709 119 → 1 – 5 GB + * ≥ 5 368 709 120 → > 5 GB + * + * @param {number} bytes File size in bytes, or -1 for folders. + * @returns {string} + */ +// biome-ignore format: keep the following indent +function sizeBucket(bytes) { + if (bytes < 0) return i18n.t('sizeBucket.folders', 'Folders'); + if (bytes === 0) return i18n.t('sizeBucket.empty', 'Empty (0 B)'); + if (bytes < 1_048_576) return i18n.t('sizeBucket.tiny', '< 1 MB'); + if (bytes < 104_857_600) return i18n.t('sizeBucket.small', '1 – 100 MB'); + if (bytes < 1_073_741_824) return i18n.t('sizeBucket.medium', '100 MB – 1 GB'); + if (bytes < 5 * 1_073_741_824) return i18n.t('sizeBucket.large', '1 – 5 GB'); + return i18n.t('sizeBucket.huge', '> 5 GB'); +} + +export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable, normalizeDateBucket, sizeBucket }; diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 3c7db48d..01a1d8c4 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -234,6 +234,10 @@ const OxiIcons = { 576, 'M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z' ], + 'layer-group': [ + 512, + 'M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z' + ], link: [ 576, 'M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z' diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index de6c493d..f95f4ff0 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -16,9 +16,9 @@ import { i18n } from '../../core/i18n.js'; import { favorites } from '../library/favorites.js'; import { musicView } from '../library/music.js'; import { fileSharing } from '../sharing/fileSharing.js'; +import { batchToolbar } from './batchToolbar.js'; import { fileOps } from './fileOperations.js'; import { inlineViewer } from './inlineViewer.js'; -import { batchToolbar } from './batchToolbar.js'; import { wopiEditor } from './wopiEditor.js'; /** diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 3bc34d1a..f62f39e7 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -7,9 +7,9 @@ */ 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 { ResourceListComponent } from '../../components/resourceList.js'; import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; @@ -195,16 +195,13 @@ const favorites = { return; } - /** @type {FolderItem[]} */ - const folders = []; - - /** @type {FileItem[]} */ - const files = []; + /** @type {Array} */ + 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') { - folders.push( + items.push( /** @type {FolderItem} */ ({ id: item.item_id, name: item.item_name || item.item_id, @@ -220,7 +217,7 @@ const favorites = { }) ); } else { - files.push( + items.push( /** @type {FileItem} */ ({ id: item.item_id, name: item.item_name || item.item_id, @@ -244,50 +241,45 @@ const favorites = { 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(); + 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(folders, files); + this._component.render(items); pathTooltip.init(filesList); } diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 55729775..8da2aa07 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -7,9 +7,9 @@ */ 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 { ResourceListComponent } from '../../components/resourceList.js'; import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; @@ -109,9 +109,7 @@ const recent = { 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' - ) + [...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified') ); if (dateHeader) { dateHeader.removeAttribute('data-i18n'); @@ -133,87 +131,83 @@ const recent = { return; } - /** @type {FolderItem[]} */ - const folders = []; - - /** @type {FileItem[]} */ - const files = []; + /** @type {Array} */ + const items = []; for (const item of recentItems) { const isFolder = item.item_type === 'folder'; if (isFolder) { - folders.push({ - 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 - }); + 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); } - files.push({ - 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 - }); + 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(); + 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(folders, files); + this._component.render(items); pathTooltip.init(filesList); } } catch (error) { diff --git a/static/js/model/grants.js b/static/js/model/grants.js index 0cdb3440..e385a74d 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -88,14 +88,16 @@ const grants = { * @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']). * @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'). * @returns {Promise} */ - async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) { + async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) { const params = new URLSearchParams({ limit: String(limit), resource_types: resourceTypes.join(',') }); if (cursor) params.set('cursor', cursor); + if (orderBy) params.set('sort_by', orderBy); const response = await fetch(`/api/grants/incoming/resources?${params}`); diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js index 7d7a20c7..7e9360ce 100644 --- a/static/js/model/systemUsers.js +++ b/static/js/model/systemUsers.js @@ -96,6 +96,19 @@ function prefetch() { _ensureIndex(); // intentionally fire-and-forget } +/** + * Synchronous best-effort display-name lookup from the pre-fetched cache. + * Returns a shortened UUID prefix when the cache is not yet loaded. + * Call `prefetch()` at view init time so the cache is warm by the time + * items are rendered. + * @param {string} userId + * @returns {string} + */ +function getDisplayNameSync(userId) { + if (_index === null) return `${userId.slice(0, 8)}…`; + return _index.get(userId) ?? `${userId.slice(0, 8)}…`; +} + /** * Resolve a user UUID to a display name. * Awaits the first load if not yet cached; subsequent calls resolve instantly. @@ -159,4 +172,4 @@ function isAvailable() { return addressBook.isSystemAvailable(); } -export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable }; +export const systemUsers = { prefetch, getDisplayName, getDisplayNameSync, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index d7ce6730..ef07c769 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -11,8 +11,9 @@ */ import { ui } from '../../app/ui.js'; -import { i18n } from '../../core/i18n.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; +import { i18n } from '../../core/i18n.js'; import { batchToolbar } from '../../features/files/batchToolbar.js'; import { favorites } from '../../features/library/favorites.js'; import { ownerTooltip } from '../../features/ownerTooltip.js'; @@ -21,6 +22,107 @@ import { systemUsers } from '../../model/systemUsers.js'; /** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ +/** + * @typedef {{ key: string, label: string, orderBy: string, + * keyFn: (item: FileItem|FolderItem) => string|null, + * labelFn?: (key: string) => string }} GroupByDef + */ + +/** + * Group-by dimension definitions for this section. + * Exported via `sharedWithMeView.groupByDefs` so `main.js` can populate + * the dropdown dynamically without knowing the internals of this view. + * + * `keyFn` returns the grouping key (stable UUID for owner, or a + * human-readable bucket label for shareDate — the bucket IS the key because + * it is already derived from the date, so no separate `labelFn` is needed + * for shareDate). + * + * @type {GroupByDef[]} + */ +const GROUP_BY_DEFS = [ + { + key: 'type', + get label() { + return i18n.t('groupby.type', 'Type'); + }, + orderBy: 'type', + // keyFn: folders get their own swimlane; files use the pre-computed + // `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …). + // The server orders by category_order (a pre-computed SMALLINT column) + // so items within the same category arrive grouped — no client sort needed. + keyFn: (item) => ('mime_type' in item ? /** @type {Record} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'), + labelFn: (key) => { + // biome-ignore format: keep indentation + /** @type {Record} */ + 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: '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} */ (/** @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() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // keyFn: the key IS the bucket label returned by sizeBucket(), so no + // separate labelFn is needed (same pattern as shareDate). + // Folders have no size — sizeBucket(-1) returns the "Folders" label. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + // No labelFn: keyFn already returns the human-readable label. + }, + { + key: 'shareDate', + get label() { + return i18n.t('groupby.shareDate', 'Share date'); + }, + orderBy: 'granted_at', + // keyFn returns the human-readable bucket label; the label IS the key + // because consecutive items with the same bucket should be in one group. + // sort_date is stored as unix seconds (number) in _mapItems(). + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.sort_date ? normalizeDateBucket(r.sort_date) : null; + } + // No labelFn: keyFn already returns the human-readable label. + } +]; + /** ID of the "Load more" wrapper injected below `.files-container`. */ const LOAD_MORE_ID = 'swm-load-more-wrapper'; @@ -35,8 +137,36 @@ const sharedWithMeView = { /** @type {ResourceListComponent|null} */ _component: null, + /** + * Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active. + * @type {string} + */ + _groupBy: '', + // ── 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 '' | 'owner' | 'shareDate' + */ + setGroupBy(key) { + if (this._groupBy === key) return; + this._groupBy = key; + this._nextCursor = null; // restart from first page + 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. @@ -44,6 +174,7 @@ const sharedWithMeView = { async init() { this._nextCursor = null; this._loading = false; + this._groupBy = ''; this._ensureLoadMoreButton(); @@ -60,47 +191,42 @@ const sharedWithMeView = { 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); - 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(); + 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); + 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); } @@ -138,10 +264,18 @@ const sharedWithMeView = { const isFirstPage = this._nextCursor === null; try { + const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy); + + // When no swimlane grouping is active, sort by resource name so the + // list is alphabetical (same expectation as the Files section). + // Group-by modes supply their own orderBy via the def. + const orderBy = def?.orderBy ?? 'name'; + const data = await grants.fetchSharedWithMe({ resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']), limit: 50, - cursor: this._nextCursor ?? undefined + cursor: this._nextCursor ?? undefined, + orderBy }); this._nextCursor = data.next_cursor ?? null; @@ -157,12 +291,12 @@ const sharedWithMeView = { return; } - const { folders, files } = this._mapItems(data.items); + const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(folders, files); + this._component?.render(items, def?.keyFn, def?.labelFn); } else { - this._component?.append(folders, files); + this._component?.append(items, def?.keyFn, def?.labelFn); } // Wire owner tooltips after items are in the DOM @@ -185,35 +319,41 @@ const sharedWithMeView = { }, /** - * Map `SharedWithMeItem[]` to separate arrays for rendering. + * Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in + * **server-returned order**. The order must be preserved so that + * swimlane grouping (group by owner / share date) works correctly when + * the server interleaves files and folders by the sort key. + * * Sets `owner_id` to `item.granted_by` so the component stamps * `data-owner-id` with the granter's user ID automatically. + * Sets `sort_date` (unix seconds) to the grant date so the shareDate + * `keyFn` buckets by when the share was created, not the resource's + * own modification time. * * @param {SharedWithMeItem[]} items - * @returns {{ folders: FolderItem[], files: FileItem[] }} + * @returns {Array} */ _mapItems(items) { - /** @type {FolderItem[]} */ - const folders = []; + /** @type {Array} */ + const result = []; - /** @type {FileItem[]} */ - const files = []; + /** @param {string} iso @returns {number} */ + const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000); for (const item of items) { if (item.resource_type === 'folder') { const f = /** @type {FolderItem} */ (item.resource); - folders.push( + result.push( /** @type {FolderItem} */ ({ id: f.id, name: f.name, path: f.path ?? '', parent_id: f.parent_id ?? '', - // Use granted_by as owner_id so the component populates - // data-owner-id with the sharing user's ID. owner_id: item.granted_by, is_root: f.is_root ?? false, created_at: f.created_at, modified_at: f.modified_at, + sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', category: 'folder' @@ -221,21 +361,19 @@ const sharedWithMeView = { ); } else if (item.resource_type === 'file') { const f = /** @type {FileItem} */ (item.resource); - files.push( + result.push( /** @type {FileItem} */ ({ id: f.id, name: f.name, path: f.path ?? '', folder_id: f.folder_id ?? '', - // Use granted_by as owner_id so the component populates - // data-owner-id with the sharing user's ID. owner_id: item.granted_by, 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: f.modified_at, + sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', category: f.category @@ -244,7 +382,7 @@ const sharedWithMeView = { } } - return { folders, files }; + return result; }, // ── "Load more" button ──────────────────────────────────────────────────── diff --git a/static/locales/ar.json b/static/locales/ar.json index 18388bd4..e6c0f39f 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -710,5 +710,16 @@ "colSharedBy": "مشترك من قِبل", "colDate": "تاريخ المشاركة", "colPermissions": "الصلاحيات" + }, + "groupby": { + "none": "لا شيء", + "title": "التجميع حسب", + "owner": "المالك", + "shareDate": "تاريخ المشاركة" + }, + "dateBucket": { + "today": "اليوم", + "last7days": "آخر 7 أيام", + "last30days": "آخر 30 يومًا" } } diff --git a/static/locales/de.json b/static/locales/de.json index 949a0e98..5351fec6 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -710,5 +710,16 @@ "colSharedBy": "Geteilt von", "colDate": "Datum der Freigabe", "colPermissions": "Berechtigungen" + }, + "groupby": { + "none": "Keine", + "title": "Gruppieren nach", + "owner": "Eigentümer", + "shareDate": "Freigabedatum" + }, + "dateBucket": { + "today": "Heute", + "last7days": "Letzte 7 Tage", + "last30days": "Letzte 30 Tage" } } diff --git a/static/locales/en.json b/static/locales/en.json index b018fe73..56421b8a 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -710,5 +710,16 @@ "colSharedBy": "Shared by", "colDate": "Date shared", "colPermissions": "Permissions" + }, + "groupby": { + "none": "None", + "title": "Group by", + "owner": "Owner", + "shareDate": "Share date" + }, + "dateBucket": { + "today": "Today", + "last7days": "Last 7 days", + "last30days": "Last 30 days" } } diff --git a/static/locales/es.json b/static/locales/es.json index a2ce7530..c9d878ec 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -710,5 +710,16 @@ "colSharedBy": "Compartido por", "colDate": "Fecha de compartición", "colPermissions": "Permisos" + }, + "groupby": { + "none": "Ninguno", + "title": "Agrupar por", + "owner": "Propietario", + "shareDate": "Fecha de compartición" + }, + "dateBucket": { + "today": "Hoy", + "last7days": "Últimos 7 días", + "last30days": "Últimos 30 días" } } diff --git a/static/locales/fa.json b/static/locales/fa.json index 5ea6655f..836106b5 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -710,5 +710,16 @@ "colSharedBy": "به اشتراک‌گذاشته توسط", "colDate": "تاریخ اشتراک‌گذاری", "colPermissions": "مجوزها" + }, + "groupby": { + "none": "هیچ", + "title": "گروه‌بندی بر اساس", + "owner": "مالک", + "shareDate": "تاریخ اشتراک" + }, + "dateBucket": { + "today": "امروز", + "last7days": "۷ روز گذشته", + "last30days": "۳۰ روز گذشته" } } diff --git a/static/locales/fr.json b/static/locales/fr.json index cc51c65c..0777b11c 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -710,5 +710,16 @@ "colSharedBy": "Partagé par", "colDate": "Date de partage", "colPermissions": "Permissions" + }, + "groupby": { + "none": "Aucun", + "title": "Grouper par", + "owner": "Propriétaire", + "shareDate": "Date de partage" + }, + "dateBucket": { + "today": "Aujourd'hui", + "last7days": "7 derniers jours", + "last30days": "30 derniers jours" } } diff --git a/static/locales/hi.json b/static/locales/hi.json index 60b5072a..b7adb1f0 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -710,5 +710,16 @@ "colSharedBy": "द्वारा साझा किया", "colDate": "साझाकरण तिथि", "colPermissions": "अनुमतियाँ" + }, + "groupby": { + "none": "कोई नहीं", + "title": "इसके अनुसार समूहीकृत करें", + "owner": "स्वामी", + "shareDate": "साझा तिथि" + }, + "dateBucket": { + "today": "आज", + "last7days": "पिछले 7 दिन", + "last30days": "पिछले 30 दिन" } } diff --git a/static/locales/it.json b/static/locales/it.json index 13390985..f3334e33 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -710,5 +710,16 @@ "colSharedBy": "Condiviso da", "colDate": "Data condivisione", "colPermissions": "Permessi" + }, + "groupby": { + "none": "Nessuno", + "title": "Raggruppa per", + "owner": "Proprietario", + "shareDate": "Data condivisione" + }, + "dateBucket": { + "today": "Oggi", + "last7days": "Ultimi 7 giorni", + "last30days": "Ultimi 30 giorni" } } diff --git a/static/locales/ja.json b/static/locales/ja.json index 3c517406..d9105cc8 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -710,5 +710,16 @@ "colSharedBy": "共有者", "colDate": "共有日", "colPermissions": "権限" + }, + "groupby": { + "none": "なし", + "title": "グループ化", + "owner": "オーナー", + "shareDate": "共有日" + }, + "dateBucket": { + "today": "今日", + "last7days": "過去7日間", + "last30days": "過去30日間" } } diff --git a/static/locales/ko.json b/static/locales/ko.json index af91d35e..13c13dd3 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -710,5 +710,16 @@ "colSharedBy": "공유한 사람", "colDate": "공유 날짜", "colPermissions": "권한" + }, + "groupby": { + "none": "없음", + "title": "그룹화 기준", + "owner": "소유자", + "shareDate": "공유 날짜" + }, + "dateBucket": { + "today": "오늘", + "last7days": "최근 7일", + "last30days": "최근 30일" } } diff --git a/static/locales/nl.json b/static/locales/nl.json index cc2ce87e..8af98820 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -710,5 +710,16 @@ "colSharedBy": "Gedeeld door", "colDate": "Datum gedeeld", "colPermissions": "Machtigingen" + }, + "groupby": { + "none": "Geen", + "title": "Groeperen op", + "owner": "Eigenaar", + "shareDate": "Deeldatum" + }, + "dateBucket": { + "today": "Vandaag", + "last7days": "Afgelopen 7 dagen", + "last30days": "Afgelopen 30 dagen" } } diff --git a/static/locales/pl.json b/static/locales/pl.json index 690353db..6228f7b3 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -710,5 +710,16 @@ "colSharedBy": "Udostępnione przez", "colDate": "Data udostępnienia", "colPermissions": "Uprawnienia" + }, + "groupby": { + "none": "Brak", + "title": "Grupuj według", + "owner": "Właściciel", + "shareDate": "Data udostępnienia" + }, + "dateBucket": { + "today": "Dzisiaj", + "last7days": "Ostatnie 7 dni", + "last30days": "Ostatnie 30 dni" } } diff --git a/static/locales/pt.json b/static/locales/pt.json index c7016202..5368127b 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -710,5 +710,16 @@ "colSharedBy": "Compartilhado por", "colDate": "Data de compartilhamento", "colPermissions": "Permissões" + }, + "groupby": { + "none": "Nenhum", + "title": "Agrupar por", + "owner": "Proprietário", + "shareDate": "Data de partilha" + }, + "dateBucket": { + "today": "Hoje", + "last7days": "Últimos 7 dias", + "last30days": "Últimos 30 dias" } } diff --git a/static/locales/ru.json b/static/locales/ru.json index ffc2cc86..42bb904f 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -710,5 +710,16 @@ "colSharedBy": "Предоставлено", "colDate": "Дата предоставления", "colPermissions": "Права" + }, + "groupby": { + "none": "Нет", + "title": "Группировать по", + "owner": "Владелец", + "shareDate": "Дата общего доступа" + }, + "dateBucket": { + "today": "Сегодня", + "last7days": "Последние 7 дней", + "last30days": "Последние 30 дней" } } diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 3378c6da..4fbd9743 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -710,5 +710,16 @@ "colSharedBy": "共享者", "colDate": "共享日期", "colPermissions": "權限" + }, + "groupby": { + "none": "無", + "title": "分組方式", + "owner": "擁有者", + "shareDate": "分享日期" + }, + "dateBucket": { + "today": "今天", + "last7days": "近7天", + "last30days": "近30天" } } diff --git a/static/locales/zh.json b/static/locales/zh.json index bf4a9100..22d9b8d1 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -710,5 +710,16 @@ "colSharedBy": "共享者", "colDate": "共享日期", "colPermissions": "权限" + }, + "groupby": { + "none": "无", + "title": "分组方式", + "owner": "所有者", + "shareDate": "分享日期" + }, + "dateBucket": { + "today": "今天", + "last7days": "近7天", + "last30days": "近30天" } } From 859f9b5f868eada136a2e9f22df898115ab9f8a3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 27 May 2026 22:51:15 +0200 Subject: [PATCH 7/9] refactor: separate roles between ui, fileView, resourceList --- static/js/app/filesView.js | 360 ++++------ static/js/app/ui.js | 645 ++---------------- static/js/components/resourceList.js | 44 ++ static/js/features/files/fileOperations.js | 4 +- static/js/features/files/search.js | 6 +- static/js/features/library/favorites.js | 2 +- static/js/model/filesModel.js | 110 +++ .../js/views/sharedWithMe/sharedWithMeView.js | 2 +- 8 files changed, 363 insertions(+), 810 deletions(-) create mode 100644 static/js/model/filesModel.js diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index aa1f96e4..747879c6 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -1,8 +1,25 @@ // @ts-check +/** + * OxiCloud – Files section view. + * + * Orchestrates the main Files section: + * - Data fetching via `filesModel` + * - Rendering via a `ResourceListComponent` instance + * - 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). + */ + +import { ResourceListComponent } from '../components/resourceList.js'; import { i18n } from '../core/i18n.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 { grants } from '../model/grants.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; import { app } from './state.js'; @@ -11,263 +28,174 @@ import { uiNotifications } from './uiNotifications.js'; /** @import {FileItem, FolderItem} from '../core/types.js' */ -let isLoadingFiles = false; +/** @type {ResourceListComponent|null} */ +let _component = null; + +/** Guard against concurrent `loadFiles` calls. */ +let _loading = false; /** - * getFolder information - * @param {string} id the id of the folder - * @returns {Promise} + * Return (creating on first call) the `ResourceListComponent` bound to + * `#files-list`. The element must already be in the DOM. + * @returns {ResourceListComponent|null} */ -async function getFolder(id) { - /** @type {HeadersInit} */ - const headers = { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache' - }; +function _ensureComponent() { + const filesList = document.getElementById('files-list'); + if (!filesList) return null; - /** @type {RequestInit} */ - const requestOptions = { - headers, - credentials: 'same-origin', - cache: 'no-store' - }; + if (!_component) { + _component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: true, + showFavorite: true, + showOwner: true, + showShareBadge: true, + draggable: true, + showContextMenu: true, + isFavorite: (id, type) => favorites.isFavorite(id, type), + isShared: (id, type) => grants.getOutgoingGrantsFor(type, id).length > 0, + 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); + _component?.setFavoriteVisualState(item.id, type, false); + } else { + await favorites.addToFavorites(item.id, item.name, type, null); + _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(); + } + }); - const folderInformations = await fetch(`/api/folders/${id}`, requestOptions); - if (folderInformations.ok) { - return folderInformations.json(); - } else { - console.warn(`Error fetching folder ${id}`); - return Promise.reject(null); + // Wire drag-and-drop on the container once the component is created. + ui.initDragDrop(/** @type {HTMLElement} */ (filesList)); } + + return _component; } /** - * Rebuild breadcrumb from selected folder (iterate up to root). + * 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 + * item is already in the list. * - * Stops traversal gracefully when a parent folder is not accessible - * (e.g. the user entered via a "Shared with me" grant whose parent - * folder they have no permission on). In that case the partial - * breadcrumb built so far is kept — the deepest reachable ancestor - * acts as the visual root, matching how Google Drive / Dropbox handle - * shared subtrees. + * Called by `fileOperations.js` and `search.js`. * - * An error on the *target folder itself* (first iteration) is still - * treated as a real error and redirects to the home folder. + * @param {FileItem|FolderItem} item */ -async function rebuildBreadCrumb() { - /** - * Store the leaf (this is the current displayed folder) - * @type {FolderItem | null} - */ - let currentFolderInfo = null; - - // rebuild full breadcrumb, - // TODO: to optimize, data may already be known / or ETAG could be interesting to reduce load - app.breadcrumbPath = []; - - /** @type {string | null} */ - let id = app.currentPath; - - // recurse from selected folder to root - while (id !== null) { - console.log(`fetching folder information for folder ${id}`); - try { - const folderInfo = await getFolder(id); - - // store the Leaf which is the current folder - if (currentFolderInfo === null) { - currentFolderInfo = folderInfo; - } - - // Add every folder to the breadcrumb, including the root (home folder). - // updateBreadcrumb() no longer auto-prepends home — it's our responsibility here. - app.breadcrumbPath.unshift({ - id: folderInfo.id, - name: folderInfo.name - }); - - // iterate to parent folder - id = folderInfo.parent_id; - } catch (_e) { - if (currentFolderInfo === null) { - // Failed on the target folder itself — real error, fall back to home. - console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`); - uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights'); - app.breadcrumbPath = []; - id = app.userHomeFolderId; - if (id) app.currentPath = id; - } else { - // Failed on a parent — hit the permission boundary of a shared subtree. - // Stop traversal; the partial breadcrumb is the best we can show. - console.log(`Stopped breadcrumb traversal at permission boundary (parent of ${currentFolderInfo.id} is not accessible)`); - break; - } - } - } - - // store informations on the current folder - app.currentFolderInfo = currentFolderInfo; +function addItem(item) { + const component = _ensureComponent(); + if (!component) return; + // Reveal the list if the empty-state is showing + ui.resetFilesList(); + component.addItem(item); } -// TODO split load() vs view() /** - * Files view loading logic - * - * @param {Object} options - * @param {boolean} [options.insertHistory] add browser history (default true) - * @param {boolean} [options.forceRefresh] force refresh of content + * Load and render the contents of `app.currentPath`, rebuilding the + * breadcrumb and updating browser history. * + * @param {Object} [options] + * @param {boolean} [options.insertHistory=true] + * @param {boolean} [options.forceRefresh=false] */ async function loadFiles(options = { insertHistory: true }) { + if (_loading) { + console.log('A file load is already in progress, ignoring request'); + return; + } + _loading = true; + + // Delay spinner so fast loads avoid the flash + const spinnerTimeout = setTimeout(() => { + ui.showError(` +
+
+ ${i18n.t('files.loading')} +
+ `); + }, 100); + try { - console.log('Starting loadFiles() - loading files...', options); - - const forceRefresh = options.forceRefresh || false; - - if (isLoadingFiles) { - console.log('A file load is already in progress, ignoring request'); - return; - } - - isLoadingFiles = true; - - // This to avoid blinking page, a better solution would be to put loading on an overlay and remove timeout - const loadingFiles = setTimeout(() => { - // display loader after few delay (will be canceled if result take less time) - ui.showError(` -
-
- ${i18n.t('files.loading')} -
- `); - }, 100); - - if (!app.userHomeFolderId) { - await resolveHomeFolder(); - } - - const timestamp = Math.floor(Date.now() / 1000); - - await rebuildBreadCrumb(); - - // request a breadcrumb paint - ui.updateBreadcrumb(); - - updateHistory(options.insertHistory || false); - - let url; + if (!app.userHomeFolderId) await resolveHomeFolder(); + // Resolve path to home folder when none is set if (!app.currentPath || app.currentPath === '') { if (app.userHomeFolderId) { - url = `/api/folders/${app.userHomeFolderId}/listing?t=${timestamp}`; app.currentPath = app.userHomeFolderId; app.breadcrumbPath = []; - ui.updateBreadcrumb(); console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`); } else { - url = `/api/folders?t=${timestamp}`; - console.warn('Emergency fallback to root folder - this should not normally happen'); + console.warn('No home folder id — this should not normally happen'); } - } else { - url = `/api/folders/${app.currentPath}/listing?t=${timestamp}`; - console.log(`Loading subfolder content: ${app.currentPath}`); } - /** @type {HeadersInit} */ - const headers = { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache' - }; + await rebuildBreadCrumb(); + ui.updateBreadcrumb(); + updateHistory(options.insertHistory ?? true); - /** @type {RequestInit} */ - const requestOptions = { - headers, - credentials: 'same-origin', - cache: 'no-store' - }; + const { folders, files } = await fetchListing(app.currentPath, { + forceRefresh: options.forceRefresh ?? false + }); - if (forceRefresh) { - url += `&force_refresh=true`; - if (requestOptions.headers) { - const headers = new Headers(requestOptions.headers); - headers.set('X-Force-Refresh', 'true'); - requestOptions.headers = headers; - } - console.log('Forcing complete refresh ignoring cache'); - } + clearTimeout(spinnerTimeout); - console.log(`Loading listing from ${url}`); - const response = await fetch(url, requestOptions); - - // not required anymore - clearTimeout(loadingFiles); - - if (response.status === 403) { - console.warn('Forbidden when loading files'); - // FIXME: i18n - ui.showError(`

Could not load files

`); - return; - } - - if (!response.ok) { - throw new Error(`Server responded with status: ${response.status}`); - } - - const listing = await response.json(); - - ui._items.clear(); + // Prepare the container (shows #files-list, hides error panel) ui.resetFilesList(); - if (batchToolbar) { - batchToolbar.clear(); - batchToolbar.init(); // this will wire buttons & select-all-checkbox - } - /** @type {FolderItem[]} */ - const folderList = Array.isArray(listing.folders) ? listing.folders : []; + const component = _ensureComponent(); + if (!component) return; - /** @type {FileItem[]} */ - const fileList = Array.isArray(listing.files) ? listing.files : []; + batchToolbar.clear(); + batchToolbar.init(); + batchToolbar.setActiveComponent(component); - if (folderList.length === 0 && fileList.length === 0) { + if (folders.length === 0 && files.length === 0) { ui.showEmptyList(); } else { - ui.renderFolders(folderList); - ui.renderFiles(fileList); - ui.resolveOwnerCells(); - - // check if a file was provided - if (app.viewFile) { - let fileFound = null; - - // lookup for the given fle - for (const file of fileList) { - if (file.id === app.viewFile) { - fileFound = file; - break; - } - } - - if (fileFound) { - console.log(`file ${app.viewFile} found, calling viewer`); - await inlineViewer.openFile(fileFound); - } else { - // remove file - console.log(`file ${app.viewFile} not found`); - app.viewFile = null; - - // correct url/history as file is not found - updateHistory(false); - } - } + component.render([...folders, ...files]); + await component.resolveOwnerCells(); } - console.log(`Loaded ${folderList.length} folders and ${fileList.length} files`); - } catch (error) { - console.error('Error loading folders:', error); - ui.showNotification('Error', 'Could not load files and folders'); + console.log(`Loaded ${folders.length} folders and ${files.length} files`); + + // Deep-link: open a specific file if requested via app.viewFile + 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); + } else { + console.log(`file ${app.viewFile} not found`); + app.viewFile = null; + updateHistory(false); + } + } + } catch (/** @type {any} */ err) { + clearTimeout(spinnerTimeout); + if (err?.status === 403) { + ui.showError(`

${i18n.t('errors.forbidden', 'Could not load files')}

`); + } else { + console.error('Error loading folders:', err); + uiNotifications.show('Error', 'Could not load files and folders'); + } } finally { - isLoadingFiles = false; + _loading = false; } } -export { loadFiles }; +export { addItem, loadFiles }; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 27776151..d4c7af10 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -5,9 +5,6 @@ // @ts-check -import { shareModal } from '../components/shareModal.js'; -import { createUserVignette } from '../components/userVignette.js'; -import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { OxiIcons } from '../core/icons.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; @@ -15,11 +12,7 @@ import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; -import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; -import { thumbnail } from '../features/thumbnail.js'; -import { grants } from '../model/grants.js'; -import { systemUsers } from '../model/systemUsers.js'; import { loadFiles } from './filesView.js'; import { updateHistory } from './main.js'; import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js'; @@ -424,8 +417,6 @@ const ui = { * Switch to grid view */ switchToGridView() { - this._hydrateViewIfNeeded(); - app.currentView = 'grid'; localStorage.setItem('oxicloud-view', 'grid'); @@ -436,8 +427,6 @@ const ui = { * Switch to list view */ switchToListView() { - this._hydrateViewIfNeeded(); - app.currentView = 'list'; localStorage.setItem('oxicloud-view', 'list'); @@ -459,32 +448,6 @@ const ui = { }); }, - /** - * Asynchronously fill every un-resolved `.owner-cell` in the current list with - * the display name for its `data-owner-id` attribute. - * - * Call this after `renderFiles()` / `renderFolders()` in sections where the owner - * column is visible. Idempotent: cells already stamped with `data-owner-resolved` - * are skipped (safe to call on each "Load more" page append). - * - * When the column is hidden nothing calls this function, so `systemUsers` is never - * touched and no address-book requests are issued. - * - * @returns {Promise} - */ - async resolveOwnerCells() { - const filesList = document.getElementById('files-list'); - const cells = /** @type {NodeListOf} */ (filesList?.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])')); - if (!cells?.length) return; - systemUsers.prefetch(); // warm cache once (idempotent, fire-and-forget) - for (const cell of cells) { - const id = cell.dataset.ownerId; - cell.dataset.ownerResolved = '1'; - if (!id) continue; - cell.replaceChildren(createUserVignette(id, 'list')); - } - }, - /** * Update breadcrumb navigation from the breadcrumbPath array. * Renders: Home > folder1 > folder2 > ... @@ -644,22 +607,6 @@ const ui = { } }, - /* ================================================================ - * Data store + event delegation (replaces per-item listeners) - * ================================================================ */ - - /** @type {Map} item data keyed by id */ - _items: new Map(), - - /** @type {FolderItem[]} last rendered folder dataset */ - _lastFolders: [], - - /** @type {FileItem[]} last rendered file dataset */ - _lastFiles: [], - - /** @type {boolean} */ - _delegationReady: false, - _getActiveView() { if (app && app.currentView === 'list') return 'list'; if (app && app.currentView === 'grid') return 'grid'; @@ -668,58 +615,6 @@ const ui = { return stored === 'list' ? 'list' : 'grid'; }, - /** - * @param {FolderItem[]} folders - */ - _renderFoldersToView(folders) { - if (!Array.isArray(folders) || folders.length === 0) return; - const target = document.getElementById('files-list'); - if (!target) return; - - const frag = document.createDocumentFragment(); - for (const folder of folders) { - try { - frag.appendChild(this._createFolderItem(folder)); - } catch (e) { - console.warn(`Error building folder item `, folder, `reason: `, e); - } - } - target.appendChild(frag); - }, - - /** - * @param {FileItem[]} files - */ - _renderFilesToView(files) { - if (!Array.isArray(files) || files.length === 0) return; - const target = document.getElementById('files-list'); - if (!target) return; - - const frag = document.createDocumentFragment(); - for (const file of files) { - try { - frag.appendChild(this._createFileItem(file)); - } catch (e) { - console.warn(`Error building file item `, file, `reason: `, e); - } - } - target.appendChild(frag); - }, - - /** - * @param {any[]} arr - * @param {any} item - */ - _upsertById(arr, item) { - if (!Array.isArray(arr) || !item?.id) return; - const idx = arr.findIndex((x) => x && x.id === item.id); - if (idx >= 0) { - arr[idx] = item; - } else { - arr.push(item); - } - }, - /** * handle the drop * @param {string} action copy|move @@ -778,164 +673,34 @@ const ui = { console.log(result); }, - _hydrateViewIfNeeded() { - // Only hydrate if there is at least one rendered item in the opposite/current DOM. - // This prevents stale cache hydration in empty-state screens. - const hasAnyRenderedItem = !!document.querySelector('#files-list .file-item'); - if (!hasAnyRenderedItem) return; - - // FIXME: thre is the header... - const listView = document.getElementById('files-list'); - if (!listView) return; - if (listView.children.length > 1) return; - - this._renderFoldersToView(this._lastFolders); - this._renderFilesToView(this._lastFiles); - }, - /** - * Attach a fixed set of delegated event listeners to the two - * container elements (files-list). - * Called once – idempotent. + * Attach delegated drag-and-drop listeners to a files-list container. + * Called once by `filesView.js` after the `ResourceListComponent` is created. + * Idempotent — a second call on the same element is a no-op. + * + * Handles: + * - `dragstart` / `dragend` — visual preview + dataTransfer payload + * - `dragover` / `dragleave` / `drop` — folder drop targets (delegated) + * + * @param {HTMLElement} container The `#files-list` element. */ - initDelegation() { - if (this._delegationReady) return; - const filesList = document.getElementById('files-list'); - if (!filesList) return; - this._delegationReady = true; + initDragDrop(container) { + if (container.dataset.dragDropReady) return; + container.dataset.dragDropReady = '1'; - // ── helpers ──────────────────────────────────────────────── - /** @param {HTMLDivElement} card */ + // ── helpers ──────────────────────────────────────────────────────── + /** @param {HTMLElement} card */ const itemInfo = (card) => { if (!card) return null; const fileId = card.dataset.fileId; - if (fileId) - return { - type: 'file', - id: fileId, - name: card.dataset.fileName, - data: this._items.get(fileId) - }; + if (fileId) return { type: 'file', id: fileId, name: card.dataset.fileName ?? '' }; const folderId = card.dataset.folderId; - if (folderId) - return { - type: 'folder', - id: folderId, - name: card.dataset.folderName, - data: this._items.get(folderId) - }; + if (folderId) return { type: 'folder', id: folderId, name: card.dataset.folderName ?? '' }; return null; }; - /** @param {FileItem} file */ - const openFile = async (file) => this._openFile(file); - - /** @param {HTMLElement} card */ - const navigateFolder = (card) => this._navigateToFolder(card.dataset.folderId, card.dataset.folderName); - - /** - * @param {HTMLElement} card - * @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info - */ - const setContextTarget = (card, info) => { - if (info.type === 'folder') { - app.contextMenuTargetFolder = /** @type {FolderItem} */ ({ - id: info.id, - name: card.dataset.folderName, - parent_id: card.dataset.parentId || '' - }); - } else { - const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id)); - app.contextMenuTargetFile = /** @type {FileItem} */ ({ - id: info.id, - name: card.dataset.fileName, - folder_id: card.dataset.folderId || '', - mime_type: fileData?.mime_type || null - }); - } - }; - - // ── click (open / navigate; select only via checkbox) ── - filesList.addEventListener('click', (e) => { - // ResourceListComponent manages its own delegation when mounted here - if (filesList.dataset.managedBy) return; - const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); - if (!card) return; - - if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) { - e.stopPropagation(); - e.preventDefault(); - const info = itemInfo(card); - if (!info) return; - setContextTarget(card, info); - const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu'; - showContextMenuAtElement(/** @type {HTMLElement} */ (e.target).closest('.file-actions'), menuId); - return; - } - - if (/** @type {HTMLElement} */ (e.target).closest('.checkbox-cell')) { - toggleCardSelection(card, e); - return; - } - - // Favorite star – handled by direct onclick on the button - if (/** @type {HTMLElement} */ (e.target).closest('.favorite-star')) return; - - // Single-click opens/navigates (selection is only via checkbox) - const info = itemInfo(card); - if (!info) return; - - // use modifier key to select/deselect item - // note: shift key is used in multiselect - // note: on MacOS, ctrl Key is used to convert click into right click, which invoke the `contextmenu` event - if (e.metaKey || e.altKey || e.ctrlKey) { - toggleCardSelection(card, e); - return; - } - - // shiftkey is used to complete selection - if (e.shiftKey && batchToolbar) { - batchToolbar.handleToggleItem(card, e); - return; - } - - if (info.type === 'folder') { - navigateFolder(card); - } else { - openFile(/** @type {FileItem} */ (info.data)); - } - }); - - // ── GRID: dblclick (navigate / open) ────────────────────── - filesList.addEventListener('dblclick', (e) => { - // Single-click already handles open/navigate. - // Prevent duplicate actions on double-click. - e.preventDefault(); - }); - - // ── shared events ────────────────────── - - filesList.addEventListener('contextmenu', (e) => { - if (filesList.dataset.managedBy) return; - const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); - if (!card) return; - e.preventDefault(); - const info = itemInfo(card); - if (!info) return; - setContextTarget(card, info); - const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu'; - const menu = document.getElementById(menuId); - contextMenus.sync(); - - if (menu) { - menu.style.left = `${e.pageX}px`; - menu.style.top = `${e.pageY}px`; - menu.classList.remove('hidden'); - } - }); - - // dragstart - filesList.addEventListener('dragstart', (e) => { + // ── dragstart ────────────────────────────────────────────────────── + container.addEventListener('dragstart', (e) => { const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card) { e.preventDefault(); @@ -947,146 +712,114 @@ const ui = { e.preventDefault(); return; } - if (!e.dataTransfer) return; e.dataTransfer.setData('text/plain', info.id); - if (info.type === 'folder') { - e.dataTransfer.setData('application/oxicloud-folder', 'true'); - } - // allow copy or move (handled by the browser) + if (info.type === 'folder') e.dataTransfer.setData('application/oxicloud-folder', 'true'); e.dataTransfer.effectAllowed = 'copyMove'; this.draggedItems = document.createElement('div'); this.draggedItems.className = 'dragged-items'; - let selectedCardFromList = filesList.querySelectorAll(`div.selected > div.name-cell`); - if (selectedCardFromList.length === 0) { - // fallback to current element - selectedCardFromList = card.querySelectorAll('div.name-cell'); - } + let selectedCards = container.querySelectorAll('div.selected > div.name-cell'); + if (selectedCards.length === 0) selectedCards = card.querySelectorAll('div.name-cell'); - let index = 0; const maxElements = 4; let lastItemDiv = null; + let index = 0; - while (index < selectedCardFromList.length && index < maxElements) { + while (index < selectedCards.length && index < maxElements) { const iconCell = document.createElement('div'); - const icon = selectedCardFromList[index].getElementsByClassName('file-icon').item(0)?.cloneNode(true); + const icon = selectedCards[index].getElementsByClassName('file-icon').item(0)?.cloneNode(true); if (icon) { iconCell.appendChild(icon); - iconCell.querySelectorAll('img')?.forEach((img) => { + iconCell.querySelectorAll('img').forEach((img) => { img.loading = 'eager'; }); } - const nameCell = document.createElement('div'); - const name = selectedCardFromList[index].getElementsByTagName('span').item(0)?.cloneNode(true); - if (name) { - nameCell.appendChild(name); - } + const name = selectedCards[index].getElementsByTagName('span').item(0)?.cloneNode(true); + if (name) nameCell.appendChild(name); const div = document.createElement('div'); div.className = 'file-item'; div.appendChild(iconCell); div.appendChild(nameCell); - this.draggedItems.appendChild(div); - index += 1; lastItemDiv = div; + index += 1; } - let downloadUrl; let nameEncoded; - - // tells Browser URL to call to drop selection on operating system (desktop, file manager etc) - // will generate a zipfile if multiple - if (selectedCardFromList.length === 1) { - // only 1 file + let downloadUrl; + if (selectedCards.length === 1) { if (info.type === 'file') { - nameEncoded = info.name.replaceAll(/:/g, '-'); // issue is that DownloadURL is using : as separator; + nameEncoded = info.name.replaceAll(/:/g, '-'); downloadUrl = `${window.location.origin}/api/files/${info.id}`; } else { - // directory into ZIP nameEncoded = info.name.replaceAll(/:/g, '-').concat('.zip'); downloadUrl = `${window.location.origin}/api/folders/${info.id}/download?format=zip`; } } else { - // must use ZIP container - // TODO better naming like ("selection in ${parent.name}") modulo i18n ? ... - const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-'); + const now = new Date().toISOString().replace(/T/, ' ').replace(/\..*/, '').replaceAll(/:/g, '-'); nameEncoded = `oxicloud ${now}.zip`; - /** @type {string[]} */ - const folders = []; - /** @type {string[]} */ - const files = []; - /** @type {NodeListOf} */ (filesList.querySelectorAll(`div.selected`)).forEach((e) => { - const item = itemInfo(e); - if (item.type === 'file') { - files.push(item.id); - } else { - folders.push(item.id); - } + /** @type {string[]} */ const folderIds = []; + /** @type {string[]} */ const fileIds = []; + /** @type {NodeListOf} */ (container.querySelectorAll('div.selected')).forEach((el) => { + const item = itemInfo(/** @type {HTMLElement} */ (el)); + if (item?.type === 'file') fileIds.push(item.id); + else if (item) folderIds.push(item.id); }); - downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${files.join(',')}&folder_ids=${folders.join(',')}`; + downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`; } - e.dataTransfer?.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); + e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); - // if more than 1 item, display the badge - if (selectedCardFromList.length > 1) { + if (selectedCards.length > 1) { const badge = document.createElement('span'); badge.className = 'dragged-items-badge'; - badge.innerText = `${selectedCardFromList.length}`; + badge.innerText = `${selectedCards.length}`; this.draggedItems.appendChild(badge); } + if (selectedCards.length > maxElements) lastItemDiv?.classList.add('fading'); - // if more than maxElements display the fading - if (selectedCardFromList.length > maxElements) { - lastItemDiv?.classList.add('fading'); - } - - this.dragPreview.appendChild(this.draggedItems); + this.dragPreview?.appendChild(this.draggedItems); e.dataTransfer.setDragImage(this.draggedItems, 0, 0); }); - // dragend - filesList.addEventListener('dragend', (_e) => { - this.dragPreview.removeChild(this.draggedItems); + // ── dragend ──────────────────────────────────────────────────────── + container.addEventListener('dragend', () => { + if (this.draggedItems && this.dragPreview?.contains(this.draggedItems)) { + this.dragPreview.removeChild(this.draggedItems); + } document.querySelectorAll('.drop-target').forEach((el) => { el.classList.remove('drop-target'); }); }); - // dragover – only folders are valid drop targets - filesList.addEventListener('dragover', (e) => { + // ── dragover — only folder cards are valid drop targets ──────────── + container.addEventListener('dragover', (e) => { const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); - if (!card || card.dataset.fileId) return; - if (!card.dataset.folderId) return; + if (!card || card.dataset.fileId || !card.dataset.folderId) return; e.preventDefault(); card.classList.add('drop-target'); }); - // dragleave - filesList.addEventListener('dragleave', (e) => { + // ── dragleave ────────────────────────────────────────────────────── + container.addEventListener('dragleave', (e) => { const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card || card.dataset.fileId) return; card.classList.remove('drop-target'); }); - // drop – only folders accept drops - filesList.addEventListener('drop', async (e) => { + // ── drop ─────────────────────────────────────────────────────────── + container.addEventListener('drop', async (e) => { const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); - if (!card || card.dataset.fileId) return; - const targetFolderId = card.dataset.folderId; - if (!targetFolderId) return; - + if (!card || card.dataset.fileId || !card.dataset.folderId) return; e.preventDefault(); card.classList.remove('drop-target'); - if (!e.dataTransfer) return; - const action = e.dataTransfer.dropEffect; - await this._dropToFolder(action, targetFolderId, e.dataTransfer); + await this._dropToFolder(e.dataTransfer.dropEffect, card.dataset.folderId, e.dataTransfer); }); }, @@ -1208,67 +941,6 @@ const ui = { } }, - /* ================================================================ - * Favorite star helper – attaches a direct click handler to a - * star - -
- `; - - if (app.currentPath !== '') { - el.setAttribute('draggable', 'true'); - } - this._bindStarClick(el); - return el; - }, - - /** - * Create a grid card for a file - * @param {FileItem} file - */ - _createFileItem(file) { - const iconClass = file.icon_class || this.getIconClass(file.name); - const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name); - const cat = file.category || ''; - const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document'); - const fileSize = file.size_formatted || formatFileSize(file.size); - const formattedDate = formatDateTime(file.modified_at); - const isFav = favorites?.isFavorite(file.id, 'file'); - const isShared = grants.getOutgoingGrantsFor('file', file.id).length > 0; - //const isShared = sharedView.isShared(file.id, 'file'); - const canThumbnail = thumbnail.canHandle(file); - - const el = document.createElement('div'); - el.className = 'file-item'; - el.dataset.fileId = file.id; - el.dataset.fileName = file.name; - el.dataset.folderId = file.folder_id || ''; - if (file.path) el.dataset.path = file.path; - el.setAttribute('draggable', 'true'); - - el.innerHTML = ` - -
-
-
- ${canThumbnail ? `` : ''} - -
- ${escapeHtml(file.name)} -
-
-
-
-
${typeLabel}
-
${fileSize}
-
${formattedDate}
-
- - -
- `; - var thumb = /** @type {HTMLImageElement} */ (el.querySelector('.file-thumb')); - if (thumb) { - thumb.addEventListener('error', () => { - console.log(`thumbnail not found for "${file.name}", try to generate it...`); - thumb.classList.add('hidden'); - thumbnail.queueGenerate(file, (dataUrl) => { - thumb.src = dataUrl; - thumb.classList.remove('hidden'); - }); - }); - } - this._bindStarClick(el); - return el; - }, - - /* ================================================================ - * Batch rendering with DocumentFragment - * ================================================================ */ - resetFilesList() { const filesList = document.getElementById('files-list'); const filesContainerError = document.getElementById('files-container-error'); @@ -1489,96 +1050,6 @@ const ui = { filesContainerError?.classList.remove('hidden'); filesList?.classList.add('hidden'); - }, - - /** - * Render an array of folders into both grid and list views - * using DocumentFragment for minimal reflows. - * - * @param {FolderItem[]} folders - */ - renderFolders(folders) { - if (!this._delegationReady) this.initDelegation(); - const safeFolders = Array.isArray(folders) ? folders : []; - this._lastFolders = safeFolders.slice(); - - for (const folder of safeFolders) { - this._items.set(folder.id, folder); - } - - this._renderFoldersToView(safeFolders); - }, - - /** - * Render an array of files into both grid and list views - * using DocumentFragment for minimal reflows. - * @param {FileItem[]} files - */ - renderFiles(files) { - if (!this._delegationReady) this.initDelegation(); - const safeFiles = Array.isArray(files) ? files : []; - this._lastFiles = safeFiles.slice(); - - for (const file of safeFiles) { - this._items.set(file.id, file); - } - - this._renderFilesToView(safeFiles); - }, - - /* ================================================================ - * Single-item add (backward-compatible API for post-upload, etc.) - * ================================================================ */ - - /** - * Add a single folder to the active view. - * @param {FolderItem} folder - */ - addFolderToView(folder) { - if (!this._delegationReady) this.initDelegation(); - - // Duplicate guard - if (document.querySelector(`.file-item[data-folder-id="${folder.id}"]`)) { - console.log(`Folder ${folder.name} (${folder.id}) already exists in the view, not duplicating`); - return; - } - - this._clearEmptyState(); - this._items.set(folder.id, folder); - this._upsertById(this._lastFolders, folder); - this._renderFoldersToView([folder]); - }, - - /** - * Add a single file to the active view. - * @param {FileItem} file - */ - addFileToView(file) { - if (!this._delegationReady) this.initDelegation(); - - // Duplicate guard - if (document.querySelector(`.file-item[data-file-id="${file.id}"]`)) { - console.log(`File ${file.name} (${file.id}) already exists in the view, not duplicating`); - return; - } - - this._clearEmptyState(); - this._items.set(file.id, file); - this._upsertById(this._lastFiles, file); - this._renderFilesToView([file]); - }, - - /** - * If the empty-state placeholder is showing, switch back to the file list. - * Called before adding any new item so the card is not appended to a hidden list. - */ - _clearEmptyState() { - const filesList = document.getElementById('files-list'); - const filesContainerError = document.getElementById('files-container-error'); - if (filesList?.classList.contains('hidden')) { - filesList.classList.remove('hidden'); - filesContainerError?.classList.add('hidden'); - } } }; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 557040d0..168d07fe 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -21,6 +21,8 @@ import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { thumbnail } from '../features/thumbnail.js'; +import { systemUsers } from '../model/systemUsers.js'; +import { createUserVignette } from './userVignette.js'; /** * @import {FileItem, FolderItem} from '../core/types.js' @@ -226,6 +228,48 @@ export class ResourceListComponent { this._container.classList.toggle('files-list-view', mode === 'list'); } + /** + * Return the registered item for the given id, or `undefined` if absent. + * @param {string} id + * @returns {FileItem|FolderItem|undefined} + */ + getItem(id) { + return this._items.get(id); + } + + /** + * Append a single item, skipping silently if already present (duplicate guard). + * Clears the empty-state placeholder when the first item is added. + * @param {FileItem|FolderItem} item + */ + addItem(item) { + if (this._items.has(item.id)) return; + // Also guard against stale DOM remnants not tracked in _items + const isFile = 'mime_type' in item; + const attr = isFile ? `data-file-id="${item.id}"` : `data-folder-id="${item.id}"`; + if (this._container.querySelector(`.file-item[${attr}]`)) return; + this._container.classList.remove('hidden'); + this._appendItems([item]); + } + + /** + * Asynchronously fill every un-resolved `.owner-cell` in this component's + * container with the display name for its `data-owner-id` attribute. + * Idempotent — cells already stamped with `data-owner-resolved` are skipped. + * @returns {Promise} + */ + async resolveOwnerCells() { + const cells = /** @type {NodeListOf} */ (this._container.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])')); + if (!cells.length) return; + systemUsers.prefetch(); // warm cache once (idempotent) + for (const cell of cells) { + const id = cell.dataset.ownerId; + cell.dataset.ownerResolved = '1'; + if (!id) continue; + cell.replaceChildren(createUserVignette(id, 'list')); + } + } + /** * Show or hide the owner column on all current and future items. * @param {boolean} visible diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index be9924ca..f5e2a78e 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -4,7 +4,7 @@ */ import { refreshUserData } from '../../app/authSession.js'; -import { loadFiles } from '../../app/filesView.js'; +import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; @@ -781,7 +781,7 @@ const fileOps = { // Optimistic UI: add folder card directly from server response // — no reload needed since the backend already confirmed creation. - ui.addFolderToView(folder); + filesViewAddItem(folder); ui.showNotification('Folder created', `"${name}" created successfully`); } else { diff --git a/static/js/features/files/search.js b/static/js/features/files/search.js index 417b6bbd..8db9fa5a 100644 --- a/static/js/features/files/search.js +++ b/static/js/features/files/search.js @@ -7,7 +7,7 @@ * displays the enriched results returned by the server. */ -import { loadFiles } from '../../app/filesView.js'; +import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { ui } from '../../app/ui.js'; import { getAuthHeaders } from './fileOperations.js'; @@ -200,12 +200,12 @@ const search = { // Render folders (server-provided enriched data) results.folders.forEach((folder) => { - ui.addFolderToView(folder); + filesViewAddItem(folder); }); // Render files (server-provided enriched data) results.files.forEach((file) => { - ui.addFileToView(file); + filesViewAddItem(file); }); }, diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index f62f39e7..fc2629ff 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -283,7 +283,7 @@ const favorites = { pathTooltip.init(filesList); } - await ui.resolveOwnerCells(); + await this._component?.resolveOwnerCells(); } catch (error) { console.error('Error displaying favorites:', error); if (ui?.showNotification) { diff --git a/static/js/model/filesModel.js b/static/js/model/filesModel.js new file mode 100644 index 00000000..a4833ca6 --- /dev/null +++ b/static/js/model/filesModel.js @@ -0,0 +1,110 @@ +// @ts-check + +/** + * OxiCloud – Files data model. + * + * Pure data layer: all API calls for file/folder listing and breadcrumb + * resolution, with zero DOM dependency. Views import these functions and + * call them without knowing the fetch details. + */ + +import { app } from '../app/state.js'; +import { uiNotifications } from '../app/uiNotifications.js'; + +/** @import {FileItem, FolderItem} from '../core/types.js' */ + +/** @type {RequestInit} */ +const NO_CACHE = { + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache' }, + credentials: 'same-origin', + cache: 'no-store' +}; + +/** + * Fetch metadata for a single folder. + * Rejects with `null` when the server returns a non-OK response. + * @param {string} id + * @returns {Promise} + */ +async function getFolder(id) { + const response = await fetch(`/api/folders/${id}`, NO_CACHE); + if (response.ok) return response.json(); + console.warn(`Error fetching folder ${id}`); + return Promise.reject(null); +} + +/** + * Walk up the folder hierarchy to rebuild `app.breadcrumbPath`. + * + * Stops gracefully at a permission boundary (shared subtrees) — the partial + * breadcrumb built so far becomes the visual root, matching how Google Drive + * handles shared folders the user cannot traverse beyond. + * + * An error on the target folder itself is treated as a real error and falls + * back to the home folder. + * + * @returns {Promise} + */ +async function rebuildBreadCrumb() { + /** @type {FolderItem|null} */ + let currentFolderInfo = null; + app.breadcrumbPath = []; + + /** @type {string|null} */ + let id = app.currentPath; + + while (id !== null) { + try { + const folderInfo = await getFolder(id); + if (currentFolderInfo === null) currentFolderInfo = folderInfo; + app.breadcrumbPath.unshift({ id: folderInfo.id, name: folderInfo.name }); + id = folderInfo.parent_id; + } catch (_e) { + if (currentFolderInfo === null) { + console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`); + uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights'); + app.breadcrumbPath = []; + id = app.userHomeFolderId; + if (id) app.currentPath = id; + } else { + console.log(`Stopped breadcrumb traversal at permission boundary (parent of ${currentFolderInfo.id} is not accessible)`); + break; + } + } + } + + app.currentFolderInfo = currentFolderInfo; +} + +/** + * Fetch the folder listing for the given folder id. + * + * @param {string} folderId + * @param {{ forceRefresh?: boolean }} [options] + * @returns {Promise<{ folders: FolderItem[], files: FileItem[] }>} + */ +async function fetchListing(folderId, options = {}) { + const timestamp = Math.floor(Date.now() / 1000); + let url = `/api/folders/${folderId}/listing?t=${timestamp}`; + + /** @type {HeadersInit} */ + const headers = { .../** @type {Record} */ (NO_CACHE.headers) }; + + if (options.forceRefresh) { + url += '&force_refresh=true'; + headers['X-Force-Refresh'] = 'true'; + } + + const response = await fetch(url, { ...NO_CACHE, headers }); + + if (response.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); + if (!response.ok) throw new Error(`Server responded with status: ${response.status}`); + + const listing = await response.json(); + return { + folders: Array.isArray(listing.folders) ? listing.folders : [], + files: Array.isArray(listing.files) ? listing.files : [] + }; +} + +export { fetchListing, getFolder, rebuildBreadCrumb }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index ef07c769..35961d6b 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -304,7 +304,7 @@ const sharedWithMeView = { if (filesList) ownerTooltip.init(filesList); // Fill the Owner column cells (idempotent: skips already-resolved rows). - await ui.resolveOwnerCells(); + await this._component?.resolveOwnerCells(); this._setLoadMoreVisible(!!this._nextCursor); } catch (err) { From 42cb3627a830534a4002a1ebfcda289443f307aa Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 01:29:10 +0200 Subject: [PATCH 8/9] fix(buid): fix JS bundler on import with alias already declared --- build.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/build.rs b/build.rs index 24712d81..1635803b 100644 --- a/build.rs +++ b/build.rs @@ -558,7 +558,7 @@ fn strip_esm_syntax( if !t.ends_with(';') && !t.contains(" from ") { skipping = true; // multi-line import } - let aliases = collect_import_aliases(t); + let aliases = collect_import_aliases(t, declared_namespaces); if aliases.is_empty() { out.push('\n'); } else { @@ -628,7 +628,9 @@ fn try_strip_export_prefix(line: &str) -> Option { /// For `import { A, B as C, D as E } from '…'` return `"const C = B;\nconst E = D;"`. /// Returns an empty string when there are no aliases. -fn collect_import_aliases(stmt: &str) -> String { +/// Aliases already present in `declared` are skipped; newly emitted aliases are +/// inserted into `declared` so that subsequent files don't re-declare them. +fn collect_import_aliases(stmt: &str, declared: &mut std::collections::HashSet) -> String { let brace_start = match stmt.find('{') { Some(i) => i + 1, None => return String::new(), @@ -645,6 +647,12 @@ fn collect_import_aliases(stmt: &str) -> String { if let Some(as_pos) = b.find(" as ") { let orig = b[..as_pos].trim(); let alias = b[as_pos + 4..].trim(); + // Already declared earlier in the bundle — skip to avoid + // `SyntaxError: Identifier already declared`. + if declared.contains(alias) { + continue; + } + declared.insert(alias.to_string()); if !out.is_empty() { out.push('\n'); } From ec43c4f9c96900daa035863a33d80cf5e6565753 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 01:39:14 +0200 Subject: [PATCH 9/9] fix(playwright): raise any pageerror, purpose is to stops immediatly on a bundle issue --- tests/e2e/scenarios/01-home-and-login.spec.ts | 4 +-- .../scenarios/02-folder-management.spec.ts | 4 +-- tests/e2e/scenarios/helpers.ts | 29 +++++++++++++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts b/tests/e2e/scenarios/01-home-and-login.spec.ts index 071b7694..6ae2c3c7 100644 --- a/tests/e2e/scenarios/01-home-and-login.spec.ts +++ b/tests/e2e/scenarios/01-home-and-login.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from '@playwright/test'; -import { goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers'; +import { expect } from '@playwright/test'; +import { test, goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers'; test('has OxiCloud title', async ({ page }) => { await page.goto('/'); diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts b/tests/e2e/scenarios/02-folder-management.spec.ts index 72dcba5e..d78d2211 100644 --- a/tests/e2e/scenarios/02-folder-management.spec.ts +++ b/tests/e2e/scenarios/02-folder-management.spec.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as fs from 'fs/promises'; -import { test, expect, Page } from '@playwright/test'; -import { loginAsAdmin } from './helpers'; +import { expect, Page } from '@playwright/test'; +import { test, loginAsAdmin } from './helpers'; const FIXTURES = path.join(__dirname, '../../fixtures'); diff --git a/tests/e2e/scenarios/helpers.ts b/tests/e2e/scenarios/helpers.ts index 369dbff6..304e6239 100644 --- a/tests/e2e/scenarios/helpers.ts +++ b/tests/e2e/scenarios/helpers.ts @@ -1,4 +1,25 @@ -import { Page, expect } from '@playwright/test'; +import { test as base, Page, expect } from '@playwright/test'; + +/** + * Extended `test` fixture that fails on any unhandled browser-side JavaScript + * error (SyntaxError, ReferenceError, uncaught promise rejections, etc.). + * + * Import `test` from this module instead of `@playwright/test` so every spec + * gets the listener automatically without per-file boilerplate. + */ +export const test = base.extend({ + page: async ({ page }, use) => { + const jsErrors: Error[] = []; + page.on('pageerror', (err) => jsErrors.push(err)); + await use(page); + if (jsErrors.length > 0) { + throw new Error( + `${jsErrors.length} unhandled JS error(s) on page:\n` + + jsErrors.map((e) => ` • ${e.message}`).join('\n') + ); + } + }, +}); export const TEST_ADMIN = { username: 'admin', @@ -38,7 +59,11 @@ export async function goToLoginPage(page: Page) { await page.goto('/'); // Both panels start with .hidden — wait for JS to reveal one. - await page.waitForSelector('#language-panel:not(.hidden), #login-panel:not(.hidden)'); + // Use expect() (5 s default) rather than waitForSelector() (30 s) so a JS + // crash fails fast instead of hanging for the full test timeout. + await expect( + page.locator('#language-panel:not(.hidden), #login-panel:not(.hidden)').first() + ).toBeAttached(); if (await page.locator('#language-panel').isVisible()) { await page.locator('#language-continue').click();