feat(swimlane): add swimlane engine with first version on SharedWithMe section

added group by:

        - None (= ordered by folders/file name)
        - Type (Folder first, then Image, Vidao, Audio, Document, etc...)
        - Owner
        - Size (With logarithmic groups))
        - Shared date (with groups: today, last 7 days, last 30 days, then year)
This commit is contained in:
Edouard Vanbelle
2026-05-27 00:32:10 +02:00
parent 2020e4374e
commit 5afb30ebfd
40 changed files with 1765 additions and 279 deletions
+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);
+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
@@ -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 -1
View File
@@ -258,11 +258,39 @@ pub struct IncomingGrantSummary {
/// Encodes the position of the last seen item in a cursor-paginated grant
/// listing. The encoding is opaque to API callers — only the backend
/// decodes it. Change the encoding algorithm in a major version bump.
/// decodes it.
///
/// The `sort_by` field must match the active sort dimension — if the caller
/// switches sort order the handler discards any cursor whose `sort_by` does
/// not match, restarting from the first page.
///
/// Sort-key fields populated per `sort_by` value:
/// - `"granted_at"` (default) — uses `granted_at` + `resource_id`
/// - `"name"` — uses `resource_name` (lowercased) + `resource_id`
/// - `"type"` — uses `type_order` + `resource_name` (lowercased) + `resource_id`
/// - `"granted_by"` — uses `resource_name` (owner display name, lowercased) + `granted_at` + `resource_id`
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GrantCursor {
/// Sort dimension that was active when this cursor was produced.
#[serde(default = "GrantCursor::default_sort")]
pub sort_by: String,
pub granted_at: chrono::DateTime<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 {
fn default_sort() -> String {
"granted_at".to_owned()
}
}
/// Delegate encode/decode to the shared [`PageCursor`] trait.
@@ -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}")))?;
+189 -33
View File
@@ -299,33 +299,49 @@ 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 (
// 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,
@@ -337,49 +353,189 @@ impl AuthorizationEngine for PgAclEngine {
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
)"#;
// ── 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
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
"#,
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'
)
.bind(subject.type_str())
.bind(subject.id())
.bind(kind_strs)
.bind(cursor_at)
.bind(cursor_id)
.bind(fetch_limit)
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: {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 {
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,
+19 -3
View File
@@ -323,13 +323,29 @@ pub async fn list_shared_with_me(
// Clamp limit to 1–200.
let limit = q.limit_clamped() as u32;
// Decode cursor (treat invalid cursor as "start from top").
let cursor = q.decode_cursor::<GrantCursor>();
// 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,
+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;
}
+60
View File
@@ -551,6 +551,66 @@
padding: 2px;
}
/* ── Swimlane group header ───────────────────────────────── */
/* Spans the full grid width in list view; no-op in grid view since
grid wraps it naturally. */
.resource-list__swimlane-header {
grid-column: 1 / -1;
padding: 6px 12px 4px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
border-bottom: 1px solid var(--color-border);
margin-top: 8px;
cursor: default;
user-select: none;
}
.resource-list__swimlane-header:first-child,
.list-header + .resource-list__swimlane-header {
margin-top: 0;
}
/* ── Swimlane group card (list view only) ────────────────── */
/* When swimlane groups are present, dissolve the outer container into the
page background so each group card reads as its own panel. */
.files-list-view:has(.resource-list__swimlane-group) {
background-color: transparent;
box-shadow: none;
border-radius: 0;
overflow: visible;
gap: 10px;
}
/* Each group is a self-contained card */
.files-list-view .resource-list__swimlane-group {
background-color: var(--color-item);
border-radius: 10px;
box-shadow: 0 1px 3px var(--color-shadow-xs);
overflow: hidden;
}
/* The header inside a group card is always first — no extra top margin */
.files-list-view .resource-list__swimlane-group .resource-list__swimlane-header {
margin-top: 0;
}
/* ── Swimlane group wrapper (grid view) ──────────────────── */
/* The group wrapper must be transparent to the grid so .file-item children
continue to flow in the parent's columns (subgrid mirrors the column tracks).
The wrapper spans the full row width; items inside fill the columns naturally. */
.files-grid-view .resource-list__swimlane-group {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
gap: 20px;
}
/* ── Section item modifiers ──────────────────────────────── */
/* — Favorites — */
+1 -1
View File
@@ -1,8 +1,8 @@
// @ts-check
import { i18n } from '../core/i18n.js';
import { inlineViewer } from '../features/files/inlineViewer.js';
import { batchToolbar } from '../features/files/batchToolbar.js';
import { inlineViewer } from '../features/files/inlineViewer.js';
import { resolveHomeFolder } from './authSession.js';
import { updateHistory } from './main.js';
import { app } from './state.js';
+101 -3
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 { fileOps } from '../features/files/fileOperations.js';
import { batchToolbar } from '../features/files/batchToolbar.js';
import { fileOps } from '../features/files/fileOperations.js';
import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
@@ -83,6 +83,15 @@ const _batchToolbarButons = `
const _toggleButtons = `
<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>
@@ -146,6 +155,7 @@ const ACTIONS_BAR_TEMPLATES = {
`,
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');
@@ -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 };
+16 -1
View File
@@ -12,7 +12,7 @@ import { recent } from '../features/library/recent.js';
import { sharedView } from '../views/shared/sharedView.js';
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
import { loadFiles } from './filesView.js';
import { setActionsBarMode } from './main.js';
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
import { app, appElements } from './state.js';
import { loadTrashItems } from './trashView.js';
import { ui } from './ui.js';
@@ -214,6 +214,11 @@ function switchToSharedWithMeSection() {
// Show actions-bar with view toggle (no upload / new-folder in this view)
setActionsBarMode('sharedwithme');
// Populate the group-by dropdown with this section's dimensions.
// Must be called AFTER setActionsBarMode() so the selector elements exist.
setGroupByView(sharedWithMeView);
syncGroupByMenu(sharedWithMeView.groupByDefs);
// Show the Owner column — names are resolved async after render.
ui.setOwnerColumnVisible(true);
@@ -232,6 +237,8 @@ function switchToFilesSection() {
// Set actions bar mode
setActionsBarMode('files', true);
setGroupByView(null);
syncGroupByMenu([]);
// Show owner column in the Files section
ui.setOwnerColumnVisible(true);
@@ -266,6 +273,8 @@ function switchToFavoritesSection() {
// Set actions bar mode
setActionsBarMode('favorites');
setGroupByView(null);
syncGroupByMenu([]);
// Show the Owner column — names are resolved async after render.
ui.setOwnerColumnVisible(true);
@@ -304,6 +313,8 @@ function switchToRecentFilesSection() {
// Set actions bar mode
setActionsBarMode('recent');
setGroupByView(null);
syncGroupByMenu([]);
// Hide breadcrumb (only shown in Files view)
const breadcrumb = document.querySelector('.breadcrumb');
@@ -367,6 +378,8 @@ function switchToTrashSection() {
toggleFileContainer(true);
setActionsBarMode('trash');
setGroupByView(null);
syncGroupByMenu([]);
//reset files view + remove any error
ui.resetFilesList();
@@ -419,6 +432,8 @@ function switchToMusicSection() {
function activateFilesUI() {
setCurrentSection('files');
setActionsBarMode('files', true);
setGroupByView(null);
syncGroupByMenu([]);
const breadcrumb = document.querySelector('.breadcrumb');
breadcrumb?.classList.remove('hidden');
toggleFileContainer(true);
+1 -1
View File
@@ -4,8 +4,8 @@
import { escapeHtml, formatDateTime } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { fileOps } from '../features/files/fileOperations.js';
import { batchToolbar } from '../features/files/batchToolbar.js';
import { fileOps } from '../features/files/fileOperations.js';
import * as pathTooltip from '../features/pathTooltip.js';
import { appElements } from './state.js';
import { ui } from './ui.js';
+1 -1
View File
@@ -10,10 +10,10 @@ import { createUserVignette } from '../components/userVignette.js';
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { OxiIcons } from '../core/icons.js';
import { batchToolbar } from '../features/files/batchToolbar.js';
import { contextMenus } from '../features/files/contextMenus.js';
import { fileOps } from '../features/files/fileOperations.js';
import { inlineViewer } from '../features/files/inlineViewer.js';
import { batchToolbar } from '../features/files/batchToolbar.js';
import { wopiEditor } from '../features/files/wopiEditor.js';
import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
+93 -30
View File
@@ -89,6 +89,30 @@ export class ResourceListComponent {
/** Index of the last clicked item — used for shift-click range selection. */
this._lastClickedIndex = -1;
/**
* The grouping key of the last rendered item — persisted across
* `append()` calls so load-more pages don't insert a redundant header
* when the first item of the new page shares a group with the last
* item of the previous page.
* `undefined` means no items have been rendered yet (reset in `render()`).
* @type {string|null|undefined}
*/
this._lastGroupKey = undefined;
/**
* The live DOM group wrapper of the last rendered swimlane — persisted
* across `append()` calls so load-more items that continue the same
* group are appended into the existing card rather than starting a new one.
* @type {HTMLElement|null}
*/
this._lastGroupEl = null;
/**
* Optional label-resolver stored between `render()` and `append()` calls.
* @type {((key: string) => string) | undefined}
*/
this._groupLabelFn = undefined;
this._ownerVisible = this._cfg.showOwner;
this._initDelegation();
@@ -100,13 +124,20 @@ export class ResourceListComponent {
* Replace the current item list. Preserves an existing `.list-header`
* at the start of the container.
*
* @param {FolderItem[]} folders
* @param {FileItem[]} files
* Items are rendered **in the order supplied** — do not pre-sort or
* split them into folders/files; the caller (or server) owns ordering.
* Folders vs. files are distinguished at render time by whether the item
* has a `mime_type` property.
*
* @param {Array<FileItem|FolderItem>} items
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
* When provided, a swimlane divider is injected whenever the returned
* label changes. Return `null` to suppress the divider for that item.
* key changes. Return `null` to suppress the divider for that item.
* @param {((key: string) => string)=} groupLabelFn
* Optional: converts the raw grouping key to a human-readable header
* label. When omitted the key itself is used.
*/
render(folders, files, groupFn) {
render(items, groupFn, groupLabelFn) {
const header = this._container.querySelector('.list-header');
this._container.innerHTML = '';
if (header) this._container.appendChild(header);
@@ -114,23 +145,29 @@ export class ResourceListComponent {
this._selected.clear();
this._items.clear();
this._lastClickedIndex = -1;
// Reset group tracking for the fresh render
this._lastGroupKey = undefined;
this._lastGroupEl = null;
this._groupLabelFn = groupLabelFn;
// Prevent ui.js global delegation from firing on this container
this._container.dataset.managedBy = 'resource-list';
this._appendItems(folders, files, groupFn);
this._appendItems(items, groupFn, groupLabelFn);
this._wireSelectAll();
}
/**
* Append additional items without clearing the existing ones (load-more).
* Continues swimlane grouping from the last item of the previous page —
* no redundant header is inserted when the key is unchanged.
*
* @param {FolderItem[]} folders
* @param {FileItem[]} files
* @param {Array<FileItem|FolderItem>} items
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
* @param {((key: string) => string)=} groupLabelFn
*/
append(folders, files, groupFn) {
this._appendItems(folders, files, groupFn);
append(items, groupFn, groupLabelFn) {
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn);
}
/** Remove all items (but keep `.list-header` if present). */
@@ -141,6 +178,8 @@ export class ResourceListComponent {
this._selected.clear();
this._items.clear();
this._lastClickedIndex = -1;
this._lastGroupKey = undefined;
this._lastGroupEl = null;
// Hand delegation back to ui.js
delete this._container.dataset.managedBy;
}
@@ -239,50 +278,74 @@ export class ResourceListComponent {
// ── Private helpers ─────────────────────────────────────────────────────
/**
* @param {FolderItem[]} folders
* @param {FileItem[]} files
* Internal: append items to the container in the order supplied.
* Files vs. folders are distinguished by presence of `mime_type`.
*
* @param {Array<FileItem|FolderItem>} items
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
* @param {((key: string) => string)=} groupLabelFn
*/
_appendItems(folders, files, groupFn) {
_appendItems(items, groupFn, groupLabelFn) {
const fragment = document.createDocumentFragment();
let lastGroupKey = /** @type {string|null|undefined} */ (undefined);
for (const folder of folders) {
this._items.set(folder.id, folder);
// Start from the persisted key so load-more pages continue seamlessly.
let lastGroupKey = this._lastGroupKey;
// liveGroup: existing DOM group element from the previous page; items
// that continue its group are appended directly into it (not via fragment).
// fragmentGroup: the group wrapper currently being built in the fragment.
let liveGroup = groupFn ? this._lastGroupEl : null;
let fragmentGroup = /** @type {HTMLElement|null} */ (null);
for (const item of items) {
this._items.set(item.id, item);
if (groupFn) {
const key = groupFn(folder);
const key = groupFn(item);
if (key !== lastGroupKey) {
lastGroupKey = key;
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
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);
}
}
fragment.appendChild(this._createFolderItem(folder));
}
for (const file of files) {
this._items.set(file.id, file);
if (groupFn) {
const key = groupFn(file);
if (key !== lastGroupKey) {
lastGroupKey = key;
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
// 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);
}
}
fragment.appendChild(this._createFileItem(file));
}
this._container.appendChild(fragment);
// Persist for the next append() call (load-more continuity).
this._lastGroupKey = lastGroupKey;
// Track the last group element (live or freshly added) for the next page.
this._lastGroupEl = fragmentGroup ?? liveGroup;
}
/**
* Create a swimlane divider element.
* @param {string} label
* @param {string} key - Raw grouping key (e.g. UUID or bucket name).
* @param {((key: string) => string)=} labelFn - Optional human-readable resolver.
*/
_createGroupHeader(label) {
_createGroupHeader(key, labelFn) {
const el = document.createElement('div');
el.className = 'resource-list__swimlane-header';
el.dataset.swimlaneHeader = 'true';
el.textContent = label;
el.textContent = labelFn ? labelFn(key) : key;
return el;
}
+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'
+1 -1
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 { batchToolbar } from './batchToolbar.js';
import { wopiEditor } from './wopiEditor.js';
/**
+9 -17
View File
@@ -7,9 +7,9 @@
*/
import { ui } from '../../app/ui.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { batchToolbar } from '../files/batchToolbar.js';
import * as pathTooltip from '../pathTooltip.js';
@@ -195,16 +195,13 @@ const favorites = {
return;
}
/** @type {FolderItem[]} */
const folders = [];
/** @type {FileItem[]} */
const files = [];
/** @type {Array<FileItem|FolderItem>} */
const items = [];
for (const item of this._cache.values()) {
// owner_id comes from the backend JOIN (actual file/folder owner)
if (item.item_type === 'folder') {
folders.push(
items.push(
/** @type {FolderItem} */ ({
id: item.item_id,
name: item.item_name || item.item_id,
@@ -220,7 +217,7 @@ const favorites = {
})
);
} else {
files.push(
items.push(
/** @type {FileItem} */ ({
id: item.item_id,
name: item.item_name || item.item_id,
@@ -244,9 +241,7 @@ const favorites = {
const filesList = document.getElementById('files-list');
if (filesList) {
if (!this._component) {
this._component = new ResourceListComponent(
/** @type {HTMLElement} */ (filesList),
{
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
selectable: true,
showFavorite: true,
showOwner: true,
@@ -276,18 +271,15 @@ const favorites = {
id: sel.id,
name: sel.name,
type: isFile ? 'file' : 'folder',
parentId: isFile
? (/** @type {FileItem} */ (sel)).folder_id || ''
: (/** @type {FolderItem} */ (sel)).parent_id || ''
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
});
}
batchToolbar._syncUI();
}
}
);
});
}
batchToolbar.setActiveComponent(this._component);
this._component.render(folders, files);
this._component.render(items);
pathTooltip.init(filesList);
}
+16 -22
View File
@@ -7,9 +7,9 @@
*/
import { ui } from '../../app/ui.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { batchToolbar } from '../files/batchToolbar.js';
import * as pathTooltip from '../pathTooltip.js';
@@ -109,9 +109,7 @@ const recent = {
if (filesList) {
// Relabel the date column header from "Modified" → "Accessed"
const dateHeader = /** @type {HTMLElement|null} */ (
[...filesList.querySelectorAll('.list-header > div')].find(
(el) => el.getAttribute('data-i18n') === 'files.modified'
)
[...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified')
);
if (dateHeader) {
dateHeader.removeAttribute('data-i18n');
@@ -133,16 +131,14 @@ const recent = {
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({
items.push(
/** @type {FolderItem} */ ({
id: item.item_id,
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
@@ -154,13 +150,15 @@ const recent = {
icon_special_class: item.icon_special_class,
owner_id: '',
is_root: false
});
})
);
} else {
if (item.item_mime_type === undefined || item.item_mime_type === null) {
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
console.warn('Broken information for RecentItem: ', item);
}
files.push({
items.push(
/** @type {FileItem} */ ({
id: item.item_id,
name: item.item_name || item.item_id,
folder_id: item.parent_id || '',
@@ -175,15 +173,14 @@ const recent = {
owner_id: '',
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
sort_date: item.accessed_at
});
})
);
}
}
if (filesList) {
if (!this._component) {
this._component = new ResourceListComponent(
/** @type {HTMLElement} */ (filesList),
{
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
selectable: true,
showFavorite: true,
showOwner: false,
@@ -202,18 +199,15 @@ const recent = {
id: sel.id,
name: sel.name,
type: isFile ? 'file' : 'folder',
parentId: isFile
? (/** @type {FileItem} */ (sel)).folder_id || ''
: (/** @type {FolderItem} */ (sel)).parent_id || ''
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
});
}
batchToolbar._syncUI();
}
}
);
});
}
batchToolbar.setActiveComponent(this._component);
this._component.render(folders, files);
this._component.render(items);
pathTooltip.init(filesList);
}
} catch (error) {
+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 };
+165 -27
View File
@@ -11,8 +11,9 @@
*/
import { ui } from '../../app/ui.js';
import { i18n } from '../../core/i18n.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import { batchToolbar } from '../../features/files/batchToolbar.js';
import { favorites } from '../../features/library/favorites.js';
import { ownerTooltip } from '../../features/ownerTooltip.js';
@@ -21,6 +22,107 @@ import { systemUsers } from '../../model/systemUsers.js';
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string,
* keyFn: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string }} GroupByDef
*/
/**
* Group-by dimension definitions for this section.
* Exported via `sharedWithMeView.groupByDefs` so `main.js` can populate
* the dropdown dynamically without knowing the internals of this view.
*
* `keyFn` returns the grouping key (stable UUID for owner, or a
* human-readable bucket label for shareDate — the bucket IS the key because
* it is already derived from the date, so no separate `labelFn` is needed
* for shareDate).
*
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: 'type',
get label() {
return i18n.t('groupby.type', 'Type');
},
orderBy: 'type',
// keyFn: folders get their own swimlane; files use the pre-computed
// `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …).
// The server orders by category_order (a pre-computed SMALLINT column)
// so items within the same category arrive grouped — no client sort needed.
keyFn: (item) => ('mime_type' in item ? /** @type {Record<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';
@@ -35,8 +137,36 @@ const sharedWithMeView = {
/** @type {ResourceListComponent|null} */
_component: null,
/**
* Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active.
* @type {string}
*/
_groupBy: '',
// ── Public API ────────────────────────────────────────────────────────────
/**
* The group-by dimension definitions for this section.
* `main.js` reads this to populate the Group-by dropdown dynamically.
* @returns {GroupByDef[]}
*/
get groupByDefs() {
return GROUP_BY_DEFS;
},
/**
* Change the active group-by dimension and reload from page 1.
* Calling with the current key is a no-op.
* @param {string} key '' | 'owner' | 'shareDate'
*/
setGroupBy(key) {
if (this._groupBy === key) return;
this._groupBy = key;
this._nextCursor = null; // restart from first page
this._component?.clear();
this._loadPage();
},
/**
* (Re-)load from page 1 and render into the existing files container.
* Called every time the user switches to this section.
@@ -44,6 +174,7 @@ const sharedWithMeView = {
async init() {
this._nextCursor = null;
this._loading = false;
this._groupBy = '';
this._ensureLoadMoreButton();
@@ -60,9 +191,7 @@ const sharedWithMeView = {
const filesList = document.getElementById('files-list');
if (filesList) {
if (!this._component) {
this._component = new ResourceListComponent(
/** @type {HTMLElement} */ (filesList),
{
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
selectable: true,
showFavorite: true,
showOwner: true,
@@ -92,15 +221,12 @@ const sharedWithMeView = {
id: sel.id,
name: sel.name,
type: isFile ? 'file' : 'folder',
parentId: isFile
? (/** @type {FileItem} */ (sel)).folder_id || ''
: (/** @type {FolderItem} */ (sel)).parent_id || ''
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
});
}
batchToolbar._syncUI();
}
}
);
});
}
batchToolbar.setActiveComponent(this._component);
}
@@ -138,10 +264,18 @@ const sharedWithMeView = {
const isFirstPage = this._nextCursor === null;
try {
const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy);
// When no swimlane grouping is active, sort by resource name so the
// list is alphabetical (same expectation as the Files section).
// Group-by modes supply their own orderBy via the def.
const orderBy = def?.orderBy ?? 'name';
const data = await grants.fetchSharedWithMe({
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
limit: 50,
cursor: this._nextCursor ?? undefined
cursor: this._nextCursor ?? undefined,
orderBy
});
this._nextCursor = data.next_cursor ?? null;
@@ -157,12 +291,12 @@ const sharedWithMeView = {
return;
}
const { folders, files } = this._mapItems(data.items);
const items = this._mapItems(data.items);
if (isFirstPage) {
this._component?.render(folders, files);
this._component?.render(items, def?.keyFn, def?.labelFn);
} else {
this._component?.append(folders, files);
this._component?.append(items, def?.keyFn, def?.labelFn);
}
// Wire owner tooltips after items are in the DOM
@@ -185,35 +319,41 @@ const sharedWithMeView = {
},
/**
* Map `SharedWithMeItem[]` to separate arrays for rendering.
* Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in
* **server-returned order**. The order must be preserved so that
* swimlane grouping (group by owner / share date) works correctly when
* the server interleaves files and folders by the sort key.
*
* Sets `owner_id` to `item.granted_by` so the component stamps
* `data-owner-id` with the granter's user ID automatically.
* Sets `sort_date` (unix seconds) to the grant date so the shareDate
* `keyFn` buckets by when the share was created, not the resource's
* own modification time.
*
* @param {SharedWithMeItem[]} items
* @returns {{ folders: FolderItem[], files: FileItem[] }}
* @returns {Array<FileItem|FolderItem>}
*/
_mapItems(items) {
/** @type {FolderItem[]} */
const folders = [];
/** @type {Array<FileItem|FolderItem>} */
const result = [];
/** @type {FileItem[]} */
const files = [];
/** @param {string} iso @returns {number} */
const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
for (const item of items) {
if (item.resource_type === 'folder') {
const f = /** @type {FolderItem} */ (item.resource);
folders.push(
result.push(
/** @type {FolderItem} */ ({
id: f.id,
name: f.name,
path: f.path ?? '',
parent_id: f.parent_id ?? '',
// Use granted_by as owner_id so the component populates
// data-owner-id with the sharing user's ID.
owner_id: item.granted_by,
is_root: f.is_root ?? false,
created_at: f.created_at,
modified_at: f.modified_at,
sort_date: grantedAtSecs(item.granted_at),
icon_class: f.icon_class,
icon_special_class: f.icon_special_class ?? '',
category: 'folder'
@@ -221,21 +361,19 @@ const sharedWithMeView = {
);
} else if (item.resource_type === 'file') {
const f = /** @type {FileItem} */ (item.resource);
files.push(
result.push(
/** @type {FileItem} */ ({
id: f.id,
name: f.name,
path: f.path ?? '',
folder_id: f.folder_id ?? '',
// Use granted_by as owner_id so the component populates
// data-owner-id with the sharing user's ID.
owner_id: item.granted_by,
mime_type: f.mime_type,
size: f.size,
size_formatted: f.size_formatted,
created_at: f.created_at,
modified_at: f.modified_at,
sort_date: f.modified_at,
sort_date: grantedAtSecs(item.granted_at),
icon_class: f.icon_class,
icon_special_class: f.icon_special_class ?? '',
category: f.category
@@ -244,7 +382,7 @@ const sharedWithMeView = {
}
}
return { folders, files };
return result;
},
// ── "Load more" button ────────────────────────────────────────────────────
+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天"
}
}