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"
|
"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).
|
/// Formats a byte count into a human-readable string (1024-based).
|
||||||
///
|
///
|
||||||
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
|
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
kinds: &[ResourceKind],
|
kinds: &[ResourceKind],
|
||||||
limit: u32,
|
limit: u32,
|
||||||
cursor: Option<GrantCursor>,
|
cursor: Option<GrantCursor>,
|
||||||
|
sort_by: &str,
|
||||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
||||||
|
|
||||||
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
/// 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
|
/// Encodes the position of the last seen item in a cursor-paginated grant
|
||||||
/// listing. The encoding is opaque to API callers — only the backend
|
/// 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)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct GrantCursor {
|
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 granted_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub resource_id: Uuid,
|
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.
|
/// Delegate encode/decode to the shared [`PageCursor`] trait.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::display_helpers::category_order_for;
|
||||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::file::File;
|
use crate::domain::entities::file::File;
|
||||||
@@ -226,8 +227,8 @@ impl FileBlobWriteRepository {
|
|||||||
|
|
||||||
let row = match sqlx::query_as::<_, (String, i64, i64)>(
|
let row = match sqlx::query_as::<_, (String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
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)
|
||||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
@@ -239,6 +240,7 @@ impl FileBlobWriteRepository {
|
|||||||
.bind(&blob_hash)
|
.bind(&blob_hash)
|
||||||
.bind(size as i64)
|
.bind(size as i64)
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
|
.bind(category_order_for(&name, &content_type))
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -374,18 +376,19 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
WITH src AS (
|
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
|
FROM storage.files
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
WHERE id = $1::uuid AND NOT is_trashed
|
||||||
),
|
),
|
||||||
new_file AS (
|
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,
|
SELECT name,
|
||||||
COALESCE($2::uuid, folder_id),
|
COALESCE($2::uuid, folder_id),
|
||||||
user_id,
|
user_id,
|
||||||
blob_hash,
|
blob_hash,
|
||||||
size,
|
size,
|
||||||
mime_type
|
mime_type,
|
||||||
|
category_order
|
||||||
FROM src
|
FROM src
|
||||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
@@ -537,8 +540,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
|
|
||||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
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)
|
||||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
@@ -550,6 +553,7 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
.bind(placeholder_hash)
|
.bind(placeholder_hash)
|
||||||
.bind(size as i64)
|
.bind(size as i64)
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
|
.bind(category_order_for(&name, &content_type))
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
||||||
|
|||||||
@@ -299,87 +299,243 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
kinds: &[ResourceKind],
|
kinds: &[ResourceKind],
|
||||||
limit: u32,
|
limit: u32,
|
||||||
cursor: Option<GrantCursor>,
|
cursor: Option<GrantCursor>,
|
||||||
|
sort_by: &str,
|
||||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
|
) -> 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() {
|
let kind_strs: Option<Vec<&str>> = if kinds.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(kinds.iter().map(|k| k.as_str()).collect())
|
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;
|
let fetch_limit = (limit as i64) + 1;
|
||||||
|
|
||||||
// Each row: (resource_type, resource_id, permissions_text_array,
|
// Unified row type — the last two columns carry the sort key when present,
|
||||||
// granted_at, granted_by)
|
// 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 = (
|
type Row = (
|
||||||
String,
|
String,
|
||||||
Uuid,
|
Uuid,
|
||||||
Vec<String>,
|
Vec<String>,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
Uuid,
|
Uuid,
|
||||||
|
Option<String>,
|
||||||
|
Option<i64>,
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows: Vec<Row> = sqlx::query_as(
|
// Extract all cursor fields up-front; each branch uses the subset it needs.
|
||||||
r#"
|
// Fixed parameter positions used in all SQL variants:
|
||||||
WITH agg AS (
|
// $4 = cursor_str (resource_name / owner_name)
|
||||||
SELECT
|
// $5 = cursor_int (type_order)
|
||||||
resource_type,
|
// $6 = cursor_at (granted_at)
|
||||||
resource_id,
|
// $7 = cursor_id (resource_id)
|
||||||
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
|
// $8 = fetch_limit
|
||||||
MIN(granted_at) AS granted_at,
|
let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone());
|
||||||
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
|
let cursor_int = cursor.as_ref().and_then(|c| c.sort_int);
|
||||||
FROM storage.access_grants
|
let cursor_at = cursor.as_ref().map(|c| c.granted_at);
|
||||||
WHERE subject_type = $1
|
let cursor_id = cursor.as_ref().map(|c| c.resource_id);
|
||||||
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}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
|
// ── 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 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 {
|
let next_cursor = if has_next {
|
||||||
rows.last().map(|r| GrantCursor {
|
rows.last().map(|r| {
|
||||||
granted_at: r.3,
|
let sort_str_lc = r.5.as_deref().map(str::to_lowercase);
|
||||||
resource_id: r.1,
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert rows into domain summaries.
|
// ── Convert rows to domain summaries ──────────────────────────────────
|
||||||
let summaries = rows
|
let summaries = rows
|
||||||
.into_iter()
|
.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 resource_type = ResourceKind::parse(&rt)?;
|
||||||
let permissions = perms_str
|
let permissions = perms_str
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter_map(|s| Permission::parse(s))
|
.filter_map(|s| Permission::parse(&s))
|
||||||
.collect();
|
.collect();
|
||||||
Some(IncomingGrantSummary {
|
Some(IncomingGrantSummary {
|
||||||
resource_type,
|
resource_type,
|
||||||
|
|||||||
@@ -323,13 +323,29 @@ pub async fn list_shared_with_me(
|
|||||||
// Clamp limit to 1–200.
|
// Clamp limit to 1–200.
|
||||||
let limit = q.limit_clamped() as u32;
|
let limit = q.limit_clamped() as u32;
|
||||||
|
|
||||||
// Decode cursor (treat invalid cursor as "start from top").
|
// Validate sort_by (defaults to "granted_at").
|
||||||
let cursor = q.decode_cursor::<GrantCursor>();
|
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.
|
// Fetch paged summaries from the ACL engine.
|
||||||
let (summaries, next_cursor) = match state
|
let (summaries, next_cursor) = match state
|
||||||
.authorization
|
.authorization
|
||||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor)
|
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
|
|||||||
@@ -105,3 +105,92 @@
|
|||||||
.toggle-btn i {
|
.toggle-btn i {
|
||||||
pointer-events: none;
|
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;
|
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 ──────────────────────────────── */
|
/* ── Section item modifiers ──────────────────────────────── */
|
||||||
|
|
||||||
/* — Favorites — */
|
/* — Favorites — */
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
|
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||||
import { resolveHomeFolder } from './authSession.js';
|
import { resolveHomeFolder } from './authSession.js';
|
||||||
import { updateHistory } from './main.js';
|
import { updateHistory } from './main.js';
|
||||||
import { app } from './state.js';
|
import { app } from './state.js';
|
||||||
|
|||||||
+101
-3
@@ -8,11 +8,11 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
|
|||||||
installFetchInterceptor();
|
installFetchInterceptor();
|
||||||
|
|
||||||
import { Modal } from '../components/modal.js';
|
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 { i18n } from '../core/i18n.js';
|
||||||
import { oxiIconsInit } from '../core/icons.js';
|
import { oxiIconsInit } from '../core/icons.js';
|
||||||
import { fileOps } from '../features/files/fileOperations.js';
|
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
|
import { fileOps } from '../features/files/fileOperations.js';
|
||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
@@ -83,6 +83,15 @@ const _batchToolbarButons = `
|
|||||||
|
|
||||||
const _toggleButtons = `
|
const _toggleButtons = `
|
||||||
<div class="view-toggle">
|
<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">
|
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||||
<i class="fas fa-th"></i>
|
<i class="fas fa-th"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -146,6 +155,7 @@ const ACTIONS_BAR_TEMPLATES = {
|
|||||||
`,
|
`,
|
||||||
sharedwithme: `
|
sharedwithme: `
|
||||||
<div class="action-buttons" id="default-buttons"></div>
|
<div class="action-buttons" id="default-buttons"></div>
|
||||||
|
${_batchToolbarButons}
|
||||||
${_toggleButtons}
|
${_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() {
|
function setupActionsBarDelegation() {
|
||||||
if (actionsBarDelegationBound || !elements.actionsBar) return;
|
if (actionsBarDelegationBound || !elements.actionsBar) return;
|
||||||
actionsBarDelegationBound = true;
|
actionsBarDelegationBound = true;
|
||||||
@@ -197,7 +276,26 @@ function setupActionsBarDelegation() {
|
|||||||
const btn = /** @type {HTMLElement} */ (e.target)?.closest('button');
|
const btn = /** @type {HTMLElement} */ (e.target)?.closest('button');
|
||||||
if (!btn) return;
|
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) {
|
switch (btn.id) {
|
||||||
|
case 'group-by-btn':
|
||||||
|
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
||||||
|
return;
|
||||||
case 'upload-files-btn': {
|
case 'upload-files-btn': {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const menu = document.getElementById('upload-dropdown-menu');
|
const menu = document.getElementById('upload-dropdown-menu');
|
||||||
@@ -798,4 +896,4 @@ function updateStorageUsageDisplay(userData) {
|
|||||||
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
|
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 { sharedView } from '../views/shared/sharedView.js';
|
||||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||||
import { loadFiles } from './filesView.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 { app, appElements } from './state.js';
|
||||||
import { loadTrashItems } from './trashView.js';
|
import { loadTrashItems } from './trashView.js';
|
||||||
import { ui } from './ui.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)
|
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
||||||
setActionsBarMode('sharedwithme');
|
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.
|
// Show the Owner column — names are resolved async after render.
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
|
|
||||||
@@ -232,6 +237,8 @@ function switchToFilesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('files', true);
|
setActionsBarMode('files', true);
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
// Show owner column in the Files section
|
// Show owner column in the Files section
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
@@ -266,6 +273,8 @@ function switchToFavoritesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('favorites');
|
setActionsBarMode('favorites');
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
// Show the Owner column — names are resolved async after render.
|
// Show the Owner column — names are resolved async after render.
|
||||||
ui.setOwnerColumnVisible(true);
|
ui.setOwnerColumnVisible(true);
|
||||||
@@ -304,6 +313,8 @@ function switchToRecentFilesSection() {
|
|||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('recent');
|
setActionsBarMode('recent');
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
// Hide breadcrumb (only shown in Files view)
|
// Hide breadcrumb (only shown in Files view)
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
@@ -367,6 +378,8 @@ function switchToTrashSection() {
|
|||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|
||||||
setActionsBarMode('trash');
|
setActionsBarMode('trash');
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
|
|
||||||
//reset files view + remove any error
|
//reset files view + remove any error
|
||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
@@ -419,6 +432,8 @@ function switchToMusicSection() {
|
|||||||
function activateFilesUI() {
|
function activateFilesUI() {
|
||||||
setCurrentSection('files');
|
setCurrentSection('files');
|
||||||
setActionsBarMode('files', true);
|
setActionsBarMode('files', true);
|
||||||
|
setGroupByView(null);
|
||||||
|
syncGroupByMenu([]);
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
breadcrumb?.classList.remove('hidden');
|
breadcrumb?.classList.remove('hidden');
|
||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { fileOps } from '../features/files/fileOperations.js';
|
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
|
import { fileOps } from '../features/files/fileOperations.js';
|
||||||
import * as pathTooltip from '../features/pathTooltip.js';
|
import * as pathTooltip from '../features/pathTooltip.js';
|
||||||
import { appElements } from './state.js';
|
import { appElements } from './state.js';
|
||||||
import { ui } from './ui.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 { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { OxiIcons } from '../core/icons.js';
|
import { OxiIcons } from '../core/icons.js';
|
||||||
|
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||||
import { contextMenus } from '../features/files/contextMenus.js';
|
import { contextMenus } from '../features/files/contextMenus.js';
|
||||||
import { fileOps } from '../features/files/fileOperations.js';
|
import { fileOps } from '../features/files/fileOperations.js';
|
||||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
|
||||||
import { wopiEditor } from '../features/files/wopiEditor.js';
|
import { wopiEditor } from '../features/files/wopiEditor.js';
|
||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { recent } from '../features/library/recent.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. */
|
/** Index of the last clicked item — used for shift-click range selection. */
|
||||||
this._lastClickedIndex = -1;
|
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._ownerVisible = this._cfg.showOwner;
|
||||||
|
|
||||||
this._initDelegation();
|
this._initDelegation();
|
||||||
@@ -100,13 +124,20 @@ export class ResourceListComponent {
|
|||||||
* Replace the current item list. Preserves an existing `.list-header`
|
* Replace the current item list. Preserves an existing `.list-header`
|
||||||
* at the start of the container.
|
* at the start of the container.
|
||||||
*
|
*
|
||||||
* @param {FolderItem[]} folders
|
* Items are rendered **in the order supplied** — do not pre-sort or
|
||||||
* @param {FileItem[]} files
|
* 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
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
* When provided, a swimlane divider is injected whenever the returned
|
* 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');
|
const header = this._container.querySelector('.list-header');
|
||||||
this._container.innerHTML = '';
|
this._container.innerHTML = '';
|
||||||
if (header) this._container.appendChild(header);
|
if (header) this._container.appendChild(header);
|
||||||
@@ -114,23 +145,29 @@ export class ResourceListComponent {
|
|||||||
this._selected.clear();
|
this._selected.clear();
|
||||||
this._items.clear();
|
this._items.clear();
|
||||||
this._lastClickedIndex = -1;
|
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
|
// Prevent ui.js global delegation from firing on this container
|
||||||
this._container.dataset.managedBy = 'resource-list';
|
this._container.dataset.managedBy = 'resource-list';
|
||||||
|
|
||||||
this._appendItems(folders, files, groupFn);
|
this._appendItems(items, groupFn, groupLabelFn);
|
||||||
this._wireSelectAll();
|
this._wireSelectAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append additional items without clearing the existing ones (load-more).
|
* 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 {Array<FileItem|FolderItem>} items
|
||||||
* @param {FileItem[]} files
|
|
||||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
|
* @param {((key: string) => string)=} groupLabelFn
|
||||||
*/
|
*/
|
||||||
append(folders, files, groupFn) {
|
append(items, groupFn, groupLabelFn) {
|
||||||
this._appendItems(folders, files, groupFn);
|
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove all items (but keep `.list-header` if present). */
|
/** Remove all items (but keep `.list-header` if present). */
|
||||||
@@ -141,6 +178,8 @@ export class ResourceListComponent {
|
|||||||
this._selected.clear();
|
this._selected.clear();
|
||||||
this._items.clear();
|
this._items.clear();
|
||||||
this._lastClickedIndex = -1;
|
this._lastClickedIndex = -1;
|
||||||
|
this._lastGroupKey = undefined;
|
||||||
|
this._lastGroupEl = null;
|
||||||
// Hand delegation back to ui.js
|
// Hand delegation back to ui.js
|
||||||
delete this._container.dataset.managedBy;
|
delete this._container.dataset.managedBy;
|
||||||
}
|
}
|
||||||
@@ -239,50 +278,74 @@ export class ResourceListComponent {
|
|||||||
// ── Private helpers ─────────────────────────────────────────────────────
|
// ── Private helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FolderItem[]} folders
|
* Internal: append items to the container in the order supplied.
|
||||||
* @param {FileItem[]} files
|
* Files vs. folders are distinguished by presence of `mime_type`.
|
||||||
|
*
|
||||||
|
* @param {Array<FileItem|FolderItem>} items
|
||||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||||
|
* @param {((key: string) => string)=} groupLabelFn
|
||||||
*/
|
*/
|
||||||
_appendItems(folders, files, groupFn) {
|
_appendItems(items, groupFn, groupLabelFn) {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let lastGroupKey = /** @type {string|null|undefined} */ (undefined);
|
|
||||||
|
|
||||||
for (const folder of folders) {
|
// Start from the persisted key so load-more pages continue seamlessly.
|
||||||
this._items.set(folder.id, folder);
|
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) {
|
if (groupFn) {
|
||||||
const key = groupFn(folder);
|
const key = groupFn(item);
|
||||||
if (key !== lastGroupKey) {
|
if (key !== lastGroupKey) {
|
||||||
lastGroupKey = key;
|
lastGroupKey = key;
|
||||||
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
|
liveGroup = null; // stop extending the previous page's group
|
||||||
}
|
fragmentGroup = null;
|
||||||
}
|
|
||||||
fragment.appendChild(this._createFolderItem(folder));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of files) {
|
if (key !== null) {
|
||||||
this._items.set(file.id, file);
|
fragmentGroup = document.createElement('div');
|
||||||
if (groupFn) {
|
fragmentGroup.className = 'resource-list__swimlane-group';
|
||||||
const key = groupFn(file);
|
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn));
|
||||||
if (key !== lastGroupKey) {
|
fragment.appendChild(fragmentGroup);
|
||||||
lastGroupKey = key;
|
}
|
||||||
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
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.
|
* 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');
|
const el = document.createElement('div');
|
||||||
el.className = 'resource-list__swimlane-header';
|
el.className = 'resource-list__swimlane-header';
|
||||||
el.dataset.swimlaneHeader = 'true';
|
el.dataset.swimlaneHeader = 'true';
|
||||||
el.textContent = label;
|
el.textContent = labelFn ? labelFn(key) : key;
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* Contains also checkers
|
* Contains also checkers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { i18n } from './i18n.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} str
|
* @param {string} str
|
||||||
@@ -107,4 +109,62 @@ function isEmailValid(email) {
|
|||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(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,
|
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'
|
'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: [
|
link: [
|
||||||
576,
|
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'
|
'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 { favorites } from '../library/favorites.js';
|
||||||
import { musicView } from '../library/music.js';
|
import { musicView } from '../library/music.js';
|
||||||
import { fileSharing } from '../sharing/fileSharing.js';
|
import { fileSharing } from '../sharing/fileSharing.js';
|
||||||
|
import { batchToolbar } from './batchToolbar.js';
|
||||||
import { fileOps } from './fileOperations.js';
|
import { fileOps } from './fileOperations.js';
|
||||||
import { inlineViewer } from './inlineViewer.js';
|
import { inlineViewer } from './inlineViewer.js';
|
||||||
import { batchToolbar } from './batchToolbar.js';
|
|
||||||
import { wopiEditor } from './wopiEditor.js';
|
import { wopiEditor } from './wopiEditor.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
|
||||||
import { batchToolbar } from '../files/batchToolbar.js';
|
import { batchToolbar } from '../files/batchToolbar.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
import * as pathTooltip from '../pathTooltip.js';
|
||||||
|
|
||||||
@@ -195,16 +195,13 @@ const favorites = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {FolderItem[]} */
|
/** @type {Array<FileItem|FolderItem>} */
|
||||||
const folders = [];
|
const items = [];
|
||||||
|
|
||||||
/** @type {FileItem[]} */
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const item of this._cache.values()) {
|
for (const item of this._cache.values()) {
|
||||||
// owner_id comes from the backend JOIN (actual file/folder owner)
|
// owner_id comes from the backend JOIN (actual file/folder owner)
|
||||||
if (item.item_type === 'folder') {
|
if (item.item_type === 'folder') {
|
||||||
folders.push(
|
items.push(
|
||||||
/** @type {FolderItem} */ ({
|
/** @type {FolderItem} */ ({
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
@@ -220,7 +217,7 @@ const favorites = {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
files.push(
|
items.push(
|
||||||
/** @type {FileItem} */ ({
|
/** @type {FileItem} */ ({
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
@@ -244,50 +241,45 @@ const favorites = {
|
|||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) {
|
if (filesList) {
|
||||||
if (!this._component) {
|
if (!this._component) {
|
||||||
this._component = new ResourceListComponent(
|
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||||
/** @type {HTMLElement} */ (filesList),
|
selectable: true,
|
||||||
{
|
showFavorite: true,
|
||||||
selectable: true,
|
showOwner: true,
|
||||||
showFavorite: true,
|
showShareBadge: true,
|
||||||
showOwner: true,
|
draggable: false,
|
||||||
showShareBadge: true,
|
showContextMenu: true,
|
||||||
draggable: false,
|
itemModifierClass: 'favorite-item',
|
||||||
showContextMenu: true,
|
isFavorite: (id, type) => this.isFavorite(id, type),
|
||||||
itemModifierClass: 'favorite-item',
|
onOpen: (item) => ui.openItem(item),
|
||||||
isFavorite: (id, type) => this.isFavorite(id, type),
|
onFavoriteToggle: async (item) => {
|
||||||
onOpen: (item) => ui.openItem(item),
|
const isFile = 'mime_type' in item;
|
||||||
onFavoriteToggle: async (item) => {
|
const type = isFile ? 'file' : 'folder';
|
||||||
const isFile = 'mime_type' in item;
|
if (this.isFavorite(item.id, type)) {
|
||||||
const type = isFile ? 'file' : 'folder';
|
await this.removeFromFavorites(item.id, type);
|
||||||
if (this.isFavorite(item.id, type)) {
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
await this.removeFromFavorites(item.id, type);
|
} else {
|
||||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
await this.addToFavorites(item.id, item.name, type, null);
|
||||||
} else {
|
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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);
|
batchToolbar.setActiveComponent(this._component);
|
||||||
this._component.render(folders, files);
|
this._component.render(items);
|
||||||
pathTooltip.init(filesList);
|
pathTooltip.init(filesList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
|
||||||
import { batchToolbar } from '../files/batchToolbar.js';
|
import { batchToolbar } from '../files/batchToolbar.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
import * as pathTooltip from '../pathTooltip.js';
|
||||||
|
|
||||||
@@ -109,9 +109,7 @@ const recent = {
|
|||||||
if (filesList) {
|
if (filesList) {
|
||||||
// Relabel the date column header from "Modified" → "Accessed"
|
// Relabel the date column header from "Modified" → "Accessed"
|
||||||
const dateHeader = /** @type {HTMLElement|null} */ (
|
const dateHeader = /** @type {HTMLElement|null} */ (
|
||||||
[...filesList.querySelectorAll('.list-header > div')].find(
|
[...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified')
|
||||||
(el) => el.getAttribute('data-i18n') === 'files.modified'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
if (dateHeader) {
|
if (dateHeader) {
|
||||||
dateHeader.removeAttribute('data-i18n');
|
dateHeader.removeAttribute('data-i18n');
|
||||||
@@ -133,87 +131,83 @@ const recent = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {FolderItem[]} */
|
/** @type {Array<FileItem|FolderItem>} */
|
||||||
const folders = [];
|
const items = [];
|
||||||
|
|
||||||
/** @type {FileItem[]} */
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const item of recentItems) {
|
for (const item of recentItems) {
|
||||||
const isFolder = item.item_type === 'folder';
|
const isFolder = item.item_type === 'folder';
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
folders.push({
|
items.push(
|
||||||
id: item.item_id,
|
/** @type {FolderItem} */ ({
|
||||||
name: item.item_name || item.item_id,
|
id: item.item_id,
|
||||||
parent_id: item.parent_id || '',
|
name: item.item_name || item.item_id,
|
||||||
modified_at: item.accessed_at,
|
parent_id: item.parent_id || '',
|
||||||
path: item.item_path || '',
|
modified_at: item.accessed_at,
|
||||||
category: 'folder',
|
path: item.item_path || '',
|
||||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
category: 'folder',
|
||||||
icon_class: item.icon_class,
|
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||||
icon_special_class: item.icon_special_class,
|
icon_class: item.icon_class,
|
||||||
owner_id: '',
|
icon_special_class: item.icon_special_class,
|
||||||
is_root: false
|
owner_id: '',
|
||||||
});
|
is_root: false
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
if (item.item_mime_type === undefined || item.item_mime_type === null) {
|
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 ?
|
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
|
||||||
console.warn('Broken information for RecentItem: ', item);
|
console.warn('Broken information for RecentItem: ', item);
|
||||||
}
|
}
|
||||||
files.push({
|
items.push(
|
||||||
id: item.item_id,
|
/** @type {FileItem} */ ({
|
||||||
name: item.item_name || item.item_id,
|
id: item.item_id,
|
||||||
folder_id: item.parent_id || '',
|
name: item.item_name || item.item_id,
|
||||||
mime_type: item.item_mime_type,
|
folder_id: item.parent_id || '',
|
||||||
icon_class: item.icon_class,
|
mime_type: item.item_mime_type,
|
||||||
icon_special_class: item.icon_special_class,
|
icon_class: item.icon_class,
|
||||||
category: item.category,
|
icon_special_class: item.icon_special_class,
|
||||||
size: item.item_size || 0,
|
category: item.category,
|
||||||
size_formatted: item.size_formatted,
|
size: item.item_size || 0,
|
||||||
modified_at: item.accessed_at,
|
size_formatted: item.size_formatted,
|
||||||
path: item.item_path || '',
|
modified_at: item.accessed_at,
|
||||||
owner_id: '',
|
path: item.item_path || '',
|
||||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
owner_id: '',
|
||||||
sort_date: item.accessed_at
|
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||||
});
|
sort_date: item.accessed_at
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filesList) {
|
if (filesList) {
|
||||||
if (!this._component) {
|
if (!this._component) {
|
||||||
this._component = new ResourceListComponent(
|
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||||
/** @type {HTMLElement} */ (filesList),
|
selectable: true,
|
||||||
{
|
showFavorite: true,
|
||||||
selectable: true,
|
showOwner: false,
|
||||||
showFavorite: true,
|
showShareBadge: false,
|
||||||
showOwner: false,
|
draggable: false,
|
||||||
showShareBadge: false,
|
showContextMenu: true,
|
||||||
draggable: false,
|
itemModifierClass: 'recent-item',
|
||||||
showContextMenu: true,
|
dateField: 'modified_at', // mapped from accessed_at above
|
||||||
itemModifierClass: 'recent-item',
|
onOpen: (item) => ui.openItem(item),
|
||||||
dateField: 'modified_at', // mapped from accessed_at above
|
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||||
onOpen: (item) => ui.openItem(item),
|
onSelectionChange: (selectedItems) => {
|
||||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
batchToolbar._selected.clear();
|
||||||
onSelectionChange: (selectedItems) => {
|
for (const sel of selectedItems) {
|
||||||
batchToolbar._selected.clear();
|
const isFile = 'mime_type' in sel;
|
||||||
for (const sel of selectedItems) {
|
batchToolbar._selected.set(sel.id, {
|
||||||
const isFile = 'mime_type' in sel;
|
id: sel.id,
|
||||||
batchToolbar._selected.set(sel.id, {
|
name: sel.name,
|
||||||
id: sel.id,
|
type: isFile ? 'file' : 'folder',
|
||||||
name: sel.name,
|
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||||
type: isFile ? 'file' : 'folder',
|
});
|
||||||
parentId: isFile
|
|
||||||
? (/** @type {FileItem} */ (sel)).folder_id || ''
|
|
||||||
: (/** @type {FolderItem} */ (sel)).parent_id || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
batchToolbar._syncUI();
|
|
||||||
}
|
}
|
||||||
|
batchToolbar._syncUI();
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
batchToolbar.setActiveComponent(this._component);
|
batchToolbar.setActiveComponent(this._component);
|
||||||
this._component.render(folders, files);
|
this._component.render(items);
|
||||||
pathTooltip.init(filesList);
|
pathTooltip.init(filesList);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -88,14 +88,16 @@ const grants = {
|
|||||||
* @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']).
|
* @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']).
|
||||||
* @param {number} [opts.limit] - Max items per page (1–200, default 50).
|
* @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.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>}
|
* @returns {Promise<SharedWithMeResponse>}
|
||||||
*/
|
*/
|
||||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) {
|
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
resource_types: resourceTypes.join(',')
|
resource_types: resourceTypes.join(',')
|
||||||
});
|
});
|
||||||
if (cursor) params.set('cursor', cursor);
|
if (cursor) params.set('cursor', cursor);
|
||||||
|
if (orderBy) params.set('sort_by', orderBy);
|
||||||
|
|
||||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ function prefetch() {
|
|||||||
_ensureIndex(); // intentionally fire-and-forget
|
_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.
|
* Resolve a user UUID to a display name.
|
||||||
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
||||||
@@ -159,4 +172,4 @@ function isAvailable() {
|
|||||||
return addressBook.isSystemAvailable();
|
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 { ui } from '../../app/ui.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
|
||||||
import { ResourceListComponent } from '../../components/resourceList.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 { batchToolbar } from '../../features/files/batchToolbar.js';
|
||||||
import { favorites } from '../../features/library/favorites.js';
|
import { favorites } from '../../features/library/favorites.js';
|
||||||
import { ownerTooltip } from '../../features/ownerTooltip.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' */
|
/** @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`. */
|
/** ID of the "Load more" wrapper injected below `.files-container`. */
|
||||||
const LOAD_MORE_ID = 'swm-load-more-wrapper';
|
const LOAD_MORE_ID = 'swm-load-more-wrapper';
|
||||||
|
|
||||||
@@ -35,8 +137,36 @@ const sharedWithMeView = {
|
|||||||
/** @type {ResourceListComponent|null} */
|
/** @type {ResourceListComponent|null} */
|
||||||
_component: null,
|
_component: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
_groupBy: '',
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── 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.
|
* (Re-)load from page 1 and render into the existing files container.
|
||||||
* Called every time the user switches to this section.
|
* Called every time the user switches to this section.
|
||||||
@@ -44,6 +174,7 @@ const sharedWithMeView = {
|
|||||||
async init() {
|
async init() {
|
||||||
this._nextCursor = null;
|
this._nextCursor = null;
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
|
this._groupBy = '';
|
||||||
|
|
||||||
this._ensureLoadMoreButton();
|
this._ensureLoadMoreButton();
|
||||||
|
|
||||||
@@ -60,47 +191,42 @@ const sharedWithMeView = {
|
|||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) {
|
if (filesList) {
|
||||||
if (!this._component) {
|
if (!this._component) {
|
||||||
this._component = new ResourceListComponent(
|
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||||
/** @type {HTMLElement} */ (filesList),
|
selectable: true,
|
||||||
{
|
showFavorite: true,
|
||||||
selectable: true,
|
showOwner: true,
|
||||||
showFavorite: true,
|
showShareBadge: false,
|
||||||
showOwner: true,
|
draggable: false,
|
||||||
showShareBadge: false,
|
showContextMenu: true,
|
||||||
draggable: false,
|
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||||||
showContextMenu: true,
|
isShared: () => false,
|
||||||
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
onOpen: (item) => ui.openItem(item),
|
||||||
isShared: () => false,
|
onFavoriteToggle: async (item) => {
|
||||||
onOpen: (item) => ui.openItem(item),
|
const isFile = 'mime_type' in item;
|
||||||
onFavoriteToggle: async (item) => {
|
const type = isFile ? 'file' : 'folder';
|
||||||
const isFile = 'mime_type' in item;
|
if (favorites.isFavorite(item.id, type)) {
|
||||||
const type = isFile ? 'file' : 'folder';
|
await favorites.removeFromFavorites(item.id, type);
|
||||||
if (favorites.isFavorite(item.id, type)) {
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
await favorites.removeFromFavorites(item.id, type);
|
} else {
|
||||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
} else {
|
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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);
|
batchToolbar.setActiveComponent(this._component);
|
||||||
}
|
}
|
||||||
@@ -138,10 +264,18 @@ const sharedWithMeView = {
|
|||||||
const isFirstPage = this._nextCursor === null;
|
const isFirstPage = this._nextCursor === null;
|
||||||
|
|
||||||
try {
|
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({
|
const data = await grants.fetchSharedWithMe({
|
||||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||||
limit: 50,
|
limit: 50,
|
||||||
cursor: this._nextCursor ?? undefined
|
cursor: this._nextCursor ?? undefined,
|
||||||
|
orderBy
|
||||||
});
|
});
|
||||||
|
|
||||||
this._nextCursor = data.next_cursor ?? null;
|
this._nextCursor = data.next_cursor ?? null;
|
||||||
@@ -157,12 +291,12 @@ const sharedWithMeView = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { folders, files } = this._mapItems(data.items);
|
const items = this._mapItems(data.items);
|
||||||
|
|
||||||
if (isFirstPage) {
|
if (isFirstPage) {
|
||||||
this._component?.render(folders, files);
|
this._component?.render(items, def?.keyFn, def?.labelFn);
|
||||||
} else {
|
} else {
|
||||||
this._component?.append(folders, files);
|
this._component?.append(items, def?.keyFn, def?.labelFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire owner tooltips after items are in the DOM
|
// 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
|
* Sets `owner_id` to `item.granted_by` so the component stamps
|
||||||
* `data-owner-id` with the granter's user ID automatically.
|
* `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
|
* @param {SharedWithMeItem[]} items
|
||||||
* @returns {{ folders: FolderItem[], files: FileItem[] }}
|
* @returns {Array<FileItem|FolderItem>}
|
||||||
*/
|
*/
|
||||||
_mapItems(items) {
|
_mapItems(items) {
|
||||||
/** @type {FolderItem[]} */
|
/** @type {Array<FileItem|FolderItem>} */
|
||||||
const folders = [];
|
const result = [];
|
||||||
|
|
||||||
/** @type {FileItem[]} */
|
/** @param {string} iso @returns {number} */
|
||||||
const files = [];
|
const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.resource_type === 'folder') {
|
if (item.resource_type === 'folder') {
|
||||||
const f = /** @type {FolderItem} */ (item.resource);
|
const f = /** @type {FolderItem} */ (item.resource);
|
||||||
folders.push(
|
result.push(
|
||||||
/** @type {FolderItem} */ ({
|
/** @type {FolderItem} */ ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
path: f.path ?? '',
|
path: f.path ?? '',
|
||||||
parent_id: f.parent_id ?? '',
|
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,
|
owner_id: item.granted_by,
|
||||||
is_root: f.is_root ?? false,
|
is_root: f.is_root ?? false,
|
||||||
created_at: f.created_at,
|
created_at: f.created_at,
|
||||||
modified_at: f.modified_at,
|
modified_at: f.modified_at,
|
||||||
|
sort_date: grantedAtSecs(item.granted_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: 'folder'
|
category: 'folder'
|
||||||
@@ -221,21 +361,19 @@ const sharedWithMeView = {
|
|||||||
);
|
);
|
||||||
} else if (item.resource_type === 'file') {
|
} else if (item.resource_type === 'file') {
|
||||||
const f = /** @type {FileItem} */ (item.resource);
|
const f = /** @type {FileItem} */ (item.resource);
|
||||||
files.push(
|
result.push(
|
||||||
/** @type {FileItem} */ ({
|
/** @type {FileItem} */ ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
path: f.path ?? '',
|
path: f.path ?? '',
|
||||||
folder_id: f.folder_id ?? '',
|
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,
|
owner_id: item.granted_by,
|
||||||
mime_type: f.mime_type,
|
mime_type: f.mime_type,
|
||||||
size: f.size,
|
size: f.size,
|
||||||
size_formatted: f.size_formatted,
|
size_formatted: f.size_formatted,
|
||||||
created_at: f.created_at,
|
created_at: f.created_at,
|
||||||
modified_at: f.modified_at,
|
modified_at: f.modified_at,
|
||||||
sort_date: f.modified_at,
|
sort_date: grantedAtSecs(item.granted_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: f.category
|
category: f.category
|
||||||
@@ -244,7 +382,7 @@ const sharedWithMeView = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { folders, files };
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── "Load more" button ────────────────────────────────────────────────────
|
// ── "Load more" button ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "مشترك من قِبل",
|
"colSharedBy": "مشترك من قِبل",
|
||||||
"colDate": "تاريخ المشاركة",
|
"colDate": "تاريخ المشاركة",
|
||||||
"colPermissions": "الصلاحيات"
|
"colPermissions": "الصلاحيات"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "لا شيء",
|
||||||
|
"title": "التجميع حسب",
|
||||||
|
"owner": "المالك",
|
||||||
|
"shareDate": "تاريخ المشاركة"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "اليوم",
|
||||||
|
"last7days": "آخر 7 أيام",
|
||||||
|
"last30days": "آخر 30 يومًا"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Geteilt von",
|
"colSharedBy": "Geteilt von",
|
||||||
"colDate": "Datum der Freigabe",
|
"colDate": "Datum der Freigabe",
|
||||||
"colPermissions": "Berechtigungen"
|
"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",
|
"colSharedBy": "Shared by",
|
||||||
"colDate": "Date shared",
|
"colDate": "Date shared",
|
||||||
"colPermissions": "Permissions"
|
"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",
|
"colSharedBy": "Compartido por",
|
||||||
"colDate": "Fecha de compartición",
|
"colDate": "Fecha de compartición",
|
||||||
"colPermissions": "Permisos"
|
"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": "به اشتراکگذاشته توسط",
|
"colSharedBy": "به اشتراکگذاشته توسط",
|
||||||
"colDate": "تاریخ اشتراکگذاری",
|
"colDate": "تاریخ اشتراکگذاری",
|
||||||
"colPermissions": "مجوزها"
|
"colPermissions": "مجوزها"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "هیچ",
|
||||||
|
"title": "گروهبندی بر اساس",
|
||||||
|
"owner": "مالک",
|
||||||
|
"shareDate": "تاریخ اشتراک"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "امروز",
|
||||||
|
"last7days": "۷ روز گذشته",
|
||||||
|
"last30days": "۳۰ روز گذشته"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Partagé par",
|
"colSharedBy": "Partagé par",
|
||||||
"colDate": "Date de partage",
|
"colDate": "Date de partage",
|
||||||
"colPermissions": "Permissions"
|
"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": "द्वारा साझा किया",
|
"colSharedBy": "द्वारा साझा किया",
|
||||||
"colDate": "साझाकरण तिथि",
|
"colDate": "साझाकरण तिथि",
|
||||||
"colPermissions": "अनुमतियाँ"
|
"colPermissions": "अनुमतियाँ"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "कोई नहीं",
|
||||||
|
"title": "इसके अनुसार समूहीकृत करें",
|
||||||
|
"owner": "स्वामी",
|
||||||
|
"shareDate": "साझा तिथि"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "आज",
|
||||||
|
"last7days": "पिछले 7 दिन",
|
||||||
|
"last30days": "पिछले 30 दिन"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Condiviso da",
|
"colSharedBy": "Condiviso da",
|
||||||
"colDate": "Data condivisione",
|
"colDate": "Data condivisione",
|
||||||
"colPermissions": "Permessi"
|
"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": "共有者",
|
"colSharedBy": "共有者",
|
||||||
"colDate": "共有日",
|
"colDate": "共有日",
|
||||||
"colPermissions": "権限"
|
"colPermissions": "権限"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "なし",
|
||||||
|
"title": "グループ化",
|
||||||
|
"owner": "オーナー",
|
||||||
|
"shareDate": "共有日"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "今日",
|
||||||
|
"last7days": "過去7日間",
|
||||||
|
"last30days": "過去30日間"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "공유한 사람",
|
"colSharedBy": "공유한 사람",
|
||||||
"colDate": "공유 날짜",
|
"colDate": "공유 날짜",
|
||||||
"colPermissions": "권한"
|
"colPermissions": "권한"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "없음",
|
||||||
|
"title": "그룹화 기준",
|
||||||
|
"owner": "소유자",
|
||||||
|
"shareDate": "공유 날짜"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "오늘",
|
||||||
|
"last7days": "최근 7일",
|
||||||
|
"last30days": "최근 30일"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "Gedeeld door",
|
"colSharedBy": "Gedeeld door",
|
||||||
"colDate": "Datum gedeeld",
|
"colDate": "Datum gedeeld",
|
||||||
"colPermissions": "Machtigingen"
|
"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",
|
"colSharedBy": "Udostępnione przez",
|
||||||
"colDate": "Data udostępnienia",
|
"colDate": "Data udostępnienia",
|
||||||
"colPermissions": "Uprawnienia"
|
"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",
|
"colSharedBy": "Compartilhado por",
|
||||||
"colDate": "Data de compartilhamento",
|
"colDate": "Data de compartilhamento",
|
||||||
"colPermissions": "Permissões"
|
"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": "Предоставлено",
|
"colSharedBy": "Предоставлено",
|
||||||
"colDate": "Дата предоставления",
|
"colDate": "Дата предоставления",
|
||||||
"colPermissions": "Права"
|
"colPermissions": "Права"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "Нет",
|
||||||
|
"title": "Группировать по",
|
||||||
|
"owner": "Владелец",
|
||||||
|
"shareDate": "Дата общего доступа"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "Сегодня",
|
||||||
|
"last7days": "Последние 7 дней",
|
||||||
|
"last30days": "Последние 30 дней"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "共享者",
|
"colSharedBy": "共享者",
|
||||||
"colDate": "共享日期",
|
"colDate": "共享日期",
|
||||||
"colPermissions": "權限"
|
"colPermissions": "權限"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "無",
|
||||||
|
"title": "分組方式",
|
||||||
|
"owner": "擁有者",
|
||||||
|
"shareDate": "分享日期"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "今天",
|
||||||
|
"last7days": "近7天",
|
||||||
|
"last30days": "近30天"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,5 +710,16 @@
|
|||||||
"colSharedBy": "共享者",
|
"colSharedBy": "共享者",
|
||||||
"colDate": "共享日期",
|
"colDate": "共享日期",
|
||||||
"colPermissions": "权限"
|
"colPermissions": "权限"
|
||||||
|
},
|
||||||
|
"groupby": {
|
||||||
|
"none": "无",
|
||||||
|
"title": "分组方式",
|
||||||
|
"owner": "所有者",
|
||||||
|
"shareDate": "分享日期"
|
||||||
|
},
|
||||||
|
"dateBucket": {
|
||||||
|
"today": "今天",
|
||||||
|
"last7days": "近7天",
|
||||||
|
"last30days": "近30天"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user