Merge pull request #396 from EdouardVanbelle/feat/itemview-and-swimlane

This commit is contained in:
Dionisio Pozo
2026-05-28 02:31:56 +02:00
committed by GitHub
61 changed files with 3772 additions and 1279 deletions
+10 -2
View File
@@ -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');
}
+2
View File
@@ -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" },
+1
View File
@@ -71,4 +71,5 @@ src/
## Further Reading
- [Caching Architecture →](/architecture/caching)
- [Resource Listing API →](/architecture/resource-listing)
- [Storage Quotas →](/architecture/storage-quotas)
+331
View File
@@ -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.
+469
View File
@@ -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);
+161
View File
@@ -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 }
}
}
+25
View File
@@ -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
+45 -20
View File
@@ -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>;
+2
View File
@@ -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
+29 -13
View File
@@ -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}")))?;
+208 -52
View File
@@ -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,
+32 -13
View File
@@ -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;
+89
View File
@@ -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;
}
+17
View File
@@ -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
View File
@@ -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");
+3 -25
View File
@@ -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;
+3 -25
View File
@@ -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;
+1 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+730
View File
@@ -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);
}
}
+61 -1
View File
@@ -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 };
+4
View File
@@ -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
View File
@@ -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.
*/
/**
@@ -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 };
+9 -9
View File
@@ -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;
}
+2 -2
View File
@@ -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 {
+3 -3
View File
@@ -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);
});
},
+61 -25
View 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) {
+91 -55
View 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 {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) {
+110
View File
@@ -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 };
+3 -1
View File
@@ -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}`);
+14 -1
View File
@@ -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 };
+238 -64
View File
@@ -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 ────────────────────────────────────────────────────
+11
View File
@@ -710,5 +710,16 @@
"colSharedBy": "مشترك من قِبل",
"colDate": "تاريخ المشاركة",
"colPermissions": "الصلاحيات"
},
"groupby": {
"none": "لا شيء",
"title": "التجميع حسب",
"owner": "المالك",
"shareDate": "تاريخ المشاركة"
},
"dateBucket": {
"today": "اليوم",
"last7days": "آخر 7 أيام",
"last30days": "آخر 30 يومًا"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -710,5 +710,16 @@
"colSharedBy": "به اشتراک‌گذاشته توسط",
"colDate": "تاریخ اشتراک‌گذاری",
"colPermissions": "مجوزها"
},
"groupby": {
"none": "هیچ",
"title": "گروه‌بندی بر اساس",
"owner": "مالک",
"shareDate": "تاریخ اشتراک"
},
"dateBucket": {
"today": "امروز",
"last7days": "۷ روز گذشته",
"last30days": "۳۰ روز گذشته"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -710,5 +710,16 @@
"colSharedBy": "द्वारा साझा किया",
"colDate": "साझाकरण तिथि",
"colPermissions": "अनुमतियाँ"
},
"groupby": {
"none": "कोई नहीं",
"title": "इसके अनुसार समूहीकृत करें",
"owner": "स्वामी",
"shareDate": "साझा तिथि"
},
"dateBucket": {
"today": "आज",
"last7days": "पिछले 7 दिन",
"last30days": "पिछले 30 दिन"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -710,5 +710,16 @@
"colSharedBy": "共有者",
"colDate": "共有日",
"colPermissions": "権限"
},
"groupby": {
"none": "なし",
"title": "グループ化",
"owner": "オーナー",
"shareDate": "共有日"
},
"dateBucket": {
"today": "今日",
"last7days": "過去7日間",
"last30days": "過去30日間"
}
}
+11
View File
@@ -710,5 +710,16 @@
"colSharedBy": "공유한 사람",
"colDate": "공유 날짜",
"colPermissions": "권한"
},
"groupby": {
"none": "없음",
"title": "그룹화 기준",
"owner": "소유자",
"shareDate": "공유 날짜"
},
"dateBucket": {
"today": "오늘",
"last7days": "최근 7일",
"last30days": "최근 30일"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -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"
}
}
+11
View File
@@ -710,5 +710,16 @@
"colSharedBy": "Предоставлено",
"colDate": "Дата предоставления",
"colPermissions": "Права"
},
"groupby": {
"none": "Нет",
"title": "Группировать по",
"owner": "Владелец",
"shareDate": "Дата общего доступа"
},
"dateBucket": {
"today": "Сегодня",
"last7days": "Последние 7 дней",
"last30days": "Последние 30 дней"
}
}
+11
View File
@@ -710,5 +710,16 @@
"colSharedBy": "共享者",
"colDate": "共享日期",
"colPermissions": "權限"
},
"groupby": {
"none": "無",
"title": "分組方式",
"owner": "擁有者",
"shareDate": "分享日期"
},
"dateBucket": {
"today": "今天",
"last7days": "近7天",
"last30days": "近30天"
}
}
+11
View File
@@ -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');
+27 -2
View File
@@ -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();