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