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 ") {
|
||||
skipping = true; // multi-line import
|
||||
}
|
||||
let aliases = collect_import_aliases(t);
|
||||
let aliases = collect_import_aliases(t, declared_namespaces);
|
||||
if aliases.is_empty() {
|
||||
out.push('\n');
|
||||
} else {
|
||||
@@ -628,7 +628,9 @@ fn try_strip_export_prefix(line: &str) -> Option<String> {
|
||||
|
||||
/// For `import { A, B as C, D as E } from '…'` return `"const C = B;\nconst E = D;"`.
|
||||
/// Returns an empty string when there are no aliases.
|
||||
fn collect_import_aliases(stmt: &str) -> String {
|
||||
/// Aliases already present in `declared` are skipped; newly emitted aliases are
|
||||
/// inserted into `declared` so that subsequent files don't re-declare them.
|
||||
fn collect_import_aliases(stmt: &str, declared: &mut std::collections::HashSet<String>) -> String {
|
||||
let brace_start = match stmt.find('{') {
|
||||
Some(i) => i + 1,
|
||||
None => return String::new(),
|
||||
@@ -645,6 +647,12 @@ fn collect_import_aliases(stmt: &str) -> String {
|
||||
if let Some(as_pos) = b.find(" as ") {
|
||||
let orig = b[..as_pos].trim();
|
||||
let alias = b[as_pos + 4..].trim();
|
||||
// Already declared earlier in the bundle — skip to avoid
|
||||
// `SyntaxError: Identifier already declared`.
|
||||
if declared.contains(alias) {
|
||||
continue;
|
||||
}
|
||||
declared.insert(alias.to_string());
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
@@ -96,10 +96,12 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Internal Architecture", link: "/architecture/" },
|
||||
{ text: "Caching", link: "/architecture/caching" },
|
||||
{ text: "Resource Listing API", link: "/architecture/resource-listing" },
|
||||
{ text: "Storage Safety", link: "/architecture/file-system-safety" },
|
||||
{ text: "Database Transactions", link: "/architecture/database-transactions" },
|
||||
{ text: "Share Integration", link: "/architecture/share-integration" },
|
||||
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
|
||||
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
|
||||
],
|
||||
},
|
||||
{ text: "FAQ", link: "/faq" },
|
||||
|
||||
@@ -71,4 +71,5 @@ src/
|
||||
## Further Reading
|
||||
|
||||
- [Caching Architecture →](/architecture/caching)
|
||||
- [Resource Listing API →](/architecture/resource-listing)
|
||||
- [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"
|
||||
}
|
||||
|
||||
/// Returns the sort order for a file category, stored as `category_order` in `storage.files`.
|
||||
///
|
||||
/// Values are **sparse multiples of 100** so a future category can be slotted between two
|
||||
/// existing ones (e.g. "RichText" = 550, between Document=500 and Spreadsheet=600) without
|
||||
/// renumbering any rows. Folders are not handled here — the SQL query hard-codes 0 for them.
|
||||
///
|
||||
/// This function delegates to [`category_for`] so the two are always in sync.
|
||||
pub fn category_order_for(name: &str, mime: &str) -> i16 {
|
||||
match category_for(name, mime) {
|
||||
"Image" => 100,
|
||||
"Video" => 200,
|
||||
"Audio" => 300,
|
||||
"PDF" => 400,
|
||||
"Document" => 500,
|
||||
"Spreadsheet" => 600,
|
||||
"Presentation" => 700,
|
||||
"Archive" => 800,
|
||||
"Code" => 900,
|
||||
"Markdown" => 1000,
|
||||
"Text" => 1100,
|
||||
"Installer" => 1200,
|
||||
_ => 9999, // Other / unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a byte count into a human-readable string (1024-based).
|
||||
///
|
||||
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||
@@ -232,26 +233,58 @@ impl From<Grant> for GrantDto {
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Query parameters for `GET /api/grants/incoming/resources`.
|
||||
///
|
||||
/// `limit`, `cursor`, and `sort_by` follow the standard [`CursorQuery`]
|
||||
/// contract. They are declared directly here rather than via
|
||||
/// `#[serde(flatten)]` because `serde_urlencoded` (Axum's query extractor)
|
||||
/// does not support flattening.
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct SharedWithMeQuery {
|
||||
/// Maximum number of items to return (1–200, default 50).
|
||||
#[serde(default = "shared_with_me_default_limit")]
|
||||
#[serde(default = "CursorQuery::default_limit")]
|
||||
pub limit: u32,
|
||||
/// Opaque cursor from a previous response. Omit to start from the
|
||||
/// most-recently-granted item.
|
||||
pub cursor: Option<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`.
|
||||
/// Omit to return all known types.
|
||||
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 {
|
||||
50
|
||||
impl SharedWithMeQuery {
|
||||
/// Returns `limit` clamped to `[1, 200]`.
|
||||
pub fn limit_clamped(&self) -> usize {
|
||||
self.limit.clamp(1, 200) as usize
|
||||
}
|
||||
|
||||
/// Decode the optional cursor string. Invalid cursor → start from top.
|
||||
pub fn decode_cursor<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
|
||||
/// populated, indicated by `resource_type`. Additional optional fields for
|
||||
/// future resource types (playlist, addressbook, …) will be added here.
|
||||
/// The resource payload for one item in the shared-with-me list.
|
||||
///
|
||||
/// The variant is discriminated by `resource_type` on the parent
|
||||
/// [`SharedWithMeItemDto`]. Serialised as the inner object (no wrapper key)
|
||||
/// via `#[serde(untagged)]`, so consumers see the file/folder fields directly
|
||||
/// under the `resource` key.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResourceContentDto {
|
||||
File(FileDto),
|
||||
Folder(FolderDto),
|
||||
}
|
||||
|
||||
/// One item in the shared-with-me list.
|
||||
///
|
||||
/// `resource_type` indicates whether `resource` contains a file or a folder.
|
||||
/// Using a single `resource` field (instead of nullable `file`/`folder` pairs)
|
||||
/// makes adding new resource types backward-compatible — only `resource_type`
|
||||
/// gains a new variant; the wrapper shape stays the same.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SharedWithMeItemDto {
|
||||
pub resource_type: ResourceTypeDto,
|
||||
@@ -261,17 +294,9 @@ pub struct SharedWithMeItemDto {
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
/// UUID of the user who created the (earliest) grant.
|
||||
pub granted_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file: Option<FileDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub folder: Option<FolderDto>,
|
||||
/// Full resource details. Shape is determined by `resource_type`.
|
||||
pub resource: ResourceContentDto,
|
||||
}
|
||||
|
||||
/// Response for `GET /api/grants/incoming/resources`.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SharedWithMeDto {
|
||||
pub items: Vec<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>,
|
||||
}
|
||||
pub type SharedWithMeDto = CursorListResponse<SharedWithMeItemDto>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod address_book_dto;
|
||||
pub mod cursor;
|
||||
pub use cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
pub mod app_password_dto;
|
||||
pub mod calendar_dto;
|
||||
pub mod contact_dto;
|
||||
|
||||
@@ -85,6 +85,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
kinds: &[ResourceKind],
|
||||
limit: u32,
|
||||
cursor: Option<GrantCursor>,
|
||||
sort_by: &str,
|
||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
||||
|
||||
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||
//! maps them to / from `storage.access_grants` rows.
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -258,28 +258,44 @@ pub struct IncomingGrantSummary {
|
||||
|
||||
/// Encodes the position of the last seen item in a cursor-paginated grant
|
||||
/// listing. The encoding is opaque to API callers — only the backend
|
||||
/// decodes it. Change the encoding algorithm in a major version bump.
|
||||
/// decodes it.
|
||||
///
|
||||
/// The `sort_by` field must match the active sort dimension — if the caller
|
||||
/// switches sort order the handler discards any cursor whose `sort_by` does
|
||||
/// not match, restarting from the first page.
|
||||
///
|
||||
/// Sort-key fields populated per `sort_by` value:
|
||||
/// - `"granted_at"` (default) — uses `granted_at` + `resource_id`
|
||||
/// - `"name"` — uses `resource_name` (lowercased) + `resource_id`
|
||||
/// - `"type"` — uses `type_order` + `resource_name` (lowercased) + `resource_id`
|
||||
/// - `"granted_by"` — uses `resource_name` (owner display name, lowercased) + `granted_at` + `resource_id`
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GrantCursor {
|
||||
/// Sort dimension that was active when this cursor was produced.
|
||||
#[serde(default = "GrantCursor::default_sort")]
|
||||
pub sort_by: String,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
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 {
|
||||
/// Encode as a URL-safe base64 JSON string (no padding).
|
||||
pub fn encode(&self) -> String {
|
||||
let json = serde_json::to_vec(self).unwrap_or_default();
|
||||
URL_SAFE_NO_PAD.encode(&json)
|
||||
}
|
||||
|
||||
/// Decode from a URL-safe base64 JSON string. Returns `None` on any
|
||||
/// parse failure — callers treat a bad cursor as "start from the top".
|
||||
pub fn decode(s: &str) -> Option<Self> {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(s).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
fn default_sort() -> String {
|
||||
"granted_at".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate encode/decode to the shared [`PageCursor`] trait.
|
||||
impl PageCursor for GrantCursor {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::display_helpers::category_order_for;
|
||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
@@ -226,8 +227,8 @@ impl FileBlobWriteRepository {
|
||||
|
||||
let row = match sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
@@ -239,6 +240,7 @@ impl FileBlobWriteRepository {
|
||||
.bind(&blob_hash)
|
||||
.bind(size as i64)
|
||||
.bind(&content_type)
|
||||
.bind(category_order_for(&name, &content_type))
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
@@ -374,18 +376,19 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
>(
|
||||
r#"
|
||||
WITH src AS (
|
||||
SELECT name, folder_id, user_id, blob_hash, size, mime_type
|
||||
SELECT name, folder_id, user_id, blob_hash, size, mime_type, category_order
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
),
|
||||
new_file AS (
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
||||
SELECT name,
|
||||
COALESCE($2::uuid, folder_id),
|
||||
user_id,
|
||||
blob_hash,
|
||||
size,
|
||||
mime_type
|
||||
mime_type,
|
||||
category_order
|
||||
FROM src
|
||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
@@ -537,8 +540,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
@@ -550,6 +553,7 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
.bind(placeholder_hash)
|
||||
.bind(size as i64)
|
||||
.bind(&content_type)
|
||||
.bind(category_order_for(&name, &content_type))
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
||||
|
||||
@@ -299,87 +299,243 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
kinds: &[ResourceKind],
|
||||
limit: u32,
|
||||
cursor: Option<GrantCursor>,
|
||||
sort_by: &str,
|
||||
) -> 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() {
|
||||
None
|
||||
} else {
|
||||
Some(kinds.iter().map(|k| k.as_str()).collect())
|
||||
};
|
||||
|
||||
let cursor_at = cursor.as_ref().map(|c| c.granted_at);
|
||||
let cursor_id = cursor.as_ref().map(|c| c.resource_id);
|
||||
|
||||
// Fetch limit+1 rows so we can detect whether a next page exists.
|
||||
let fetch_limit = (limit as i64) + 1;
|
||||
|
||||
// Each row: (resource_type, resource_id, permissions_text_array,
|
||||
// granted_at, granted_by)
|
||||
// Unified row type — the last two columns carry the sort key when present,
|
||||
// NULL otherwise. This lets every sort mode share a single query_as call.
|
||||
// 0 resource_type String
|
||||
// 1 resource_id Uuid
|
||||
// 2 permissions Vec<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 = (
|
||||
String,
|
||||
Uuid,
|
||||
Vec<String>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Uuid,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = sqlx::query_as(
|
||||
r#"
|
||||
WITH agg AS (
|
||||
SELECT
|
||||
resource_type,
|
||||
resource_id,
|
||||
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
|
||||
MIN(granted_at) AS granted_at,
|
||||
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
|
||||
FROM storage.access_grants
|
||||
WHERE subject_type = $1
|
||||
AND subject_id = $2
|
||||
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
||||
GROUP BY resource_type, resource_id
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by
|
||||
FROM agg
|
||||
WHERE ( $4::timestamptz IS NULL
|
||||
OR granted_at < $4
|
||||
OR (granted_at = $4 AND resource_id < $5::uuid))
|
||||
ORDER BY granted_at DESC, resource_id DESC
|
||||
LIMIT $6
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(kind_strs)
|
||||
.bind(cursor_at)
|
||||
.bind(cursor_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("list_incoming_resources_paged: {e}"))
|
||||
})?;
|
||||
// Extract all cursor fields up-front; each branch uses the subset it needs.
|
||||
// Fixed parameter positions used in all SQL variants:
|
||||
// $4 = cursor_str (resource_name / owner_name)
|
||||
// $5 = cursor_int (type_order)
|
||||
// $6 = cursor_at (granted_at)
|
||||
// $7 = cursor_id (resource_id)
|
||||
// $8 = fetch_limit
|
||||
let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone());
|
||||
let cursor_int = cursor.as_ref().and_then(|c| c.sort_int);
|
||||
let cursor_at = cursor.as_ref().map(|c| c.granted_at);
|
||||
let cursor_id = cursor.as_ref().map(|c| c.resource_id);
|
||||
|
||||
// ── agg CTE (identical in all branches) ───────────────────────────────
|
||||
const AGG: &str = r#"agg AS (
|
||||
SELECT
|
||||
resource_type,
|
||||
resource_id,
|
||||
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
|
||||
MIN(granted_at) AS granted_at,
|
||||
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
|
||||
FROM storage.access_grants
|
||||
WHERE subject_type = $1
|
||||
AND subject_id = $2
|
||||
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
||||
GROUP BY resource_type, resource_id
|
||||
)"#;
|
||||
|
||||
// ── Build sort-specific SQL fragments ─────────────────────────────────
|
||||
// "name" and "type" share the same LEFT JOINs; only sort_int_expr,
|
||||
// the cursor WHERE condition, and ORDER BY differ.
|
||||
let sql = match sort_by {
|
||||
"name" | "type" => {
|
||||
let sort_int_expr = if sort_by == "type" {
|
||||
"CASE WHEN agg.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END"
|
||||
} else {
|
||||
"NULL::bigint"
|
||||
};
|
||||
let where_clause = if sort_by == "type" {
|
||||
r#"( $5::integer IS NULL
|
||||
OR sort_int > $5
|
||||
OR (sort_int = $5 AND LOWER(sort_str) > $4)
|
||||
OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#
|
||||
} else {
|
||||
r#"( $4::text IS NULL
|
||||
OR LOWER(sort_str) > $4
|
||||
OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"#
|
||||
};
|
||||
let order_clause = if sort_by == "type" {
|
||||
"sort_int ASC, LOWER(sort_str) ASC, resource_id ASC"
|
||||
} else {
|
||||
"LOWER(sort_str) ASC, resource_id ASC"
|
||||
};
|
||||
format!(
|
||||
r#"WITH {AGG},
|
||||
named AS (
|
||||
SELECT agg.*,
|
||||
COALESCE(
|
||||
CASE WHEN agg.resource_type = 'folder' THEN f.name END,
|
||||
CASE WHEN agg.resource_type = 'file' THEN fi.name END
|
||||
) AS sort_str,
|
||||
{sort_int_expr} AS sort_int
|
||||
FROM agg
|
||||
LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder'
|
||||
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
FROM named
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $8"#
|
||||
)
|
||||
}
|
||||
"granted_by" => format!(
|
||||
// Joins auth.users to sort alphabetically by username.
|
||||
// Cursor encodes (owner_name=$4, granted_at=$6, resource_id=$7).
|
||||
r#"WITH {AGG},
|
||||
owner_named AS (
|
||||
SELECT agg.*,
|
||||
LOWER(u.username) AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
LEFT JOIN auth.users u ON u.id = agg.granted_by
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
FROM owner_named
|
||||
WHERE ( $4::text IS NULL
|
||||
OR sort_str > $4
|
||||
OR (sort_str = $4 AND (
|
||||
$6::timestamptz IS NULL
|
||||
OR granted_at < $6
|
||||
OR (granted_at = $6 AND resource_id < $7::uuid))))
|
||||
ORDER BY sort_str ASC, granted_at DESC, resource_id DESC
|
||||
LIMIT $8"#
|
||||
),
|
||||
"size" => format!(
|
||||
// Folders have no size — they sort first with a sentinel of -1.
|
||||
// Files sort by size ASC; resource_id breaks ties.
|
||||
// Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused.
|
||||
r#"WITH {AGG},
|
||||
sized AS (
|
||||
SELECT agg.*,
|
||||
NULL::text AS sort_str,
|
||||
CASE WHEN agg.resource_type = 'folder' THEN -1
|
||||
ELSE fi.size
|
||||
END AS sort_int
|
||||
FROM agg
|
||||
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
FROM sized
|
||||
WHERE ( $5::bigint IS NULL
|
||||
OR sort_int > $5
|
||||
OR (sort_int = $5 AND resource_id > $7::uuid))
|
||||
ORDER BY sort_int ASC, resource_id ASC
|
||||
LIMIT $8"#
|
||||
),
|
||||
_ => format!(
|
||||
// Default: sort by grant date DESC (newest first).
|
||||
// Cursor encodes (granted_at=$6, resource_id=$7); $4/$5 unused.
|
||||
r#"WITH {AGG}
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
|
||||
NULL::text AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
WHERE ( $6::timestamptz IS NULL
|
||||
OR granted_at < $6
|
||||
OR (granted_at = $6 AND resource_id < $7::uuid))
|
||||
ORDER BY granted_at DESC, resource_id DESC
|
||||
LIMIT $8"#
|
||||
),
|
||||
};
|
||||
|
||||
// ── Execute — uniform 8 binds for every sort mode ─────────────────────
|
||||
let mut rows: Vec<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())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"PgAcl",
|
||||
format!("list_incoming_resources_paged ({sort_by}): {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// ── Pagination ────────────────────────────────────────────────────────
|
||||
let has_next = rows.len() > limit as usize;
|
||||
let rows: Vec<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 {
|
||||
rows.last().map(|r| GrantCursor {
|
||||
granted_at: r.3,
|
||||
resource_id: r.1,
|
||||
rows.last().map(|r| {
|
||||
let sort_str_lc = r.5.as_deref().map(str::to_lowercase);
|
||||
match sort_by {
|
||||
"name" => GrantCursor {
|
||||
sort_by: "name".to_owned(),
|
||||
granted_at: r.3,
|
||||
resource_id: r.1,
|
||||
resource_name: sort_str_lc,
|
||||
sort_int: None,
|
||||
},
|
||||
"type" => GrantCursor {
|
||||
sort_by: "type".to_owned(),
|
||||
granted_at: r.3,
|
||||
resource_id: r.1,
|
||||
resource_name: sort_str_lc,
|
||||
sort_int: r.6,
|
||||
},
|
||||
"granted_by" => GrantCursor {
|
||||
sort_by: "granted_by".to_owned(),
|
||||
granted_at: r.3,
|
||||
resource_id: r.1,
|
||||
resource_name: r.5.clone(), // already lowercased by SQL
|
||||
sort_int: None,
|
||||
},
|
||||
"size" => GrantCursor {
|
||||
sort_by: "size".to_owned(),
|
||||
granted_at: r.3,
|
||||
resource_id: r.1,
|
||||
resource_name: None,
|
||||
sort_int: r.6,
|
||||
},
|
||||
_ => GrantCursor {
|
||||
sort_by: "granted_at".to_owned(),
|
||||
granted_at: r.3,
|
||||
resource_id: r.1,
|
||||
resource_name: None,
|
||||
sort_int: None,
|
||||
},
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Convert rows into domain summaries.
|
||||
// ── Convert rows to domain summaries ──────────────────────────────────
|
||||
let summaries = rows
|
||||
.into_iter()
|
||||
.filter_map(|(rt, rid, perms_str, granted_at, granted_by)| {
|
||||
.filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| {
|
||||
let resource_type = ResourceKind::parse(&rt)?;
|
||||
let permissions = perms_str
|
||||
.iter()
|
||||
.filter_map(|s| Permission::parse(s))
|
||||
.into_iter()
|
||||
.filter_map(|s| Permission::parse(&s))
|
||||
.collect();
|
||||
Some(IncomingGrantSummary {
|
||||
resource_type,
|
||||
|
||||
@@ -18,9 +18,10 @@ use tracing::{error, info, warn};
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||
CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto,
|
||||
SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -325,15 +326,31 @@ pub async fn list_shared_with_me(
|
||||
.unwrap_or_default();
|
||||
|
||||
// Clamp limit to 1–200.
|
||||
let limit = q.limit.clamp(1, 200);
|
||||
let limit = q.limit_clamped() as u32;
|
||||
|
||||
// Decode cursor (treat invalid cursor as "start from top").
|
||||
let cursor = q.cursor.as_deref().and_then(GrantCursor::decode);
|
||||
// Validate sort_by (defaults to "granted_at").
|
||||
let sort_by = q.sort_by.as_deref().unwrap_or("granted_at");
|
||||
if !matches!(
|
||||
sort_by,
|
||||
"granted_at" | "granted_by" | "name" | "type" | "size"
|
||||
) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type, size"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Decode cursor — discard it when the sort dimension changed to avoid
|
||||
// keyset confusion across sort modes.
|
||||
let cursor = q
|
||||
.decode_cursor::<GrantCursor>()
|
||||
.filter(|c| c.sort_by == sort_by);
|
||||
|
||||
// Fetch paged summaries from the ACL engine.
|
||||
let (summaries, next_cursor) = match state
|
||||
.authorization
|
||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor)
|
||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
@@ -389,8 +406,9 @@ pub async fn list_shared_with_me(
|
||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||
granted_at: summary.granted_at,
|
||||
granted_by: summary.granted_by,
|
||||
file: Some(file_dto.clone().without_hierarchy_info()),
|
||||
folder: None,
|
||||
resource: ResourceContentDto::File(
|
||||
file_dto.clone().without_hierarchy_info(),
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
@@ -419,8 +437,9 @@ pub async fn list_shared_with_me(
|
||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||
granted_at: summary.granted_at,
|
||||
granted_by: summary.granted_by,
|
||||
file: None,
|
||||
folder: Some(folder_dto.clone().without_hierarchy_info()),
|
||||
resource: ResourceContentDto::Folder(
|
||||
folder_dto.clone().without_hierarchy_info(),
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
@@ -443,10 +462,10 @@ pub async fn list_shared_with_me(
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(SharedWithMeDto {
|
||||
Json(SharedWithMeDto::with_cursor(
|
||||
items,
|
||||
next_cursor: next_cursor.map(|c| c.encode()),
|
||||
}),
|
||||
next_cursor.map(|c| c.encode()),
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
/* Multi-Select – checkboxes & batch action bar */
|
||||
.list-header-checkbox,
|
||||
.file-item .checkbox-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-header-checkbox input[type="checkbox"],
|
||||
.file-item .checkbox-cell input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list-header.selection-mode {
|
||||
grid-template-columns: 36px 1fr;
|
||||
background-color: var(--color-multiselect-bg);
|
||||
color: var(--color-multiselect-text);
|
||||
border-bottom-color: var(--color-multiselect-border);
|
||||
}
|
||||
|
||||
.list-header.selection-mode .list-header-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
/* Multi-Select – batch action toolbar
|
||||
* Per-item checkbox styles (.file-item .checkbox-cell, .list-header.selection-mode)
|
||||
* live in resourceList.css alongside the item renderer. */
|
||||
|
||||
.batch-selection-info {
|
||||
display: flex;
|
||||
@@ -105,3 +105,92 @@
|
||||
.toggle-btn i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Group-by selector (inside .view-toggle) ────────────── */
|
||||
|
||||
.view-toggle-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-border-medium);
|
||||
align-self: center;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.view-toggle-separator.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.group-by-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.group-by-selector.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.group-by-btn.active {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Active label shown inline next to the icon */
|
||||
.group-by-label {
|
||||
display: none;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* When a group-by is selected the label has text — expand the button to fit */
|
||||
.group-by-btn:has(.group-by-label:not(:empty)) {
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.group-by-btn:has(.group-by-label:not(:empty)) .group-by-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.group-by-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
min-width: 140px;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.group-by-menu.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.group-by-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.group-by-option:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.group-by-option.active {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
.selection-rect {
|
||||
position: fixed;
|
||||
border: 1.5px solid var(--primary-color, var(--color-card-drop-border));
|
||||
background-color: var(--color-card-drop-tint);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
border-radius: 3px;
|
||||
display: none;
|
||||
}
|
||||
/* ── Base icon container ─────────────────────────────────── */
|
||||
|
||||
.file-icon {
|
||||
width: 100px;
|
||||
@@ -35,6 +35,8 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Item states ─────────────────────────────────────────── */
|
||||
|
||||
.file-item {
|
||||
background-color: var(--color-item);
|
||||
}
|
||||
@@ -73,7 +75,36 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ------------------File list View --------------------- */
|
||||
/* ── Per-item checkboxes (list + grid) ───────────────────── */
|
||||
|
||||
.list-header-checkbox,
|
||||
.file-item .checkbox-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-header-checkbox input[type="checkbox"],
|
||||
.file-item .checkbox-cell input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list-header.selection-mode {
|
||||
grid-template-columns: 36px 1fr;
|
||||
background-color: var(--color-multiselect-bg);
|
||||
color: var(--color-multiselect-text);
|
||||
border-bottom-color: var(--color-multiselect-border);
|
||||
}
|
||||
|
||||
.list-header.selection-mode .list-header-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── List view ───────────────────────────────────────────── */
|
||||
|
||||
.list-header {
|
||||
display: grid;
|
||||
@@ -108,7 +139,7 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Owner column ─────────────────────────────────────────── */
|
||||
/* ── Owner column ────────────────────────────────────────── */
|
||||
|
||||
/* Styles applied whenever the cell is visible (hidden class absent).
|
||||
The .hidden utility class (display:none !important) keeps it invisible
|
||||
@@ -183,6 +214,7 @@
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.files-list-view .file-item .date-cell {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
@@ -236,7 +268,7 @@
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* --------------------- Files grid view --------------------------- */
|
||||
/* ── Grid view ───────────────────────────────────────────── */
|
||||
|
||||
.files-grid-view {
|
||||
display: grid;
|
||||
@@ -289,12 +321,13 @@
|
||||
0 6px 18px var(--color-accent-ring-dark);
|
||||
}
|
||||
|
||||
/* element hidden on grid view */
|
||||
/* elements hidden in grid view */
|
||||
.files-grid-view .file-item .date-cell,
|
||||
.files-grid-view .file-item .size-cell,
|
||||
.files-grid-view .file-item .owner-cell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Selection checkbox */
|
||||
.files-grid-view .file-item .checkbox-cell {
|
||||
position: absolute;
|
||||
@@ -373,7 +406,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
z-index: 12;
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
@@ -461,7 +493,7 @@
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
/* ----------------------- dragged items -----------*/
|
||||
/* ── Drag ghost ──────────────────────────────────────────── */
|
||||
|
||||
.dragged-items {
|
||||
--dragged-files-list-columns: 36px minmax(200px, 1fr);
|
||||
@@ -484,7 +516,7 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* file name, text is wrapped */
|
||||
/* file name text is truncated */
|
||||
.dragged-items .file-item div:nth-child(2) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -505,22 +537,135 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
transform: translate(50%, -50%);
|
||||
|
||||
background: var(--color-content-debug-bg);
|
||||
color: var(--color-content-debug-text);
|
||||
|
||||
border-radius: 50%;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ── 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/buttons.css");
|
||||
@import url("./components/fileType.css");
|
||||
@import url("./components/filesView.css");
|
||||
@import url("./components/fileManager.css");
|
||||
@import url("./components/resourceList.css");
|
||||
@import url("./components/contextMenu.css");
|
||||
@import url("./components/dialogs.css");
|
||||
@import url("./components/modals.css");
|
||||
@@ -24,7 +25,7 @@
|
||||
@import url("./components/notifications.css");
|
||||
@import url("./components/userMenu.css");
|
||||
@import url("./components/languageSelector.css");
|
||||
@import url("./components/multiSelect.css");
|
||||
@import url("./components/batchToolbar.css");
|
||||
@import url("./components/spinner.css");
|
||||
@import url("./components/search.css");
|
||||
@import url("./components/icons.css");
|
||||
|
||||
@@ -31,39 +31,17 @@
|
||||
text-shadow: 0 0 5px var(--color-warning-shadow);
|
||||
}
|
||||
|
||||
/* Styles for the favorites view items */
|
||||
/* Item modifier rules (.file-item.favorite-item) → resourceList.css */
|
||||
|
||||
.favorite-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Adjustments for grid view */
|
||||
.file-item.favorite-item {
|
||||
border-left: 3px solid var(--color-warning-border);
|
||||
|
||||
[dir="rtl"] & {
|
||||
border-right: 3px solid var(--color-warning-border);
|
||||
border-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjustments for list view */
|
||||
/* Adjustments for list view header (page-level, not item-level) */
|
||||
.list-header.favorites-header {
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.favorite-item {
|
||||
position: relative;
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.favorite-item .favorite-indicator {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* Styles for the favorites-specific empty state */
|
||||
.favorites-empty-state {
|
||||
display: flex;
|
||||
|
||||
@@ -20,39 +20,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles for the recent view items */
|
||||
/* Item modifier rules (.file-item.recent-item) → resourceList.css */
|
||||
|
||||
.recent-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Adjustments for grid view */
|
||||
.files-grid-view .file-item.recent-item {
|
||||
border-left: 3px solid var(--color-recent-border);
|
||||
|
||||
[dir="rtl"] & {
|
||||
border-right: 3px solid var(--color-recent-border);
|
||||
border-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjustments for list view */
|
||||
/* Adjustments for list view header (page-level, not item-level) */
|
||||
.list-header.recent-header {
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.recent-item {
|
||||
position: relative;
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.recent-item .recent-indicator {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* Styles for the recent-specific empty state */
|
||||
.recents-empty-state {
|
||||
display: flex;
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
color: var(--color-trash-delete);
|
||||
}
|
||||
|
||||
.file-item.trash-item > .path-cell {
|
||||
display: none;
|
||||
}
|
||||
/* .file-item.trash-item > .path-cell → resourceList.css */
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
|
||||
+145
-217
@@ -1,8 +1,25 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* OxiCloud – Files section view.
|
||||
*
|
||||
* Orchestrates the main Files section:
|
||||
* - Data fetching via `filesModel`
|
||||
* - Rendering via a `ResourceListComponent` instance
|
||||
* - Drag-and-drop initialisation (delegated to `ui.initDragDrop`)
|
||||
*
|
||||
* Exports `loadFiles` (navigation & deep-link entry-point) and `addItem`
|
||||
* (post-upload / post-create optimistic UI updates used by fileOperations
|
||||
* and search).
|
||||
*/
|
||||
|
||||
import { ResourceListComponent } from '../components/resourceList.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { 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 { updateHistory } from './main.js';
|
||||
import { app } from './state.js';
|
||||
@@ -11,263 +28,174 @@ import { uiNotifications } from './uiNotifications.js';
|
||||
|
||||
/** @import {FileItem, FolderItem} from '../core/types.js' */
|
||||
|
||||
let isLoadingFiles = false;
|
||||
/** @type {ResourceListComponent|null} */
|
||||
let _component = null;
|
||||
|
||||
/** Guard against concurrent `loadFiles` calls. */
|
||||
let _loading = false;
|
||||
|
||||
/**
|
||||
* getFolder information
|
||||
* @param {string} id the id of the folder
|
||||
* @returns {Promise<FolderItem>}
|
||||
* Return (creating on first call) the `ResourceListComponent` bound to
|
||||
* `#files-list`. The element must already be in the DOM.
|
||||
* @returns {ResourceListComponent|null}
|
||||
*/
|
||||
async function getFolder(id) {
|
||||
/** @type {HeadersInit} */
|
||||
const headers = {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache'
|
||||
};
|
||||
function _ensureComponent() {
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (!filesList) return null;
|
||||
|
||||
/** @type {RequestInit} */
|
||||
const requestOptions = {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
};
|
||||
if (!_component) {
|
||||
_component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: true,
|
||||
showShareBadge: true,
|
||||
draggable: true,
|
||||
showContextMenu: true,
|
||||
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||||
isShared: (id, type) => grants.getOutgoingGrantsFor(type, id).length > 0,
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onFavoriteToggle: async (item) => {
|
||||
const isFile = 'mime_type' in item;
|
||||
const type = isFile ? 'file' : 'folder';
|
||||
if (favorites.isFavorite(item.id, type)) {
|
||||
await favorites.removeFromFavorites(item.id, type);
|
||||
_component?.setFavoriteVisualState(item.id, type, false);
|
||||
} else {
|
||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||
_component?.setFavoriteVisualState(item.id, type, true);
|
||||
}
|
||||
},
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
}
|
||||
});
|
||||
|
||||
const folderInformations = await fetch(`/api/folders/${id}`, requestOptions);
|
||||
if (folderInformations.ok) {
|
||||
return folderInformations.json();
|
||||
} else {
|
||||
console.warn(`Error fetching folder ${id}`);
|
||||
return Promise.reject(null);
|
||||
// Wire drag-and-drop on the container once the component is created.
|
||||
ui.initDragDrop(/** @type {HTMLElement} */ (filesList));
|
||||
}
|
||||
|
||||
return _component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild breadcrumb from selected folder (iterate up to root).
|
||||
* Append a single item to the current view (post-upload / post-create
|
||||
* optimistic update). No-op when the Files section is not active or the
|
||||
* item is already in the list.
|
||||
*
|
||||
* Stops traversal gracefully when a parent folder is not accessible
|
||||
* (e.g. the user entered via a "Shared with me" grant whose parent
|
||||
* folder they have no permission on). In that case the partial
|
||||
* breadcrumb built so far is kept — the deepest reachable ancestor
|
||||
* acts as the visual root, matching how Google Drive / Dropbox handle
|
||||
* shared subtrees.
|
||||
* Called by `fileOperations.js` and `search.js`.
|
||||
*
|
||||
* An error on the *target folder itself* (first iteration) is still
|
||||
* treated as a real error and redirects to the home folder.
|
||||
* @param {FileItem|FolderItem} item
|
||||
*/
|
||||
async function rebuildBreadCrumb() {
|
||||
/**
|
||||
* Store the leaf (this is the current displayed folder)
|
||||
* @type {FolderItem | null}
|
||||
*/
|
||||
let currentFolderInfo = null;
|
||||
|
||||
// rebuild full breadcrumb,
|
||||
// TODO: to optimize, data may already be known / or ETAG could be interesting to reduce load
|
||||
app.breadcrumbPath = [];
|
||||
|
||||
/** @type {string | null} */
|
||||
let id = app.currentPath;
|
||||
|
||||
// recurse from selected folder to root
|
||||
while (id !== null) {
|
||||
console.log(`fetching folder information for folder ${id}`);
|
||||
try {
|
||||
const folderInfo = await getFolder(id);
|
||||
|
||||
// store the Leaf which is the current folder
|
||||
if (currentFolderInfo === null) {
|
||||
currentFolderInfo = folderInfo;
|
||||
}
|
||||
|
||||
// Add every folder to the breadcrumb, including the root (home folder).
|
||||
// updateBreadcrumb() no longer auto-prepends home — it's our responsibility here.
|
||||
app.breadcrumbPath.unshift({
|
||||
id: folderInfo.id,
|
||||
name: folderInfo.name
|
||||
});
|
||||
|
||||
// iterate to parent folder
|
||||
id = folderInfo.parent_id;
|
||||
} catch (_e) {
|
||||
if (currentFolderInfo === null) {
|
||||
// Failed on the target folder itself — real error, fall back to home.
|
||||
console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`);
|
||||
uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
|
||||
app.breadcrumbPath = [];
|
||||
id = app.userHomeFolderId;
|
||||
if (id) app.currentPath = id;
|
||||
} else {
|
||||
// Failed on a parent — hit the permission boundary of a shared subtree.
|
||||
// Stop traversal; the partial breadcrumb is the best we can show.
|
||||
console.log(`Stopped breadcrumb traversal at permission boundary (parent of ${currentFolderInfo.id} is not accessible)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store informations on the current folder
|
||||
app.currentFolderInfo = currentFolderInfo;
|
||||
function addItem(item) {
|
||||
const component = _ensureComponent();
|
||||
if (!component) return;
|
||||
// Reveal the list if the empty-state is showing
|
||||
ui.resetFilesList();
|
||||
component.addItem(item);
|
||||
}
|
||||
|
||||
// TODO split load() vs view()
|
||||
/**
|
||||
* Files view loading logic
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.insertHistory] add browser history (default true)
|
||||
* @param {boolean} [options.forceRefresh] force refresh of content
|
||||
* Load and render the contents of `app.currentPath`, rebuilding the
|
||||
* breadcrumb and updating browser history.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.insertHistory=true]
|
||||
* @param {boolean} [options.forceRefresh=false]
|
||||
*/
|
||||
async function loadFiles(options = { insertHistory: true }) {
|
||||
if (_loading) {
|
||||
console.log('A file load is already in progress, ignoring request');
|
||||
return;
|
||||
}
|
||||
_loading = true;
|
||||
|
||||
// Delay spinner so fast loads avoid the flash
|
||||
const spinnerTimeout = setTimeout(() => {
|
||||
ui.showError(`
|
||||
<div class="files-loading-spinner">
|
||||
<div class="spinner"></div>
|
||||
<span>${i18n.t('files.loading')}</span>
|
||||
</div>
|
||||
`);
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
console.log('Starting loadFiles() - loading files...', options);
|
||||
|
||||
const forceRefresh = options.forceRefresh || false;
|
||||
|
||||
if (isLoadingFiles) {
|
||||
console.log('A file load is already in progress, ignoring request');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingFiles = true;
|
||||
|
||||
// This to avoid blinking page, a better solution would be to put loading on an overlay and remove timeout
|
||||
const loadingFiles = setTimeout(() => {
|
||||
// display loader after few delay (will be canceled if result take less time)
|
||||
ui.showError(`
|
||||
<div class="files-loading-spinner">
|
||||
<div class="spinner"></div>
|
||||
<span>${i18n.t('files.loading')}</span>
|
||||
</div>
|
||||
`);
|
||||
}, 100);
|
||||
|
||||
if (!app.userHomeFolderId) {
|
||||
await resolveHomeFolder();
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
await rebuildBreadCrumb();
|
||||
|
||||
// request a breadcrumb paint
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
updateHistory(options.insertHistory || false);
|
||||
|
||||
let url;
|
||||
if (!app.userHomeFolderId) await resolveHomeFolder();
|
||||
|
||||
// Resolve path to home folder when none is set
|
||||
if (!app.currentPath || app.currentPath === '') {
|
||||
if (app.userHomeFolderId) {
|
||||
url = `/api/folders/${app.userHomeFolderId}/listing?t=${timestamp}`;
|
||||
app.currentPath = app.userHomeFolderId;
|
||||
app.breadcrumbPath = [];
|
||||
ui.updateBreadcrumb();
|
||||
console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`);
|
||||
} else {
|
||||
url = `/api/folders?t=${timestamp}`;
|
||||
console.warn('Emergency fallback to root folder - this should not normally happen');
|
||||
console.warn('No home folder id — this should not normally happen');
|
||||
}
|
||||
} else {
|
||||
url = `/api/folders/${app.currentPath}/listing?t=${timestamp}`;
|
||||
console.log(`Loading subfolder content: ${app.currentPath}`);
|
||||
}
|
||||
|
||||
/** @type {HeadersInit} */
|
||||
const headers = {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache'
|
||||
};
|
||||
await rebuildBreadCrumb();
|
||||
ui.updateBreadcrumb();
|
||||
updateHistory(options.insertHistory ?? true);
|
||||
|
||||
/** @type {RequestInit} */
|
||||
const requestOptions = {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
};
|
||||
const { folders, files } = await fetchListing(app.currentPath, {
|
||||
forceRefresh: options.forceRefresh ?? false
|
||||
});
|
||||
|
||||
if (forceRefresh) {
|
||||
url += `&force_refresh=true`;
|
||||
if (requestOptions.headers) {
|
||||
const headers = new Headers(requestOptions.headers);
|
||||
headers.set('X-Force-Refresh', 'true');
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
console.log('Forcing complete refresh ignoring cache');
|
||||
}
|
||||
clearTimeout(spinnerTimeout);
|
||||
|
||||
console.log(`Loading listing from ${url}`);
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
// not required anymore
|
||||
clearTimeout(loadingFiles);
|
||||
|
||||
if (response.status === 403) {
|
||||
console.warn('Forbidden when loading files');
|
||||
// FIXME: i18n
|
||||
ui.showError(`<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();
|
||||
// Prepare the container (shows #files-list, hides error panel)
|
||||
ui.resetFilesList();
|
||||
if (multiSelect) {
|
||||
multiSelect.clear();
|
||||
multiSelect.init(); // this will wire buttons & select-all-checkbox
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folderList = Array.isArray(listing.folders) ? listing.folders : [];
|
||||
const component = _ensureComponent();
|
||||
if (!component) return;
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const fileList = Array.isArray(listing.files) ? listing.files : [];
|
||||
batchToolbar.clear();
|
||||
batchToolbar.init();
|
||||
batchToolbar.setActiveComponent(component);
|
||||
|
||||
if (folderList.length === 0 && fileList.length === 0) {
|
||||
if (folders.length === 0 && files.length === 0) {
|
||||
ui.showEmptyList();
|
||||
} else {
|
||||
ui.renderFolders(folderList);
|
||||
ui.renderFiles(fileList);
|
||||
ui.resolveOwnerCells();
|
||||
|
||||
// check if a file was provided
|
||||
if (app.viewFile) {
|
||||
let fileFound = null;
|
||||
|
||||
// lookup for the given fle
|
||||
for (const file of fileList) {
|
||||
if (file.id === app.viewFile) {
|
||||
fileFound = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileFound) {
|
||||
console.log(`file ${app.viewFile} found, calling viewer`);
|
||||
await inlineViewer.openFile(fileFound);
|
||||
} else {
|
||||
// remove file
|
||||
console.log(`file ${app.viewFile} not found`);
|
||||
app.viewFile = null;
|
||||
|
||||
// correct url/history as file is not found
|
||||
updateHistory(false);
|
||||
}
|
||||
}
|
||||
component.render([...folders, ...files]);
|
||||
await component.resolveOwnerCells();
|
||||
}
|
||||
|
||||
console.log(`Loaded ${folderList.length} folders and ${fileList.length} files`);
|
||||
} catch (error) {
|
||||
console.error('Error loading folders:', error);
|
||||
ui.showNotification('Error', 'Could not load files and folders');
|
||||
console.log(`Loaded ${folders.length} folders and ${files.length} files`);
|
||||
|
||||
// Deep-link: open a specific file if requested via app.viewFile
|
||||
if (app.viewFile) {
|
||||
const fileFound = files.find((f) => f.id === app.viewFile) ?? null;
|
||||
if (fileFound) {
|
||||
console.log(`file ${app.viewFile} found, calling viewer`);
|
||||
await inlineViewer.openFile(fileFound);
|
||||
} else {
|
||||
console.log(`file ${app.viewFile} not found`);
|
||||
app.viewFile = null;
|
||||
updateHistory(false);
|
||||
}
|
||||
}
|
||||
} catch (/** @type {any} */ err) {
|
||||
clearTimeout(spinnerTimeout);
|
||||
if (err?.status === 403) {
|
||||
ui.showError(`<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');
|
||||
}
|
||||
} finally {
|
||||
isLoadingFiles = false;
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export { loadFiles };
|
||||
export { addItem, loadFiles };
|
||||
|
||||
+107
-9
@@ -8,11 +8,11 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
|
||||
installFetchInterceptor();
|
||||
|
||||
import { Modal } from '../components/modal.js';
|
||||
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { escapeHtml, formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { oxiIconsInit } from '../core/icons.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { multiSelect } from '../features/files/multiSelect.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||
@@ -50,7 +50,7 @@ let uploadDropdownDocumentClickHandler = null;
|
||||
let uploadDropdownBindingsController = null;
|
||||
let actionsBarDelegationBound = false;
|
||||
|
||||
const _multiSelectButons = `
|
||||
const _batchToolbarButons = `
|
||||
<div class="action-buttons batch-selection-bar hidden" id="multi-select-buttons">
|
||||
<div class="list-header-checkbox">
|
||||
<button class="batch-bar-close" id="batch-selection-close" title="Cancel selection">
|
||||
@@ -83,6 +83,15 @@ const _multiSelectButons = `
|
||||
|
||||
const _toggleButtons = `
|
||||
<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">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
@@ -117,7 +126,7 @@ const ACTIONS_BAR_TEMPLATES = {
|
||||
<span data-i18n="actions.new_folder">New folder</span>
|
||||
</button>
|
||||
</div>
|
||||
${_multiSelectButons}
|
||||
${_batchToolbarButons}
|
||||
${_toggleButtons}
|
||||
`,
|
||||
trash: `
|
||||
@@ -131,7 +140,7 @@ const ACTIONS_BAR_TEMPLATES = {
|
||||
`,
|
||||
favorites: `
|
||||
<div class="action-buttons" id="default-buttons"></div>
|
||||
${_multiSelectButons}
|
||||
${_batchToolbarButons}
|
||||
${_toggleButtons}
|
||||
`,
|
||||
recent: `
|
||||
@@ -141,11 +150,12 @@ const ACTIONS_BAR_TEMPLATES = {
|
||||
<span data-i18n="actions.clear_recent">Clear recent</span>
|
||||
</button>
|
||||
</div>
|
||||
${_multiSelectButons}
|
||||
${_batchToolbarButons}
|
||||
${_toggleButtons}
|
||||
`,
|
||||
sharedwithme: `
|
||||
<div class="action-buttons" id="default-buttons"></div>
|
||||
${_batchToolbarButons}
|
||||
${_toggleButtons}
|
||||
`
|
||||
};
|
||||
@@ -189,6 +199,75 @@ function setActionsBarMode(mode, force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ key: string, label: string, setGroupBy: (key: string) => void }} GroupByCapableView
|
||||
*/
|
||||
|
||||
/**
|
||||
* The view that currently owns the group-by selector, or `null` when no
|
||||
* section supports grouping. Set by `setGroupByView()` from navigation.js.
|
||||
* @type {{ setGroupBy: (key: string) => void } | null}
|
||||
*/
|
||||
let _groupByView = null;
|
||||
|
||||
/**
|
||||
* Update the reference to the view that handles group-by changes.
|
||||
* Called by navigation.js when the active section changes.
|
||||
* @param {{ setGroupBy: (key: string) => void } | null} view
|
||||
*/
|
||||
function setGroupByView(view) {
|
||||
_groupByView = view;
|
||||
}
|
||||
|
||||
/** @type {((e: MouseEvent) => void) | null} */
|
||||
let _groupByDocumentClickHandler = null;
|
||||
|
||||
/**
|
||||
* Populate and show (or hide) the group-by dropdown based on the active
|
||||
* section's `groupByDefs`. Pass an empty array (or omit) to hide the button.
|
||||
*
|
||||
* Must be called AFTER `setActionsBarMode()` so the selector elements exist
|
||||
* in the DOM.
|
||||
*
|
||||
* @param {Array<{key: string, label: string}>} [defs]
|
||||
*/
|
||||
function syncGroupByMenu(defs = []) {
|
||||
const selector = document.getElementById('group-by-selector');
|
||||
const separator = document.getElementById('group-by-separator');
|
||||
const menu = document.getElementById('group-by-menu');
|
||||
if (!selector || !menu) return;
|
||||
|
||||
const hasDefs = defs.length > 0;
|
||||
selector.classList.toggle('hidden', !hasDefs);
|
||||
separator?.classList.toggle('hidden', !hasDefs);
|
||||
|
||||
if (!hasDefs) {
|
||||
// Reset active indicator when the section has no group-by support
|
||||
const btn = document.getElementById('group-by-btn');
|
||||
btn?.classList.remove('active');
|
||||
const lbl = btn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild menu options — call i18n.t() directly so each label is resolved
|
||||
// at call time (translations are loaded by the time any section switch runs).
|
||||
menu.innerHTML = `<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() {
|
||||
if (actionsBarDelegationBound || !elements.actionsBar) return;
|
||||
actionsBarDelegationBound = true;
|
||||
@@ -197,7 +276,26 @@ function setupActionsBarDelegation() {
|
||||
const btn = /** @type {HTMLElement} */ (e.target)?.closest('button');
|
||||
if (!btn) return;
|
||||
|
||||
// ── Group-by option selected ──────────────────────────────────────────
|
||||
if (btn.classList.contains('group-by-option')) {
|
||||
const key = btn.dataset.groupBy ?? '';
|
||||
_groupByView?.setGroupBy(key);
|
||||
document.querySelectorAll('.group-by-option').forEach((b) => {
|
||||
b.classList.remove('active');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
document.getElementById('group-by-menu')?.classList.add('hidden');
|
||||
const groupByBtn = document.getElementById('group-by-btn');
|
||||
groupByBtn?.classList.toggle('active', key !== '');
|
||||
const lbl = groupByBtn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : '';
|
||||
return;
|
||||
}
|
||||
|
||||
switch (btn.id) {
|
||||
case 'group-by-btn':
|
||||
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
||||
return;
|
||||
case 'upload-files-btn': {
|
||||
e.stopPropagation();
|
||||
const menu = document.getElementById('upload-dropdown-menu');
|
||||
@@ -386,9 +484,9 @@ function initApp() {
|
||||
}
|
||||
|
||||
// Initialize multi-select / batch actions
|
||||
if (multiSelect?.init) {
|
||||
if (batchToolbar?.init) {
|
||||
console.log('Initializing multi-select module');
|
||||
multiSelect.init();
|
||||
batchToolbar.init();
|
||||
}
|
||||
|
||||
window.addEventListener('authenticationDone', async () => {
|
||||
@@ -798,4 +896,4 @@ function updateStorageUsageDisplay(userData) {
|
||||
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
|
||||
}
|
||||
|
||||
export { deserializeHash, initApp, setActionsBarMode, updateHistory, updateStorageUsageDisplay };
|
||||
export { deserializeHash, initApp, setActionsBarMode, setGroupByView, syncGroupByMenu, updateHistory, updateStorageUsageDisplay };
|
||||
|
||||
+26
-11
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { multiSelect } from '../features/files/multiSelect.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { musicView } from '../features/library/music.js';
|
||||
import { photosView } from '../features/library/photos.js';
|
||||
@@ -12,7 +12,7 @@ import { recent } from '../features/library/recent.js';
|
||||
import { sharedView } from '../views/shared/sharedView.js';
|
||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import { setActionsBarMode } from './main.js';
|
||||
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||
import { app, appElements } from './state.js';
|
||||
import { loadTrashItems } from './trashView.js';
|
||||
import { ui } from './ui.js';
|
||||
@@ -201,7 +201,7 @@ function switchToSharedSection() {
|
||||
sharedView.show();
|
||||
});
|
||||
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
function switchToSharedWithMeSection() {
|
||||
@@ -214,6 +214,11 @@ function switchToSharedWithMeSection() {
|
||||
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
||||
setActionsBarMode('sharedwithme');
|
||||
|
||||
// Populate the group-by dropdown with this section's dimensions.
|
||||
// Must be called AFTER setActionsBarMode() so the selector elements exist.
|
||||
setGroupByView(sharedWithMeView);
|
||||
syncGroupByMenu(sharedWithMeView.groupByDefs);
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
|
||||
@@ -221,7 +226,7 @@ function switchToSharedWithMeSection() {
|
||||
toggleFileContainer(true);
|
||||
syncViewContainers();
|
||||
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
|
||||
// Load and render items into the files container
|
||||
sharedWithMeView.init();
|
||||
@@ -232,6 +237,8 @@ function switchToFilesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
// Show owner column in the Files section
|
||||
ui.setOwnerColumnVisible(true);
|
||||
@@ -253,7 +260,7 @@ function switchToFilesSection() {
|
||||
app.currentPath = app.userHomeFolderId || '';
|
||||
app.breadcrumbPath = [];
|
||||
ui.updateBreadcrumb();
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
|
||||
// temp solution
|
||||
sharedView.loadItems().then(() => {
|
||||
@@ -266,6 +273,8 @@ function switchToFavoritesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('favorites');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
@@ -296,7 +305,7 @@ function switchToFavoritesSection() {
|
||||
`);
|
||||
}
|
||||
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
function switchToRecentFilesSection() {
|
||||
@@ -304,6 +313,8 @@ function switchToRecentFilesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('recent');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
// Hide breadcrumb (only shown in Files view)
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
@@ -329,7 +340,7 @@ function switchToRecentFilesSection() {
|
||||
<p>Error loading the recent module</p>
|
||||
`);
|
||||
}
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
function switchToPhotosSection() {
|
||||
@@ -352,7 +363,7 @@ function switchToPhotosSection() {
|
||||
if (photosView) {
|
||||
photosView.show();
|
||||
}
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
function switchToTrashSection() {
|
||||
@@ -367,6 +378,8 @@ function switchToTrashSection() {
|
||||
toggleFileContainer(true);
|
||||
|
||||
setActionsBarMode('trash');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
//reset files view + remove any error
|
||||
ui.resetFilesList();
|
||||
@@ -377,7 +390,7 @@ function switchToTrashSection() {
|
||||
// Load trash items
|
||||
loadTrashItems();
|
||||
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
function switchToMusicSection() {
|
||||
@@ -404,7 +417,7 @@ function switchToMusicSection() {
|
||||
if (musicView) {
|
||||
musicView.show();
|
||||
}
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,11 +432,13 @@ function switchToMusicSection() {
|
||||
function activateFilesUI() {
|
||||
setCurrentSection('files');
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.remove('hidden');
|
||||
toggleFileContainer(true);
|
||||
syncViewContainers();
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { multiSelect } from '../features/files/multiSelect.js';
|
||||
import * as pathTooltip from '../features/pathTooltip.js';
|
||||
import { appElements } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
@@ -22,7 +22,7 @@ async function loadTrashItems() {
|
||||
const elements = appElements;
|
||||
|
||||
try {
|
||||
if (multiSelect) multiSelect.clear();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
pathTooltip.destroy(elements.filesList);
|
||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
||||
elements.filesList.innerHTML = `
|
||||
|
||||
+185
-640
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
|
||||
*/
|
||||
|
||||
import { i18n } from './i18n.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
@@ -107,4 +109,62 @@ function isEmailValid(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable };
|
||||
/**
|
||||
* Normalize a date value into a human-readable bucket label.
|
||||
* Buckets (newest-first): Today | Last 7 days | Last 30 days | <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,
|
||||
'M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z'
|
||||
],
|
||||
'layer-group': [
|
||||
512,
|
||||
'M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z'
|
||||
],
|
||||
link: [
|
||||
576,
|
||||
'M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z'
|
||||
|
||||
+22
-3
@@ -295,16 +295,35 @@
|
||||
* 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`.
|
||||
* Exactly one of `file` / `folder` is populated (indicated by `resource_type`).
|
||||
* `resource_type` discriminates the shape of `resource`.
|
||||
* @typedef {Object} SharedWithMeItem
|
||||
* @property {ResourceTypeEnum} resource_type
|
||||
* @property {PermissionTypeEnum[]} permissions - All permissions the caller holds on this resource.
|
||||
* @property {string} granted_at - ISO-8601 timestamp of the earliest grant.
|
||||
* @property {string} granted_by - UUID of the user who created the grant.
|
||||
* @property {FileItem|undefined} [file] - Populated when resource_type === 'file'.
|
||||
* @property {FolderItem|undefined} [folder] - Populated when resource_type === 'folder'.
|
||||
* @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+52
-15
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* OxiCloud - Multi-Select & Batch Actions Module
|
||||
* OxiCloud — Batch Toolbar Module
|
||||
*
|
||||
* Adds checkboxes to grid and list views, replaces the list-view header
|
||||
* with a NextCloud-style selection bar when items are selected, and
|
||||
* provides batch delete / move / download / favorites operations.
|
||||
* Manages the floating selection bar that appears when items are selected,
|
||||
* and executes batch operations (delete, move, download, favorites).
|
||||
*
|
||||
* Selection state (_selected, handleToggleItem, selectAll, …) is kept here
|
||||
* while the main file manager still uses its own delegation (ui.js).
|
||||
* Once ui.js is migrated to ResourceListComponent (plan step B5), all
|
||||
* selection mechanics will live in the component and this module will
|
||||
* shrink to only the toolbar UI and batch-operation API calls.
|
||||
*/
|
||||
|
||||
// TODO: rename into selection-bar ?
|
||||
// TODO: merge with photo part
|
||||
|
||||
import { loadFiles } from '../../app/filesView.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||
@@ -20,9 +22,10 @@ import { getAuthHeaders } from './fileOperations.js';
|
||||
/**
|
||||
* @import {ItemTypeEnum, LightItem} from '../../core/types.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 }> */
|
||||
|
||||
_selected: new Map(),
|
||||
@@ -33,6 +36,23 @@ const multiSelect = {
|
||||
/** Whether the selection bar is currently visible */
|
||||
_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 ──────────────────────────────────────────
|
||||
|
||||
get count() {
|
||||
@@ -112,6 +132,13 @@ const multiSelect = {
|
||||
document.querySelectorAll('.item-checkbox').forEach((cb) => {
|
||||
/** @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();
|
||||
},
|
||||
|
||||
@@ -324,6 +351,8 @@ const multiSelect = {
|
||||
},
|
||||
|
||||
_syncItemCheckboxes() {
|
||||
// When a ResourceListComponent is active it owns checkbox state — skip.
|
||||
if (this._activeComponent) return;
|
||||
document.querySelectorAll('.file-item').forEach((el) => {
|
||||
const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
|
||||
if (cb) cb.checked = el.classList.contains('selected');
|
||||
@@ -512,15 +541,23 @@ const multiSelect = {
|
||||
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
||||
|
||||
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 (selectAllCheckbox) selectAllCheckbox.checked = true;
|
||||
this.selectAll();
|
||||
if (this._activeComponent) {
|
||||
this._activeComponent.selectAll();
|
||||
} else {
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = true;
|
||||
this.selectAll();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === 'Escape' && this.hasSelection) {
|
||||
this.clear();
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||
if (e.key === 'Escape') {
|
||||
if (this._activeComponent && this._activeComponent._selected.size > 0) {
|
||||
this._activeComponent.clearSelection();
|
||||
} else if (this.hasSelection) {
|
||||
this.clear();
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||
}
|
||||
}
|
||||
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 { musicView } from '../library/music.js';
|
||||
import { fileSharing } from '../sharing/fileSharing.js';
|
||||
import { batchToolbar } from './batchToolbar.js';
|
||||
import { fileOps } from './fileOperations.js';
|
||||
import { inlineViewer } from './inlineViewer.js';
|
||||
import { multiSelect } from './multiSelect.js';
|
||||
import { wopiEditor } from './wopiEditor.js';
|
||||
|
||||
/**
|
||||
@@ -330,8 +330,8 @@ const contextMenus = {
|
||||
|
||||
// Copy button handler
|
||||
copyConfirmBtn.addEventListener('click', async () => {
|
||||
// Batch copy mode (from multiSelect)
|
||||
if (app.moveDialogMode === 'batch' && multiSelect) {
|
||||
// Batch copy mode (from batchToolbar)
|
||||
if (app.moveDialogMode === 'batch' && batchToolbar) {
|
||||
const targetId = app.selectedTargetFolderId;
|
||||
const items = app.batchMoveItems || [];
|
||||
|
||||
@@ -341,10 +341,10 @@ const contextMenus = {
|
||||
const result = await fileOps.batchCopy(fileIds, folderIds, targetId);
|
||||
|
||||
this.closeMoveDialog();
|
||||
multiSelect.clear();
|
||||
batchToolbar.clear();
|
||||
loadFiles();
|
||||
|
||||
multiSelect.showBatchResult('copy', result);
|
||||
batchToolbar.showBatchResult('copy', result);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,8 +363,8 @@ const contextMenus = {
|
||||
});
|
||||
|
||||
moveConfirmBtn.addEventListener('click', async () => {
|
||||
// Batch move mode (from multiSelect)
|
||||
if (app.moveDialogMode === 'batch' && multiSelect) {
|
||||
// Batch move mode (from batchToolbar)
|
||||
if (app.moveDialogMode === 'batch' && batchToolbar) {
|
||||
const targetId = app.selectedTargetFolderId;
|
||||
const items = app.batchMoveItems || [];
|
||||
|
||||
@@ -374,9 +374,9 @@ const contextMenus = {
|
||||
const result = await fileOps.batchMove(fileIds, folderIds, targetId);
|
||||
|
||||
this.closeMoveDialog();
|
||||
multiSelect.clear();
|
||||
batchToolbar.clear();
|
||||
loadFiles();
|
||||
multiSelect.showBatchResult('move', result);
|
||||
batchToolbar.showBatchResult('move', result);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { refreshUserData } from '../../app/authSession.js';
|
||||
import { loadFiles } from '../../app/filesView.js';
|
||||
import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
||||
@@ -781,7 +781,7 @@ const fileOps = {
|
||||
|
||||
// Optimistic UI: add folder card directly from server response
|
||||
// — no reload needed since the backend already confirmed creation.
|
||||
ui.addFolderToView(folder);
|
||||
filesViewAddItem(folder);
|
||||
|
||||
ui.showNotification('Folder created', `"${name}" created successfully`);
|
||||
} else {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* displays the enriched results returned by the server.
|
||||
*/
|
||||
|
||||
import { loadFiles } from '../../app/filesView.js';
|
||||
import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
@@ -200,12 +200,12 @@ const search = {
|
||||
|
||||
// Render folders (server-provided enriched data)
|
||||
results.folders.forEach((folder) => {
|
||||
ui.addFolderToView(folder);
|
||||
filesViewAddItem(folder);
|
||||
});
|
||||
|
||||
// Render files (server-provided enriched data)
|
||||
results.files.forEach((file) => {
|
||||
ui.addFileToView(file);
|
||||
filesViewAddItem(file);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { multiSelect } from '../files/multiSelect.js';
|
||||
import { batchToolbar } from '../files/batchToolbar.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
||||
@@ -21,6 +22,9 @@ const favorites = {
|
||||
/** Whether the initial fetch from the server has completed */
|
||||
_ready: false,
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
_component: null,
|
||||
|
||||
// ───────────────────── helpers ─────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
@@ -105,7 +109,7 @@ const favorites = {
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @param {string} type
|
||||
* @param {string} _parentId
|
||||
* @param {string | null} _parentId
|
||||
*/
|
||||
async addToFavorites(id, name, type, _parentId) {
|
||||
try {
|
||||
@@ -178,11 +182,8 @@ const favorites = {
|
||||
try {
|
||||
await this._fetchFromServer();
|
||||
|
||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
||||
// wire buttons & select-all-checkbox as list header has changed in ui.resetFilesList()
|
||||
// FIXME: this case is not easy to understand, should apply better implementation
|
||||
multiSelect.init();
|
||||
|
||||
ui.resetFilesList();
|
||||
batchToolbar.init();
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
if (this._cache.size === 0) {
|
||||
@@ -194,18 +195,14 @@ const favorites = {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const items = [];
|
||||
|
||||
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') {
|
||||
folders.push(
|
||||
// FIXME: better to grab the real values
|
||||
/** @type {FolderItem} */ {
|
||||
items.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
@@ -217,12 +214,11 @@ const favorites = {
|
||||
icon_special_class: item.icon_special_class,
|
||||
owner_id: item.owner_id ?? '',
|
||||
is_root: false
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
files.push(
|
||||
// FIXME: better to grab the real values
|
||||
/** @type {FileItem} */ {
|
||||
items.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
@@ -237,17 +233,57 @@ const favorites = {
|
||||
owner_id: item.owner_id ?? '',
|
||||
created_at: 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');
|
||||
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) {
|
||||
console.error('Error displaying favorites:', error);
|
||||
if (ui?.showNotification) {
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { multiSelect } from '../files/multiSelect.js';
|
||||
import { batchToolbar } from '../files/batchToolbar.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
|
||||
@@ -18,6 +19,9 @@ const recent = {
|
||||
/** Maximum items to request from the server */
|
||||
MAX_RECENT_FILES: 20,
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
_component: null,
|
||||
|
||||
// ───────────────────── helpers ─────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
@@ -97,24 +101,25 @@ const recent = {
|
||||
|
||||
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');
|
||||
|
||||
filesList.innerHTML = `
|
||||
<div class="list-header">
|
||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||
<div data-i18n="files.name">Name</div>
|
||||
<div data-i18n="files.type">Type</div>
|
||||
<div data-i18n="files.size">Size</div>
|
||||
<div data-i18n="recent.accessed">Accessed</div>
|
||||
<div></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (multiSelect) {
|
||||
multiSelect.clear();
|
||||
multiSelect.init(); // this will wire buttons & select-all-checkbox
|
||||
if (filesList) {
|
||||
// Relabel the date column header from "Modified" → "Accessed"
|
||||
const dateHeader = /** @type {HTMLElement|null} */ (
|
||||
[...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified')
|
||||
);
|
||||
if (dateHeader) {
|
||||
dateHeader.removeAttribute('data-i18n');
|
||||
dateHeader.setAttribute('data-i18n', 'recent.accessed');
|
||||
dateHeader.textContent = i18n.t('recent.accessed', 'Accessed');
|
||||
}
|
||||
}
|
||||
|
||||
batchToolbar.clear();
|
||||
batchToolbar.init();
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
if (recentItems.length === 0) {
|
||||
@@ -123,57 +128,88 @@ const recent = {
|
||||
<p>${i18n.t('recent.empty_state')}</p>
|
||||
<p>${i18n.t('recent.empty_hint')}</p>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const items = [];
|
||||
|
||||
for (const item of recentItems) {
|
||||
const isFolder = item.item_type === 'folder';
|
||||
if (isFolder) {
|
||||
folders.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
category: 'folder',
|
||||
created_at: item.accessed_at, //Wrong information
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
owner_id: '',
|
||||
is_root: false
|
||||
});
|
||||
items.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
category: 'folder',
|
||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
owner_id: '',
|
||||
is_root: false
|
||||
})
|
||||
);
|
||||
} else {
|
||||
if (item.item_mime_type === undefined || item.item_mime_type === null) {
|
||||
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
|
||||
console.warn('Broken information for RecentItem: ', item);
|
||||
//continue;
|
||||
}
|
||||
files.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
owner_id: '',
|
||||
created_at: item.accessed_at, //wrong information
|
||||
sort_date: item.accessed_at
|
||||
});
|
||||
items.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
owner_id: '',
|
||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||
sort_date: item.accessed_at
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (folders.length) ui.renderFolders(folders);
|
||||
if (files.length) ui.renderFiles(files);
|
||||
if (filesList) pathTooltip.init(filesList);
|
||||
|
||||
if (filesList) {
|
||||
if (!this._component) {
|
||||
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: false,
|
||||
showShareBadge: false,
|
||||
draggable: false,
|
||||
showContextMenu: true,
|
||||
itemModifierClass: 'recent-item',
|
||||
dateField: 'modified_at', // mapped from accessed_at above
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
}
|
||||
});
|
||||
}
|
||||
batchToolbar.setActiveComponent(this._component);
|
||||
this._component.render(items);
|
||||
pathTooltip.init(filesList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error displaying recent files:', error);
|
||||
if (ui?.showNotification) {
|
||||
|
||||
@@ -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 {number} [opts.limit] - Max items per page (1–200, default 50).
|
||||
* @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page.
|
||||
* @param {string} [opts.orderBy] - Sort dimension: 'granted_at' | 'granted_by' (default: 'granted_at').
|
||||
* @returns {Promise<SharedWithMeResponse>}
|
||||
*/
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) {
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
resource_types: resourceTypes.join(',')
|
||||
});
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (orderBy) params.set('sort_by', orderBy);
|
||||
|
||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
||||
|
||||
|
||||
@@ -96,6 +96,19 @@ function prefetch() {
|
||||
_ensureIndex(); // intentionally fire-and-forget
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous best-effort display-name lookup from the pre-fetched cache.
|
||||
* Returns a shortened UUID prefix when the cache is not yet loaded.
|
||||
* Call `prefetch()` at view init time so the cache is warm by the time
|
||||
* items are rendered.
|
||||
* @param {string} userId
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDisplayNameSync(userId) {
|
||||
if (_index === null) return `${userId.slice(0, 8)}…`;
|
||||
return _index.get(userId) ?? `${userId.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user UUID to a display name.
|
||||
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
||||
@@ -159,4 +172,4 @@ function isAvailable() {
|
||||
return addressBook.isSystemAvailable();
|
||||
}
|
||||
|
||||
export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
||||
export const systemUsers = { prefetch, getDisplayName, getDisplayNameSync, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
||||
|
||||
@@ -5,24 +5,124 @@
|
||||
* current user access to, using the cursor-paginated
|
||||
* `GET /api/grants/incoming/resources` endpoint.
|
||||
*
|
||||
* Reuses the existing `#files-list` container and `ui.renderFolders` /
|
||||
* `ui.renderFiles` so the grid ↔ list toggle and all card components work
|
||||
* out of the box. A "Load more" button is injected below the files container
|
||||
* for cursor-based pagination.
|
||||
*
|
||||
* NOTE: the grid/list container will be extracted into a reusable component
|
||||
* in a future refactor — this view is intentionally kept thin.
|
||||
* Uses `ResourceListComponent` so the grid ↔ list toggle and all card
|
||||
* components work out of the box. A "Load more" button is injected below
|
||||
* the files container for cursor-based pagination.
|
||||
*/
|
||||
|
||||
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 { 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 { grants } from '../../model/grants.js';
|
||||
import { systemUsers } from '../../model/systemUsers.js';
|
||||
|
||||
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
|
||||
|
||||
/**
|
||||
* @typedef {{ key: string, label: string, orderBy: string,
|
||||
* keyFn: (item: FileItem|FolderItem) => string|null,
|
||||
* labelFn?: (key: string) => string }} GroupByDef
|
||||
*/
|
||||
|
||||
/**
|
||||
* Group-by dimension definitions for this section.
|
||||
* Exported via `sharedWithMeView.groupByDefs` so `main.js` can populate
|
||||
* the dropdown dynamically without knowing the internals of this view.
|
||||
*
|
||||
* `keyFn` returns the grouping key (stable UUID for owner, or a
|
||||
* human-readable bucket label for shareDate — the bucket IS the key because
|
||||
* it is already derived from the date, so no separate `labelFn` is needed
|
||||
* for shareDate).
|
||||
*
|
||||
* @type {GroupByDef[]}
|
||||
*/
|
||||
const GROUP_BY_DEFS = [
|
||||
{
|
||||
key: 'type',
|
||||
get label() {
|
||||
return i18n.t('groupby.type', 'Type');
|
||||
},
|
||||
orderBy: 'type',
|
||||
// keyFn: folders get their own swimlane; files use the pre-computed
|
||||
// `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …).
|
||||
// The server orders by category_order (a pre-computed SMALLINT column)
|
||||
// so items within the same category arrive grouped — no client sort needed.
|
||||
keyFn: (item) => ('mime_type' in item ? /** @type {Record<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`. */
|
||||
const LOAD_MORE_ID = 'swm-load-more-wrapper';
|
||||
|
||||
@@ -34,8 +134,39 @@ const sharedWithMeView = {
|
||||
|
||||
_loading: false,
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
_component: null,
|
||||
|
||||
/**
|
||||
* Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active.
|
||||
* @type {string}
|
||||
*/
|
||||
_groupBy: '',
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The group-by dimension definitions for this section.
|
||||
* `main.js` reads this to populate the Group-by dropdown dynamically.
|
||||
* @returns {GroupByDef[]}
|
||||
*/
|
||||
get groupByDefs() {
|
||||
return GROUP_BY_DEFS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Change the active group-by dimension and reload from page 1.
|
||||
* Calling with the current key is a no-op.
|
||||
* @param {string} key '' | 'owner' | 'shareDate'
|
||||
*/
|
||||
setGroupBy(key) {
|
||||
if (this._groupBy === key) return;
|
||||
this._groupBy = key;
|
||||
this._nextCursor = null; // restart from first page
|
||||
this._component?.clear();
|
||||
this._loadPage();
|
||||
},
|
||||
|
||||
/**
|
||||
* (Re-)load from page 1 and render into the existing files container.
|
||||
* Called every time the user switches to this section.
|
||||
@@ -43,6 +174,7 @@ const sharedWithMeView = {
|
||||
async init() {
|
||||
this._nextCursor = null;
|
||||
this._loading = false;
|
||||
this._groupBy = '';
|
||||
|
||||
this._ensureLoadMoreButton();
|
||||
|
||||
@@ -50,11 +182,55 @@ const sharedWithMeView = {
|
||||
// by the time the user hovers over an item.
|
||||
systemUsers.prefetch();
|
||||
|
||||
// Standard files-view setup: clear list, show container, init multiselect
|
||||
// Standard files-view setup: clear list, show container
|
||||
ui.resetFilesList();
|
||||
multiSelect.init();
|
||||
batchToolbar.init();
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -66,6 +242,8 @@ const sharedWithMeView = {
|
||||
const w = document.getElementById(LOAD_MORE_ID);
|
||||
if (w) w.classList.add('hidden');
|
||||
|
||||
batchToolbar.setActiveComponent(null);
|
||||
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) ownerTooltip.destroy(filesList);
|
||||
},
|
||||
@@ -74,23 +252,35 @@ const sharedWithMeView = {
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
*/
|
||||
async _loadPage() {
|
||||
if (this._loading) return;
|
||||
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 {
|
||||
const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy);
|
||||
|
||||
// When no swimlane grouping is active, sort by resource name so the
|
||||
// list is alphabetical (same expectation as the Files section).
|
||||
// Group-by modes supply their own orderBy via the def.
|
||||
const orderBy = def?.orderBy ?? 'name';
|
||||
|
||||
const data = await grants.fetchSharedWithMe({
|
||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||
limit: 50,
|
||||
cursor: this._nextCursor ?? undefined
|
||||
cursor: this._nextCursor ?? undefined,
|
||||
orderBy
|
||||
});
|
||||
|
||||
this._nextCursor = data.next_cursor ?? null;
|
||||
|
||||
if (data.items.length === 0 && !this._nextCursor) {
|
||||
if (data.items.length === 0 && isFirstPage) {
|
||||
// First page came back empty
|
||||
ui.showError(`
|
||||
<i class="fas fa-share-alt empty-state-icon"></i>
|
||||
@@ -101,19 +291,20 @@ const sharedWithMeView = {
|
||||
return;
|
||||
}
|
||||
|
||||
const { folders, files, ownerMap } = this._mapItems(data.items);
|
||||
if (folders.length) ui.renderFolders(folders);
|
||||
if (files.length) ui.renderFiles(files);
|
||||
const items = this._mapItems(data.items);
|
||||
|
||||
// Stamp data-owner-id on the freshly-rendered cards and attach tooltips.
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) {
|
||||
this._stampOwnerIds(filesList, ownerMap);
|
||||
ownerTooltip.init(filesList);
|
||||
if (isFirstPage) {
|
||||
this._component?.render(items, def?.keyFn, def?.labelFn);
|
||||
} else {
|
||||
this._component?.append(items, def?.keyFn, def?.labelFn);
|
||||
}
|
||||
|
||||
// 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).
|
||||
await ui.resolveOwnerCells();
|
||||
await this._component?.resolveOwnerCells();
|
||||
|
||||
this._setLoadMoreVisible(!!this._nextCursor);
|
||||
} catch (err) {
|
||||
@@ -128,87 +319,70 @@ const sharedWithMeView = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Map `SharedWithMeItem[]` to separate arrays for rendering plus an
|
||||
* `ownerMap` (itemId → grantedBy userId) used to stamp `data-owner-id`
|
||||
* after the cards are in the DOM.
|
||||
* Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in
|
||||
* **server-returned order**. The order must be preserved so that
|
||||
* swimlane grouping (group by owner / share date) works correctly when
|
||||
* the server interleaves files and folders by the sort key.
|
||||
*
|
||||
* The backend already includes all display fields (`icon_class`,
|
||||
* `icon_special_class`, `category`, `size_formatted`) inside the nested
|
||||
* `file` / `folder` objects, so no client-side enrichment is needed.
|
||||
* Sets `owner_id` to `item.granted_by` so the component stamps
|
||||
* `data-owner-id` with the granter's user ID automatically.
|
||||
* Sets `sort_date` (unix seconds) to the grant date so the shareDate
|
||||
* `keyFn` buckets by when the share was created, not the resource's
|
||||
* own modification time.
|
||||
*
|
||||
* @param {SharedWithMeItem[]} items
|
||||
* @returns {{ folders: FolderItem[], files: FileItem[], ownerMap: Map<string,string> }}
|
||||
* @returns {Array<FileItem|FolderItem>}
|
||||
*/
|
||||
_mapItems(items) {
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const result = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
|
||||
/** @type {Map<string, string>} itemId → grantedBy userId */
|
||||
const ownerMap = new Map();
|
||||
/** @param {string} iso @returns {number} */
|
||||
const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.resource_type === 'folder' && item.folder) {
|
||||
const f = item.folder;
|
||||
folders.push(
|
||||
if (item.resource_type === 'folder') {
|
||||
const f = /** @type {FolderItem} */ (item.resource);
|
||||
result.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
parent_id: f.parent_id ?? '',
|
||||
owner_id: f.owner_id ?? '',
|
||||
owner_id: item.granted_by,
|
||||
is_root: f.is_root ?? false,
|
||||
created_at: f.created_at,
|
||||
modified_at: f.modified_at,
|
||||
sort_date: grantedAtSecs(item.granted_at),
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: 'folder'
|
||||
})
|
||||
);
|
||||
ownerMap.set(f.id, item.granted_by);
|
||||
} else if (item.resource_type === 'file' && item.file) {
|
||||
const f = item.file;
|
||||
files.push(
|
||||
} else if (item.resource_type === 'file') {
|
||||
const f = /** @type {FileItem} */ (item.resource);
|
||||
result.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
folder_id: f.folder_id ?? '',
|
||||
owner_id: f.owner_id ?? '',
|
||||
owner_id: item.granted_by,
|
||||
mime_type: f.mime_type,
|
||||
size: f.size,
|
||||
size_formatted: f.size_formatted,
|
||||
created_at: f.created_at,
|
||||
modified_at: f.modified_at,
|
||||
sort_date: f.modified_at,
|
||||
sort_date: grantedAtSecs(item.granted_at),
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: f.category
|
||||
})
|
||||
);
|
||||
ownerMap.set(f.id, item.granted_by);
|
||||
}
|
||||
}
|
||||
|
||||
return { folders, files, ownerMap };
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// ── "Load more" button ────────────────────────────────────────────────────
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "مشترك من قِبل",
|
||||
"colDate": "تاريخ المشاركة",
|
||||
"colPermissions": "الصلاحيات"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "لا شيء",
|
||||
"title": "التجميع حسب",
|
||||
"owner": "المالك",
|
||||
"shareDate": "تاريخ المشاركة"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "اليوم",
|
||||
"last7days": "آخر 7 أيام",
|
||||
"last30days": "آخر 30 يومًا"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Geteilt von",
|
||||
"colDate": "Datum der Freigabe",
|
||||
"colPermissions": "Berechtigungen"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Keine",
|
||||
"title": "Gruppieren nach",
|
||||
"owner": "Eigentümer",
|
||||
"shareDate": "Freigabedatum"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Heute",
|
||||
"last7days": "Letzte 7 Tage",
|
||||
"last30days": "Letzte 30 Tage"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Shared by",
|
||||
"colDate": "Date shared",
|
||||
"colPermissions": "Permissions"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "None",
|
||||
"title": "Group by",
|
||||
"owner": "Owner",
|
||||
"shareDate": "Share date"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Today",
|
||||
"last7days": "Last 7 days",
|
||||
"last30days": "Last 30 days"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Compartido por",
|
||||
"colDate": "Fecha de compartición",
|
||||
"colPermissions": "Permisos"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Ninguno",
|
||||
"title": "Agrupar por",
|
||||
"owner": "Propietario",
|
||||
"shareDate": "Fecha de compartición"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Hoy",
|
||||
"last7days": "Últimos 7 días",
|
||||
"last30days": "Últimos 30 días"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "به اشتراکگذاشته توسط",
|
||||
"colDate": "تاریخ اشتراکگذاری",
|
||||
"colPermissions": "مجوزها"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "هیچ",
|
||||
"title": "گروهبندی بر اساس",
|
||||
"owner": "مالک",
|
||||
"shareDate": "تاریخ اشتراک"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "امروز",
|
||||
"last7days": "۷ روز گذشته",
|
||||
"last30days": "۳۰ روز گذشته"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Partagé par",
|
||||
"colDate": "Date de partage",
|
||||
"colPermissions": "Permissions"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Aucun",
|
||||
"title": "Grouper par",
|
||||
"owner": "Propriétaire",
|
||||
"shareDate": "Date de partage"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Aujourd'hui",
|
||||
"last7days": "7 derniers jours",
|
||||
"last30days": "30 derniers jours"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "द्वारा साझा किया",
|
||||
"colDate": "साझाकरण तिथि",
|
||||
"colPermissions": "अनुमतियाँ"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "कोई नहीं",
|
||||
"title": "इसके अनुसार समूहीकृत करें",
|
||||
"owner": "स्वामी",
|
||||
"shareDate": "साझा तिथि"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "आज",
|
||||
"last7days": "पिछले 7 दिन",
|
||||
"last30days": "पिछले 30 दिन"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Condiviso da",
|
||||
"colDate": "Data condivisione",
|
||||
"colPermissions": "Permessi"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Nessuno",
|
||||
"title": "Raggruppa per",
|
||||
"owner": "Proprietario",
|
||||
"shareDate": "Data condivisione"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Oggi",
|
||||
"last7days": "Ultimi 7 giorni",
|
||||
"last30days": "Ultimi 30 giorni"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "共有者",
|
||||
"colDate": "共有日",
|
||||
"colPermissions": "権限"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "なし",
|
||||
"title": "グループ化",
|
||||
"owner": "オーナー",
|
||||
"shareDate": "共有日"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今日",
|
||||
"last7days": "過去7日間",
|
||||
"last30days": "過去30日間"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "공유한 사람",
|
||||
"colDate": "공유 날짜",
|
||||
"colPermissions": "권한"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "없음",
|
||||
"title": "그룹화 기준",
|
||||
"owner": "소유자",
|
||||
"shareDate": "공유 날짜"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "오늘",
|
||||
"last7days": "최근 7일",
|
||||
"last30days": "최근 30일"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Gedeeld door",
|
||||
"colDate": "Datum gedeeld",
|
||||
"colPermissions": "Machtigingen"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Geen",
|
||||
"title": "Groeperen op",
|
||||
"owner": "Eigenaar",
|
||||
"shareDate": "Deeldatum"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Vandaag",
|
||||
"last7days": "Afgelopen 7 dagen",
|
||||
"last30days": "Afgelopen 30 dagen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Udostępnione przez",
|
||||
"colDate": "Data udostępnienia",
|
||||
"colPermissions": "Uprawnienia"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Brak",
|
||||
"title": "Grupuj według",
|
||||
"owner": "Właściciel",
|
||||
"shareDate": "Data udostępnienia"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Dzisiaj",
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
"last30days": "Ostatnie 30 dni"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Compartilhado por",
|
||||
"colDate": "Data de compartilhamento",
|
||||
"colPermissions": "Permissões"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Nenhum",
|
||||
"title": "Agrupar por",
|
||||
"owner": "Proprietário",
|
||||
"shareDate": "Data de partilha"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Hoje",
|
||||
"last7days": "Últimos 7 dias",
|
||||
"last30days": "Últimos 30 dias"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "Предоставлено",
|
||||
"colDate": "Дата предоставления",
|
||||
"colPermissions": "Права"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "Нет",
|
||||
"title": "Группировать по",
|
||||
"owner": "Владелец",
|
||||
"shareDate": "Дата общего доступа"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Сегодня",
|
||||
"last7days": "Последние 7 дней",
|
||||
"last30days": "Последние 30 дней"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "共享者",
|
||||
"colDate": "共享日期",
|
||||
"colPermissions": "權限"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "無",
|
||||
"title": "分組方式",
|
||||
"owner": "擁有者",
|
||||
"shareDate": "分享日期"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今天",
|
||||
"last7days": "近7天",
|
||||
"last30days": "近30天"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,5 +710,16 @@
|
||||
"colSharedBy": "共享者",
|
||||
"colDate": "共享日期",
|
||||
"colPermissions": "权限"
|
||||
},
|
||||
"groupby": {
|
||||
"none": "无",
|
||||
"title": "分组方式",
|
||||
"owner": "所有者",
|
||||
"shareDate": "分享日期"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今天",
|
||||
"last7days": "近7天",
|
||||
"last30days": "近30天"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers';
|
||||
import { expect } from '@playwright/test';
|
||||
import { test, goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers';
|
||||
|
||||
test('has OxiCloud title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { loginAsAdmin } from './helpers';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import { test, loginAsAdmin } from './helpers';
|
||||
|
||||
const FIXTURES = path.join(__dirname, '../../fixtures');
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
username: 'admin',
|
||||
@@ -38,7 +59,11 @@ export async function goToLoginPage(page: Page) {
|
||||
await page.goto('/');
|
||||
|
||||
// Both panels start with .hidden — wait for JS to reveal one.
|
||||
await page.waitForSelector('#language-panel:not(.hidden), #login-panel:not(.hidden)');
|
||||
// Use expect() (5 s default) rather than waitForSelector() (30 s) so a JS
|
||||
// crash fails fast instead of hanging for the full test timeout.
|
||||
await expect(
|
||||
page.locator('#language-panel:not(.hidden), #login-panel:not(.hidden)').first()
|
||||
).toBeAttached();
|
||||
|
||||
if (await page.locator('#language-panel').isVisible()) {
|
||||
await page.locator('#language-continue').click();
|
||||
|
||||
Reference in New Issue
Block a user