diff --git a/docs/plan/ResourceView-with-swimlane.md b/docs/plan/ResourceView-with-swimlane.md new file mode 100644 index 00000000..02809ae9 --- /dev/null +++ b/docs/plan/ResourceView-with-swimlane.md @@ -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 | + * @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} */ (/** @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 = ` +
+
+ + +
+ + + +
+`; +``` + +**B. Update sharedwithme template:** Also add missing `_batchToolbarButons`: +```js +sharedwithme: ` +
+ ${_batchToolbarButons} + ${_toggleButtonsWithGroupBy} +` +``` + +**C. `setupActionsBarDelegation()` — add group-by handling before the switch:** +```js +// Group-by option selected +if (btn.classList.contains('group-by-option')) { + const key = btn.dataset.groupBy ?? ''; + sharedWithMeView.setGroupBy(key); + document.querySelectorAll('.group-by-option').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.getElementById('group-by-menu')?.classList.add('hidden'); + document.getElementById('group-by-btn')?.classList.toggle('active', !!key); + return; +} +switch (btn.id) { + case 'group-by-btn': + document.getElementById('group-by-menu')?.classList.toggle('hidden'); + break; + … +} +``` + +**D.** Add a `document.addEventListener('click', …)` (or reuse the existing upload-dropdown pattern) to close the group-by menu on outside clicks. + +### 8. CSS for group-by dropdown + +Add to `static/css/components/buttons.css` (already contains `.view-toggle` styles): + +```css +/* ── Group-by selector (inside .view-toggle) ─────────── */ +.view-toggle-separator { + width: 1px; + height: 20px; + background: var(--color-border); + align-self: center; + margin: 0 2px; +} + +.group-by-selector { + position: relative; +} + +.group-by-btn.active { color: var(--color-accent); } + +.group-by-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 200; + min-width: 140px; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 16px var(--color-shadow); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.group-by-menu.hidden { display: none; } + +.group-by-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + color: var(--color-text); + text-align: left; + width: 100%; +} + +.group-by-option:hover { background: var(--color-border); } +.group-by-option.active { color: var(--color-accent); font-weight: 600; } +``` + +--- + +## Changes — Backend + +### 9. `src/domain/services/authorization.rs` — update `GrantCursor` + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GrantCursor { + /// Sort dimension active when this cursor was issued. + /// Mis-match with the current `sort_by` param → cursor is discarded. + #[serde(default = "GrantCursor::default_sort")] + pub sort_by: String, + pub granted_at: chrono::DateTime, + pub resource_id: Uuid, + /// Present only when `sort_by == "granted_by"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub granted_by: Option, +} +impl GrantCursor { + fn default_sort() -> String { "granted_at".to_owned() } +} +impl PageCursor for GrantCursor {} +``` + +Old cursors (which lack `sort_by`) fail serde and are treated as "start from top" — the existing "undecodable cursor → restart" invariant applies. + +### 10. `src/application/ports/authorization_ports.rs` — add `sort_by` arg + +```rust +async fn list_incoming_resources_paged( + &self, + subject: Subject, + kinds: &[ResourceKind], + limit: u32, + cursor: Option, + sort_by: &str, // "granted_at" | "granted_by" +) -> Result<(Vec, Option), DomainError>; +``` + +### 11. `src/infrastructure/services/pg_acl_engine.rs` — branch SQL on sort_by + +Two separate `sqlx::query_as` calls, selected at runtime: + +**`sort_by = "granted_by"` SQL:** +```sql +WITH agg AS ( … same aggregation … ) +SELECT resource_type, resource_id, permissions, granted_at, granted_by +FROM agg +WHERE ( $4::uuid IS NULL -- cursor_by + OR granted_by > $4::uuid + OR (granted_by = $4::uuid AND ( + $5::timestamptz IS NULL -- cursor_at + OR granted_at < $5 + OR (granted_at = $5 AND resource_id < $6::uuid)))) +ORDER BY granted_by ASC, granted_at DESC, resource_id DESC +LIMIT $7 +``` +Cursor for next page: `GrantCursor { sort_by: "granted_by", granted_at: r.3, resource_id: r.1, granted_by: Some(r.4) }` + +**`sort_by = "granted_at"` SQL (existing, unchanged except cursor struct gains `sort_by` field):** +Cursor for next page: `GrantCursor { sort_by: "granted_at", granted_at: r.3, resource_id: r.1, granted_by: None }` + +### 12. `src/interfaces/api/handlers/grant_handler.rs` + +```rust +let sort_by = q.sort_by.as_deref().unwrap_or("granted_at"); +if !matches!(sort_by, "granted_at" | "granted_by") { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "invalid sort_by"}))).into_response(); +} +// Invalidate cursor when sort mode changed (prevents keyset confusion) +let cursor = q.decode_cursor::() + .filter(|c| c.sort_by == sort_by); + +let (summaries, next_cursor) = state.authorization + .list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by) + .await …; +``` + +--- + +## Files touched + +| File | Change | +|---|---| +| `static/js/components/resourceList.js` | Persist `_lastGroupKey`; add `groupLabelFn` param to `render`/`append` | +| `static/css/components/resourceList.css` | Add `.resource-list__swimlane-header` styles | +| `static/js/core/formatters.js` | Add `normalizeDateBucket()` | +| `static/js/model/systemUsers.js` | Add `getDisplayNameSync()` | +| `static/js/model/grants.js` | Add `orderBy` param | +| `static/js/views/sharedWithMe/sharedWithMeView.js` | Add `_groupBy`, `setGroupBy()`, `GROUP_BY_DEFS`; update `_mapItems`, `_loadPage` | +| `static/js/app/main.js` | Add `_toggleButtonsWithGroupBy`; add batch toolbar to sharedwithme; wire group-by delegation | +| `static/css/components/buttons.css` | Add group-by dropdown styles + `.view-toggle-separator` | +| `src/domain/services/authorization.rs` | Update `GrantCursor` struct | +| `src/application/ports/authorization_ports.rs` | Add `sort_by` to trait method signature | +| `src/infrastructure/services/pg_acl_engine.rs` | Branch SQL on `sort_by`; emit new cursor shape | +| `src/interfaces/api/handlers/grant_handler.rs` | Extract + validate `sort_by`; filter cursor on mismatch | + +--- + +## Known limitations (out of scope) + +- **Owner sort order is by UUID, not display name.** Items from the same owner are correctly grouped, but the ORDER of groups is UUID-lexicographic, not alphabetical by name. Alphabetical ordering would require a server-side join to the users table and a different cursor — deferred. +- **Batch operations from SharedWithMe navigate to Files** — `batchDelete()` calls `loadFiles()`. Pre-existing bug; separate PR. +- **Group-by is SharedWithMe-only** — the `GroupByDef` contract is extensible but no other view is wired in this PR. + +--- + +## Verification + +```bash +# Backend +cargo fmt --all +cargo clippy --all-features --all-targets -- -D warnings +cargo test --workspace + +# Frontend +biome lint static/js/ +stylelint static/css/ +tsc -p jsconfig.json --noEmit +``` + +Manual smoke tests: +1. SharedWithMe loads with no group-by → items appear, no swimlane headers +2. Select "Owner" → page reloads, swimlane headers show resolved display names grouped by granter +3. "Load more" appends without inserting a redundant header for a continuing group +4. Select "Share date" → swimlane headers: Today / Last 7 days / Last 30 days / year +5. Switch back to "None" → plain list, no headers +6. Cursor cursor changes don't bleed across sort modes (switching group-by resets to page 1) +7. Grid ↔ list toggle still works in all group-by states +8. Group-by menu closes when clicking outside diff --git a/migrations/20260527000001_files_category_order.sql b/migrations/20260527000001_files_category_order.sql new file mode 100644 index 00000000..c6ad5f30 --- /dev/null +++ b/migrations/20260527000001_files_category_order.sql @@ -0,0 +1,83 @@ +-- ── storage.files: pre-computed category_order ────────────────────────────── +-- Stores a numeric sort bucket derived from the file's mime_type so that +-- GROUP-BY-TYPE queries in the grants engine can ORDER BY an indexed integer +-- instead of evaluating a long CASE WHEN mime_type LIKE '…' chain at runtime. +-- +-- Values are sparse multiples of 100 so future categories can be inserted +-- between existing ones without renumbering (e.g. "RichText" = 550). +-- Folder rows live in storage.folders and are not affected; the SQL query +-- hard-codes 0 for them. +-- +-- Mapping (mirrors category_order_for() in display_helpers.rs): +-- 0 → Folder (SQL-only constant, not stored) +-- 100 → Image +-- 200 → Video +-- 300 → Audio +-- 400 → PDF +-- 500 → Document +-- 600 → Spreadsheet +-- 700 → Presentation +-- 800 → Archive +-- 900 → Code +-- 1000 → Markdown +-- 1100 → Text +-- 1200 → Installer +-- 9999 → Other (default) + +ALTER TABLE storage.files + ADD COLUMN IF NOT EXISTS category_order SMALLINT NOT NULL DEFAULT 9999; + +-- Backfill existing rows. The CASE mirrors category_for() + category_order_for(). +UPDATE storage.files +SET category_order = CASE + -- Image + WHEN mime_type LIKE 'image/%' THEN 100 + -- Video + WHEN mime_type LIKE 'video/%' THEN 200 + -- Audio + WHEN mime_type LIKE 'audio/%' THEN 300 + -- PDF + WHEN mime_type = 'application/pdf' THEN 400 + -- Document + WHEN mime_type IN ( + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'application/rtf') THEN 500 + -- Spreadsheet + WHEN mime_type IN ( + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + 'text/csv') THEN 600 + -- Presentation + WHEN mime_type IN ( + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation') THEN 700 + -- Archive + WHEN mime_type IN ( + 'application/zip', 'application/x-rar-compressed', 'application/vnd.rar', + 'application/x-7z-compressed', 'application/gzip', 'application/x-tar') THEN 800 + -- Code (application/* and text/x-* variants) + WHEN mime_type IN ( + 'application/json', 'application/javascript', 'application/typescript', + 'application/xml', 'application/sql', + 'application/x-sh', 'application/x-shellscript') + OR mime_type LIKE 'text/x-%' + OR mime_type LIKE 'text/html%' + OR mime_type = 'text/css' THEN 900 + -- Markdown + WHEN mime_type LIKE 'text/markdown%' THEN 1000 + -- Text (generic) + WHEN mime_type LIKE 'text/%' THEN 1100 + -- Installer / disk image + WHEN mime_type IN ( + 'application/x-apple-diskimage', 'application/x-ms-dos-executable', + 'application/x-msdownload', 'application/x-msi') THEN 1200 + -- Everything else → Other + ELSE 9999 +END; + +-- Index so ORDER BY category_order is a fast index scan, not a table sort. +CREATE INDEX IF NOT EXISTS idx_files_category_order ON storage.files (category_order); diff --git a/src/application/dtos/display_helpers.rs b/src/application/dtos/display_helpers.rs index b9590d2c..4ad0f059 100644 --- a/src/application/dtos/display_helpers.rs +++ b/src/application/dtos/display_helpers.rs @@ -344,6 +344,31 @@ pub fn category_for(name: &str, mime: &str) -> &'static str { "Document" } +/// Returns the sort order for a file category, stored as `category_order` in `storage.files`. +/// +/// Values are **sparse multiples of 100** so a future category can be slotted between two +/// existing ones (e.g. "RichText" = 550, between Document=500 and Spreadsheet=600) without +/// renumbering any rows. Folders are not handled here — the SQL query hard-codes 0 for them. +/// +/// This function delegates to [`category_for`] so the two are always in sync. +pub fn category_order_for(name: &str, mime: &str) -> i16 { + match category_for(name, mime) { + "Image" => 100, + "Video" => 200, + "Audio" => 300, + "PDF" => 400, + "Document" => 500, + "Spreadsheet" => 600, + "Presentation" => 700, + "Archive" => 800, + "Code" => 900, + "Markdown" => 1000, + "Text" => 1100, + "Installer" => 1200, + _ => 9999, // Other / unknown + } +} + /// Formats a byte count into a human-readable string (1024-based). /// /// Matches the JavaScript `formatFileSize()` output exactly so the frontend diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 2e46dddb..6e752f33 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -85,6 +85,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static { kinds: &[ResourceKind], limit: u32, cursor: Option, + sort_by: &str, ) -> Result<(Vec, Option), DomainError>; /// All grants on a specific resource (for "Manage sharing" UI). Caller diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index bdfcbb22..c062c273 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -258,11 +258,39 @@ pub struct IncomingGrantSummary { /// Encodes the position of the last seen item in a cursor-paginated grant /// listing. The encoding is opaque to API callers — only the backend -/// decodes it. Change the encoding algorithm in a major version bump. +/// decodes it. +/// +/// The `sort_by` field must match the active sort dimension — if the caller +/// switches sort order the handler discards any cursor whose `sort_by` does +/// not match, restarting from the first page. +/// +/// Sort-key fields populated per `sort_by` value: +/// - `"granted_at"` (default) — uses `granted_at` + `resource_id` +/// - `"name"` — uses `resource_name` (lowercased) + `resource_id` +/// - `"type"` — uses `type_order` + `resource_name` (lowercased) + `resource_id` +/// - `"granted_by"` — uses `resource_name` (owner display name, lowercased) + `granted_at` + `resource_id` #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GrantCursor { + /// Sort dimension that was active when this cursor was produced. + #[serde(default = "GrantCursor::default_sort")] + pub sort_by: String, pub granted_at: chrono::DateTime, pub resource_id: Uuid, + /// Lowercased sort string — resource name for `"name"`/`"type"`, + /// owner display name for `"granted_by"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_name: Option, + /// Generic integer sort key: + /// - `"type"` — category_order (0 = Folder, 100 = Image, …) + /// - `"size"` — file size in bytes (-1 = Folder sentinel) + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_int: Option, +} + +impl GrantCursor { + fn default_sort() -> String { + "granted_at".to_owned() + } } /// Delegate encode/decode to the shared [`PageCursor`] trait. diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 942f4615..3bc1a9bd 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use std::sync::Arc; use uuid::Uuid; +use crate::application::dtos::display_helpers::category_order_for; use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort}; use crate::common::errors::DomainError; use crate::domain::entities::file::File; @@ -226,8 +227,8 @@ impl FileBlobWriteRepository { let row = match sqlx::query_as::<_, (String, i64, i64)>( r#" - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) - VALUES ($1, $2::uuid, $3, $4, $5, $6) + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order) + VALUES ($1, $2::uuid, $3, $4, $5, $6, $7) RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -239,6 +240,7 @@ impl FileBlobWriteRepository { .bind(&blob_hash) .bind(size as i64) .bind(&content_type) + .bind(category_order_for(&name, &content_type)) .fetch_one(self.pool.as_ref()) .await { @@ -374,18 +376,19 @@ impl FileWritePort for FileBlobWriteRepository { >( r#" WITH src AS ( - SELECT name, folder_id, user_id, blob_hash, size, mime_type + SELECT name, folder_id, user_id, blob_hash, size, mime_type, category_order FROM storage.files WHERE id = $1::uuid AND NOT is_trashed ), new_file AS ( - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order) SELECT name, COALESCE($2::uuid, folder_id), user_id, blob_hash, size, - mime_type + mime_type, + category_order FROM src RETURNING id::text, name, folder_id::text, size, mime_type, EXTRACT(EPOCH FROM created_at)::bigint, @@ -537,8 +540,8 @@ impl FileWritePort for FileBlobWriteRepository { let row = sqlx::query_as::<_, (String, i64, i64)>( r#" - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) - VALUES ($1, $2::uuid, $3, $4, $5, $6) + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order) + VALUES ($1, $2::uuid, $3, $4, $5, $6, $7) RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -550,6 +553,7 @@ impl FileWritePort for FileBlobWriteRepository { .bind(placeholder_hash) .bind(size as i64) .bind(&content_type) + .bind(category_order_for(&name, &content_type)) .fetch_one(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?; diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 34d1be13..54df9614 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -299,87 +299,243 @@ impl AuthorizationEngine for PgAclEngine { kinds: &[ResourceKind], limit: u32, cursor: Option, + sort_by: &str, ) -> Result<(Vec, Option), DomainError> { - // Build kind filter array — NULL means "all kinds". + // ── Common setup ────────────────────────────────────────────────────── let kind_strs: Option> = if kinds.is_empty() { None } else { Some(kinds.iter().map(|k| k.as_str()).collect()) }; - - let cursor_at = cursor.as_ref().map(|c| c.granted_at); - let cursor_id = cursor.as_ref().map(|c| c.resource_id); - - // Fetch limit+1 rows so we can detect whether a next page exists. let fetch_limit = (limit as i64) + 1; - // Each row: (resource_type, resource_id, permissions_text_array, - // granted_at, granted_by) + // Unified row type — the last two columns carry the sort key when present, + // NULL otherwise. This lets every sort mode share a single query_as call. + // 0 resource_type String + // 1 resource_id Uuid + // 2 permissions Vec + // 3 granted_at DateTime + // 4 granted_by Uuid + // 5 sort_str Option — resource_name (name/type) or owner_name (granted_by) + // 6 sort_int Option — category_order (type) or file size in bytes (size) type Row = ( String, Uuid, Vec, chrono::DateTime, Uuid, + Option, + Option, ); - let rows: Vec = sqlx::query_as( - r#" - WITH agg AS ( - SELECT - resource_type, - resource_id, - array_agg(DISTINCT permission ORDER BY permission) AS permissions, - MIN(granted_at) AS granted_at, - (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by - FROM storage.access_grants - WHERE subject_type = $1 - AND subject_id = $2 - AND ($3::text[] IS NULL OR resource_type = ANY($3)) - GROUP BY resource_type, resource_id - ) - SELECT resource_type, resource_id, permissions, granted_at, granted_by - FROM agg - WHERE ( $4::timestamptz IS NULL - OR granted_at < $4 - OR (granted_at = $4 AND resource_id < $5::uuid)) - ORDER BY granted_at DESC, resource_id DESC - LIMIT $6 - "#, - ) - .bind(subject.type_str()) - .bind(subject.id()) - .bind(kind_strs) - .bind(cursor_at) - .bind(cursor_id) - .bind(fetch_limit) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("PgAcl", format!("list_incoming_resources_paged: {e}")) - })?; + // Extract all cursor fields up-front; each branch uses the subset it needs. + // Fixed parameter positions used in all SQL variants: + // $4 = cursor_str (resource_name / owner_name) + // $5 = cursor_int (type_order) + // $6 = cursor_at (granted_at) + // $7 = cursor_id (resource_id) + // $8 = fetch_limit + let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone()); + let cursor_int = cursor.as_ref().and_then(|c| c.sort_int); + let cursor_at = cursor.as_ref().map(|c| c.granted_at); + let cursor_id = cursor.as_ref().map(|c| c.resource_id); + // ── agg CTE (identical in all branches) ─────────────────────────────── + const AGG: &str = r#"agg AS ( + SELECT + resource_type, + resource_id, + array_agg(DISTINCT permission ORDER BY permission) AS permissions, + MIN(granted_at) AS granted_at, + (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by + FROM storage.access_grants + WHERE subject_type = $1 + AND subject_id = $2 + AND ($3::text[] IS NULL OR resource_type = ANY($3)) + GROUP BY resource_type, resource_id + )"#; + + // ── Build sort-specific SQL fragments ───────────────────────────────── + // "name" and "type" share the same LEFT JOINs; only sort_int_expr, + // the cursor WHERE condition, and ORDER BY differ. + let sql = match sort_by { + "name" | "type" => { + let sort_int_expr = if sort_by == "type" { + "CASE WHEN agg.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END" + } else { + "NULL::bigint" + }; + let where_clause = if sort_by == "type" { + r#"( $5::integer IS NULL + OR sort_int > $5 + OR (sort_int = $5 AND LOWER(sort_str) > $4) + OR (sort_int = $5 AND LOWER(sort_str) = $4 AND resource_id > $7::uuid))"# + } else { + r#"( $4::text IS NULL + OR LOWER(sort_str) > $4 + OR (LOWER(sort_str) = $4 AND resource_id > $7::uuid))"# + }; + let order_clause = if sort_by == "type" { + "sort_int ASC, LOWER(sort_str) ASC, resource_id ASC" + } else { + "LOWER(sort_str) ASC, resource_id ASC" + }; + format!( + r#"WITH {AGG}, + named AS ( + SELECT agg.*, + COALESCE( + CASE WHEN agg.resource_type = 'folder' THEN f.name END, + CASE WHEN agg.resource_type = 'file' THEN fi.name END + ) AS sort_str, + {sort_int_expr} AS sort_int + FROM agg + LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder' + LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file' + ) + SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int + FROM named + WHERE {where_clause} + ORDER BY {order_clause} + LIMIT $8"# + ) + } + "granted_by" => format!( + // Joins auth.users to sort alphabetically by username. + // Cursor encodes (owner_name=$4, granted_at=$6, resource_id=$7). + r#"WITH {AGG}, + owner_named AS ( + SELECT agg.*, + LOWER(u.username) AS sort_str, + NULL::bigint AS sort_int + FROM agg + LEFT JOIN auth.users u ON u.id = agg.granted_by + ) + SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int + FROM owner_named + WHERE ( $4::text IS NULL + OR sort_str > $4 + OR (sort_str = $4 AND ( + $6::timestamptz IS NULL + OR granted_at < $6 + OR (granted_at = $6 AND resource_id < $7::uuid)))) + ORDER BY sort_str ASC, granted_at DESC, resource_id DESC + LIMIT $8"# + ), + "size" => format!( + // Folders have no size — they sort first with a sentinel of -1. + // Files sort by size ASC; resource_id breaks ties. + // Cursor encodes (sort_int=$5, resource_id=$7); $4/$6 unused. + r#"WITH {AGG}, + sized AS ( + SELECT agg.*, + NULL::text AS sort_str, + CASE WHEN agg.resource_type = 'folder' THEN -1 + ELSE fi.size + END AS sort_int + FROM agg + LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file' + ) + SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int + FROM sized + WHERE ( $5::bigint IS NULL + OR sort_int > $5 + OR (sort_int = $5 AND resource_id > $7::uuid)) + ORDER BY sort_int ASC, resource_id ASC + LIMIT $8"# + ), + _ => format!( + // Default: sort by grant date DESC (newest first). + // Cursor encodes (granted_at=$6, resource_id=$7); $4/$5 unused. + r#"WITH {AGG} + SELECT resource_type, resource_id, permissions, granted_at, granted_by, + NULL::text AS sort_str, + NULL::bigint AS sort_int + FROM agg + WHERE ( $6::timestamptz IS NULL + OR granted_at < $6 + OR (granted_at = $6 AND resource_id < $7::uuid)) + ORDER BY granted_at DESC, resource_id DESC + LIMIT $8"# + ), + }; + + // ── Execute — uniform 8 binds for every sort mode ───────────────────── + let mut rows: Vec = sqlx::query_as::<_, Row>(&sql) + .bind(subject.type_str()) // $1 + .bind(subject.id()) // $2 + .bind(&kind_strs) // $3 + .bind(&cursor_str) // $4 sort_str cursor + .bind(cursor_int) // $5 sort_int cursor + .bind(cursor_at) // $6 granted_at cursor + .bind(cursor_id) // $7 resource_id cursor + .bind(fetch_limit) // $8 + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error( + "PgAcl", + format!("list_incoming_resources_paged ({sort_by}): {e}"), + ) + })?; + + // ── Pagination ──────────────────────────────────────────────────────── let has_next = rows.len() > limit as usize; - let rows: Vec = rows.into_iter().take(limit as usize).collect(); + rows.truncate(limit as usize); - // Determine the next cursor from the last item we're actually returning. let next_cursor = if has_next { - rows.last().map(|r| GrantCursor { - granted_at: r.3, - resource_id: r.1, + rows.last().map(|r| { + let sort_str_lc = r.5.as_deref().map(str::to_lowercase); + match sort_by { + "name" => GrantCursor { + sort_by: "name".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: None, + }, + "type" => GrantCursor { + sort_by: "type".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: r.6, + }, + "granted_by" => GrantCursor { + sort_by: "granted_by".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: r.5.clone(), // already lowercased by SQL + sort_int: None, + }, + "size" => GrantCursor { + sort_by: "size".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: None, + sort_int: r.6, + }, + _ => GrantCursor { + sort_by: "granted_at".to_owned(), + granted_at: r.3, + resource_id: r.1, + resource_name: None, + sort_int: None, + }, + } }) } else { None }; - // Convert rows into domain summaries. + // ── Convert rows to domain summaries ────────────────────────────────── let summaries = rows .into_iter() - .filter_map(|(rt, rid, perms_str, granted_at, granted_by)| { + .filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| { let resource_type = ResourceKind::parse(&rt)?; let permissions = perms_str - .iter() - .filter_map(|s| Permission::parse(s)) + .into_iter() + .filter_map(|s| Permission::parse(&s)) .collect(); Some(IncomingGrantSummary { resource_type, diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 12c34128..4019d96c 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -323,13 +323,29 @@ pub async fn list_shared_with_me( // Clamp limit to 1–200. let limit = q.limit_clamped() as u32; - // Decode cursor (treat invalid cursor as "start from top"). - let cursor = q.decode_cursor::(); + // Validate sort_by (defaults to "granted_at"). + let sort_by = q.sort_by.as_deref().unwrap_or("granted_at"); + if !matches!( + sort_by, + "granted_at" | "granted_by" | "name" | "type" | "size" + ) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type, size"})), + ) + .into_response(); + } + + // Decode cursor — discard it when the sort dimension changed to avoid + // keyset confusion across sort modes. + let cursor = q + .decode_cursor::() + .filter(|c| c.sort_by == sort_by); // Fetch paged summaries from the ACL engine. let (summaries, next_cursor) = match state .authorization - .list_incoming_resources_paged(subject, &kinds, limit, cursor) + .list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by) .await { Ok(r) => r, diff --git a/static/css/components/buttons.css b/static/css/components/buttons.css index f9f7c398..82a690e2 100644 --- a/static/css/components/buttons.css +++ b/static/css/components/buttons.css @@ -105,3 +105,92 @@ .toggle-btn i { pointer-events: none; } + +/* ── Group-by selector (inside .view-toggle) ────────────── */ + +.view-toggle-separator { + width: 1px; + height: 20px; + background: var(--color-border-medium); + align-self: center; + margin: 0 2px; +} + +.view-toggle-separator.hidden { + display: none; +} + +.group-by-selector { + position: relative; +} + +.group-by-selector.hidden { + display: none; +} + +.group-by-btn.active { + color: var(--color-accent); +} + +/* Active label shown inline next to the icon */ +.group-by-label { + display: none; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} + +/* When a group-by is selected the label has text — expand the button to fit */ +.group-by-btn:has(.group-by-label:not(:empty)) { + width: auto; + padding: 0 8px; + gap: 5px; +} + +.group-by-btn:has(.group-by-label:not(:empty)) .group-by-label { + display: inline; +} + +.group-by-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 200; + min-width: 140px; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 16px var(--color-shadow); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.group-by-menu.hidden { + display: none; +} + +.group-by-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + color: var(--color-text); + text-align: left; + width: 100%; +} + +.group-by-option:hover { + background: var(--color-border); +} + +.group-by-option.active { + color: var(--color-accent); + font-weight: 600; +} diff --git a/static/css/components/resourceList.css b/static/css/components/resourceList.css index ad57be34..a26e0d90 100644 --- a/static/css/components/resourceList.css +++ b/static/css/components/resourceList.css @@ -551,6 +551,66 @@ padding: 2px; } +/* ── Swimlane group header ───────────────────────────────── */ + +/* Spans the full grid width in list view; no-op in grid view since + grid wraps it naturally. */ +.resource-list__swimlane-header { + grid-column: 1 / -1; + padding: 6px 12px 4px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-faint); + border-bottom: 1px solid var(--color-border); + margin-top: 8px; + cursor: default; + user-select: none; +} + +.resource-list__swimlane-header:first-child, +.list-header + .resource-list__swimlane-header { + margin-top: 0; +} + +/* ── Swimlane group card (list view only) ────────────────── */ + +/* When swimlane groups are present, dissolve the outer container into the + page background so each group card reads as its own panel. */ +.files-list-view:has(.resource-list__swimlane-group) { + background-color: transparent; + box-shadow: none; + border-radius: 0; + overflow: visible; + gap: 10px; +} + +/* Each group is a self-contained card */ +.files-list-view .resource-list__swimlane-group { + background-color: var(--color-item); + border-radius: 10px; + box-shadow: 0 1px 3px var(--color-shadow-xs); + overflow: hidden; +} + +/* The header inside a group card is always first — no extra top margin */ +.files-list-view .resource-list__swimlane-group .resource-list__swimlane-header { + margin-top: 0; +} + +/* ── Swimlane group wrapper (grid view) ──────────────────── */ + +/* The group wrapper must be transparent to the grid so .file-item children + continue to flow in the parent's columns (subgrid mirrors the column tracks). + The wrapper spans the full row width; items inside fill the columns naturally. */ +.files-grid-view .resource-list__swimlane-group { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + gap: 20px; +} + /* ── Section item modifiers ──────────────────────────────── */ /* — Favorites — */ diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 12be1db0..aa1f96e4 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -1,8 +1,8 @@ // @ts-check import { i18n } from '../core/i18n.js'; -import { inlineViewer } from '../features/files/inlineViewer.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; +import { inlineViewer } from '../features/files/inlineViewer.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; import { app } from './state.js'; diff --git a/static/js/app/main.js b/static/js/app/main.js index e6c8768d..6ca2fe07 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -8,11 +8,11 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js'; installFetchInterceptor(); import { Modal } from '../components/modal.js'; -import { formatFileSize, formatQuotaSize } from '../core/formatters.js'; +import { escapeHtml, formatFileSize, formatQuotaSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { oxiIconsInit } from '../core/icons.js'; -import { fileOps } from '../features/files/fileOperations.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; +import { fileOps } from '../features/files/fileOperations.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; @@ -83,6 +83,15 @@ const _batchToolbarButons = ` const _toggleButtons = `
+ + @@ -146,6 +155,7 @@ const ACTIONS_BAR_TEMPLATES = { `, sharedwithme: `
+ ${_batchToolbarButons} ${_toggleButtons} ` }; @@ -189,6 +199,75 @@ function setActionsBarMode(mode, force = false) { } } +/** + * @typedef {{ key: string, label: string, setGroupBy: (key: string) => void }} GroupByCapableView + */ + +/** + * The view that currently owns the group-by selector, or `null` when no + * section supports grouping. Set by `setGroupByView()` from navigation.js. + * @type {{ setGroupBy: (key: string) => void } | null} + */ +let _groupByView = null; + +/** + * Update the reference to the view that handles group-by changes. + * Called by navigation.js when the active section changes. + * @param {{ setGroupBy: (key: string) => void } | null} view + */ +function setGroupByView(view) { + _groupByView = view; +} + +/** @type {((e: MouseEvent) => void) | null} */ +let _groupByDocumentClickHandler = null; + +/** + * Populate and show (or hide) the group-by dropdown based on the active + * section's `groupByDefs`. Pass an empty array (or omit) to hide the button. + * + * Must be called AFTER `setActionsBarMode()` so the selector elements exist + * in the DOM. + * + * @param {Array<{key: string, label: string}>} [defs] + */ +function syncGroupByMenu(defs = []) { + const selector = document.getElementById('group-by-selector'); + const separator = document.getElementById('group-by-separator'); + const menu = document.getElementById('group-by-menu'); + if (!selector || !menu) return; + + const hasDefs = defs.length > 0; + selector.classList.toggle('hidden', !hasDefs); + separator?.classList.toggle('hidden', !hasDefs); + + if (!hasDefs) { + // Reset active indicator when the section has no group-by support + const btn = document.getElementById('group-by-btn'); + btn?.classList.remove('active'); + const lbl = btn?.querySelector('.group-by-label'); + if (lbl) lbl.textContent = ''; + return; + } + + // Rebuild menu options — call i18n.t() directly so each label is resolved + // at call time (translations are loaded by the time any section switch runs). + menu.innerHTML = ``; + for (const def of defs) { + menu.insertAdjacentHTML('beforeend', ``); + } + + // One stable document-level handler to close the menu on outside clicks. + if (_groupByDocumentClickHandler) { + document.removeEventListener('click', _groupByDocumentClickHandler); + } + _groupByDocumentClickHandler = (e) => { + if (/** @type {HTMLElement} */ (e.target)?.closest('#group-by-selector')) return; + document.getElementById('group-by-menu')?.classList.add('hidden'); + }; + document.addEventListener('click', _groupByDocumentClickHandler); +} + function setupActionsBarDelegation() { if (actionsBarDelegationBound || !elements.actionsBar) return; actionsBarDelegationBound = true; @@ -197,7 +276,26 @@ function setupActionsBarDelegation() { const btn = /** @type {HTMLElement} */ (e.target)?.closest('button'); if (!btn) return; + // ── Group-by option selected ────────────────────────────────────────── + if (btn.classList.contains('group-by-option')) { + const key = btn.dataset.groupBy ?? ''; + _groupByView?.setGroupBy(key); + document.querySelectorAll('.group-by-option').forEach((b) => { + b.classList.remove('active'); + }); + btn.classList.add('active'); + document.getElementById('group-by-menu')?.classList.add('hidden'); + const groupByBtn = document.getElementById('group-by-btn'); + groupByBtn?.classList.toggle('active', key !== ''); + const lbl = groupByBtn?.querySelector('.group-by-label'); + if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : ''; + return; + } + switch (btn.id) { + case 'group-by-btn': + document.getElementById('group-by-menu')?.classList.toggle('hidden'); + return; case 'upload-files-btn': { e.stopPropagation(); const menu = document.getElementById('upload-dropdown-menu'); @@ -798,4 +896,4 @@ function updateStorageUsageDisplay(userData) { console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`); } -export { deserializeHash, initApp, setActionsBarMode, updateHistory, updateStorageUsageDisplay }; +export { deserializeHash, initApp, setActionsBarMode, setGroupByView, syncGroupByMenu, updateHistory, updateStorageUsageDisplay }; diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 3be34396..4f590fa2 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -12,7 +12,7 @@ import { recent } from '../features/library/recent.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; import { loadFiles } from './filesView.js'; -import { setActionsBarMode } from './main.js'; +import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js'; import { app, appElements } from './state.js'; import { loadTrashItems } from './trashView.js'; import { ui } from './ui.js'; @@ -214,6 +214,11 @@ function switchToSharedWithMeSection() { // Show actions-bar with view toggle (no upload / new-folder in this view) setActionsBarMode('sharedwithme'); + // Populate the group-by dropdown with this section's dimensions. + // Must be called AFTER setActionsBarMode() so the selector elements exist. + setGroupByView(sharedWithMeView); + syncGroupByMenu(sharedWithMeView.groupByDefs); + // Show the Owner column — names are resolved async after render. ui.setOwnerColumnVisible(true); @@ -232,6 +237,8 @@ function switchToFilesSection() { // Set actions bar mode setActionsBarMode('files', true); + setGroupByView(null); + syncGroupByMenu([]); // Show owner column in the Files section ui.setOwnerColumnVisible(true); @@ -266,6 +273,8 @@ function switchToFavoritesSection() { // Set actions bar mode setActionsBarMode('favorites'); + setGroupByView(null); + syncGroupByMenu([]); // Show the Owner column — names are resolved async after render. ui.setOwnerColumnVisible(true); @@ -304,6 +313,8 @@ function switchToRecentFilesSection() { // Set actions bar mode setActionsBarMode('recent'); + setGroupByView(null); + syncGroupByMenu([]); // Hide breadcrumb (only shown in Files view) const breadcrumb = document.querySelector('.breadcrumb'); @@ -367,6 +378,8 @@ function switchToTrashSection() { toggleFileContainer(true); setActionsBarMode('trash'); + setGroupByView(null); + syncGroupByMenu([]); //reset files view + remove any error ui.resetFilesList(); @@ -419,6 +432,8 @@ function switchToMusicSection() { function activateFilesUI() { setCurrentSection('files'); setActionsBarMode('files', true); + setGroupByView(null); + syncGroupByMenu([]); const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.remove('hidden'); toggleFileContainer(true); diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index c9cb2d33..2b4ceb1b 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -4,8 +4,8 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; -import { fileOps } from '../features/files/fileOperations.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; +import { fileOps } from '../features/files/fileOperations.js'; import * as pathTooltip from '../features/pathTooltip.js'; import { appElements } from './state.js'; import { ui } from './ui.js'; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index c80b5a89..27776151 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -10,10 +10,10 @@ import { createUserVignette } from '../components/userVignette.js'; import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { OxiIcons } from '../core/icons.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; -import { batchToolbar } from '../features/files/batchToolbar.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 277a4c78..557040d0 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -89,6 +89,30 @@ export class ResourceListComponent { /** Index of the last clicked item — used for shift-click range selection. */ this._lastClickedIndex = -1; + /** + * The grouping key of the last rendered item — persisted across + * `append()` calls so load-more pages don't insert a redundant header + * when the first item of the new page shares a group with the last + * item of the previous page. + * `undefined` means no items have been rendered yet (reset in `render()`). + * @type {string|null|undefined} + */ + this._lastGroupKey = undefined; + + /** + * The live DOM group wrapper of the last rendered swimlane — persisted + * across `append()` calls so load-more items that continue the same + * group are appended into the existing card rather than starting a new one. + * @type {HTMLElement|null} + */ + this._lastGroupEl = null; + + /** + * Optional label-resolver stored between `render()` and `append()` calls. + * @type {((key: string) => string) | undefined} + */ + this._groupLabelFn = undefined; + this._ownerVisible = this._cfg.showOwner; this._initDelegation(); @@ -100,13 +124,20 @@ export class ResourceListComponent { * Replace the current item list. Preserves an existing `.list-header` * at the start of the container. * - * @param {FolderItem[]} folders - * @param {FileItem[]} files + * Items are rendered **in the order supplied** — do not pre-sort or + * split them into folders/files; the caller (or server) owns ordering. + * Folders vs. files are distinguished at render time by whether the item + * has a `mime_type` property. + * + * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn * When provided, a swimlane divider is injected whenever the returned - * label changes. Return `null` to suppress the divider for that item. + * key changes. Return `null` to suppress the divider for that item. + * @param {((key: string) => string)=} groupLabelFn + * Optional: converts the raw grouping key to a human-readable header + * label. When omitted the key itself is used. */ - render(folders, files, groupFn) { + render(items, groupFn, groupLabelFn) { const header = this._container.querySelector('.list-header'); this._container.innerHTML = ''; if (header) this._container.appendChild(header); @@ -114,23 +145,29 @@ export class ResourceListComponent { this._selected.clear(); this._items.clear(); this._lastClickedIndex = -1; + // Reset group tracking for the fresh render + this._lastGroupKey = undefined; + this._lastGroupEl = null; + this._groupLabelFn = groupLabelFn; // Prevent ui.js global delegation from firing on this container this._container.dataset.managedBy = 'resource-list'; - this._appendItems(folders, files, groupFn); + this._appendItems(items, groupFn, groupLabelFn); this._wireSelectAll(); } /** * Append additional items without clearing the existing ones (load-more). + * Continues swimlane grouping from the last item of the previous page — + * no redundant header is inserted when the key is unchanged. * - * @param {FolderItem[]} folders - * @param {FileItem[]} files + * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn + * @param {((key: string) => string)=} groupLabelFn */ - append(folders, files, groupFn) { - this._appendItems(folders, files, groupFn); + append(items, groupFn, groupLabelFn) { + this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn); } /** Remove all items (but keep `.list-header` if present). */ @@ -141,6 +178,8 @@ export class ResourceListComponent { this._selected.clear(); this._items.clear(); this._lastClickedIndex = -1; + this._lastGroupKey = undefined; + this._lastGroupEl = null; // Hand delegation back to ui.js delete this._container.dataset.managedBy; } @@ -239,50 +278,74 @@ export class ResourceListComponent { // ── Private helpers ───────────────────────────────────────────────────── /** - * @param {FolderItem[]} folders - * @param {FileItem[]} files + * Internal: append items to the container in the order supplied. + * Files vs. folders are distinguished by presence of `mime_type`. + * + * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn + * @param {((key: string) => string)=} groupLabelFn */ - _appendItems(folders, files, groupFn) { + _appendItems(items, groupFn, groupLabelFn) { const fragment = document.createDocumentFragment(); - let lastGroupKey = /** @type {string|null|undefined} */ (undefined); - for (const folder of folders) { - this._items.set(folder.id, folder); + // Start from the persisted key so load-more pages continue seamlessly. + let lastGroupKey = this._lastGroupKey; + + // liveGroup: existing DOM group element from the previous page; items + // that continue its group are appended directly into it (not via fragment). + // fragmentGroup: the group wrapper currently being built in the fragment. + let liveGroup = groupFn ? this._lastGroupEl : null; + let fragmentGroup = /** @type {HTMLElement|null} */ (null); + + for (const item of items) { + this._items.set(item.id, item); + if (groupFn) { - const key = groupFn(folder); + const key = groupFn(item); if (key !== lastGroupKey) { lastGroupKey = key; - if (key !== null) fragment.appendChild(this._createGroupHeader(key)); - } - } - fragment.appendChild(this._createFolderItem(folder)); - } + liveGroup = null; // stop extending the previous page's group + fragmentGroup = null; - for (const file of files) { - this._items.set(file.id, file); - if (groupFn) { - const key = groupFn(file); - if (key !== lastGroupKey) { - lastGroupKey = key; - if (key !== null) fragment.appendChild(this._createGroupHeader(key)); + if (key !== null) { + fragmentGroup = document.createElement('div'); + fragmentGroup.className = 'resource-list__swimlane-group'; + fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn)); + fragment.appendChild(fragmentGroup); + } } } - fragment.appendChild(this._createFileItem(file)); + + // Dispatch to the correct renderer: files have mime_type, folders do not. + const isFile = 'mime_type' in item; + const itemEl = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item)); + + // Priority: live DOM group (load-more continuation) > current fragment group > bare container + const target = liveGroup ?? fragmentGroup; + if (target) { + target.appendChild(itemEl); + } else { + fragment.appendChild(itemEl); + } } this._container.appendChild(fragment); + // Persist for the next append() call (load-more continuity). + this._lastGroupKey = lastGroupKey; + // Track the last group element (live or freshly added) for the next page. + this._lastGroupEl = fragmentGroup ?? liveGroup; } /** * Create a swimlane divider element. - * @param {string} label + * @param {string} key - Raw grouping key (e.g. UUID or bucket name). + * @param {((key: string) => string)=} labelFn - Optional human-readable resolver. */ - _createGroupHeader(label) { + _createGroupHeader(key, labelFn) { const el = document.createElement('div'); el.className = 'resource-list__swimlane-header'; el.dataset.swimlaneHeader = 'true'; - el.textContent = label; + el.textContent = labelFn ? labelFn(key) : key; return el; } diff --git a/static/js/core/formatters.js b/static/js/core/formatters.js index 2263c436..84b069a3 100644 --- a/static/js/core/formatters.js +++ b/static/js/core/formatters.js @@ -4,6 +4,8 @@ * Contains also checkers */ +import { i18n } from './i18n.js'; + /** * * @param {string} str @@ -107,4 +109,62 @@ function isEmailValid(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } -export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable }; +/** + * Normalize a date value into a human-readable bucket label. + * Buckets (newest-first): Today | Last 7 days | Last 30 days | + * + * Accepts: + * - `string` — ISO-8601 date string (e.g. `granted_at` from the API) + * - `number` — Unix timestamp in **seconds** (e.g. `sort_date`, `modified_at`) + * Values < 1e12 are treated as seconds; larger values as milliseconds. + * - `Date` — JavaScript Date object + * + * @param {string | number | Date} value + * @returns {string} + */ +function normalizeDateBucket(value) { + let date; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number') { + date = new Date(value < 1e12 ? value * 1000 : value); + } else { + date = new Date(value); + } + const diffDays = Math.floor((Date.now() - date.getTime()) / 86_400_000); + if (diffDays === 0) return i18n.t('dateBucket.today', 'Today'); + if (diffDays <= 7) return i18n.t('dateBucket.last7days', 'Last 7 days'); + if (diffDays <= 30) return i18n.t('dateBucket.last30days', 'Last 30 days'); + return String(date.getFullYear()); +} + +/** + * Maps a file size in bytes to a coarse, human-readable bucket label. + * + * Pass `-1` for folders — they sort before all files on the server and + * receive the "Folders" label client-side. + * + * Buckets: + * -1 → Folders + * 0 → Empty (0 B) + * 1 – 1 048 575 → < 1 MB + * 1 048 576 – 104 857 599 → 1 – 100 MB + * 104 857 600 – 1 073 741 823 → 100 MB – 1 GB + * 1 073 741 824 – 5 368 709 119 → 1 – 5 GB + * ≥ 5 368 709 120 → > 5 GB + * + * @param {number} bytes File size in bytes, or -1 for folders. + * @returns {string} + */ +// biome-ignore format: keep the following indent +function sizeBucket(bytes) { + if (bytes < 0) return i18n.t('sizeBucket.folders', 'Folders'); + if (bytes === 0) return i18n.t('sizeBucket.empty', 'Empty (0 B)'); + if (bytes < 1_048_576) return i18n.t('sizeBucket.tiny', '< 1 MB'); + if (bytes < 104_857_600) return i18n.t('sizeBucket.small', '1 – 100 MB'); + if (bytes < 1_073_741_824) return i18n.t('sizeBucket.medium', '100 MB – 1 GB'); + if (bytes < 5 * 1_073_741_824) return i18n.t('sizeBucket.large', '1 – 5 GB'); + return i18n.t('sizeBucket.huge', '> 5 GB'); +} + +export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable, normalizeDateBucket, sizeBucket }; diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 3c7db48d..01a1d8c4 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -234,6 +234,10 @@ const OxiIcons = { 576, 'M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z' ], + 'layer-group': [ + 512, + 'M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z' + ], link: [ 576, 'M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z' diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index de6c493d..f95f4ff0 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -16,9 +16,9 @@ import { i18n } from '../../core/i18n.js'; import { favorites } from '../library/favorites.js'; import { musicView } from '../library/music.js'; import { fileSharing } from '../sharing/fileSharing.js'; +import { batchToolbar } from './batchToolbar.js'; import { fileOps } from './fileOperations.js'; import { inlineViewer } from './inlineViewer.js'; -import { batchToolbar } from './batchToolbar.js'; import { wopiEditor } from './wopiEditor.js'; /** diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 3bc34d1a..f62f39e7 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -7,9 +7,9 @@ */ import { ui } from '../../app/ui.js'; +import { ResourceListComponent } from '../../components/resourceList.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; -import { ResourceListComponent } from '../../components/resourceList.js'; import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; @@ -195,16 +195,13 @@ const favorites = { return; } - /** @type {FolderItem[]} */ - const folders = []; - - /** @type {FileItem[]} */ - const files = []; + /** @type {Array} */ + const items = []; for (const item of this._cache.values()) { // owner_id comes from the backend JOIN (actual file/folder owner) if (item.item_type === 'folder') { - folders.push( + items.push( /** @type {FolderItem} */ ({ id: item.item_id, name: item.item_name || item.item_id, @@ -220,7 +217,7 @@ const favorites = { }) ); } else { - files.push( + items.push( /** @type {FileItem} */ ({ id: item.item_id, name: item.item_name || item.item_id, @@ -244,50 +241,45 @@ const favorites = { const filesList = document.getElementById('files-list'); if (filesList) { if (!this._component) { - this._component = new ResourceListComponent( - /** @type {HTMLElement} */ (filesList), - { - selectable: true, - showFavorite: true, - showOwner: true, - showShareBadge: true, - draggable: false, - showContextMenu: true, - itemModifierClass: 'favorite-item', - isFavorite: (id, type) => this.isFavorite(id, type), - onOpen: (item) => ui.openItem(item), - onFavoriteToggle: async (item) => { - const isFile = 'mime_type' in item; - const type = isFile ? 'file' : 'folder'; - if (this.isFavorite(item.id, type)) { - await this.removeFromFavorites(item.id, type); - this._component?.setFavoriteVisualState(item.id, type, false); - } else { - await this.addToFavorites(item.id, item.name, type, null); - this._component?.setFavoriteVisualState(item.id, type, true); - } - }, - onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), - onSelectionChange: (selectedItems) => { - batchToolbar._selected.clear(); - for (const sel of selectedItems) { - const isFile = 'mime_type' in sel; - batchToolbar._selected.set(sel.id, { - id: sel.id, - name: sel.name, - type: isFile ? 'file' : 'folder', - parentId: isFile - ? (/** @type {FileItem} */ (sel)).folder_id || '' - : (/** @type {FolderItem} */ (sel)).parent_id || '' - }); - } - batchToolbar._syncUI(); + this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: true, + showFavorite: true, + showOwner: true, + showShareBadge: true, + draggable: false, + showContextMenu: true, + itemModifierClass: 'favorite-item', + isFavorite: (id, type) => this.isFavorite(id, type), + onOpen: (item) => ui.openItem(item), + onFavoriteToggle: async (item) => { + const isFile = 'mime_type' in item; + const type = isFile ? 'file' : 'folder'; + if (this.isFavorite(item.id, type)) { + await this.removeFromFavorites(item.id, type); + this._component?.setFavoriteVisualState(item.id, type, false); + } else { + await this.addToFavorites(item.id, item.name, type, null); + this._component?.setFavoriteVisualState(item.id, type, true); } + }, + onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), + onSelectionChange: (selectedItems) => { + batchToolbar._selected.clear(); + for (const sel of selectedItems) { + const isFile = 'mime_type' in sel; + batchToolbar._selected.set(sel.id, { + id: sel.id, + name: sel.name, + type: isFile ? 'file' : 'folder', + parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' + }); + } + batchToolbar._syncUI(); } - ); + }); } batchToolbar.setActiveComponent(this._component); - this._component.render(folders, files); + this._component.render(items); pathTooltip.init(filesList); } diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 55729775..8da2aa07 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -7,9 +7,9 @@ */ import { ui } from '../../app/ui.js'; +import { ResourceListComponent } from '../../components/resourceList.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; -import { ResourceListComponent } from '../../components/resourceList.js'; import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; @@ -109,9 +109,7 @@ const recent = { if (filesList) { // Relabel the date column header from "Modified" → "Accessed" const dateHeader = /** @type {HTMLElement|null} */ ( - [...filesList.querySelectorAll('.list-header > div')].find( - (el) => el.getAttribute('data-i18n') === 'files.modified' - ) + [...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified') ); if (dateHeader) { dateHeader.removeAttribute('data-i18n'); @@ -133,87 +131,83 @@ const recent = { return; } - /** @type {FolderItem[]} */ - const folders = []; - - /** @type {FileItem[]} */ - const files = []; + /** @type {Array} */ + const items = []; for (const item of recentItems) { const isFolder = item.item_type === 'folder'; if (isFolder) { - folders.push({ - id: item.item_id, - name: item.item_name || item.item_id, - parent_id: item.parent_id || '', - modified_at: item.accessed_at, - path: item.item_path || '', - category: 'folder', - created_at: item.accessed_at, // Wrong information — server only stores accessed_at - icon_class: item.icon_class, - icon_special_class: item.icon_special_class, - owner_id: '', - is_root: false - }); + items.push( + /** @type {FolderItem} */ ({ + id: item.item_id, + name: item.item_name || item.item_id, + parent_id: item.parent_id || '', + modified_at: item.accessed_at, + path: item.item_path || '', + category: 'folder', + created_at: item.accessed_at, // Wrong information — server only stores accessed_at + icon_class: item.icon_class, + icon_special_class: item.icon_special_class, + owner_id: '', + is_root: false + }) + ); } else { if (item.item_mime_type === undefined || item.item_mime_type === null) { // FIXME: this case should not be possible, is it an information badly cleaned up on server ? console.warn('Broken information for RecentItem: ', item); } - files.push({ - id: item.item_id, - name: item.item_name || item.item_id, - folder_id: item.parent_id || '', - mime_type: item.item_mime_type, - icon_class: item.icon_class, - icon_special_class: item.icon_special_class, - category: item.category, - size: item.item_size || 0, - size_formatted: item.size_formatted, - modified_at: item.accessed_at, - path: item.item_path || '', - owner_id: '', - created_at: item.accessed_at, // Wrong information — server only stores accessed_at - sort_date: item.accessed_at - }); + items.push( + /** @type {FileItem} */ ({ + id: item.item_id, + name: item.item_name || item.item_id, + folder_id: item.parent_id || '', + mime_type: item.item_mime_type, + icon_class: item.icon_class, + icon_special_class: item.icon_special_class, + category: item.category, + size: item.item_size || 0, + size_formatted: item.size_formatted, + modified_at: item.accessed_at, + path: item.item_path || '', + owner_id: '', + created_at: item.accessed_at, // Wrong information — server only stores accessed_at + sort_date: item.accessed_at + }) + ); } } if (filesList) { if (!this._component) { - this._component = new ResourceListComponent( - /** @type {HTMLElement} */ (filesList), - { - selectable: true, - showFavorite: true, - showOwner: false, - showShareBadge: false, - draggable: false, - showContextMenu: true, - itemModifierClass: 'recent-item', - dateField: 'modified_at', // mapped from accessed_at above - onOpen: (item) => ui.openItem(item), - onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), - onSelectionChange: (selectedItems) => { - batchToolbar._selected.clear(); - for (const sel of selectedItems) { - const isFile = 'mime_type' in sel; - batchToolbar._selected.set(sel.id, { - id: sel.id, - name: sel.name, - type: isFile ? 'file' : 'folder', - parentId: isFile - ? (/** @type {FileItem} */ (sel)).folder_id || '' - : (/** @type {FolderItem} */ (sel)).parent_id || '' - }); - } - batchToolbar._syncUI(); + this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: true, + showFavorite: true, + showOwner: false, + showShareBadge: false, + draggable: false, + showContextMenu: true, + itemModifierClass: 'recent-item', + dateField: 'modified_at', // mapped from accessed_at above + onOpen: (item) => ui.openItem(item), + onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), + onSelectionChange: (selectedItems) => { + batchToolbar._selected.clear(); + for (const sel of selectedItems) { + const isFile = 'mime_type' in sel; + batchToolbar._selected.set(sel.id, { + id: sel.id, + name: sel.name, + type: isFile ? 'file' : 'folder', + parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' + }); } + batchToolbar._syncUI(); } - ); + }); } batchToolbar.setActiveComponent(this._component); - this._component.render(folders, files); + this._component.render(items); pathTooltip.init(filesList); } } catch (error) { diff --git a/static/js/model/grants.js b/static/js/model/grants.js index 0cdb3440..e385a74d 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -88,14 +88,16 @@ const grants = { * @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']). * @param {number} [opts.limit] - Max items per page (1–200, default 50). * @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page. + * @param {string} [opts.orderBy] - Sort dimension: 'granted_at' | 'granted_by' (default: 'granted_at'). * @returns {Promise} */ - async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) { + async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) { const params = new URLSearchParams({ limit: String(limit), resource_types: resourceTypes.join(',') }); if (cursor) params.set('cursor', cursor); + if (orderBy) params.set('sort_by', orderBy); const response = await fetch(`/api/grants/incoming/resources?${params}`); diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js index 7d7a20c7..7e9360ce 100644 --- a/static/js/model/systemUsers.js +++ b/static/js/model/systemUsers.js @@ -96,6 +96,19 @@ function prefetch() { _ensureIndex(); // intentionally fire-and-forget } +/** + * Synchronous best-effort display-name lookup from the pre-fetched cache. + * Returns a shortened UUID prefix when the cache is not yet loaded. + * Call `prefetch()` at view init time so the cache is warm by the time + * items are rendered. + * @param {string} userId + * @returns {string} + */ +function getDisplayNameSync(userId) { + if (_index === null) return `${userId.slice(0, 8)}…`; + return _index.get(userId) ?? `${userId.slice(0, 8)}…`; +} + /** * Resolve a user UUID to a display name. * Awaits the first load if not yet cached; subsequent calls resolve instantly. @@ -159,4 +172,4 @@ function isAvailable() { return addressBook.isSystemAvailable(); } -export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable }; +export const systemUsers = { prefetch, getDisplayName, getDisplayNameSync, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index d7ce6730..ef07c769 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -11,8 +11,9 @@ */ import { ui } from '../../app/ui.js'; -import { i18n } from '../../core/i18n.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; +import { i18n } from '../../core/i18n.js'; import { batchToolbar } from '../../features/files/batchToolbar.js'; import { favorites } from '../../features/library/favorites.js'; import { ownerTooltip } from '../../features/ownerTooltip.js'; @@ -21,6 +22,107 @@ import { systemUsers } from '../../model/systemUsers.js'; /** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ +/** + * @typedef {{ key: string, label: string, orderBy: string, + * keyFn: (item: FileItem|FolderItem) => string|null, + * labelFn?: (key: string) => string }} GroupByDef + */ + +/** + * Group-by dimension definitions for this section. + * Exported via `sharedWithMeView.groupByDefs` so `main.js` can populate + * the dropdown dynamically without knowing the internals of this view. + * + * `keyFn` returns the grouping key (stable UUID for owner, or a + * human-readable bucket label for shareDate — the bucket IS the key because + * it is already derived from the date, so no separate `labelFn` is needed + * for shareDate). + * + * @type {GroupByDef[]} + */ +const GROUP_BY_DEFS = [ + { + key: 'type', + get label() { + return i18n.t('groupby.type', 'Type'); + }, + orderBy: 'type', + // keyFn: folders get their own swimlane; files use the pre-computed + // `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …). + // The server orders by category_order (a pre-computed SMALLINT column) + // so items within the same category arrive grouped — no client sort needed. + keyFn: (item) => ('mime_type' in item ? /** @type {Record} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'), + labelFn: (key) => { + // biome-ignore format: keep indentation + /** @type {Record} */ + const labels = { + Folder: i18n.t('groupby.type.folders', 'Folders'), + Image: i18n.t('category.images', 'Images'), + Video: i18n.t('category.videos', 'Videos'), + Audio: i18n.t('category.audio', 'Audio'), + PDF: 'PDF', + Document: i18n.t('category.documents', 'Documents'), + Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'), + Presentation: i18n.t('category.presentations', 'Presentations'), + Archive: i18n.t('category.archives', 'Archives'), + Code: i18n.t('category.code', 'Code'), + Markdown: i18n.t('category.markdown', 'Markdown'), + Text: i18n.t('category.text', 'Text'), + Installer: i18n.t('category.installers', 'Installers') + }; + return labels[key] ?? key; + } + }, + { + key: 'owner', + // label is accessed via syncGroupByMenu → read at section-switch time, + // when translations are guaranteed to be loaded. + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'granted_by', + // keyFn groups by UUID — stable and unique, avoids collisions between + // users with the same display name. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + // labelFn resolves UUID → display name from the pre-fetched cache. + labelFn: (id) => systemUsers.getDisplayNameSync(id) + }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // keyFn: the key IS the bucket label returned by sizeBucket(), so no + // separate labelFn is needed (same pattern as shareDate). + // Folders have no size — sizeBucket(-1) returns the "Folders" label. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + // No labelFn: keyFn already returns the human-readable label. + }, + { + key: 'shareDate', + get label() { + return i18n.t('groupby.shareDate', 'Share date'); + }, + orderBy: 'granted_at', + // keyFn returns the human-readable bucket label; the label IS the key + // because consecutive items with the same bucket should be in one group. + // sort_date is stored as unix seconds (number) in _mapItems(). + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.sort_date ? normalizeDateBucket(r.sort_date) : null; + } + // No labelFn: keyFn already returns the human-readable label. + } +]; + /** ID of the "Load more" wrapper injected below `.files-container`. */ const LOAD_MORE_ID = 'swm-load-more-wrapper'; @@ -35,8 +137,36 @@ const sharedWithMeView = { /** @type {ResourceListComponent|null} */ _component: null, + /** + * Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active. + * @type {string} + */ + _groupBy: '', + // ── Public API ──────────────────────────────────────────────────────────── + /** + * The group-by dimension definitions for this section. + * `main.js` reads this to populate the Group-by dropdown dynamically. + * @returns {GroupByDef[]} + */ + get groupByDefs() { + return GROUP_BY_DEFS; + }, + + /** + * Change the active group-by dimension and reload from page 1. + * Calling with the current key is a no-op. + * @param {string} key '' | 'owner' | 'shareDate' + */ + setGroupBy(key) { + if (this._groupBy === key) return; + this._groupBy = key; + this._nextCursor = null; // restart from first page + this._component?.clear(); + this._loadPage(); + }, + /** * (Re-)load from page 1 and render into the existing files container. * Called every time the user switches to this section. @@ -44,6 +174,7 @@ const sharedWithMeView = { async init() { this._nextCursor = null; this._loading = false; + this._groupBy = ''; this._ensureLoadMoreButton(); @@ -60,47 +191,42 @@ const sharedWithMeView = { const filesList = document.getElementById('files-list'); if (filesList) { if (!this._component) { - this._component = new ResourceListComponent( - /** @type {HTMLElement} */ (filesList), - { - selectable: true, - showFavorite: true, - showOwner: true, - showShareBadge: false, - draggable: false, - showContextMenu: true, - isFavorite: (id, type) => favorites.isFavorite(id, type), - isShared: () => false, - onOpen: (item) => ui.openItem(item), - onFavoriteToggle: async (item) => { - const isFile = 'mime_type' in item; - const type = isFile ? 'file' : 'folder'; - if (favorites.isFavorite(item.id, type)) { - await favorites.removeFromFavorites(item.id, type); - this._component?.setFavoriteVisualState(item.id, type, false); - } else { - await favorites.addToFavorites(item.id, item.name, type, null); - this._component?.setFavoriteVisualState(item.id, type, true); - } - }, - onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), - onSelectionChange: (selectedItems) => { - batchToolbar._selected.clear(); - for (const sel of selectedItems) { - const isFile = 'mime_type' in sel; - batchToolbar._selected.set(sel.id, { - id: sel.id, - name: sel.name, - type: isFile ? 'file' : 'folder', - parentId: isFile - ? (/** @type {FileItem} */ (sel)).folder_id || '' - : (/** @type {FolderItem} */ (sel)).parent_id || '' - }); - } - batchToolbar._syncUI(); + this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: true, + showFavorite: true, + showOwner: true, + showShareBadge: false, + draggable: false, + showContextMenu: true, + isFavorite: (id, type) => favorites.isFavorite(id, type), + isShared: () => false, + onOpen: (item) => ui.openItem(item), + onFavoriteToggle: async (item) => { + const isFile = 'mime_type' in item; + const type = isFile ? 'file' : 'folder'; + if (favorites.isFavorite(item.id, type)) { + await favorites.removeFromFavorites(item.id, type); + this._component?.setFavoriteVisualState(item.id, type, false); + } else { + await favorites.addToFavorites(item.id, item.name, type, null); + this._component?.setFavoriteVisualState(item.id, type, true); } + }, + onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), + onSelectionChange: (selectedItems) => { + batchToolbar._selected.clear(); + for (const sel of selectedItems) { + const isFile = 'mime_type' in sel; + batchToolbar._selected.set(sel.id, { + id: sel.id, + name: sel.name, + type: isFile ? 'file' : 'folder', + parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' + }); + } + batchToolbar._syncUI(); } - ); + }); } batchToolbar.setActiveComponent(this._component); } @@ -138,10 +264,18 @@ const sharedWithMeView = { const isFirstPage = this._nextCursor === null; try { + const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy); + + // When no swimlane grouping is active, sort by resource name so the + // list is alphabetical (same expectation as the Files section). + // Group-by modes supply their own orderBy via the def. + const orderBy = def?.orderBy ?? 'name'; + const data = await grants.fetchSharedWithMe({ resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']), limit: 50, - cursor: this._nextCursor ?? undefined + cursor: this._nextCursor ?? undefined, + orderBy }); this._nextCursor = data.next_cursor ?? null; @@ -157,12 +291,12 @@ const sharedWithMeView = { return; } - const { folders, files } = this._mapItems(data.items); + const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(folders, files); + this._component?.render(items, def?.keyFn, def?.labelFn); } else { - this._component?.append(folders, files); + this._component?.append(items, def?.keyFn, def?.labelFn); } // Wire owner tooltips after items are in the DOM @@ -185,35 +319,41 @@ const sharedWithMeView = { }, /** - * Map `SharedWithMeItem[]` to separate arrays for rendering. + * Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in + * **server-returned order**. The order must be preserved so that + * swimlane grouping (group by owner / share date) works correctly when + * the server interleaves files and folders by the sort key. + * * Sets `owner_id` to `item.granted_by` so the component stamps * `data-owner-id` with the granter's user ID automatically. + * Sets `sort_date` (unix seconds) to the grant date so the shareDate + * `keyFn` buckets by when the share was created, not the resource's + * own modification time. * * @param {SharedWithMeItem[]} items - * @returns {{ folders: FolderItem[], files: FileItem[] }} + * @returns {Array} */ _mapItems(items) { - /** @type {FolderItem[]} */ - const folders = []; + /** @type {Array} */ + const result = []; - /** @type {FileItem[]} */ - const files = []; + /** @param {string} iso @returns {number} */ + const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000); for (const item of items) { if (item.resource_type === 'folder') { const f = /** @type {FolderItem} */ (item.resource); - folders.push( + result.push( /** @type {FolderItem} */ ({ id: f.id, name: f.name, path: f.path ?? '', parent_id: f.parent_id ?? '', - // Use granted_by as owner_id so the component populates - // data-owner-id with the sharing user's ID. owner_id: item.granted_by, is_root: f.is_root ?? false, created_at: f.created_at, modified_at: f.modified_at, + sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', category: 'folder' @@ -221,21 +361,19 @@ const sharedWithMeView = { ); } else if (item.resource_type === 'file') { const f = /** @type {FileItem} */ (item.resource); - files.push( + result.push( /** @type {FileItem} */ ({ id: f.id, name: f.name, path: f.path ?? '', folder_id: f.folder_id ?? '', - // Use granted_by as owner_id so the component populates - // data-owner-id with the sharing user's ID. owner_id: item.granted_by, mime_type: f.mime_type, size: f.size, size_formatted: f.size_formatted, created_at: f.created_at, modified_at: f.modified_at, - sort_date: f.modified_at, + sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', category: f.category @@ -244,7 +382,7 @@ const sharedWithMeView = { } } - return { folders, files }; + return result; }, // ── "Load more" button ──────────────────────────────────────────────────── diff --git a/static/locales/ar.json b/static/locales/ar.json index 18388bd4..e6c0f39f 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -710,5 +710,16 @@ "colSharedBy": "مشترك من قِبل", "colDate": "تاريخ المشاركة", "colPermissions": "الصلاحيات" + }, + "groupby": { + "none": "لا شيء", + "title": "التجميع حسب", + "owner": "المالك", + "shareDate": "تاريخ المشاركة" + }, + "dateBucket": { + "today": "اليوم", + "last7days": "آخر 7 أيام", + "last30days": "آخر 30 يومًا" } } diff --git a/static/locales/de.json b/static/locales/de.json index 949a0e98..5351fec6 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -710,5 +710,16 @@ "colSharedBy": "Geteilt von", "colDate": "Datum der Freigabe", "colPermissions": "Berechtigungen" + }, + "groupby": { + "none": "Keine", + "title": "Gruppieren nach", + "owner": "Eigentümer", + "shareDate": "Freigabedatum" + }, + "dateBucket": { + "today": "Heute", + "last7days": "Letzte 7 Tage", + "last30days": "Letzte 30 Tage" } } diff --git a/static/locales/en.json b/static/locales/en.json index b018fe73..56421b8a 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -710,5 +710,16 @@ "colSharedBy": "Shared by", "colDate": "Date shared", "colPermissions": "Permissions" + }, + "groupby": { + "none": "None", + "title": "Group by", + "owner": "Owner", + "shareDate": "Share date" + }, + "dateBucket": { + "today": "Today", + "last7days": "Last 7 days", + "last30days": "Last 30 days" } } diff --git a/static/locales/es.json b/static/locales/es.json index a2ce7530..c9d878ec 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -710,5 +710,16 @@ "colSharedBy": "Compartido por", "colDate": "Fecha de compartición", "colPermissions": "Permisos" + }, + "groupby": { + "none": "Ninguno", + "title": "Agrupar por", + "owner": "Propietario", + "shareDate": "Fecha de compartición" + }, + "dateBucket": { + "today": "Hoy", + "last7days": "Últimos 7 días", + "last30days": "Últimos 30 días" } } diff --git a/static/locales/fa.json b/static/locales/fa.json index 5ea6655f..836106b5 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -710,5 +710,16 @@ "colSharedBy": "به اشتراک‌گذاشته توسط", "colDate": "تاریخ اشتراک‌گذاری", "colPermissions": "مجوزها" + }, + "groupby": { + "none": "هیچ", + "title": "گروه‌بندی بر اساس", + "owner": "مالک", + "shareDate": "تاریخ اشتراک" + }, + "dateBucket": { + "today": "امروز", + "last7days": "۷ روز گذشته", + "last30days": "۳۰ روز گذشته" } } diff --git a/static/locales/fr.json b/static/locales/fr.json index cc51c65c..0777b11c 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -710,5 +710,16 @@ "colSharedBy": "Partagé par", "colDate": "Date de partage", "colPermissions": "Permissions" + }, + "groupby": { + "none": "Aucun", + "title": "Grouper par", + "owner": "Propriétaire", + "shareDate": "Date de partage" + }, + "dateBucket": { + "today": "Aujourd'hui", + "last7days": "7 derniers jours", + "last30days": "30 derniers jours" } } diff --git a/static/locales/hi.json b/static/locales/hi.json index 60b5072a..b7adb1f0 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -710,5 +710,16 @@ "colSharedBy": "द्वारा साझा किया", "colDate": "साझाकरण तिथि", "colPermissions": "अनुमतियाँ" + }, + "groupby": { + "none": "कोई नहीं", + "title": "इसके अनुसार समूहीकृत करें", + "owner": "स्वामी", + "shareDate": "साझा तिथि" + }, + "dateBucket": { + "today": "आज", + "last7days": "पिछले 7 दिन", + "last30days": "पिछले 30 दिन" } } diff --git a/static/locales/it.json b/static/locales/it.json index 13390985..f3334e33 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -710,5 +710,16 @@ "colSharedBy": "Condiviso da", "colDate": "Data condivisione", "colPermissions": "Permessi" + }, + "groupby": { + "none": "Nessuno", + "title": "Raggruppa per", + "owner": "Proprietario", + "shareDate": "Data condivisione" + }, + "dateBucket": { + "today": "Oggi", + "last7days": "Ultimi 7 giorni", + "last30days": "Ultimi 30 giorni" } } diff --git a/static/locales/ja.json b/static/locales/ja.json index 3c517406..d9105cc8 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -710,5 +710,16 @@ "colSharedBy": "共有者", "colDate": "共有日", "colPermissions": "権限" + }, + "groupby": { + "none": "なし", + "title": "グループ化", + "owner": "オーナー", + "shareDate": "共有日" + }, + "dateBucket": { + "today": "今日", + "last7days": "過去7日間", + "last30days": "過去30日間" } } diff --git a/static/locales/ko.json b/static/locales/ko.json index af91d35e..13c13dd3 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -710,5 +710,16 @@ "colSharedBy": "공유한 사람", "colDate": "공유 날짜", "colPermissions": "권한" + }, + "groupby": { + "none": "없음", + "title": "그룹화 기준", + "owner": "소유자", + "shareDate": "공유 날짜" + }, + "dateBucket": { + "today": "오늘", + "last7days": "최근 7일", + "last30days": "최근 30일" } } diff --git a/static/locales/nl.json b/static/locales/nl.json index cc2ce87e..8af98820 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -710,5 +710,16 @@ "colSharedBy": "Gedeeld door", "colDate": "Datum gedeeld", "colPermissions": "Machtigingen" + }, + "groupby": { + "none": "Geen", + "title": "Groeperen op", + "owner": "Eigenaar", + "shareDate": "Deeldatum" + }, + "dateBucket": { + "today": "Vandaag", + "last7days": "Afgelopen 7 dagen", + "last30days": "Afgelopen 30 dagen" } } diff --git a/static/locales/pl.json b/static/locales/pl.json index 690353db..6228f7b3 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -710,5 +710,16 @@ "colSharedBy": "Udostępnione przez", "colDate": "Data udostępnienia", "colPermissions": "Uprawnienia" + }, + "groupby": { + "none": "Brak", + "title": "Grupuj według", + "owner": "Właściciel", + "shareDate": "Data udostępnienia" + }, + "dateBucket": { + "today": "Dzisiaj", + "last7days": "Ostatnie 7 dni", + "last30days": "Ostatnie 30 dni" } } diff --git a/static/locales/pt.json b/static/locales/pt.json index c7016202..5368127b 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -710,5 +710,16 @@ "colSharedBy": "Compartilhado por", "colDate": "Data de compartilhamento", "colPermissions": "Permissões" + }, + "groupby": { + "none": "Nenhum", + "title": "Agrupar por", + "owner": "Proprietário", + "shareDate": "Data de partilha" + }, + "dateBucket": { + "today": "Hoje", + "last7days": "Últimos 7 dias", + "last30days": "Últimos 30 dias" } } diff --git a/static/locales/ru.json b/static/locales/ru.json index ffc2cc86..42bb904f 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -710,5 +710,16 @@ "colSharedBy": "Предоставлено", "colDate": "Дата предоставления", "colPermissions": "Права" + }, + "groupby": { + "none": "Нет", + "title": "Группировать по", + "owner": "Владелец", + "shareDate": "Дата общего доступа" + }, + "dateBucket": { + "today": "Сегодня", + "last7days": "Последние 7 дней", + "last30days": "Последние 30 дней" } } diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 3378c6da..4fbd9743 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -710,5 +710,16 @@ "colSharedBy": "共享者", "colDate": "共享日期", "colPermissions": "權限" + }, + "groupby": { + "none": "無", + "title": "分組方式", + "owner": "擁有者", + "shareDate": "分享日期" + }, + "dateBucket": { + "today": "今天", + "last7days": "近7天", + "last30days": "近30天" } } diff --git a/static/locales/zh.json b/static/locales/zh.json index bf4a9100..22d9b8d1 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -710,5 +710,16 @@ "colSharedBy": "共享者", "colDate": "共享日期", "colPermissions": "权限" + }, + "groupby": { + "none": "无", + "title": "分组方式", + "owner": "所有者", + "shareDate": "分享日期" + }, + "dateBucket": { + "today": "今天", + "last7days": "近7天", + "last30days": "近30天" } }