Merge pull request #396 from EdouardVanbelle/feat/itemview-and-swimlane
This commit is contained in:
@@ -558,7 +558,7 @@ fn strip_esm_syntax(
|
|||||||
if !t.ends_with(';') && !t.contains(" from ") {
|
if !t.ends_with(';') && !t.contains(" from ") {
|
||||||
skipping = true; // multi-line import
|
skipping = true; // multi-line import
|
||||||
}
|
}
|
||||||
let aliases = collect_import_aliases(t);
|
let aliases = collect_import_aliases(t, declared_namespaces);
|
||||||
if aliases.is_empty() {
|
if aliases.is_empty() {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
} else {
|
} else {
|
||||||
@@ -628,7 +628,9 @@ fn try_strip_export_prefix(line: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// For `import { A, B as C, D as E } from '…'` return `"const C = B;\nconst E = D;"`.
|
/// 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.
|
/// 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>) -> String {
|
||||||
let brace_start = match stmt.find('{') {
|
let brace_start = match stmt.find('{') {
|
||||||
Some(i) => i + 1,
|
Some(i) => i + 1,
|
||||||
None => return String::new(),
|
None => return String::new(),
|
||||||
@@ -645,6 +647,12 @@ fn collect_import_aliases(stmt: &str) -> String {
|
|||||||
if let Some(as_pos) = b.find(" as ") {
|
if let Some(as_pos) = b.find(" as ") {
|
||||||
let orig = b[..as_pos].trim();
|
let orig = b[..as_pos].trim();
|
||||||
let alias = b[as_pos + 4..].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() {
|
if !out.is_empty() {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,10 +96,12 @@ export default defineConfig({
|
|||||||
items: [
|
items: [
|
||||||
{ text: "Internal Architecture", link: "/architecture/" },
|
{ text: "Internal Architecture", link: "/architecture/" },
|
||||||
{ text: "Caching", link: "/architecture/caching" },
|
{ text: "Caching", link: "/architecture/caching" },
|
||||||
|
{ text: "Resource Listing API", link: "/architecture/resource-listing" },
|
||||||
{ text: "Storage Safety", link: "/architecture/file-system-safety" },
|
{ text: "Storage Safety", link: "/architecture/file-system-safety" },
|
||||||
{ text: "Database Transactions", link: "/architecture/database-transactions" },
|
{ text: "Database Transactions", link: "/architecture/database-transactions" },
|
||||||
{ text: "Share Integration", link: "/architecture/share-integration" },
|
{ text: "Share Integration", link: "/architecture/share-integration" },
|
||||||
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
|
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
|
||||||
|
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ text: "FAQ", link: "/faq" },
|
{ text: "FAQ", link: "/faq" },
|
||||||
|
|||||||
@@ -71,4 +71,5 @@ src/
|
|||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- [Caching Architecture →](/architecture/caching)
|
- [Caching Architecture →](/architecture/caching)
|
||||||
|
- [Resource Listing API →](/architecture/resource-listing)
|
||||||
- [Storage Quotas →](/architecture/storage-quotas)
|
- [Storage Quotas →](/architecture/storage-quotas)
|
||||||
|
|||||||
@@ -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<T>` → `{ 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::<MyCursor>()` — 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<T>`
|
||||||
|
|
||||||
|
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<CursorQuery>` 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<String>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
// Endpoint-specific extra
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 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<Utc>,
|
||||||
|
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<Utc>, pub id: Uuid }
|
||||||
|
impl PageCursor for ThingCursor {}
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct ThingQuery {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub paging: CursorQuery,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn list_things(
|
||||||
|
Query(q): Query<ThingQuery>,
|
||||||
|
State(state): State<AppStateRef>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let limit = q.paging.limit_clamped();
|
||||||
|
let cursor = q.paging.decode_cursor::<ThingCursor>();
|
||||||
|
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<MyItem>} */
|
||||||
|
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.
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
# Plan: Group-by swimlanes in SharedWithMe
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The SharedWithMe view now uses `ResourceListComponent` which already accepts an optional `groupFn` in `render()` / `append()`. The task is to expose a **Group by** dropdown in the actions-bar that lets users cluster items into swimlane sections by **Owner** or **Share date**. Changing the grouping restarts the cursor-paginated fetch with the matching `sort_by` query param so the server delivers items pre-sorted for the chosen dimension — the frontend only needs to inject dividers when the key changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
main.js (UI) sharedWithMeView.js grants.js / backend
|
||||||
|
──────────────────────── ─────────────────── ──────────────────
|
||||||
|
[Group by] dropdown ──────→ setGroupBy(key) ──────→ fetchSharedWithMe({ orderBy })
|
||||||
|
shows: None / Owner / _groupBy state ↳ GET …?sort_by=granted_by
|
||||||
|
Share date resets cursor ↳ GET …?sort_by=granted_at
|
||||||
|
_makeGroupFn() ←────── items in server sort order
|
||||||
|
render(f, flds, keyFn, labelFn)
|
||||||
|
↓
|
||||||
|
ResourceListComponent
|
||||||
|
injects swimlane dividers
|
||||||
|
when keyFn(item) changes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensibility contract (`GroupByDef`)
|
||||||
|
|
||||||
|
Each view that supports grouping defines a `GroupByDef[]` array locally:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* @typedef {{ key: string, orderBy: string, keyFn: (item: FileItem|FolderItem) => string|null, labelFn?: (key: string) => string }} GroupByDef
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
- `key` — internal identifier (`''` = none, `'owner'`, `'shareDate'`)
|
||||||
|
- `orderBy` — value forwarded to the API as `sort_by`
|
||||||
|
- `keyFn(item)` — returns the grouping key (UUID, bucket name). Same key → same swimlane.
|
||||||
|
- `labelFn(key)` — converts the raw key to a human-readable header. Optional (identity if omitted).
|
||||||
|
|
||||||
|
The separation of `keyFn` / `labelFn` is critical for the Owner case: grouping is keyed by UUID (stable, unique), but the swimlane header shows the resolved display name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes — Frontend
|
||||||
|
|
||||||
|
### 1. `static/js/components/resourceList.js`
|
||||||
|
|
||||||
|
**A. Persist `_lastGroupKey` across `append()` calls**
|
||||||
|
|
||||||
|
Current bug: `_lastGroupKey` is local to `_appendItems`, so loading page 2 always inserts a redundant swimlane header for the first item even if it belongs to the same group as the last item on page 1.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
```js
|
||||||
|
// constructor
|
||||||
|
this._lastGroupKey = /** @type {string|null|undefined} */ (undefined);
|
||||||
|
|
||||||
|
// render() — reset before first page
|
||||||
|
this._lastGroupKey = undefined;
|
||||||
|
|
||||||
|
// _appendItems() — read and write instance field
|
||||||
|
let lastGroupKey = this._lastGroupKey;
|
||||||
|
// ... existing loop (unchanged) ...
|
||||||
|
this._lastGroupKey = lastGroupKey; // persist for next append()
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Add optional `groupLabelFn` parameter**
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* @param {FolderItem[]} folders
|
||||||
|
* @param {FileItem[]} files
|
||||||
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupKeyFn
|
||||||
|
* @param {((key: string) => string)=} groupLabelFn — defaults to identity
|
||||||
|
*/
|
||||||
|
render(folders, files, groupKeyFn, groupLabelFn) { … }
|
||||||
|
append(folders, files, groupKeyFn, groupLabelFn) { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `groupLabelFn` down to `_appendItems` and use it in `_createGroupHeader`:
|
||||||
|
```js
|
||||||
|
_createGroupHeader(key, labelFn) {
|
||||||
|
const label = labelFn ? labelFn(key) : key;
|
||||||
|
el.textContent = label;
|
||||||
|
…
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store `this._groupLabelFn` on the instance between `render()` and `append()` calls (same pattern as `_lastGroupKey`).
|
||||||
|
|
||||||
|
### 2. `static/css/components/resourceList.css`
|
||||||
|
|
||||||
|
Add missing swimlane-header styles (block was referenced in JS but had no CSS):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── swimlane group header ─────────────────────────── */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.resource-list__swimlane-header:first-child { margin-top: 0; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `static/js/core/formatters.js`
|
||||||
|
|
||||||
|
Add `normalizeDateBucket(dateStr)` — pure, no imports needed:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* Normalize an ISO-8601 date string into a human-readable bucket label.
|
||||||
|
* Buckets (newest-first): Today | Last 7 days | Last 30 days | <YYYY>
|
||||||
|
* @param {string} dateStr
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function normalizeDateBucket(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Import `i18n` at top of `formatters.js` if not already present — check first.)
|
||||||
|
|
||||||
|
### 4. `static/js/model/systemUsers.js`
|
||||||
|
|
||||||
|
Add synchronous best-effort lookup for use in `groupKeyFn` / swimlane labels:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* Synchronous best-effort display-name lookup from the pre-fetched cache.
|
||||||
|
* Returns a shortened UUID prefix when the cache is not yet loaded.
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getDisplayNameSync(userId) {
|
||||||
|
if (_index === null) return `${userId.slice(0, 8)}…`;
|
||||||
|
return _index.get(userId) ?? `${userId.slice(0, 8)}…`;
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache is loaded by `prefetch()` which `sharedWithMeView.init()` already calls at startup. By the time the first items render, the cache is warm in virtually all cases.
|
||||||
|
|
||||||
|
### 5. `static/js/model/grants.js`
|
||||||
|
|
||||||
|
Add `orderBy` param to `fetchSharedWithMe`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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);
|
||||||
|
…
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. `static/js/views/sharedWithMe/sharedWithMeView.js`
|
||||||
|
|
||||||
|
**New state:**
|
||||||
|
```js
|
||||||
|
/** @type {string} '' | 'owner' | 'shareDate' */
|
||||||
|
_groupBy: '',
|
||||||
|
```
|
||||||
|
|
||||||
|
**`GROUP_BY_DEFS` constant (module-level):**
|
||||||
|
```js
|
||||||
|
const GROUP_BY_DEFS = [
|
||||||
|
{
|
||||||
|
key: 'owner',
|
||||||
|
orderBy: 'granted_by',
|
||||||
|
keyFn: (item) => item.owner_id || null,
|
||||||
|
labelFn: (id) => systemUsers.getDisplayNameSync(id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shareDate',
|
||||||
|
orderBy: 'granted_at',
|
||||||
|
// sort_date is set to item.granted_at in _mapItems()
|
||||||
|
keyFn: (item) => {
|
||||||
|
const d = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).sort_date;
|
||||||
|
return d ? normalizeDateBucket(d) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**`setGroupBy(key)` public method:**
|
||||||
|
```js
|
||||||
|
setGroupBy(key) {
|
||||||
|
if (this._groupBy === key) return;
|
||||||
|
this._groupBy = key;
|
||||||
|
this._nextCursor = null; // restart from page 1
|
||||||
|
this._component?.clear(); // clear DOM items
|
||||||
|
this._loadPage();
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_mapItems()` change:** Set `sort_date: item.granted_at` on both folders and files (replaces `f.modified_at` in files). This is the field the shareDate `keyFn` reads.
|
||||||
|
|
||||||
|
**`_loadPage()` change:** Derive active def and pass to API + component:
|
||||||
|
```js
|
||||||
|
const def = GROUP_BY_DEFS.find(d => d.key === this._groupBy);
|
||||||
|
const data = await grants.fetchSharedWithMe({
|
||||||
|
…,
|
||||||
|
orderBy: def?.orderBy // undefined when no grouping
|
||||||
|
});
|
||||||
|
…
|
||||||
|
if (isFirstPage) {
|
||||||
|
this._component?.render(folders, files, def?.keyFn, def?.labelFn);
|
||||||
|
} else {
|
||||||
|
this._component?.append(folders, files, def?.keyFn, def?.labelFn);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. `static/js/app/main.js`
|
||||||
|
|
||||||
|
**A. New `_toggleButtonsWithGroupBy` template (inside `.view-toggle`):**
|
||||||
|
```js
|
||||||
|
const _toggleButtonsWithGroupBy = `
|
||||||
|
<div class="view-toggle">
|
||||||
|
<div class="group-by-selector" id="group-by-selector">
|
||||||
|
<button class="toggle-btn group-by-btn" id="group-by-btn" title="Group by" data-i18n-title="groupby.title">
|
||||||
|
<i class="fas fa-layer-group"></i>
|
||||||
|
</button>
|
||||||
|
<div class="group-by-menu hidden" id="group-by-menu">
|
||||||
|
<button class="group-by-option active" data-group-by="" data-i18n="groupby.none">None</button>
|
||||||
|
<button class="group-by-option" data-group-by="owner" data-i18n="groupby.owner">Owner</button>
|
||||||
|
<button class="group-by-option" data-group-by="shareDate" data-i18n="groupby.shareDate">Share date</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="view-toggle-separator"></span>
|
||||||
|
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||||
|
<i class="fas fa-th"></i>
|
||||||
|
</button>
|
||||||
|
<button class="toggle-btn" id="list-view-btn" title="List view">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Update sharedwithme template:** Also add missing `_batchToolbarButons`:
|
||||||
|
```js
|
||||||
|
sharedwithme: `
|
||||||
|
<div class="action-buttons" id="default-buttons"></div>
|
||||||
|
${_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<chrono::Utc>,
|
||||||
|
pub resource_id: Uuid,
|
||||||
|
/// Present only when `sort_by == "granted_by"`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub granted_by: Option<Uuid>,
|
||||||
|
}
|
||||||
|
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<GrantCursor>,
|
||||||
|
sort_by: &str, // "granted_at" | "granted_by"
|
||||||
|
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), 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::<GrantCursor>()
|
||||||
|
.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
|
||||||
@@ -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);
|
||||||
@@ -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<Utc>, 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<String>, // endpoint-specific extras
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! // 3. Return the standard envelope
|
||||||
|
//! async fn list_things(Query(q): Query<MyQuery>, …) -> Json<CursorListResponse<ThingDto>> {
|
||||||
|
//! let limit = q.paging.limit_clamped();
|
||||||
|
//! let cursor = q.paging.decode_cursor::<MyCursor>();
|
||||||
|
//! // 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<Self> {
|
||||||
|
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<CursorQuery>` 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<String>,
|
||||||
|
/// pub sort_by: Option<String>,
|
||||||
|
/// pub status: Option<String>, // 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<String>,
|
||||||
|
/// Sort dimension. Valid values are endpoint-defined (e.g. `"granted_at"`,
|
||||||
|
/// `"name"`, `"granted_by"`). Unknown values should return HTTP 400.
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<C: PageCursor>(&self) -> Option<C> {
|
||||||
|
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<T: Serialize> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
/// Opaque cursor for the next page. Absent when this is the last page.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> CursorListResponse<T> {
|
||||||
|
/// 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<C: PageCursor>(
|
||||||
|
mut items: Vec<T>,
|
||||||
|
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<T>, next_cursor: Option<String>) -> Self {
|
||||||
|
Self { items, next_cursor }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -344,6 +344,31 @@ pub fn category_for(name: &str, mime: &str) -> &'static str {
|
|||||||
"Document"
|
"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).
|
/// Formats a byte count into a human-readable string (1024-based).
|
||||||
///
|
///
|
||||||
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
|
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||||
use crate::application::dtos::file_dto::FileDto;
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
use crate::application::dtos::folder_dto::FolderDto;
|
use crate::application::dtos::folder_dto::FolderDto;
|
||||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||||
@@ -232,26 +233,58 @@ impl From<Grant> for GrantDto {
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/// Query parameters for `GET /api/grants/incoming/resources`.
|
/// 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)]
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
pub struct SharedWithMeQuery {
|
pub struct SharedWithMeQuery {
|
||||||
/// Maximum number of items to return (1–200, default 50).
|
/// 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,
|
pub limit: u32,
|
||||||
|
/// Opaque cursor from a previous response. Omit to start from the
|
||||||
|
/// most-recently-granted item.
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
/// Sort dimension. Supported values: `"granted_at"` (default),
|
||||||
|
/// `"granted_by"` (for swimlane grouping).
|
||||||
|
pub sort_by: Option<String>,
|
||||||
/// Comma-separated resource types to include, e.g. `file,folder`.
|
/// Comma-separated resource types to include, e.g. `file,folder`.
|
||||||
/// Omit to return all known types.
|
/// Omit to return all known types.
|
||||||
pub resource_types: Option<String>,
|
pub resource_types: Option<String>,
|
||||||
/// Opaque cursor returned by a previous call. Omit to start from the
|
|
||||||
/// most-recently-granted item.
|
|
||||||
pub cursor: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shared_with_me_default_limit() -> u32 {
|
impl SharedWithMeQuery {
|
||||||
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. Invalid cursor → start from top.
|
||||||
|
pub fn decode_cursor<C: PageCursor>(&self) -> Option<C> {
|
||||||
|
self.cursor.as_deref().and_then(C::decode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One item in the shared-with-me list. Exactly one of `file` / `folder` is
|
/// The resource payload for one item in the shared-with-me list.
|
||||||
/// populated, indicated by `resource_type`. Additional optional fields for
|
///
|
||||||
/// future resource types (playlist, addressbook, …) will be added here.
|
/// 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)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
pub struct SharedWithMeItemDto {
|
pub struct SharedWithMeItemDto {
|
||||||
pub resource_type: ResourceTypeDto,
|
pub resource_type: ResourceTypeDto,
|
||||||
@@ -261,17 +294,9 @@ pub struct SharedWithMeItemDto {
|
|||||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||||
/// UUID of the user who created the (earliest) grant.
|
/// UUID of the user who created the (earliest) grant.
|
||||||
pub granted_by: Uuid,
|
pub granted_by: Uuid,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
/// Full resource details. Shape is determined by `resource_type`.
|
||||||
pub file: Option<FileDto>,
|
pub resource: ResourceContentDto,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub folder: Option<FolderDto>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response for `GET /api/grants/incoming/resources`.
|
/// Response for `GET /api/grants/incoming/resources`.
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
pub type SharedWithMeDto = CursorListResponse<SharedWithMeItemDto>;
|
||||||
pub struct SharedWithMeDto {
|
|
||||||
pub items: Vec<SharedWithMeItemDto>,
|
|
||||||
/// Opaque cursor for the next page. Absent when the last page is reached.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub next_cursor: Option<String>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
pub mod address_book_dto;
|
pub mod address_book_dto;
|
||||||
|
pub mod cursor;
|
||||||
|
pub use cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||||
pub mod app_password_dto;
|
pub mod app_password_dto;
|
||||||
pub mod calendar_dto;
|
pub mod calendar_dto;
|
||||||
pub mod contact_dto;
|
pub mod contact_dto;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
kinds: &[ResourceKind],
|
kinds: &[ResourceKind],
|
||||||
limit: u32,
|
limit: u32,
|
||||||
cursor: Option<GrantCursor>,
|
cursor: Option<GrantCursor>,
|
||||||
|
sort_by: &str,
|
||||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
||||||
|
|
||||||
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||||
//! maps them to / from `storage.access_grants` rows.
|
//! 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 std::fmt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -258,28 +258,44 @@ pub struct IncomingGrantSummary {
|
|||||||
|
|
||||||
/// Encodes the position of the last seen item in a cursor-paginated grant
|
/// Encodes the position of the last seen item in a cursor-paginated grant
|
||||||
/// listing. The encoding is opaque to API callers — only the backend
|
/// 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)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct GrantCursor {
|
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<chrono::Utc>,
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub resource_id: Uuid,
|
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<String>,
|
||||||
|
/// 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<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GrantCursor {
|
impl GrantCursor {
|
||||||
/// Encode as a URL-safe base64 JSON string (no padding).
|
fn default_sort() -> String {
|
||||||
pub fn encode(&self) -> String {
|
"granted_at".to_owned()
|
||||||
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<Self> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::display_helpers::category_order_for;
|
||||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::file::File;
|
use crate::domain::entities::file::File;
|
||||||
@@ -226,8 +227,8 @@ impl FileBlobWriteRepository {
|
|||||||
|
|
||||||
let row = match sqlx::query_as::<_, (String, i64, i64)>(
|
let row = match sqlx::query_as::<_, (String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
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)
|
||||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
@@ -239,6 +240,7 @@ impl FileBlobWriteRepository {
|
|||||||
.bind(&blob_hash)
|
.bind(&blob_hash)
|
||||||
.bind(size as i64)
|
.bind(size as i64)
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
|
.bind(category_order_for(&name, &content_type))
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -374,18 +376,19 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
WITH src AS (
|
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
|
FROM storage.files
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
WHERE id = $1::uuid AND NOT is_trashed
|
||||||
),
|
),
|
||||||
new_file AS (
|
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,
|
SELECT name,
|
||||||
COALESCE($2::uuid, folder_id),
|
COALESCE($2::uuid, folder_id),
|
||||||
user_id,
|
user_id,
|
||||||
blob_hash,
|
blob_hash,
|
||||||
size,
|
size,
|
||||||
mime_type
|
mime_type,
|
||||||
|
category_order
|
||||||
FROM src
|
FROM src
|
||||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
@@ -537,8 +540,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
|
|
||||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
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)
|
||||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
@@ -550,6 +553,7 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
.bind(placeholder_hash)
|
.bind(placeholder_hash)
|
||||||
.bind(size as i64)
|
.bind(size as i64)
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
|
.bind(category_order_for(&name, &content_type))
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
||||||
|
|||||||
@@ -299,33 +299,49 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
kinds: &[ResourceKind],
|
kinds: &[ResourceKind],
|
||||||
limit: u32,
|
limit: u32,
|
||||||
cursor: Option<GrantCursor>,
|
cursor: Option<GrantCursor>,
|
||||||
|
sort_by: &str,
|
||||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
|
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
|
||||||
// Build kind filter array — NULL means "all kinds".
|
// ── Common setup ──────────────────────────────────────────────────────
|
||||||
let kind_strs: Option<Vec<&str>> = if kinds.is_empty() {
|
let kind_strs: Option<Vec<&str>> = if kinds.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(kinds.iter().map(|k| k.as_str()).collect())
|
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;
|
let fetch_limit = (limit as i64) + 1;
|
||||||
|
|
||||||
// Each row: (resource_type, resource_id, permissions_text_array,
|
// Unified row type — the last two columns carry the sort key when present,
|
||||||
// granted_at, granted_by)
|
// NULL otherwise. This lets every sort mode share a single query_as call.
|
||||||
|
// 0 resource_type String
|
||||||
|
// 1 resource_id Uuid
|
||||||
|
// 2 permissions Vec<String>
|
||||||
|
// 3 granted_at DateTime<Utc>
|
||||||
|
// 4 granted_by Uuid
|
||||||
|
// 5 sort_str Option<String> — resource_name (name/type) or owner_name (granted_by)
|
||||||
|
// 6 sort_int Option<i64> — category_order (type) or file size in bytes (size)
|
||||||
type Row = (
|
type Row = (
|
||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
Vec<String>,
|
Vec<String>,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
Uuid,
|
Uuid,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows: Vec<Row> = sqlx::query_as(
|
// Extract all cursor fields up-front; each branch uses the subset it needs.
|
||||||
r#"
|
// Fixed parameter positions used in all SQL variants:
|
||||||
WITH agg AS (
|
// $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
|
SELECT
|
||||||
resource_type,
|
resource_type,
|
||||||
resource_id,
|
resource_id,
|
||||||
@@ -337,49 +353,189 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
AND subject_id = $2
|
AND subject_id = $2
|
||||||
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
||||||
GROUP BY resource_type, resource_id
|
GROUP BY resource_type, resource_id
|
||||||
)
|
)"#;
|
||||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by
|
|
||||||
|
// ── 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
|
FROM agg
|
||||||
WHERE ( $4::timestamptz IS NULL
|
LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder'
|
||||||
OR granted_at < $4
|
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
|
||||||
OR (granted_at = $4 AND resource_id < $5::uuid))
|
|
||||||
ORDER BY granted_at DESC, resource_id DESC
|
|
||||||
LIMIT $6
|
|
||||||
"#,
|
|
||||||
)
|
)
|
||||||
.bind(subject.type_str())
|
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||||
.bind(subject.id())
|
FROM named
|
||||||
.bind(kind_strs)
|
WHERE {where_clause}
|
||||||
.bind(cursor_at)
|
ORDER BY {order_clause}
|
||||||
.bind(cursor_id)
|
LIMIT $8"#
|
||||||
.bind(fetch_limit)
|
)
|
||||||
|
}
|
||||||
|
"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<Row> = 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())
|
.fetch_all(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::internal_error("PgAcl", format!("list_incoming_resources_paged: {e}"))
|
DomainError::internal_error(
|
||||||
|
"PgAcl",
|
||||||
|
format!("list_incoming_resources_paged ({sort_by}): {e}"),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// ── Pagination ────────────────────────────────────────────────────────
|
||||||
let has_next = rows.len() > limit as usize;
|
let has_next = rows.len() > limit as usize;
|
||||||
let rows: Vec<Row> = 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 {
|
let next_cursor = if has_next {
|
||||||
rows.last().map(|r| GrantCursor {
|
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,
|
granted_at: r.3,
|
||||||
resource_id: r.1,
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert rows into domain summaries.
|
// ── Convert rows to domain summaries ──────────────────────────────────
|
||||||
let summaries = rows
|
let summaries = rows
|
||||||
.into_iter()
|
.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 resource_type = ResourceKind::parse(&rt)?;
|
||||||
let permissions = perms_str
|
let permissions = perms_str
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter_map(|s| Permission::parse(s))
|
.filter_map(|s| Permission::parse(&s))
|
||||||
.collect();
|
.collect();
|
||||||
Some(IncomingGrantSummary {
|
Some(IncomingGrantSummary {
|
||||||
resource_type,
|
resource_type,
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ use tracing::{error, info, warn};
|
|||||||
use utoipa::IntoParams;
|
use utoipa::IntoParams;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::cursor::PageCursor;
|
||||||
use crate::application::dtos::grant_dto::{
|
use crate::application::dtos::grant_dto::{
|
||||||
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto,
|
||||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||||
};
|
};
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||||
@@ -325,15 +326,31 @@ pub async fn list_shared_with_me(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Clamp limit to 1–200.
|
// 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").
|
// Validate sort_by (defaults to "granted_at").
|
||||||
let cursor = q.cursor.as_deref().and_then(GrantCursor::decode);
|
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::<GrantCursor>()
|
||||||
|
.filter(|c| c.sort_by == sort_by);
|
||||||
|
|
||||||
// Fetch paged summaries from the ACL engine.
|
// Fetch paged summaries from the ACL engine.
|
||||||
let (summaries, next_cursor) = match state
|
let (summaries, next_cursor) = match state
|
||||||
.authorization
|
.authorization
|
||||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor)
|
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
@@ -389,8 +406,9 @@ pub async fn list_shared_with_me(
|
|||||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||||
granted_at: summary.granted_at,
|
granted_at: summary.granted_at,
|
||||||
granted_by: summary.granted_by,
|
granted_by: summary.granted_by,
|
||||||
file: Some(file_dto.clone().without_hierarchy_info()),
|
resource: ResourceContentDto::File(
|
||||||
folder: None,
|
file_dto.clone().without_hierarchy_info(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||||
@@ -419,8 +437,9 @@ pub async fn list_shared_with_me(
|
|||||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||||
granted_at: summary.granted_at,
|
granted_at: summary.granted_at,
|
||||||
granted_by: summary.granted_by,
|
granted_by: summary.granted_by,
|
||||||
file: None,
|
resource: ResourceContentDto::Folder(
|
||||||
folder: Some(folder_dto.clone().without_hierarchy_info()),
|
folder_dto.clone().without_hierarchy_info(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||||
@@ -443,10 +462,10 @@ pub async fn list_shared_with_me(
|
|||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(SharedWithMeDto {
|
Json(SharedWithMeDto::with_cursor(
|
||||||
items,
|
items,
|
||||||
next_cursor: next_cursor.map(|c| c.encode()),
|
next_cursor.map(|c| c.encode()),
|
||||||
}),
|
)),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,6 @@
|
|||||||
/* Multi-Select – checkboxes & batch action bar */
|
/* Multi-Select – batch action toolbar
|
||||||
.list-header-checkbox,
|
* Per-item checkbox styles (.file-item .checkbox-cell, .list-header.selection-mode)
|
||||||
.file-item .checkbox-cell {
|
* live in resourceList.css alongside the item renderer. */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-selection-info {
|
.batch-selection-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -105,3 +105,92 @@
|
|||||||
.toggle-btn i {
|
.toggle-btn i {
|
||||||
pointer-events: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 */
|
/* ── Base icon container ─────────────────────────────────── */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
.file-icon {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
@@ -35,6 +35,8 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Item states ─────────────────────────────────────────── */
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
background-color: var(--color-item);
|
background-color: var(--color-item);
|
||||||
}
|
}
|
||||||
@@ -73,7 +75,36 @@
|
|||||||
justify-content: center;
|
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 {
|
.list-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -108,7 +139,7 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Owner column ─────────────────────────────────────────── */
|
/* ── Owner column ────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Styles applied whenever the cell is visible (hidden class absent).
|
/* Styles applied whenever the cell is visible (hidden class absent).
|
||||||
The .hidden utility class (display:none !important) keeps it invisible
|
The .hidden utility class (display:none !important) keeps it invisible
|
||||||
@@ -183,6 +214,7 @@
|
|||||||
justify-self: center;
|
justify-self: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-list-view .file-item .date-cell {
|
.files-list-view .file-item .date-cell {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -236,7 +268,7 @@
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------- Files grid view --------------------------- */
|
/* ── Grid view ───────────────────────────────────────────── */
|
||||||
|
|
||||||
.files-grid-view {
|
.files-grid-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -289,12 +321,13 @@
|
|||||||
0 6px 18px var(--color-accent-ring-dark);
|
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 .date-cell,
|
||||||
.files-grid-view .file-item .size-cell,
|
.files-grid-view .file-item .size-cell,
|
||||||
.files-grid-view .file-item .owner-cell {
|
.files-grid-view .file-item .owner-cell {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection checkbox */
|
/* Selection checkbox */
|
||||||
.files-grid-view .file-item .checkbox-cell {
|
.files-grid-view .file-item .checkbox-cell {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -373,7 +406,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
z-index: 12;
|
z-index: 12;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -461,7 +493,7 @@
|
|||||||
height: 60px;
|
height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------- dragged items -----------*/
|
/* ── Drag ghost ──────────────────────────────────────────── */
|
||||||
|
|
||||||
.dragged-items {
|
.dragged-items {
|
||||||
--dragged-files-list-columns: 36px minmax(200px, 1fr);
|
--dragged-files-list-columns: 36px minmax(200px, 1fr);
|
||||||
@@ -484,7 +516,7 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* file name, text is wrapped */
|
/* file name text is truncated */
|
||||||
.dragged-items .file-item div:nth-child(2) {
|
.dragged-items .file-item div:nth-child(2) {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -505,22 +537,135 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
||||||
transform: translate(50%, -50%);
|
transform: translate(50%, -50%);
|
||||||
|
|
||||||
background: var(--color-content-debug-bg);
|
background: var(--color-content-debug-bg);
|
||||||
color: var(--color-content-debug-text);
|
color: var(--color-content-debug-text);
|
||||||
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
||||||
padding: 2px;
|
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 — */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
+3
-2
@@ -13,7 +13,8 @@
|
|||||||
@import url("./components/breadcrumb.css");
|
@import url("./components/breadcrumb.css");
|
||||||
@import url("./components/buttons.css");
|
@import url("./components/buttons.css");
|
||||||
@import url("./components/fileType.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/contextMenu.css");
|
||||||
@import url("./components/dialogs.css");
|
@import url("./components/dialogs.css");
|
||||||
@import url("./components/modals.css");
|
@import url("./components/modals.css");
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
@import url("./components/notifications.css");
|
@import url("./components/notifications.css");
|
||||||
@import url("./components/userMenu.css");
|
@import url("./components/userMenu.css");
|
||||||
@import url("./components/languageSelector.css");
|
@import url("./components/languageSelector.css");
|
||||||
@import url("./components/multiSelect.css");
|
@import url("./components/batchToolbar.css");
|
||||||
@import url("./components/spinner.css");
|
@import url("./components/spinner.css");
|
||||||
@import url("./components/search.css");
|
@import url("./components/search.css");
|
||||||
@import url("./components/icons.css");
|
@import url("./components/icons.css");
|
||||||
|
|||||||
@@ -31,39 +31,17 @@
|
|||||||
text-shadow: 0 0 5px var(--color-warning-shadow);
|
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 {
|
.favorite-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjustments for grid view */
|
/* Adjustments for list view header (page-level, not item-level) */
|
||||||
.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 */
|
|
||||||
.list-header.favorites-header {
|
.list-header.favorites-header {
|
||||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
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 */
|
/* Styles for the favorites-specific empty state */
|
||||||
.favorites-empty-state {
|
.favorites-empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -20,39 +20,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles for the recent view items */
|
/* Item modifier rules (.file-item.recent-item) → resourceList.css */
|
||||||
|
|
||||||
.recent-item {
|
.recent-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjustments for grid view */
|
/* Adjustments for list view header (page-level, not item-level) */
|
||||||
.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 */
|
|
||||||
.list-header.recent-header {
|
.list-header.recent-header {
|
||||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
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 */
|
/* Styles for the recent-specific empty state */
|
||||||
.recents-empty-state {
|
.recents-empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -43,9 +43,7 @@
|
|||||||
color: var(--color-trash-delete);
|
color: var(--color-trash-delete);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item.trash-item > .path-cell {
|
/* .file-item.trash-item > .path-cell → resourceList.css */
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-cell {
|
.actions-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+124
-196
@@ -1,8 +1,25 @@
|
|||||||
// @ts-check
|
// @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 { i18n } from '../core/i18n.js';
|
||||||
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||||
import { multiSelect } from '../features/files/multiSelect.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 { resolveHomeFolder } from './authSession.js';
|
||||||
import { updateHistory } from './main.js';
|
import { updateHistory } from './main.js';
|
||||||
import { app } from './state.js';
|
import { app } from './state.js';
|
||||||
@@ -11,129 +28,100 @@ import { uiNotifications } from './uiNotifications.js';
|
|||||||
|
|
||||||
/** @import {FileItem, FolderItem} from '../core/types.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
|
* Return (creating on first call) the `ResourceListComponent` bound to
|
||||||
* @param {string} id the id of the folder
|
* `#files-list`. The element must already be in the DOM.
|
||||||
* @returns {Promise<FolderItem>}
|
* @returns {ResourceListComponent|null}
|
||||||
*/
|
*/
|
||||||
async function getFolder(id) {
|
function _ensureComponent() {
|
||||||
/** @type {HeadersInit} */
|
const filesList = document.getElementById('files-list');
|
||||||
const headers = {
|
if (!filesList) return null;
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
Pragma: 'no-cache'
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {RequestInit} */
|
if (!_component) {
|
||||||
const requestOptions = {
|
_component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||||
headers,
|
selectable: true,
|
||||||
credentials: 'same-origin',
|
showFavorite: true,
|
||||||
cache: 'no-store'
|
showOwner: true,
|
||||||
};
|
showShareBadge: true,
|
||||||
|
draggable: true,
|
||||||
const folderInformations = await fetch(`/api/folders/${id}`, requestOptions);
|
showContextMenu: true,
|
||||||
if (folderInformations.ok) {
|
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||||||
return folderInformations.json();
|
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 {
|
} else {
|
||||||
console.warn(`Error fetching folder ${id}`);
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
return Promise.reject(null);
|
_component?.setFavoriteVisualState(item.id, type, true);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||||
/**
|
onSelectionChange: (selectedItems) => {
|
||||||
* Rebuild breadcrumb from selected folder (iterate up to root).
|
batchToolbar._selected.clear();
|
||||||
*
|
for (const sel of selectedItems) {
|
||||||
* Stops traversal gracefully when a parent folder is not accessible
|
const isFile = 'mime_type' in sel;
|
||||||
* (e.g. the user entered via a "Shared with me" grant whose parent
|
batchToolbar._selected.set(sel.id, {
|
||||||
* folder they have no permission on). In that case the partial
|
id: sel.id,
|
||||||
* breadcrumb built so far is kept — the deepest reachable ancestor
|
name: sel.name,
|
||||||
* acts as the visual root, matching how Google Drive / Dropbox handle
|
type: isFile ? 'file' : 'folder',
|
||||||
* shared subtrees.
|
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||||
*
|
});
|
||||||
* An error on the *target folder itself* (first iteration) is still
|
}
|
||||||
* treated as a real error and redirects to the home folder.
|
batchToolbar._syncUI();
|
||||||
*/
|
|
||||||
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
|
// Wire drag-and-drop on the container once the component is created.
|
||||||
id = folderInfo.parent_id;
|
ui.initDragDrop(/** @type {HTMLElement} */ (filesList));
|
||||||
} 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
|
return _component;
|
||||||
app.currentFolderInfo = currentFolderInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO split load() vs view()
|
|
||||||
/**
|
/**
|
||||||
* Files view loading logic
|
* 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.
|
||||||
*
|
*
|
||||||
* @param {Object} options
|
* Called by `fileOperations.js` and `search.js`.
|
||||||
* @param {boolean} [options.insertHistory] add browser history (default true)
|
|
||||||
* @param {boolean} [options.forceRefresh] force refresh of content
|
|
||||||
*
|
*
|
||||||
|
* @param {FileItem|FolderItem} item
|
||||||
|
*/
|
||||||
|
function addItem(item) {
|
||||||
|
const component = _ensureComponent();
|
||||||
|
if (!component) return;
|
||||||
|
// Reveal the list if the empty-state is showing
|
||||||
|
ui.resetFilesList();
|
||||||
|
component.addItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
async function loadFiles(options = { insertHistory: true }) {
|
||||||
try {
|
if (_loading) {
|
||||||
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');
|
console.log('A file load is already in progress, ignoring request');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_loading = true;
|
||||||
|
|
||||||
isLoadingFiles = true;
|
// Delay spinner so fast loads avoid the flash
|
||||||
|
const spinnerTimeout = setTimeout(() => {
|
||||||
// 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(`
|
ui.showError(`
|
||||||
<div class="files-loading-spinner">
|
<div class="files-loading-spinner">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
@@ -142,132 +130,72 @@ async function loadFiles(options = { insertHistory: true }) {
|
|||||||
`);
|
`);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
if (!app.userHomeFolderId) {
|
try {
|
||||||
await resolveHomeFolder();
|
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;
|
|
||||||
|
|
||||||
|
// Resolve path to home folder when none is set
|
||||||
if (!app.currentPath || app.currentPath === '') {
|
if (!app.currentPath || app.currentPath === '') {
|
||||||
if (app.userHomeFolderId) {
|
if (app.userHomeFolderId) {
|
||||||
url = `/api/folders/${app.userHomeFolderId}/listing?t=${timestamp}`;
|
|
||||||
app.currentPath = app.userHomeFolderId;
|
app.currentPath = app.userHomeFolderId;
|
||||||
app.breadcrumbPath = [];
|
app.breadcrumbPath = [];
|
||||||
ui.updateBreadcrumb();
|
|
||||||
console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`);
|
console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`);
|
||||||
} else {
|
} else {
|
||||||
url = `/api/folders?t=${timestamp}`;
|
console.warn('No home folder id — this should not normally happen');
|
||||||
console.warn('Emergency fallback to root folder - this should not normally happen');
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
url = `/api/folders/${app.currentPath}/listing?t=${timestamp}`;
|
|
||||||
console.log(`Loading subfolder content: ${app.currentPath}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {HeadersInit} */
|
await rebuildBreadCrumb();
|
||||||
const headers = {
|
ui.updateBreadcrumb();
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
updateHistory(options.insertHistory ?? true);
|
||||||
Pragma: 'no-cache'
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {RequestInit} */
|
const { folders, files } = await fetchListing(app.currentPath, {
|
||||||
const requestOptions = {
|
forceRefresh: options.forceRefresh ?? false
|
||||||
headers,
|
});
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-store'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (forceRefresh) {
|
clearTimeout(spinnerTimeout);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Loading listing from ${url}`);
|
// Prepare the container (shows #files-list, hides error panel)
|
||||||
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(`<p>Could not load files</p>`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Server responded with status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const listing = await response.json();
|
|
||||||
|
|
||||||
ui._items.clear();
|
|
||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
if (multiSelect) {
|
|
||||||
multiSelect.clear();
|
|
||||||
multiSelect.init(); // this will wire buttons & select-all-checkbox
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {FolderItem[]} */
|
const component = _ensureComponent();
|
||||||
const folderList = Array.isArray(listing.folders) ? listing.folders : [];
|
if (!component) return;
|
||||||
|
|
||||||
/** @type {FileItem[]} */
|
batchToolbar.clear();
|
||||||
const fileList = Array.isArray(listing.files) ? listing.files : [];
|
batchToolbar.init();
|
||||||
|
batchToolbar.setActiveComponent(component);
|
||||||
|
|
||||||
if (folderList.length === 0 && fileList.length === 0) {
|
if (folders.length === 0 && files.length === 0) {
|
||||||
ui.showEmptyList();
|
ui.showEmptyList();
|
||||||
} else {
|
} else {
|
||||||
ui.renderFolders(folderList);
|
component.render([...folders, ...files]);
|
||||||
ui.renderFiles(fileList);
|
await component.resolveOwnerCells();
|
||||||
ui.resolveOwnerCells();
|
}
|
||||||
|
|
||||||
// check if a file was provided
|
console.log(`Loaded ${folders.length} folders and ${files.length} files`);
|
||||||
|
|
||||||
|
// Deep-link: open a specific file if requested via app.viewFile
|
||||||
if (app.viewFile) {
|
if (app.viewFile) {
|
||||||
let fileFound = null;
|
const fileFound = files.find((f) => f.id === app.viewFile) ?? null;
|
||||||
|
|
||||||
// lookup for the given fle
|
|
||||||
for (const file of fileList) {
|
|
||||||
if (file.id === app.viewFile) {
|
|
||||||
fileFound = file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileFound) {
|
if (fileFound) {
|
||||||
console.log(`file ${app.viewFile} found, calling viewer`);
|
console.log(`file ${app.viewFile} found, calling viewer`);
|
||||||
await inlineViewer.openFile(fileFound);
|
await inlineViewer.openFile(fileFound);
|
||||||
} else {
|
} else {
|
||||||
// remove file
|
|
||||||
console.log(`file ${app.viewFile} not found`);
|
console.log(`file ${app.viewFile} not found`);
|
||||||
app.viewFile = null;
|
app.viewFile = null;
|
||||||
|
|
||||||
// correct url/history as file is not found
|
|
||||||
updateHistory(false);
|
updateHistory(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (/** @type {any} */ err) {
|
||||||
|
clearTimeout(spinnerTimeout);
|
||||||
|
if (err?.status === 403) {
|
||||||
|
ui.showError(`<p>${i18n.t('errors.forbidden', 'Could not load files')}</p>`);
|
||||||
|
} else {
|
||||||
|
console.error('Error loading folders:', err);
|
||||||
|
uiNotifications.show('Error', 'Could not load files and folders');
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingFiles = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { loadFiles };
|
export { addItem, loadFiles };
|
||||||
|
|||||||
+107
-9
@@ -8,11 +8,11 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
|
|||||||
installFetchInterceptor();
|
installFetchInterceptor();
|
||||||
|
|
||||||
import { Modal } from '../components/modal.js';
|
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 { i18n } from '../core/i18n.js';
|
||||||
import { oxiIconsInit } from '../core/icons.js';
|
import { oxiIconsInit } from '../core/icons.js';
|
||||||
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { fileOps } from '../features/files/fileOperations.js';
|
import { fileOps } from '../features/files/fileOperations.js';
|
||||||
import { multiSelect } from '../features/files/multiSelect.js';
|
|
||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
@@ -50,7 +50,7 @@ let uploadDropdownDocumentClickHandler = null;
|
|||||||
let uploadDropdownBindingsController = null;
|
let uploadDropdownBindingsController = null;
|
||||||
let actionsBarDelegationBound = false;
|
let actionsBarDelegationBound = false;
|
||||||
|
|
||||||
const _multiSelectButons = `
|
const _batchToolbarButons = `
|
||||||
<div class="action-buttons batch-selection-bar hidden" id="multi-select-buttons">
|
<div class="action-buttons batch-selection-bar hidden" id="multi-select-buttons">
|
||||||
<div class="list-header-checkbox">
|
<div class="list-header-checkbox">
|
||||||
<button class="batch-bar-close" id="batch-selection-close" title="Cancel selection">
|
<button class="batch-bar-close" id="batch-selection-close" title="Cancel selection">
|
||||||
@@ -83,6 +83,15 @@ const _multiSelectButons = `
|
|||||||
|
|
||||||
const _toggleButtons = `
|
const _toggleButtons = `
|
||||||
<div class="view-toggle">
|
<div class="view-toggle">
|
||||||
|
<div class="group-by-selector hidden" id="group-by-selector">
|
||||||
|
<button class="toggle-btn group-by-btn" id="group-by-btn"
|
||||||
|
title="Group by" data-i18n-title="groupby.title">
|
||||||
|
<i class="fas fa-layer-group"></i>
|
||||||
|
<span class="group-by-label"></span>
|
||||||
|
</button>
|
||||||
|
<div class="group-by-menu hidden" id="group-by-menu"></div>
|
||||||
|
</div>
|
||||||
|
<span class="view-toggle-separator hidden" id="group-by-separator"></span>
|
||||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||||
<i class="fas fa-th"></i>
|
<i class="fas fa-th"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -117,7 +126,7 @@ const ACTIONS_BAR_TEMPLATES = {
|
|||||||
<span data-i18n="actions.new_folder">New folder</span>
|
<span data-i18n="actions.new_folder">New folder</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
${_multiSelectButons}
|
${_batchToolbarButons}
|
||||||
${_toggleButtons}
|
${_toggleButtons}
|
||||||
`,
|
`,
|
||||||
trash: `
|
trash: `
|
||||||
@@ -131,7 +140,7 @@ const ACTIONS_BAR_TEMPLATES = {
|
|||||||
`,
|
`,
|
||||||
favorites: `
|
favorites: `
|
||||||
<div class="action-buttons" id="default-buttons"></div>
|
<div class="action-buttons" id="default-buttons"></div>
|
||||||
${_multiSelectButons}
|
${_batchToolbarButons}
|
||||||
${_toggleButtons}
|
${_toggleButtons}
|
||||||
`,
|
`,
|
||||||
recent: `
|
recent: `
|
||||||
@@ -141,11 +150,12 @@ const ACTIONS_BAR_TEMPLATES = {
|
|||||||
<span data-i18n="actions.clear_recent">Clear recent</span>
|
<span data-i18n="actions.clear_recent">Clear recent</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
${_multiSelectButons}
|
${_batchToolbarButons}
|
||||||
${_toggleButtons}
|
${_toggleButtons}
|
||||||
`,
|
`,
|
||||||
sharedwithme: `
|
sharedwithme: `
|
||||||
<div class="action-buttons" id="default-buttons"></div>
|
<div class="action-buttons" id="default-buttons"></div>
|
||||||
|
${_batchToolbarButons}
|
||||||
${_toggleButtons}
|
${_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 = `<button class="group-by-option active" data-group-by="">${escapeHtml(i18n.t('groupby.none', 'None'))}</button>`;
|
||||||
|
for (const def of defs) {
|
||||||
|
menu.insertAdjacentHTML('beforeend', `<button class="group-by-option" data-group-by="${escapeHtml(def.key)}">${escapeHtml(def.label)}</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
function setupActionsBarDelegation() {
|
||||||
if (actionsBarDelegationBound || !elements.actionsBar) return;
|
if (actionsBarDelegationBound || !elements.actionsBar) return;
|
||||||
actionsBarDelegationBound = true;
|
actionsBarDelegationBound = true;
|
||||||
@@ -197,7 +276,26 @@ function setupActionsBarDelegation() {
|
|||||||
const btn = /** @type {HTMLElement} */ (e.target)?.closest('button');
|
const btn = /** @type {HTMLElement} */ (e.target)?.closest('button');
|
||||||
if (!btn) return;
|
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) {
|
switch (btn.id) {
|
||||||
|
case 'group-by-btn':
|
||||||
|
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
||||||
|
return;
|
||||||
case 'upload-files-btn': {
|
case 'upload-files-btn': {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const menu = document.getElementById('upload-dropdown-menu');
|
const menu = document.getElementById('upload-dropdown-menu');
|
||||||
@@ -386,9 +484,9 @@ function initApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize multi-select / batch actions
|
// Initialize multi-select / batch actions
|
||||||
if (multiSelect?.init) {
|
if (batchToolbar?.init) {
|
||||||
console.log('Initializing multi-select module');
|
console.log('Initializing multi-select module');
|
||||||
multiSelect.init();
|
batchToolbar.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('authenticationDone', async () => {
|
window.addEventListener('authenticationDone', async () => {
|
||||||
@@ -798,4 +896,4 @@ function updateStorageUsageDisplay(userData) {
|
|||||||
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
|
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { deserializeHash, initApp, setActionsBarMode, updateHistory, updateStorageUsageDisplay };
|
export { deserializeHash, initApp, setActionsBarMode, setGroupByView, syncGroupByMenu, updateHistory, updateStorageUsageDisplay };
|
||||||
|
|||||||
+26
-11
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { i18n } from '../core/i18n.js';
|
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 { favorites } from '../features/library/favorites.js';
|
||||||
import { musicView } from '../features/library/music.js';
|
import { musicView } from '../features/library/music.js';
|
||||||
import { photosView } from '../features/library/photos.js';
|
import { photosView } from '../features/library/photos.js';
|
||||||
@@ -12,7 +12,7 @@ import { recent } from '../features/library/recent.js';
|
|||||||
import { sharedView } from '../views/shared/sharedView.js';
|
import { sharedView } from '../views/shared/sharedView.js';
|
||||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
import { setActionsBarMode } from './main.js';
|
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||||
import { app, appElements } from './state.js';
|
import { app, appElements } from './state.js';
|
||||||
import { loadTrashItems } from './trashView.js';
|
import { loadTrashItems } from './trashView.js';
|
||||||
import { ui } from './ui.js';
|
import { ui } from './ui.js';
|
||||||
@@ -201,7 +201,7 @@ function switchToSharedSection() {
|
|||||||
sharedView.show();
|
sharedView.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToSharedWithMeSection() {
|
function switchToSharedWithMeSection() {
|
||||||
@@ -214,6 +214,11 @@ function switchToSharedWithMeSection() {
|
|||||||
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
||||||
setActionsBarMode('sharedwithme');
|
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.
|
// Show the Owner column — names are resolved async after render.
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
|
|
||||||
@@ -221,7 +226,7 @@ function switchToSharedWithMeSection() {
|
|||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
|
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
|
|
||||||
// Load and render items into the files container
|
// Load and render items into the files container
|
||||||
sharedWithMeView.init();
|
sharedWithMeView.init();
|
||||||
@@ -232,6 +237,8 @@ function switchToFilesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('files', true);
|
setActionsBarMode('files', true);
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
// Show owner column in the Files section
|
// Show owner column in the Files section
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
@@ -253,7 +260,7 @@ function switchToFilesSection() {
|
|||||||
app.currentPath = app.userHomeFolderId || '';
|
app.currentPath = app.userHomeFolderId || '';
|
||||||
app.breadcrumbPath = [];
|
app.breadcrumbPath = [];
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
|
|
||||||
// temp solution
|
// temp solution
|
||||||
sharedView.loadItems().then(() => {
|
sharedView.loadItems().then(() => {
|
||||||
@@ -266,6 +273,8 @@ function switchToFavoritesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('favorites');
|
setActionsBarMode('favorites');
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
// Show the Owner column — names are resolved async after render.
|
// Show the Owner column — names are resolved async after render.
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
@@ -296,7 +305,7 @@ function switchToFavoritesSection() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToRecentFilesSection() {
|
function switchToRecentFilesSection() {
|
||||||
@@ -304,6 +313,8 @@ function switchToRecentFilesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('recent');
|
setActionsBarMode('recent');
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
// Hide breadcrumb (only shown in Files view)
|
// Hide breadcrumb (only shown in Files view)
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
@@ -329,7 +340,7 @@ function switchToRecentFilesSection() {
|
|||||||
<p>Error loading the recent module</p>
|
<p>Error loading the recent module</p>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToPhotosSection() {
|
function switchToPhotosSection() {
|
||||||
@@ -352,7 +363,7 @@ function switchToPhotosSection() {
|
|||||||
if (photosView) {
|
if (photosView) {
|
||||||
photosView.show();
|
photosView.show();
|
||||||
}
|
}
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToTrashSection() {
|
function switchToTrashSection() {
|
||||||
@@ -367,6 +378,8 @@ function switchToTrashSection() {
|
|||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|
||||||
setActionsBarMode('trash');
|
setActionsBarMode('trash');
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
//reset files view + remove any error
|
//reset files view + remove any error
|
||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
@@ -377,7 +390,7 @@ function switchToTrashSection() {
|
|||||||
// Load trash items
|
// Load trash items
|
||||||
loadTrashItems();
|
loadTrashItems();
|
||||||
|
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToMusicSection() {
|
function switchToMusicSection() {
|
||||||
@@ -404,7 +417,7 @@ function switchToMusicSection() {
|
|||||||
if (musicView) {
|
if (musicView) {
|
||||||
musicView.show();
|
musicView.show();
|
||||||
}
|
}
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -419,11 +432,13 @@ function switchToMusicSection() {
|
|||||||
function activateFilesUI() {
|
function activateFilesUI() {
|
||||||
setCurrentSection('files');
|
setCurrentSection('files');
|
||||||
setActionsBarMode('files', true);
|
setActionsBarMode('files', true);
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
breadcrumb?.classList.remove('hidden');
|
breadcrumb?.classList.remove('hidden');
|
||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { fileOps } from '../features/files/fileOperations.js';
|
import { fileOps } from '../features/files/fileOperations.js';
|
||||||
import { multiSelect } from '../features/files/multiSelect.js';
|
|
||||||
import * as pathTooltip from '../features/pathTooltip.js';
|
import * as pathTooltip from '../features/pathTooltip.js';
|
||||||
import { appElements } from './state.js';
|
import { appElements } from './state.js';
|
||||||
import { ui } from './ui.js';
|
import { ui } from './ui.js';
|
||||||
@@ -22,7 +22,7 @@ async function loadTrashItems() {
|
|||||||
const elements = appElements;
|
const elements = appElements;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (multiSelect) multiSelect.clear();
|
if (batchToolbar) batchToolbar.clear();
|
||||||
pathTooltip.destroy(elements.filesList);
|
pathTooltip.destroy(elements.filesList);
|
||||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
ui.resetFilesList(); // ensure also list visible & error hidden
|
||||||
elements.filesList.innerHTML = `
|
elements.filesList.innerHTML = `
|
||||||
|
|||||||
+214
-669
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,730 @@
|
|||||||
|
/**
|
||||||
|
* 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 { systemUsers } from '../model/systemUsers.js';
|
||||||
|
import { createUserVignette } from './userVignette.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<void>} [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<FileItem|FolderItem>) => 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<Pick<ResourceListConfig,'selectable'|'showFavorite'|'showOwner'|'showShareBadge'|'draggable'|'showContextMenu'|'dateField'>> & 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<string, FileItem|FolderItem>} */
|
||||||
|
this._items = new Map();
|
||||||
|
|
||||||
|
/** IDs of currently selected items. */
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
this._selected = new Set();
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the current item list. Preserves an existing `.list-header`
|
||||||
|
* at the start of the container.
|
||||||
|
*
|
||||||
|
* 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<FileItem|FolderItem>} items
|
||||||
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
|
* When provided, a swimlane divider is injected whenever the returned
|
||||||
|
* 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(items, groupFn, groupLabelFn) {
|
||||||
|
const header = this._container.querySelector('.list-header');
|
||||||
|
this._container.innerHTML = '';
|
||||||
|
if (header) this._container.appendChild(header);
|
||||||
|
|
||||||
|
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(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 {Array<FileItem|FolderItem>} items
|
||||||
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
|
* @param {((key: string) => string)=} groupLabelFn
|
||||||
|
*/
|
||||||
|
append(items, groupFn, groupLabelFn) {
|
||||||
|
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
this._lastClickedIndex = -1;
|
||||||
|
this._lastGroupKey = undefined;
|
||||||
|
this._lastGroupEl = null;
|
||||||
|
// Hand delegation back to ui.js
|
||||||
|
delete this._container.dataset.managedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>}
|
||||||
|
*/
|
||||||
|
async resolveOwnerCells() {
|
||||||
|
const cells = /** @type {NodeListOf<HTMLElement>} */ (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
|
||||||
|
*/
|
||||||
|
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 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: append items to the container in the order supplied.
|
||||||
|
* Files vs. folders are distinguished by presence of `mime_type`.
|
||||||
|
*
|
||||||
|
* @param {Array<FileItem|FolderItem>} items
|
||||||
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
|
* @param {((key: string) => string)=} groupLabelFn
|
||||||
|
*/
|
||||||
|
_appendItems(items, groupFn, groupLabelFn) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
// 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(item);
|
||||||
|
if (key !== lastGroupKey) {
|
||||||
|
lastGroupKey = key;
|
||||||
|
liveGroup = null; // stop extending the previous page's group
|
||||||
|
fragmentGroup = null;
|
||||||
|
|
||||||
|
if (key !== null) {
|
||||||
|
fragmentGroup = document.createElement('div');
|
||||||
|
fragmentGroup.className = 'resource-list__swimlane-group';
|
||||||
|
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn));
|
||||||
|
fragment.appendChild(fragmentGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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} key - Raw grouping key (e.g. UUID or bucket name).
|
||||||
|
* @param {((key: string) => string)=} labelFn - Optional human-readable resolver.
|
||||||
|
*/
|
||||||
|
_createGroupHeader(key, labelFn) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'resource-list__swimlane-header';
|
||||||
|
el.dataset.swimlaneHeader = 'true';
|
||||||
|
el.textContent = labelFn ? labelFn(key) : key;
|
||||||
|
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<string,string>} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at;
|
||||||
|
const formattedDate = formatDateTime(new Date(dateVal));
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||||
|
<div class="name-cell">
|
||||||
|
<div class="file-icon folder-icon">
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
</div>
|
||||||
|
<span>${escapeHtml(folder.name)}</span>
|
||||||
|
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||||
|
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
|
||||||
|
<div class="type-cell">${i18n.t('files.file_types.folder')}</div>
|
||||||
|
<div class="size-cell">--</div>
|
||||||
|
<div class="date-cell">${formattedDate}</div>
|
||||||
|
<div class="action-cell">
|
||||||
|
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
||||||
|
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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<string,string>} */ (/** @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 ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||||
|
<div class="name-cell">
|
||||||
|
<div class="file-icon ${iconSpecialClass}">
|
||||||
|
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
|
||||||
|
<i class="${iconClass}"></i>
|
||||||
|
</div>
|
||||||
|
<span>${escapeHtml(file.name)}</span>
|
||||||
|
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||||
|
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
|
||||||
|
<div class="type-cell">${typeLabel}</div>
|
||||||
|
<div class="size-cell">${fileSize}</div>
|
||||||
|
<div class="date-cell">${formattedDate}</div>
|
||||||
|
<div class="action-cell">
|
||||||
|
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
||||||
|
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 (shift extends range)
|
||||||
|
if (cfg.selectable && target.closest('.checkbox-cell')) {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
this._handleShiftSelect(card);
|
||||||
|
} else {
|
||||||
|
this._toggleSelection(card);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift-click anywhere on the card → extend selection range
|
||||||
|
if (e.shiftKey && cfg.selectable) {
|
||||||
|
this._handleShiftSelect(card);
|
||||||
|
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 on a single card and notify.
|
||||||
|
* Tracks `_lastClickedIndex` for subsequent shift-clicks.
|
||||||
|
* @param {HTMLElement} card
|
||||||
|
*/
|
||||||
|
_toggleSelection(card) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<FileItem|FolderItem>} */
|
||||||
|
const selectedItems = [...this._selected].flatMap((id) => {
|
||||||
|
const item = this._items.get(id);
|
||||||
|
return item ? [item] : [];
|
||||||
|
});
|
||||||
|
this._cfg.onSelectionChange(selectedItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
* Contains also checkers
|
* Contains also checkers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { i18n } from './i18n.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} str
|
* @param {string} str
|
||||||
@@ -107,4 +109,62 @@ function isEmailValid(email) {
|
|||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(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 | <YYYY>
|
||||||
|
*
|
||||||
|
* 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 };
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ const OxiIcons = {
|
|||||||
576,
|
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'
|
'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: [
|
link: [
|
||||||
576,
|
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'
|
'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'
|
||||||
|
|||||||
+22
-3
@@ -295,16 +295,35 @@
|
|||||||
* Roles: `viewer`, `commenter`, `editor`, `manager`, `admin`
|
* 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<void>} [onFavoriteToggle] - Favorite-star click callback.
|
||||||
|
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu] - Context menu callback.
|
||||||
|
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange] - Selection change callback.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One item returned by `GET /api/grants/incoming/resources`.
|
* 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
|
* @typedef {Object} SharedWithMeItem
|
||||||
* @property {ResourceTypeEnum} resource_type
|
* @property {ResourceTypeEnum} resource_type
|
||||||
* @property {PermissionTypeEnum[]} permissions - All permissions the caller holds on this resource.
|
* @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_at - ISO-8601 timestamp of the earliest grant.
|
||||||
* @property {string} granted_by - UUID of the user who created the grant.
|
* @property {string} granted_by - UUID of the user who created the grant.
|
||||||
* @property {FileItem|undefined} [file] - Populated when resource_type === 'file'.
|
* @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type.
|
||||||
* @property {FolderItem|undefined} [folder] - Populated when resource_type === 'folder'.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+48
-11
@@ -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
|
* Manages the floating selection bar that appears when items are selected,
|
||||||
* with a NextCloud-style selection bar when items are selected, and
|
* and executes batch operations (delete, move, download, favorites).
|
||||||
* provides batch delete / move / download / favorites operations.
|
*
|
||||||
|
* 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 { loadFiles } from '../../app/filesView.js';
|
||||||
import { app } from '../../app/state.js';
|
import { app } from '../../app/state.js';
|
||||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||||
@@ -20,9 +22,10 @@ import { getAuthHeaders } from './fileOperations.js';
|
|||||||
/**
|
/**
|
||||||
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
|
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
|
||||||
* @import {BatchResult} from './fileOperations.js'
|
* @import {BatchResult} from './fileOperations.js'
|
||||||
|
* @import {ResourceListComponent} from '../../components/resourceList.js'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const multiSelect = {
|
const batchToolbar = {
|
||||||
/** @type {Map<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
|
/** @type {Map<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
|
||||||
|
|
||||||
_selected: new Map(),
|
_selected: new Map(),
|
||||||
@@ -33,6 +36,23 @@ const multiSelect = {
|
|||||||
/** Whether the selection bar is currently visible */
|
/** Whether the selection bar is currently visible */
|
||||||
_barVisible: false,
|
_barVisible: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `ResourceListComponent` currently managing the active view.
|
||||||
|
* When set, keyboard shortcuts (Ctrl+A, Escape) delegate to the component
|
||||||
|
* so its internal selection state stays consistent.
|
||||||
|
* @type {ResourceListComponent | null}
|
||||||
|
*/
|
||||||
|
_activeComponent: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register (or unregister) the component that owns the current view's
|
||||||
|
* selection state. Pass `null` when leaving a component-managed view.
|
||||||
|
* @param {ResourceListComponent | null} component
|
||||||
|
*/
|
||||||
|
setActiveComponent(component) {
|
||||||
|
this._activeComponent = component;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────
|
||||||
|
|
||||||
get count() {
|
get count() {
|
||||||
@@ -112,6 +132,13 @@ const multiSelect = {
|
|||||||
document.querySelectorAll('.item-checkbox').forEach((cb) => {
|
document.querySelectorAll('.item-checkbox').forEach((cb) => {
|
||||||
/** @type {HTMLInputElement} */ (cb).checked = false;
|
/** @type {HTMLInputElement} */ (cb).checked = false;
|
||||||
});
|
});
|
||||||
|
// Reset the active component's internal selection state without going
|
||||||
|
// through onSelectionChange (which would re-enter this method).
|
||||||
|
if (this._activeComponent) {
|
||||||
|
this._activeComponent._selected.clear();
|
||||||
|
this._activeComponent._lastClickedIndex = -1;
|
||||||
|
this._activeComponent._syncSelectAllCheckbox();
|
||||||
|
}
|
||||||
this._syncUI();
|
this._syncUI();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -324,6 +351,8 @@ const multiSelect = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_syncItemCheckboxes() {
|
_syncItemCheckboxes() {
|
||||||
|
// When a ResourceListComponent is active it owns checkbox state — skip.
|
||||||
|
if (this._activeComponent) return;
|
||||||
document.querySelectorAll('.file-item').forEach((el) => {
|
document.querySelectorAll('.file-item').forEach((el) => {
|
||||||
const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
|
const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
|
||||||
if (cb) cb.checked = el.classList.contains('selected');
|
if (cb) cb.checked = el.classList.contains('selected');
|
||||||
@@ -512,16 +541,24 @@ const multiSelect = {
|
|||||||
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
||||||
|
|
||||||
const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
|
const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
|
||||||
// ctrl+a cmd+a
|
// ctrl+a / cmd+a — delegate to active component when present
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||||
|
if (this._activeComponent) {
|
||||||
|
this._activeComponent.selectAll();
|
||||||
|
} else {
|
||||||
if (selectAllCheckbox) selectAllCheckbox.checked = true;
|
if (selectAllCheckbox) selectAllCheckbox.checked = true;
|
||||||
this.selectAll();
|
this.selectAll();
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape' && this.hasSelection) {
|
if (e.key === 'Escape') {
|
||||||
|
if (this._activeComponent && this._activeComponent._selected.size > 0) {
|
||||||
|
this._activeComponent.clearSelection();
|
||||||
|
} else if (this.hasSelection) {
|
||||||
this.clear();
|
this.clear();
|
||||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (e.key === 'Delete' && this.hasSelection) this.batchDelete();
|
if (e.key === 'Delete' && this.hasSelection) this.batchDelete();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,4 +573,4 @@ const multiSelect = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { multiSelect };
|
export { batchToolbar };
|
||||||
@@ -16,9 +16,9 @@ import { i18n } from '../../core/i18n.js';
|
|||||||
import { favorites } from '../library/favorites.js';
|
import { favorites } from '../library/favorites.js';
|
||||||
import { musicView } from '../library/music.js';
|
import { musicView } from '../library/music.js';
|
||||||
import { fileSharing } from '../sharing/fileSharing.js';
|
import { fileSharing } from '../sharing/fileSharing.js';
|
||||||
|
import { batchToolbar } from './batchToolbar.js';
|
||||||
import { fileOps } from './fileOperations.js';
|
import { fileOps } from './fileOperations.js';
|
||||||
import { inlineViewer } from './inlineViewer.js';
|
import { inlineViewer } from './inlineViewer.js';
|
||||||
import { multiSelect } from './multiSelect.js';
|
|
||||||
import { wopiEditor } from './wopiEditor.js';
|
import { wopiEditor } from './wopiEditor.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,8 +330,8 @@ const contextMenus = {
|
|||||||
|
|
||||||
// Copy button handler
|
// Copy button handler
|
||||||
copyConfirmBtn.addEventListener('click', async () => {
|
copyConfirmBtn.addEventListener('click', async () => {
|
||||||
// Batch copy mode (from multiSelect)
|
// Batch copy mode (from batchToolbar)
|
||||||
if (app.moveDialogMode === 'batch' && multiSelect) {
|
if (app.moveDialogMode === 'batch' && batchToolbar) {
|
||||||
const targetId = app.selectedTargetFolderId;
|
const targetId = app.selectedTargetFolderId;
|
||||||
const items = app.batchMoveItems || [];
|
const items = app.batchMoveItems || [];
|
||||||
|
|
||||||
@@ -341,10 +341,10 @@ const contextMenus = {
|
|||||||
const result = await fileOps.batchCopy(fileIds, folderIds, targetId);
|
const result = await fileOps.batchCopy(fileIds, folderIds, targetId);
|
||||||
|
|
||||||
this.closeMoveDialog();
|
this.closeMoveDialog();
|
||||||
multiSelect.clear();
|
batchToolbar.clear();
|
||||||
loadFiles();
|
loadFiles();
|
||||||
|
|
||||||
multiSelect.showBatchResult('copy', result);
|
batchToolbar.showBatchResult('copy', result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,8 +363,8 @@ const contextMenus = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
moveConfirmBtn.addEventListener('click', async () => {
|
moveConfirmBtn.addEventListener('click', async () => {
|
||||||
// Batch move mode (from multiSelect)
|
// Batch move mode (from batchToolbar)
|
||||||
if (app.moveDialogMode === 'batch' && multiSelect) {
|
if (app.moveDialogMode === 'batch' && batchToolbar) {
|
||||||
const targetId = app.selectedTargetFolderId;
|
const targetId = app.selectedTargetFolderId;
|
||||||
const items = app.batchMoveItems || [];
|
const items = app.batchMoveItems || [];
|
||||||
|
|
||||||
@@ -374,9 +374,9 @@ const contextMenus = {
|
|||||||
const result = await fileOps.batchMove(fileIds, folderIds, targetId);
|
const result = await fileOps.batchMove(fileIds, folderIds, targetId);
|
||||||
|
|
||||||
this.closeMoveDialog();
|
this.closeMoveDialog();
|
||||||
multiSelect.clear();
|
batchToolbar.clear();
|
||||||
loadFiles();
|
loadFiles();
|
||||||
multiSelect.showBatchResult('move', result);
|
batchToolbar.showBatchResult('move', result);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { refreshUserData } from '../../app/authSession.js';
|
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 { app } from '../../app/state.js';
|
||||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||||
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
||||||
@@ -781,7 +781,7 @@ const fileOps = {
|
|||||||
|
|
||||||
// Optimistic UI: add folder card directly from server response
|
// Optimistic UI: add folder card directly from server response
|
||||||
// — no reload needed since the backend already confirmed creation.
|
// — no reload needed since the backend already confirmed creation.
|
||||||
ui.addFolderToView(folder);
|
filesViewAddItem(folder);
|
||||||
|
|
||||||
ui.showNotification('Folder created', `"${name}" created successfully`);
|
ui.showNotification('Folder created', `"${name}" created successfully`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* displays the enriched results returned by the server.
|
* 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 { app } from '../../app/state.js';
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
import { getAuthHeaders } from './fileOperations.js';
|
import { getAuthHeaders } from './fileOperations.js';
|
||||||
@@ -200,12 +200,12 @@ const search = {
|
|||||||
|
|
||||||
// Render folders (server-provided enriched data)
|
// Render folders (server-provided enriched data)
|
||||||
results.folders.forEach((folder) => {
|
results.folders.forEach((folder) => {
|
||||||
ui.addFolderToView(folder);
|
filesViewAddItem(folder);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render files (server-provided enriched data)
|
// Render files (server-provided enriched data)
|
||||||
results.files.forEach((file) => {
|
results.files.forEach((file) => {
|
||||||
ui.addFileToView(file);
|
filesViewAddItem(file);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { multiSelect } from '../files/multiSelect.js';
|
import { batchToolbar } from '../files/batchToolbar.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
import * as pathTooltip from '../pathTooltip.js';
|
||||||
|
|
||||||
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
||||||
@@ -21,6 +22,9 @@ const favorites = {
|
|||||||
/** Whether the initial fetch from the server has completed */
|
/** Whether the initial fetch from the server has completed */
|
||||||
_ready: false,
|
_ready: false,
|
||||||
|
|
||||||
|
/** @type {ResourceListComponent|null} */
|
||||||
|
_component: null,
|
||||||
|
|
||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
@@ -105,7 +109,7 @@ const favorites = {
|
|||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
* @param {string} _parentId
|
* @param {string | null} _parentId
|
||||||
*/
|
*/
|
||||||
async addToFavorites(id, name, type, _parentId) {
|
async addToFavorites(id, name, type, _parentId) {
|
||||||
try {
|
try {
|
||||||
@@ -178,11 +182,8 @@ const favorites = {
|
|||||||
try {
|
try {
|
||||||
await this._fetchFromServer();
|
await this._fetchFromServer();
|
||||||
|
|
||||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
ui.resetFilesList();
|
||||||
// wire buttons & select-all-checkbox as list header has changed in ui.resetFilesList()
|
batchToolbar.init();
|
||||||
// FIXME: this case is not easy to understand, should apply better implementation
|
|
||||||
multiSelect.init();
|
|
||||||
|
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
|
|
||||||
if (this._cache.size === 0) {
|
if (this._cache.size === 0) {
|
||||||
@@ -194,18 +195,14 @@ const favorites = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {FolderItem[]} */
|
/** @type {Array<FileItem|FolderItem>} */
|
||||||
const folders = [];
|
const items = [];
|
||||||
|
|
||||||
/** @type {FileItem[]} */
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const item of this._cache.values()) {
|
for (const item of this._cache.values()) {
|
||||||
// owner_id comes from the backend JOIN (actual file/folder owner, not the favoriter)
|
// owner_id comes from the backend JOIN (actual file/folder owner)
|
||||||
if (item.item_type === 'folder') {
|
if (item.item_type === 'folder') {
|
||||||
folders.push(
|
items.push(
|
||||||
// FIXME: better to grab the real values
|
/** @type {FolderItem} */ ({
|
||||||
/** @type {FolderItem} */ {
|
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
parent_id: item.parent_id || '',
|
parent_id: item.parent_id || '',
|
||||||
@@ -217,12 +214,11 @@ const favorites = {
|
|||||||
icon_special_class: item.icon_special_class,
|
icon_special_class: item.icon_special_class,
|
||||||
owner_id: item.owner_id ?? '',
|
owner_id: item.owner_id ?? '',
|
||||||
is_root: false
|
is_root: false
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
files.push(
|
items.push(
|
||||||
// FIXME: better to grab the real values
|
/** @type {FileItem} */ ({
|
||||||
/** @type {FileItem} */ {
|
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
folder_id: item.parent_id || '',
|
folder_id: item.parent_id || '',
|
||||||
@@ -237,17 +233,57 @@ const favorites = {
|
|||||||
owner_id: item.owner_id ?? '',
|
owner_id: item.owner_id ?? '',
|
||||||
created_at: item.created_at,
|
created_at: item.created_at,
|
||||||
sort_date: item.created_at
|
sort_date: item.created_at
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (folders.length) ui.renderFolders(folders);
|
|
||||||
if (files.length) ui.renderFiles(files);
|
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) pathTooltip.init(filesList);
|
if (filesList) {
|
||||||
|
if (!this._component) {
|
||||||
|
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||||
|
selectable: true,
|
||||||
|
showFavorite: true,
|
||||||
|
showOwner: true,
|
||||||
|
showShareBadge: true,
|
||||||
|
draggable: false,
|
||||||
|
showContextMenu: true,
|
||||||
|
itemModifierClass: 'favorite-item',
|
||||||
|
isFavorite: (id, type) => this.isFavorite(id, type),
|
||||||
|
onOpen: (item) => ui.openItem(item),
|
||||||
|
onFavoriteToggle: async (item) => {
|
||||||
|
const isFile = 'mime_type' in item;
|
||||||
|
const type = isFile ? 'file' : 'folder';
|
||||||
|
if (this.isFavorite(item.id, type)) {
|
||||||
|
await this.removeFromFavorites(item.id, type);
|
||||||
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
|
} else {
|
||||||
|
await this.addToFavorites(item.id, item.name, type, null);
|
||||||
|
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||||
|
onSelectionChange: (selectedItems) => {
|
||||||
|
batchToolbar._selected.clear();
|
||||||
|
for (const sel of selectedItems) {
|
||||||
|
const isFile = 'mime_type' in sel;
|
||||||
|
batchToolbar._selected.set(sel.id, {
|
||||||
|
id: sel.id,
|
||||||
|
name: sel.name,
|
||||||
|
type: isFile ? 'file' : 'folder',
|
||||||
|
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchToolbar._syncUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchToolbar.setActiveComponent(this._component);
|
||||||
|
this._component.render(items);
|
||||||
|
pathTooltip.init(filesList);
|
||||||
|
}
|
||||||
|
|
||||||
await ui.resolveOwnerCells();
|
await this._component?.resolveOwnerCells();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error displaying favorites:', error);
|
console.error('Error displaying favorites:', error);
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { multiSelect } from '../files/multiSelect.js';
|
import { batchToolbar } from '../files/batchToolbar.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
import * as pathTooltip from '../pathTooltip.js';
|
||||||
|
|
||||||
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
|
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
|
||||||
@@ -18,6 +19,9 @@ const recent = {
|
|||||||
/** Maximum items to request from the server */
|
/** Maximum items to request from the server */
|
||||||
MAX_RECENT_FILES: 20,
|
MAX_RECENT_FILES: 20,
|
||||||
|
|
||||||
|
/** @type {ResourceListComponent|null} */
|
||||||
|
_component: null,
|
||||||
|
|
||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
@@ -97,24 +101,25 @@ const recent = {
|
|||||||
|
|
||||||
const recentItems = /** @type {RecentItem[]} */ (await response.json());
|
const recentItems = /** @type {RecentItem[]} */ (await response.json());
|
||||||
|
|
||||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
// resetFilesList injects the standard list-header with the
|
||||||
|
// Modified column label; we swap the last header cell to "Accessed".
|
||||||
|
ui.resetFilesList();
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
|
if (filesList) {
|
||||||
filesList.innerHTML = `
|
// Relabel the date column header from "Modified" → "Accessed"
|
||||||
<div class="list-header">
|
const dateHeader = /** @type {HTMLElement|null} */ (
|
||||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
[...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified')
|
||||||
<div data-i18n="files.name">Name</div>
|
);
|
||||||
<div data-i18n="files.type">Type</div>
|
if (dateHeader) {
|
||||||
<div data-i18n="files.size">Size</div>
|
dateHeader.removeAttribute('data-i18n');
|
||||||
<div data-i18n="recent.accessed">Accessed</div>
|
dateHeader.setAttribute('data-i18n', 'recent.accessed');
|
||||||
<div></div>
|
dateHeader.textContent = i18n.t('recent.accessed', 'Accessed');
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (multiSelect) {
|
|
||||||
multiSelect.clear();
|
|
||||||
multiSelect.init(); // this will wire buttons & select-all-checkbox
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batchToolbar.clear();
|
||||||
|
batchToolbar.init();
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
|
|
||||||
if (recentItems.length === 0) {
|
if (recentItems.length === 0) {
|
||||||
@@ -123,37 +128,37 @@ const recent = {
|
|||||||
<p>${i18n.t('recent.empty_state')}</p>
|
<p>${i18n.t('recent.empty_state')}</p>
|
||||||
<p>${i18n.t('recent.empty_hint')}</p>
|
<p>${i18n.t('recent.empty_hint')}</p>
|
||||||
`);
|
`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {FolderItem[]} */
|
/** @type {Array<FileItem|FolderItem>} */
|
||||||
const folders = [];
|
const items = [];
|
||||||
|
|
||||||
/** @type {FileItem[]} */
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const item of recentItems) {
|
for (const item of recentItems) {
|
||||||
const isFolder = item.item_type === 'folder';
|
const isFolder = item.item_type === 'folder';
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
folders.push({
|
items.push(
|
||||||
|
/** @type {FolderItem} */ ({
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
parent_id: item.parent_id || '',
|
parent_id: item.parent_id || '',
|
||||||
modified_at: item.accessed_at,
|
modified_at: item.accessed_at,
|
||||||
path: item.item_path || '',
|
path: item.item_path || '',
|
||||||
category: 'folder',
|
category: 'folder',
|
||||||
created_at: item.accessed_at, //Wrong information
|
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||||
icon_class: item.icon_class,
|
icon_class: item.icon_class,
|
||||||
icon_special_class: item.icon_special_class,
|
icon_special_class: item.icon_special_class,
|
||||||
owner_id: '',
|
owner_id: '',
|
||||||
is_root: false
|
is_root: false
|
||||||
});
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
if (item.item_mime_type === undefined || item.item_mime_type === null) {
|
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 ?
|
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
|
||||||
console.warn('Broken information for RecentItem: ', item);
|
console.warn('Broken information for RecentItem: ', item);
|
||||||
//continue;
|
|
||||||
}
|
}
|
||||||
files.push({
|
items.push(
|
||||||
|
/** @type {FileItem} */ ({
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
folder_id: item.parent_id || '',
|
folder_id: item.parent_id || '',
|
||||||
@@ -166,14 +171,45 @@ const recent = {
|
|||||||
modified_at: item.accessed_at,
|
modified_at: item.accessed_at,
|
||||||
path: item.item_path || '',
|
path: item.item_path || '',
|
||||||
owner_id: '',
|
owner_id: '',
|
||||||
created_at: item.accessed_at, //wrong information
|
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||||
sort_date: item.accessed_at
|
sort_date: item.accessed_at
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesList) {
|
||||||
|
if (!this._component) {
|
||||||
|
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||||
|
selectable: true,
|
||||||
|
showFavorite: true,
|
||||||
|
showOwner: false,
|
||||||
|
showShareBadge: false,
|
||||||
|
draggable: false,
|
||||||
|
showContextMenu: true,
|
||||||
|
itemModifierClass: 'recent-item',
|
||||||
|
dateField: 'modified_at', // mapped from accessed_at above
|
||||||
|
onOpen: (item) => ui.openItem(item),
|
||||||
|
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||||
|
onSelectionChange: (selectedItems) => {
|
||||||
|
batchToolbar._selected.clear();
|
||||||
|
for (const sel of selectedItems) {
|
||||||
|
const isFile = 'mime_type' in sel;
|
||||||
|
batchToolbar._selected.set(sel.id, {
|
||||||
|
id: sel.id,
|
||||||
|
name: sel.name,
|
||||||
|
type: isFile ? 'file' : 'folder',
|
||||||
|
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
batchToolbar._syncUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchToolbar.setActiveComponent(this._component);
|
||||||
|
this._component.render(items);
|
||||||
|
pathTooltip.init(filesList);
|
||||||
}
|
}
|
||||||
if (folders.length) ui.renderFolders(folders);
|
|
||||||
if (files.length) ui.renderFiles(files);
|
|
||||||
if (filesList) pathTooltip.init(filesList);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error displaying recent files:', error);
|
console.error('Error displaying recent files:', error);
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
|
|||||||
@@ -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<FolderItem>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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<string,string>} */ (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 };
|
||||||
@@ -88,14 +88,16 @@ const grants = {
|
|||||||
* @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']).
|
* @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']).
|
||||||
* @param {number} [opts.limit] - Max items per page (1–200, default 50).
|
* @param {number} [opts.limit] - Max items per page (1–200, default 50).
|
||||||
* @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page.
|
* @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page.
|
||||||
|
* @param {string} [opts.orderBy] - Sort dimension: 'granted_at' | 'granted_by' (default: 'granted_at').
|
||||||
* @returns {Promise<SharedWithMeResponse>}
|
* @returns {Promise<SharedWithMeResponse>}
|
||||||
*/
|
*/
|
||||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) {
|
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
resource_types: resourceTypes.join(',')
|
resource_types: resourceTypes.join(',')
|
||||||
});
|
});
|
||||||
if (cursor) params.set('cursor', cursor);
|
if (cursor) params.set('cursor', cursor);
|
||||||
|
if (orderBy) params.set('sort_by', orderBy);
|
||||||
|
|
||||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ function prefetch() {
|
|||||||
_ensureIndex(); // intentionally fire-and-forget
|
_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.
|
* Resolve a user UUID to a display name.
|
||||||
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
||||||
@@ -159,4 +172,4 @@ function isAvailable() {
|
|||||||
return addressBook.isSystemAvailable();
|
return addressBook.isSystemAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
export const systemUsers = { prefetch, getDisplayName, getDisplayNameSync, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
||||||
|
|||||||
@@ -5,24 +5,124 @@
|
|||||||
* current user access to, using the cursor-paginated
|
* current user access to, using the cursor-paginated
|
||||||
* `GET /api/grants/incoming/resources` endpoint.
|
* `GET /api/grants/incoming/resources` endpoint.
|
||||||
*
|
*
|
||||||
* Reuses the existing `#files-list` container and `ui.renderFolders` /
|
* Uses `ResourceListComponent` so the grid ↔ list toggle and all card
|
||||||
* `ui.renderFiles` so the grid ↔ list toggle and all card components work
|
* components work out of the box. A "Load more" button is injected below
|
||||||
* out of the box. A "Load more" button is injected below the files container
|
* the files container for cursor-based pagination.
|
||||||
* for cursor-based pagination.
|
|
||||||
*
|
|
||||||
* NOTE: the grid/list container will be extracted into a reusable component
|
|
||||||
* in a future refactor — this view is intentionally kept thin.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
|
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { 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 { ownerTooltip } from '../../features/ownerTooltip.js';
|
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
||||||
import { grants } from '../../model/grants.js';
|
import { grants } from '../../model/grants.js';
|
||||||
import { systemUsers } from '../../model/systemUsers.js';
|
import { systemUsers } from '../../model/systemUsers.js';
|
||||||
|
|
||||||
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.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<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
|
||||||
|
labelFn: (key) => {
|
||||||
|
// biome-ignore format: keep indentation
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const labels = {
|
||||||
|
Folder: i18n.t('groupby.type.folders', 'Folders'),
|
||||||
|
Image: i18n.t('category.images', 'Images'),
|
||||||
|
Video: i18n.t('category.videos', 'Videos'),
|
||||||
|
Audio: i18n.t('category.audio', 'Audio'),
|
||||||
|
PDF: 'PDF',
|
||||||
|
Document: i18n.t('category.documents', 'Documents'),
|
||||||
|
Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'),
|
||||||
|
Presentation: i18n.t('category.presentations', 'Presentations'),
|
||||||
|
Archive: i18n.t('category.archives', 'Archives'),
|
||||||
|
Code: i18n.t('category.code', 'Code'),
|
||||||
|
Markdown: i18n.t('category.markdown', 'Markdown'),
|
||||||
|
Text: i18n.t('category.text', 'Text'),
|
||||||
|
Installer: i18n.t('category.installers', 'Installers')
|
||||||
|
};
|
||||||
|
return labels[key] ?? key;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'owner',
|
||||||
|
// label is accessed via syncGroupByMenu → read at section-switch time,
|
||||||
|
// when translations are guaranteed to be loaded.
|
||||||
|
get label() {
|
||||||
|
return i18n.t('groupby.owner', 'Owner');
|
||||||
|
},
|
||||||
|
orderBy: 'granted_by',
|
||||||
|
// keyFn groups by UUID — stable and unique, avoids collisions between
|
||||||
|
// users with the same display name.
|
||||||
|
keyFn: (item) => {
|
||||||
|
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
|
||||||
|
return r.owner_id || null;
|
||||||
|
},
|
||||||
|
// labelFn resolves UUID → display name from the pre-fetched cache.
|
||||||
|
labelFn: (id) => systemUsers.getDisplayNameSync(id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'size',
|
||||||
|
get label() {
|
||||||
|
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<string,number>} */ (/** @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<string,number>} */ (/** @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`. */
|
/** ID of the "Load more" wrapper injected below `.files-container`. */
|
||||||
const LOAD_MORE_ID = 'swm-load-more-wrapper';
|
const LOAD_MORE_ID = 'swm-load-more-wrapper';
|
||||||
|
|
||||||
@@ -34,8 +134,39 @@ const sharedWithMeView = {
|
|||||||
|
|
||||||
_loading: false,
|
_loading: false,
|
||||||
|
|
||||||
|
/** @type {ResourceListComponent|null} */
|
||||||
|
_component: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
_groupBy: '',
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── 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.
|
* (Re-)load from page 1 and render into the existing files container.
|
||||||
* Called every time the user switches to this section.
|
* Called every time the user switches to this section.
|
||||||
@@ -43,6 +174,7 @@ const sharedWithMeView = {
|
|||||||
async init() {
|
async init() {
|
||||||
this._nextCursor = null;
|
this._nextCursor = null;
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
|
this._groupBy = '';
|
||||||
|
|
||||||
this._ensureLoadMoreButton();
|
this._ensureLoadMoreButton();
|
||||||
|
|
||||||
@@ -50,11 +182,55 @@ const sharedWithMeView = {
|
|||||||
// by the time the user hovers over an item.
|
// by the time the user hovers over an item.
|
||||||
systemUsers.prefetch();
|
systemUsers.prefetch();
|
||||||
|
|
||||||
// Standard files-view setup: clear list, show container, init multiselect
|
// Standard files-view setup: clear list, show container
|
||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
multiSelect.init();
|
batchToolbar.init();
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
|
|
||||||
|
// Create (or re-use) the component bound to #files-list.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchToolbar.setActiveComponent(this._component);
|
||||||
|
}
|
||||||
|
|
||||||
await this._loadPage();
|
await this._loadPage();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -66,6 +242,8 @@ const sharedWithMeView = {
|
|||||||
const w = document.getElementById(LOAD_MORE_ID);
|
const w = document.getElementById(LOAD_MORE_ID);
|
||||||
if (w) w.classList.add('hidden');
|
if (w) w.classList.add('hidden');
|
||||||
|
|
||||||
|
batchToolbar.setActiveComponent(null);
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) ownerTooltip.destroy(filesList);
|
if (filesList) ownerTooltip.destroy(filesList);
|
||||||
},
|
},
|
||||||
@@ -74,23 +252,35 @@ const sharedWithMeView = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch one page, map items → FileItem / FolderItem, render them, then
|
* Fetch one page, map items → FileItem / FolderItem, render them, then
|
||||||
* stamp `data-owner-id` and wire the owner tooltip.
|
* wire the owner tooltip.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async _loadPage() {
|
async _loadPage() {
|
||||||
if (this._loading) return;
|
if (this._loading) return;
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
|
|
||||||
|
// Remember whether this is a fresh first-page load (cursor was null on
|
||||||
|
// entry) so we know whether to replace or append items.
|
||||||
|
const isFirstPage = this._nextCursor === null;
|
||||||
|
|
||||||
try {
|
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({
|
const data = await grants.fetchSharedWithMe({
|
||||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||||
limit: 50,
|
limit: 50,
|
||||||
cursor: this._nextCursor ?? undefined
|
cursor: this._nextCursor ?? undefined,
|
||||||
|
orderBy
|
||||||
});
|
});
|
||||||
|
|
||||||
this._nextCursor = data.next_cursor ?? null;
|
this._nextCursor = data.next_cursor ?? null;
|
||||||
|
|
||||||
if (data.items.length === 0 && !this._nextCursor) {
|
if (data.items.length === 0 && isFirstPage) {
|
||||||
// First page came back empty
|
// First page came back empty
|
||||||
ui.showError(`
|
ui.showError(`
|
||||||
<i class="fas fa-share-alt empty-state-icon"></i>
|
<i class="fas fa-share-alt empty-state-icon"></i>
|
||||||
@@ -101,19 +291,20 @@ const sharedWithMeView = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { folders, files, ownerMap } = this._mapItems(data.items);
|
const items = this._mapItems(data.items);
|
||||||
if (folders.length) ui.renderFolders(folders);
|
|
||||||
if (files.length) ui.renderFiles(files);
|
|
||||||
|
|
||||||
// Stamp data-owner-id on the freshly-rendered cards and attach tooltips.
|
if (isFirstPage) {
|
||||||
const filesList = document.getElementById('files-list');
|
this._component?.render(items, def?.keyFn, def?.labelFn);
|
||||||
if (filesList) {
|
} else {
|
||||||
this._stampOwnerIds(filesList, ownerMap);
|
this._component?.append(items, def?.keyFn, def?.labelFn);
|
||||||
ownerTooltip.init(filesList);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire owner tooltips after items are in the DOM
|
||||||
|
const filesList = document.getElementById('files-list');
|
||||||
|
if (filesList) ownerTooltip.init(filesList);
|
||||||
|
|
||||||
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
||||||
await ui.resolveOwnerCells();
|
await this._component?.resolveOwnerCells();
|
||||||
|
|
||||||
this._setLoadMoreVisible(!!this._nextCursor);
|
this._setLoadMoreVisible(!!this._nextCursor);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -128,87 +319,70 @@ const sharedWithMeView = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map `SharedWithMeItem[]` to separate arrays for rendering plus an
|
* Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in
|
||||||
* `ownerMap` (itemId → grantedBy userId) used to stamp `data-owner-id`
|
* **server-returned order**. The order must be preserved so that
|
||||||
* after the cards are in the DOM.
|
* swimlane grouping (group by owner / share date) works correctly when
|
||||||
|
* the server interleaves files and folders by the sort key.
|
||||||
*
|
*
|
||||||
* The backend already includes all display fields (`icon_class`,
|
* Sets `owner_id` to `item.granted_by` so the component stamps
|
||||||
* `icon_special_class`, `category`, `size_formatted`) inside the nested
|
* `data-owner-id` with the granter's user ID automatically.
|
||||||
* `file` / `folder` objects, so no client-side enrichment is needed.
|
* 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
|
* @param {SharedWithMeItem[]} items
|
||||||
* @returns {{ folders: FolderItem[], files: FileItem[], ownerMap: Map<string,string> }}
|
* @returns {Array<FileItem|FolderItem>}
|
||||||
*/
|
*/
|
||||||
_mapItems(items) {
|
_mapItems(items) {
|
||||||
/** @type {FolderItem[]} */
|
/** @type {Array<FileItem|FolderItem>} */
|
||||||
const folders = [];
|
const result = [];
|
||||||
|
|
||||||
/** @type {FileItem[]} */
|
/** @param {string} iso @returns {number} */
|
||||||
const files = [];
|
const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
|
||||||
|
|
||||||
/** @type {Map<string, string>} itemId → grantedBy userId */
|
|
||||||
const ownerMap = new Map();
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.resource_type === 'folder' && item.folder) {
|
if (item.resource_type === 'folder') {
|
||||||
const f = item.folder;
|
const f = /** @type {FolderItem} */ (item.resource);
|
||||||
folders.push(
|
result.push(
|
||||||
/** @type {FolderItem} */ ({
|
/** @type {FolderItem} */ ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
path: f.path ?? '',
|
path: f.path ?? '',
|
||||||
parent_id: f.parent_id ?? '',
|
parent_id: f.parent_id ?? '',
|
||||||
owner_id: f.owner_id ?? '',
|
owner_id: item.granted_by,
|
||||||
is_root: f.is_root ?? false,
|
is_root: f.is_root ?? false,
|
||||||
created_at: f.created_at,
|
created_at: f.created_at,
|
||||||
modified_at: f.modified_at,
|
modified_at: f.modified_at,
|
||||||
|
sort_date: grantedAtSecs(item.granted_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: 'folder'
|
category: 'folder'
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
ownerMap.set(f.id, item.granted_by);
|
} else if (item.resource_type === 'file') {
|
||||||
} else if (item.resource_type === 'file' && item.file) {
|
const f = /** @type {FileItem} */ (item.resource);
|
||||||
const f = item.file;
|
result.push(
|
||||||
files.push(
|
|
||||||
/** @type {FileItem} */ ({
|
/** @type {FileItem} */ ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
path: f.path ?? '',
|
path: f.path ?? '',
|
||||||
folder_id: f.folder_id ?? '',
|
folder_id: f.folder_id ?? '',
|
||||||
owner_id: f.owner_id ?? '',
|
owner_id: item.granted_by,
|
||||||
mime_type: f.mime_type,
|
mime_type: f.mime_type,
|
||||||
size: f.size,
|
size: f.size,
|
||||||
size_formatted: f.size_formatted,
|
size_formatted: f.size_formatted,
|
||||||
created_at: f.created_at,
|
created_at: f.created_at,
|
||||||
modified_at: f.modified_at,
|
modified_at: f.modified_at,
|
||||||
sort_date: f.modified_at,
|
sort_date: grantedAtSecs(item.granted_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: f.category
|
category: f.category
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
ownerMap.set(f.id, item.granted_by);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { folders, files, ownerMap };
|
return result;
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk `ownerMap` and set `data-owner-id` on matching `.file-item` cards
|
|
||||||
* inside `container`. Must be called after `renderFolders`/`renderFiles`.
|
|
||||||
*
|
|
||||||
* @param {HTMLElement} container
|
|
||||||
* @param {Map<string,string>} ownerMap itemId → grantedBy userId
|
|
||||||
*/
|
|
||||||
_stampOwnerIds(container, ownerMap) {
|
|
||||||
for (const [itemId, ownerId] of ownerMap) {
|
|
||||||
const el = container.querySelector(`[data-folder-id="${itemId}"], [data-file-id="${itemId}"]`);
|
|
||||||
if (el instanceof HTMLElement) {
|
|
||||||
el.dataset.ownerId = ownerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── "Load more" button ────────────────────────────────────────────────────
|
// ── "Load more" button ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "مشترك من قِبل",
|
"colSharedBy": "مشترك من قِبل",
|
||||||
"colDate": "تاريخ المشاركة",
|
"colDate": "تاريخ المشاركة",
|
||||||
"colPermissions": "الصلاحيات"
|
"colPermissions": "الصلاحيات"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "لا شيء",
|
||||||
|
"title": "التجميع حسب",
|
||||||
|
"owner": "المالك",
|
||||||
|
"shareDate": "تاريخ المشاركة"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "اليوم",
|
||||||
|
"last7days": "آخر 7 أيام",
|
||||||
|
"last30days": "آخر 30 يومًا"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Geteilt von",
|
"colSharedBy": "Geteilt von",
|
||||||
"colDate": "Datum der Freigabe",
|
"colDate": "Datum der Freigabe",
|
||||||
"colPermissions": "Berechtigungen"
|
"colPermissions": "Berechtigungen"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "Keine",
|
||||||
|
"title": "Gruppieren nach",
|
||||||
|
"owner": "Eigentümer",
|
||||||
|
"shareDate": "Freigabedatum"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "Heute",
|
||||||
|
"last7days": "Letzte 7 Tage",
|
||||||
|
"last30days": "Letzte 30 Tage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Shared by",
|
"colSharedBy": "Shared by",
|
||||||
"colDate": "Date shared",
|
"colDate": "Date shared",
|
||||||
"colPermissions": "Permissions"
|
"colPermissions": "Permissions"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "None",
|
||||||
|
"title": "Group by",
|
||||||
|
"owner": "Owner",
|
||||||
|
"shareDate": "Share date"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "Today",
|
||||||
|
"last7days": "Last 7 days",
|
||||||
|
"last30days": "Last 30 days"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Compartido por",
|
"colSharedBy": "Compartido por",
|
||||||
"colDate": "Fecha de compartición",
|
"colDate": "Fecha de compartición",
|
||||||
"colPermissions": "Permisos"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "به اشتراکگذاشته توسط",
|
"colSharedBy": "به اشتراکگذاشته توسط",
|
||||||
"colDate": "تاریخ اشتراکگذاری",
|
"colDate": "تاریخ اشتراکگذاری",
|
||||||
"colPermissions": "مجوزها"
|
"colPermissions": "مجوزها"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "هیچ",
|
||||||
|
"title": "گروهبندی بر اساس",
|
||||||
|
"owner": "مالک",
|
||||||
|
"shareDate": "تاریخ اشتراک"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "امروز",
|
||||||
|
"last7days": "۷ روز گذشته",
|
||||||
|
"last30days": "۳۰ روز گذشته"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Partagé par",
|
"colSharedBy": "Partagé par",
|
||||||
"colDate": "Date de partage",
|
"colDate": "Date de partage",
|
||||||
"colPermissions": "Permissions"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "द्वारा साझा किया",
|
"colSharedBy": "द्वारा साझा किया",
|
||||||
"colDate": "साझाकरण तिथि",
|
"colDate": "साझाकरण तिथि",
|
||||||
"colPermissions": "अनुमतियाँ"
|
"colPermissions": "अनुमतियाँ"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "कोई नहीं",
|
||||||
|
"title": "इसके अनुसार समूहीकृत करें",
|
||||||
|
"owner": "स्वामी",
|
||||||
|
"shareDate": "साझा तिथि"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "आज",
|
||||||
|
"last7days": "पिछले 7 दिन",
|
||||||
|
"last30days": "पिछले 30 दिन"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Condiviso da",
|
"colSharedBy": "Condiviso da",
|
||||||
"colDate": "Data condivisione",
|
"colDate": "Data condivisione",
|
||||||
"colPermissions": "Permessi"
|
"colPermissions": "Permessi"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "Nessuno",
|
||||||
|
"title": "Raggruppa per",
|
||||||
|
"owner": "Proprietario",
|
||||||
|
"shareDate": "Data condivisione"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "Oggi",
|
||||||
|
"last7days": "Ultimi 7 giorni",
|
||||||
|
"last30days": "Ultimi 30 giorni"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "共有者",
|
"colSharedBy": "共有者",
|
||||||
"colDate": "共有日",
|
"colDate": "共有日",
|
||||||
"colPermissions": "権限"
|
"colPermissions": "権限"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "なし",
|
||||||
|
"title": "グループ化",
|
||||||
|
"owner": "オーナー",
|
||||||
|
"shareDate": "共有日"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "今日",
|
||||||
|
"last7days": "過去7日間",
|
||||||
|
"last30days": "過去30日間"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "공유한 사람",
|
"colSharedBy": "공유한 사람",
|
||||||
"colDate": "공유 날짜",
|
"colDate": "공유 날짜",
|
||||||
"colPermissions": "권한"
|
"colPermissions": "권한"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "없음",
|
||||||
|
"title": "그룹화 기준",
|
||||||
|
"owner": "소유자",
|
||||||
|
"shareDate": "공유 날짜"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "오늘",
|
||||||
|
"last7days": "최근 7일",
|
||||||
|
"last30days": "최근 30일"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Gedeeld door",
|
"colSharedBy": "Gedeeld door",
|
||||||
"colDate": "Datum gedeeld",
|
"colDate": "Datum gedeeld",
|
||||||
"colPermissions": "Machtigingen"
|
"colPermissions": "Machtigingen"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "Geen",
|
||||||
|
"title": "Groeperen op",
|
||||||
|
"owner": "Eigenaar",
|
||||||
|
"shareDate": "Deeldatum"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "Vandaag",
|
||||||
|
"last7days": "Afgelopen 7 dagen",
|
||||||
|
"last30days": "Afgelopen 30 dagen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Udostępnione przez",
|
"colSharedBy": "Udostępnione przez",
|
||||||
"colDate": "Data udostępnienia",
|
"colDate": "Data udostępnienia",
|
||||||
"colPermissions": "Uprawnienia"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Compartilhado por",
|
"colSharedBy": "Compartilhado por",
|
||||||
"colDate": "Data de compartilhamento",
|
"colDate": "Data de compartilhamento",
|
||||||
"colPermissions": "Permissões"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Предоставлено",
|
"colSharedBy": "Предоставлено",
|
||||||
"colDate": "Дата предоставления",
|
"colDate": "Дата предоставления",
|
||||||
"colPermissions": "Права"
|
"colPermissions": "Права"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "Нет",
|
||||||
|
"title": "Группировать по",
|
||||||
|
"owner": "Владелец",
|
||||||
|
"shareDate": "Дата общего доступа"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "Сегодня",
|
||||||
|
"last7days": "Последние 7 дней",
|
||||||
|
"last30days": "Последние 30 дней"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "共享者",
|
"colSharedBy": "共享者",
|
||||||
"colDate": "共享日期",
|
"colDate": "共享日期",
|
||||||
"colPermissions": "權限"
|
"colPermissions": "權限"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "無",
|
||||||
|
"title": "分組方式",
|
||||||
|
"owner": "擁有者",
|
||||||
|
"shareDate": "分享日期"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "今天",
|
||||||
|
"last7days": "近7天",
|
||||||
|
"last30days": "近30天"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "共享者",
|
"colSharedBy": "共享者",
|
||||||
"colDate": "共享日期",
|
"colDate": "共享日期",
|
||||||
"colPermissions": "权限"
|
"colPermissions": "权限"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "无",
|
||||||
|
"title": "分组方式",
|
||||||
|
"owner": "所有者",
|
||||||
|
"shareDate": "分享日期"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "今天",
|
||||||
|
"last7days": "近7天",
|
||||||
|
"last30days": "近30天"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import { goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers';
|
import { test, goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers';
|
||||||
|
|
||||||
test('has OxiCloud title', async ({ page }) => {
|
test('has OxiCloud title', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { test, expect, Page } from '@playwright/test';
|
import { expect, Page } from '@playwright/test';
|
||||||
import { loginAsAdmin } from './helpers';
|
import { test, loginAsAdmin } from './helpers';
|
||||||
|
|
||||||
const FIXTURES = path.join(__dirname, '../../fixtures');
|
const FIXTURES = path.join(__dirname, '../../fixtures');
|
||||||
|
|
||||||
|
|||||||
@@ -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<object>({
|
||||||
|
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 = {
|
export const TEST_ADMIN = {
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
@@ -38,7 +59,11 @@ export async function goToLoginPage(page: Page) {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Both panels start with .hidden — wait for JS to reveal one.
|
// 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()) {
|
if (await page.locator('#language-panel').isVisible()) {
|
||||||
await page.locator('#language-continue').click();
|
await page.locator('#language-continue').click();
|
||||||
|
|||||||
Reference in New Issue
Block a user