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:
@@ -1,8 +1,8 @@
|
||||
// @ts-check
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { resolveHomeFolder } from './authSession.js';
|
||||
import { updateHistory } from './main.js';
|
||||
import { app } from './state.js';
|
||||
|
||||
+101
-3
@@ -8,11 +8,11 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
|
||||
installFetchInterceptor();
|
||||
|
||||
import { Modal } from '../components/modal.js';
|
||||
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { escapeHtml, formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { oxiIconsInit } from '../core/icons.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||
@@ -83,6 +83,15 @@ const _batchToolbarButons = `
|
||||
|
||||
const _toggleButtons = `
|
||||
<div class="view-toggle">
|
||||
<div class="group-by-selector hidden" id="group-by-selector">
|
||||
<button class="toggle-btn group-by-btn" id="group-by-btn"
|
||||
title="Group by" data-i18n-title="groupby.title">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span class="group-by-label"></span>
|
||||
</button>
|
||||
<div class="group-by-menu hidden" id="group-by-menu"></div>
|
||||
</div>
|
||||
<span class="view-toggle-separator hidden" id="group-by-separator"></span>
|
||||
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
|
||||
<i class="fas fa-th"></i>
|
||||
</button>
|
||||
@@ -146,6 +155,7 @@ const ACTIONS_BAR_TEMPLATES = {
|
||||
`,
|
||||
sharedwithme: `
|
||||
<div class="action-buttons" id="default-buttons"></div>
|
||||
${_batchToolbarButons}
|
||||
${_toggleButtons}
|
||||
`
|
||||
};
|
||||
@@ -189,6 +199,75 @@ function setActionsBarMode(mode, force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ key: string, label: string, setGroupBy: (key: string) => void }} GroupByCapableView
|
||||
*/
|
||||
|
||||
/**
|
||||
* The view that currently owns the group-by selector, or `null` when no
|
||||
* section supports grouping. Set by `setGroupByView()` from navigation.js.
|
||||
* @type {{ setGroupBy: (key: string) => void } | null}
|
||||
*/
|
||||
let _groupByView = null;
|
||||
|
||||
/**
|
||||
* Update the reference to the view that handles group-by changes.
|
||||
* Called by navigation.js when the active section changes.
|
||||
* @param {{ setGroupBy: (key: string) => void } | null} view
|
||||
*/
|
||||
function setGroupByView(view) {
|
||||
_groupByView = view;
|
||||
}
|
||||
|
||||
/** @type {((e: MouseEvent) => void) | null} */
|
||||
let _groupByDocumentClickHandler = null;
|
||||
|
||||
/**
|
||||
* Populate and show (or hide) the group-by dropdown based on the active
|
||||
* section's `groupByDefs`. Pass an empty array (or omit) to hide the button.
|
||||
*
|
||||
* Must be called AFTER `setActionsBarMode()` so the selector elements exist
|
||||
* in the DOM.
|
||||
*
|
||||
* @param {Array<{key: string, label: string}>} [defs]
|
||||
*/
|
||||
function syncGroupByMenu(defs = []) {
|
||||
const selector = document.getElementById('group-by-selector');
|
||||
const separator = document.getElementById('group-by-separator');
|
||||
const menu = document.getElementById('group-by-menu');
|
||||
if (!selector || !menu) return;
|
||||
|
||||
const hasDefs = defs.length > 0;
|
||||
selector.classList.toggle('hidden', !hasDefs);
|
||||
separator?.classList.toggle('hidden', !hasDefs);
|
||||
|
||||
if (!hasDefs) {
|
||||
// Reset active indicator when the section has no group-by support
|
||||
const btn = document.getElementById('group-by-btn');
|
||||
btn?.classList.remove('active');
|
||||
const lbl = btn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild menu options — call i18n.t() directly so each label is resolved
|
||||
// at call time (translations are loaded by the time any section switch runs).
|
||||
menu.innerHTML = `<button class="group-by-option active" data-group-by="">${escapeHtml(i18n.t('groupby.none', 'None'))}</button>`;
|
||||
for (const def of defs) {
|
||||
menu.insertAdjacentHTML('beforeend', `<button class="group-by-option" data-group-by="${escapeHtml(def.key)}">${escapeHtml(def.label)}</button>`);
|
||||
}
|
||||
|
||||
// One stable document-level handler to close the menu on outside clicks.
|
||||
if (_groupByDocumentClickHandler) {
|
||||
document.removeEventListener('click', _groupByDocumentClickHandler);
|
||||
}
|
||||
_groupByDocumentClickHandler = (e) => {
|
||||
if (/** @type {HTMLElement} */ (e.target)?.closest('#group-by-selector')) return;
|
||||
document.getElementById('group-by-menu')?.classList.add('hidden');
|
||||
};
|
||||
document.addEventListener('click', _groupByDocumentClickHandler);
|
||||
}
|
||||
|
||||
function setupActionsBarDelegation() {
|
||||
if (actionsBarDelegationBound || !elements.actionsBar) return;
|
||||
actionsBarDelegationBound = true;
|
||||
@@ -197,7 +276,26 @@ function setupActionsBarDelegation() {
|
||||
const btn = /** @type {HTMLElement} */ (e.target)?.closest('button');
|
||||
if (!btn) return;
|
||||
|
||||
// ── Group-by option selected ──────────────────────────────────────────
|
||||
if (btn.classList.contains('group-by-option')) {
|
||||
const key = btn.dataset.groupBy ?? '';
|
||||
_groupByView?.setGroupBy(key);
|
||||
document.querySelectorAll('.group-by-option').forEach((b) => {
|
||||
b.classList.remove('active');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
document.getElementById('group-by-menu')?.classList.add('hidden');
|
||||
const groupByBtn = document.getElementById('group-by-btn');
|
||||
groupByBtn?.classList.toggle('active', key !== '');
|
||||
const lbl = groupByBtn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : '';
|
||||
return;
|
||||
}
|
||||
|
||||
switch (btn.id) {
|
||||
case 'group-by-btn':
|
||||
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
||||
return;
|
||||
case 'upload-files-btn': {
|
||||
e.stopPropagation();
|
||||
const menu = document.getElementById('upload-dropdown-menu');
|
||||
@@ -798,4 +896,4 @@ function updateStorageUsageDisplay(userData) {
|
||||
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
|
||||
}
|
||||
|
||||
export { deserializeHash, initApp, setActionsBarMode, updateHistory, updateStorageUsageDisplay };
|
||||
export { deserializeHash, initApp, setActionsBarMode, setGroupByView, syncGroupByMenu, updateHistory, updateStorageUsageDisplay };
|
||||
|
||||
@@ -12,7 +12,7 @@ import { recent } from '../features/library/recent.js';
|
||||
import { sharedView } from '../views/shared/sharedView.js';
|
||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import { setActionsBarMode } from './main.js';
|
||||
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||
import { app, appElements } from './state.js';
|
||||
import { loadTrashItems } from './trashView.js';
|
||||
import { ui } from './ui.js';
|
||||
@@ -214,6 +214,11 @@ function switchToSharedWithMeSection() {
|
||||
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
||||
setActionsBarMode('sharedwithme');
|
||||
|
||||
// Populate the group-by dropdown with this section's dimensions.
|
||||
// Must be called AFTER setActionsBarMode() so the selector elements exist.
|
||||
setGroupByView(sharedWithMeView);
|
||||
syncGroupByMenu(sharedWithMeView.groupByDefs);
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
|
||||
@@ -232,6 +237,8 @@ function switchToFilesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
// Show owner column in the Files section
|
||||
ui.setOwnerColumnVisible(true);
|
||||
@@ -266,6 +273,8 @@ function switchToFavoritesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('favorites');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
@@ -304,6 +313,8 @@ function switchToRecentFilesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('recent');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
// Hide breadcrumb (only shown in Files view)
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
@@ -367,6 +378,8 @@ function switchToTrashSection() {
|
||||
toggleFileContainer(true);
|
||||
|
||||
setActionsBarMode('trash');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
|
||||
//reset files view + remove any error
|
||||
ui.resetFilesList();
|
||||
@@ -419,6 +432,8 @@ function switchToMusicSection() {
|
||||
function activateFilesUI() {
|
||||
setCurrentSection('files');
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.remove('hidden');
|
||||
toggleFileContainer(true);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
import { escapeHtml, formatDateTime } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import * as pathTooltip from '../features/pathTooltip.js';
|
||||
import { appElements } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
|
||||
+1
-1
@@ -10,10 +10,10 @@ import { createUserVignette } from '../components/userVignette.js';
|
||||
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { OxiIcons } from '../core/icons.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { contextMenus } from '../features/files/contextMenus.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { wopiEditor } from '../features/files/wopiEditor.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
|
||||
@@ -89,6 +89,30 @@ export class ResourceListComponent {
|
||||
/** Index of the last clicked item — used for shift-click range selection. */
|
||||
this._lastClickedIndex = -1;
|
||||
|
||||
/**
|
||||
* The grouping key of the last rendered item — persisted across
|
||||
* `append()` calls so load-more pages don't insert a redundant header
|
||||
* when the first item of the new page shares a group with the last
|
||||
* item of the previous page.
|
||||
* `undefined` means no items have been rendered yet (reset in `render()`).
|
||||
* @type {string|null|undefined}
|
||||
*/
|
||||
this._lastGroupKey = undefined;
|
||||
|
||||
/**
|
||||
* The live DOM group wrapper of the last rendered swimlane — persisted
|
||||
* across `append()` calls so load-more items that continue the same
|
||||
* group are appended into the existing card rather than starting a new one.
|
||||
* @type {HTMLElement|null}
|
||||
*/
|
||||
this._lastGroupEl = null;
|
||||
|
||||
/**
|
||||
* Optional label-resolver stored between `render()` and `append()` calls.
|
||||
* @type {((key: string) => string) | undefined}
|
||||
*/
|
||||
this._groupLabelFn = undefined;
|
||||
|
||||
this._ownerVisible = this._cfg.showOwner;
|
||||
|
||||
this._initDelegation();
|
||||
@@ -100,13 +124,20 @@ export class ResourceListComponent {
|
||||
* Replace the current item list. Preserves an existing `.list-header`
|
||||
* at the start of the container.
|
||||
*
|
||||
* @param {FolderItem[]} folders
|
||||
* @param {FileItem[]} files
|
||||
* Items are rendered **in the order supplied** — do not pre-sort or
|
||||
* split them into folders/files; the caller (or server) owns ordering.
|
||||
* Folders vs. files are distinguished at render time by whether the item
|
||||
* has a `mime_type` property.
|
||||
*
|
||||
* @param {Array<FileItem|FolderItem>} items
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
* When provided, a swimlane divider is injected whenever the returned
|
||||
* label changes. Return `null` to suppress the divider for that item.
|
||||
* key changes. Return `null` to suppress the divider for that item.
|
||||
* @param {((key: string) => string)=} groupLabelFn
|
||||
* Optional: converts the raw grouping key to a human-readable header
|
||||
* label. When omitted the key itself is used.
|
||||
*/
|
||||
render(folders, files, groupFn) {
|
||||
render(items, groupFn, groupLabelFn) {
|
||||
const header = this._container.querySelector('.list-header');
|
||||
this._container.innerHTML = '';
|
||||
if (header) this._container.appendChild(header);
|
||||
@@ -114,23 +145,29 @@ export class ResourceListComponent {
|
||||
this._selected.clear();
|
||||
this._items.clear();
|
||||
this._lastClickedIndex = -1;
|
||||
// Reset group tracking for the fresh render
|
||||
this._lastGroupKey = undefined;
|
||||
this._lastGroupEl = null;
|
||||
this._groupLabelFn = groupLabelFn;
|
||||
|
||||
// Prevent ui.js global delegation from firing on this container
|
||||
this._container.dataset.managedBy = 'resource-list';
|
||||
|
||||
this._appendItems(folders, files, groupFn);
|
||||
this._appendItems(items, groupFn, groupLabelFn);
|
||||
this._wireSelectAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append additional items without clearing the existing ones (load-more).
|
||||
* Continues swimlane grouping from the last item of the previous page —
|
||||
* no redundant header is inserted when the key is unchanged.
|
||||
*
|
||||
* @param {FolderItem[]} folders
|
||||
* @param {FileItem[]} files
|
||||
* @param {Array<FileItem|FolderItem>} items
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
* @param {((key: string) => string)=} groupLabelFn
|
||||
*/
|
||||
append(folders, files, groupFn) {
|
||||
this._appendItems(folders, files, groupFn);
|
||||
append(items, groupFn, groupLabelFn) {
|
||||
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn);
|
||||
}
|
||||
|
||||
/** Remove all items (but keep `.list-header` if present). */
|
||||
@@ -141,6 +178,8 @@ export class ResourceListComponent {
|
||||
this._selected.clear();
|
||||
this._items.clear();
|
||||
this._lastClickedIndex = -1;
|
||||
this._lastGroupKey = undefined;
|
||||
this._lastGroupEl = null;
|
||||
// Hand delegation back to ui.js
|
||||
delete this._container.dataset.managedBy;
|
||||
}
|
||||
@@ -239,50 +278,74 @@ export class ResourceListComponent {
|
||||
// ── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {FolderItem[]} folders
|
||||
* @param {FileItem[]} files
|
||||
* Internal: append items to the container in the order supplied.
|
||||
* Files vs. folders are distinguished by presence of `mime_type`.
|
||||
*
|
||||
* @param {Array<FileItem|FolderItem>} items
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
* @param {((key: string) => string)=} groupLabelFn
|
||||
*/
|
||||
_appendItems(folders, files, groupFn) {
|
||||
_appendItems(items, groupFn, groupLabelFn) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastGroupKey = /** @type {string|null|undefined} */ (undefined);
|
||||
|
||||
for (const folder of folders) {
|
||||
this._items.set(folder.id, folder);
|
||||
// Start from the persisted key so load-more pages continue seamlessly.
|
||||
let lastGroupKey = this._lastGroupKey;
|
||||
|
||||
// liveGroup: existing DOM group element from the previous page; items
|
||||
// that continue its group are appended directly into it (not via fragment).
|
||||
// fragmentGroup: the group wrapper currently being built in the fragment.
|
||||
let liveGroup = groupFn ? this._lastGroupEl : null;
|
||||
let fragmentGroup = /** @type {HTMLElement|null} */ (null);
|
||||
|
||||
for (const item of items) {
|
||||
this._items.set(item.id, item);
|
||||
|
||||
if (groupFn) {
|
||||
const key = groupFn(folder);
|
||||
const key = groupFn(item);
|
||||
if (key !== lastGroupKey) {
|
||||
lastGroupKey = key;
|
||||
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
|
||||
}
|
||||
}
|
||||
fragment.appendChild(this._createFolderItem(folder));
|
||||
}
|
||||
liveGroup = null; // stop extending the previous page's group
|
||||
fragmentGroup = null;
|
||||
|
||||
for (const file of files) {
|
||||
this._items.set(file.id, file);
|
||||
if (groupFn) {
|
||||
const key = groupFn(file);
|
||||
if (key !== lastGroupKey) {
|
||||
lastGroupKey = key;
|
||||
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
|
||||
if (key !== null) {
|
||||
fragmentGroup = document.createElement('div');
|
||||
fragmentGroup.className = 'resource-list__swimlane-group';
|
||||
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn));
|
||||
fragment.appendChild(fragmentGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment.appendChild(this._createFileItem(file));
|
||||
|
||||
// Dispatch to the correct renderer: files have mime_type, folders do not.
|
||||
const isFile = 'mime_type' in item;
|
||||
const itemEl = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item));
|
||||
|
||||
// Priority: live DOM group (load-more continuation) > current fragment group > bare container
|
||||
const target = liveGroup ?? fragmentGroup;
|
||||
if (target) {
|
||||
target.appendChild(itemEl);
|
||||
} else {
|
||||
fragment.appendChild(itemEl);
|
||||
}
|
||||
}
|
||||
|
||||
this._container.appendChild(fragment);
|
||||
// Persist for the next append() call (load-more continuity).
|
||||
this._lastGroupKey = lastGroupKey;
|
||||
// Track the last group element (live or freshly added) for the next page.
|
||||
this._lastGroupEl = fragmentGroup ?? liveGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a swimlane divider element.
|
||||
* @param {string} label
|
||||
* @param {string} key - Raw grouping key (e.g. UUID or bucket name).
|
||||
* @param {((key: string) => string)=} labelFn - Optional human-readable resolver.
|
||||
*/
|
||||
_createGroupHeader(label) {
|
||||
_createGroupHeader(key, labelFn) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'resource-list__swimlane-header';
|
||||
el.dataset.swimlaneHeader = 'true';
|
||||
el.textContent = label;
|
||||
el.textContent = labelFn ? labelFn(key) : key;
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Contains also checkers
|
||||
*/
|
||||
|
||||
import { i18n } from './i18n.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
@@ -107,4 +109,62 @@ function isEmailValid(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable };
|
||||
/**
|
||||
* Normalize a date value into a human-readable bucket label.
|
||||
* Buckets (newest-first): Today | Last 7 days | Last 30 days | <YYYY>
|
||||
*
|
||||
* Accepts:
|
||||
* - `string` — ISO-8601 date string (e.g. `granted_at` from the API)
|
||||
* - `number` — Unix timestamp in **seconds** (e.g. `sort_date`, `modified_at`)
|
||||
* Values < 1e12 are treated as seconds; larger values as milliseconds.
|
||||
* - `Date` — JavaScript Date object
|
||||
*
|
||||
* @param {string | number | Date} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeDateBucket(value) {
|
||||
let date;
|
||||
if (value instanceof Date) {
|
||||
date = value;
|
||||
} else if (typeof value === 'number') {
|
||||
date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else {
|
||||
date = new Date(value);
|
||||
}
|
||||
const diffDays = Math.floor((Date.now() - date.getTime()) / 86_400_000);
|
||||
if (diffDays === 0) return i18n.t('dateBucket.today', 'Today');
|
||||
if (diffDays <= 7) return i18n.t('dateBucket.last7days', 'Last 7 days');
|
||||
if (diffDays <= 30) return i18n.t('dateBucket.last30days', 'Last 30 days');
|
||||
return String(date.getFullYear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a file size in bytes to a coarse, human-readable bucket label.
|
||||
*
|
||||
* Pass `-1` for folders — they sort before all files on the server and
|
||||
* receive the "Folders" label client-side.
|
||||
*
|
||||
* Buckets:
|
||||
* -1 → Folders
|
||||
* 0 → Empty (0 B)
|
||||
* 1 – 1 048 575 → < 1 MB
|
||||
* 1 048 576 – 104 857 599 → 1 – 100 MB
|
||||
* 104 857 600 – 1 073 741 823 → 100 MB – 1 GB
|
||||
* 1 073 741 824 – 5 368 709 119 → 1 – 5 GB
|
||||
* ≥ 5 368 709 120 → > 5 GB
|
||||
*
|
||||
* @param {number} bytes File size in bytes, or -1 for folders.
|
||||
* @returns {string}
|
||||
*/
|
||||
// biome-ignore format: keep the following indent
|
||||
function sizeBucket(bytes) {
|
||||
if (bytes < 0) return i18n.t('sizeBucket.folders', 'Folders');
|
||||
if (bytes === 0) return i18n.t('sizeBucket.empty', 'Empty (0 B)');
|
||||
if (bytes < 1_048_576) return i18n.t('sizeBucket.tiny', '< 1 MB');
|
||||
if (bytes < 104_857_600) return i18n.t('sizeBucket.small', '1 – 100 MB');
|
||||
if (bytes < 1_073_741_824) return i18n.t('sizeBucket.medium', '100 MB – 1 GB');
|
||||
if (bytes < 5 * 1_073_741_824) return i18n.t('sizeBucket.large', '1 – 5 GB');
|
||||
return i18n.t('sizeBucket.huge', '> 5 GB');
|
||||
}
|
||||
|
||||
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable, normalizeDateBucket, sizeBucket };
|
||||
|
||||
@@ -234,6 +234,10 @@ const OxiIcons = {
|
||||
576,
|
||||
'M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z'
|
||||
],
|
||||
'layer-group': [
|
||||
512,
|
||||
'M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z'
|
||||
],
|
||||
link: [
|
||||
576,
|
||||
'M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z'
|
||||
|
||||
@@ -16,9 +16,9 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { favorites } from '../library/favorites.js';
|
||||
import { musicView } from '../library/music.js';
|
||||
import { fileSharing } from '../sharing/fileSharing.js';
|
||||
import { batchToolbar } from './batchToolbar.js';
|
||||
import { fileOps } from './fileOperations.js';
|
||||
import { inlineViewer } from './inlineViewer.js';
|
||||
import { batchToolbar } from './batchToolbar.js';
|
||||
import { wopiEditor } from './wopiEditor.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { batchToolbar } from '../files/batchToolbar.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
@@ -195,16 +195,13 @@ const favorites = {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const items = [];
|
||||
|
||||
for (const item of this._cache.values()) {
|
||||
// owner_id comes from the backend JOIN (actual file/folder owner)
|
||||
if (item.item_type === 'folder') {
|
||||
folders.push(
|
||||
items.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
@@ -220,7 +217,7 @@ const favorites = {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
files.push(
|
||||
items.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
@@ -244,50 +241,45 @@ const favorites = {
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) {
|
||||
if (!this._component) {
|
||||
this._component = new ResourceListComponent(
|
||||
/** @type {HTMLElement} */ (filesList),
|
||||
{
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: true,
|
||||
showShareBadge: true,
|
||||
draggable: false,
|
||||
showContextMenu: true,
|
||||
itemModifierClass: 'favorite-item',
|
||||
isFavorite: (id, type) => this.isFavorite(id, type),
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onFavoriteToggle: async (item) => {
|
||||
const isFile = 'mime_type' in item;
|
||||
const type = isFile ? 'file' : 'folder';
|
||||
if (this.isFavorite(item.id, type)) {
|
||||
await this.removeFromFavorites(item.id, type);
|
||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||
} else {
|
||||
await this.addToFavorites(item.id, item.name, type, null);
|
||||
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||
}
|
||||
},
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile
|
||||
? (/** @type {FileItem} */ (sel)).folder_id || ''
|
||||
: (/** @type {FolderItem} */ (sel)).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: true,
|
||||
showShareBadge: true,
|
||||
draggable: false,
|
||||
showContextMenu: true,
|
||||
itemModifierClass: 'favorite-item',
|
||||
isFavorite: (id, type) => this.isFavorite(id, type),
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onFavoriteToggle: async (item) => {
|
||||
const isFile = 'mime_type' in item;
|
||||
const type = isFile ? 'file' : 'folder';
|
||||
if (this.isFavorite(item.id, type)) {
|
||||
await this.removeFromFavorites(item.id, type);
|
||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||
} else {
|
||||
await this.addToFavorites(item.id, item.name, type, null);
|
||||
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||
}
|
||||
},
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
batchToolbar.setActiveComponent(this._component);
|
||||
this._component.render(folders, files);
|
||||
this._component.render(items);
|
||||
pathTooltip.init(filesList);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { batchToolbar } from '../files/batchToolbar.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
@@ -109,9 +109,7 @@ const recent = {
|
||||
if (filesList) {
|
||||
// Relabel the date column header from "Modified" → "Accessed"
|
||||
const dateHeader = /** @type {HTMLElement|null} */ (
|
||||
[...filesList.querySelectorAll('.list-header > div')].find(
|
||||
(el) => el.getAttribute('data-i18n') === 'files.modified'
|
||||
)
|
||||
[...filesList.querySelectorAll('.list-header > div')].find((el) => el.getAttribute('data-i18n') === 'files.modified')
|
||||
);
|
||||
if (dateHeader) {
|
||||
dateHeader.removeAttribute('data-i18n');
|
||||
@@ -133,87 +131,83 @@ const recent = {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const items = [];
|
||||
|
||||
for (const item of recentItems) {
|
||||
const isFolder = item.item_type === 'folder';
|
||||
if (isFolder) {
|
||||
folders.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
category: 'folder',
|
||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
owner_id: '',
|
||||
is_root: false
|
||||
});
|
||||
items.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
category: 'folder',
|
||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
owner_id: '',
|
||||
is_root: false
|
||||
})
|
||||
);
|
||||
} else {
|
||||
if (item.item_mime_type === undefined || item.item_mime_type === null) {
|
||||
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
|
||||
console.warn('Broken information for RecentItem: ', item);
|
||||
}
|
||||
files.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
owner_id: '',
|
||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||
sort_date: item.accessed_at
|
||||
});
|
||||
items.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || '',
|
||||
owner_id: '',
|
||||
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||
sort_date: item.accessed_at
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesList) {
|
||||
if (!this._component) {
|
||||
this._component = new ResourceListComponent(
|
||||
/** @type {HTMLElement} */ (filesList),
|
||||
{
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: false,
|
||||
showShareBadge: false,
|
||||
draggable: false,
|
||||
showContextMenu: true,
|
||||
itemModifierClass: 'recent-item',
|
||||
dateField: 'modified_at', // mapped from accessed_at above
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile
|
||||
? (/** @type {FileItem} */ (sel)).folder_id || ''
|
||||
: (/** @type {FolderItem} */ (sel)).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: false,
|
||||
showShareBadge: false,
|
||||
draggable: false,
|
||||
showContextMenu: true,
|
||||
itemModifierClass: 'recent-item',
|
||||
dateField: 'modified_at', // mapped from accessed_at above
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
batchToolbar.setActiveComponent(this._component);
|
||||
this._component.render(folders, files);
|
||||
this._component.render(items);
|
||||
pathTooltip.init(filesList);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -88,14 +88,16 @@ const grants = {
|
||||
* @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']).
|
||||
* @param {number} [opts.limit] - Max items per page (1–200, default 50).
|
||||
* @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page.
|
||||
* @param {string} [opts.orderBy] - Sort dimension: 'granted_at' | 'granted_by' (default: 'granted_at').
|
||||
* @returns {Promise<SharedWithMeResponse>}
|
||||
*/
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) {
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
resource_types: resourceTypes.join(',')
|
||||
});
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (orderBy) params.set('sort_by', orderBy);
|
||||
|
||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
||||
|
||||
|
||||
@@ -96,6 +96,19 @@ function prefetch() {
|
||||
_ensureIndex(); // intentionally fire-and-forget
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous best-effort display-name lookup from the pre-fetched cache.
|
||||
* Returns a shortened UUID prefix when the cache is not yet loaded.
|
||||
* Call `prefetch()` at view init time so the cache is warm by the time
|
||||
* items are rendered.
|
||||
* @param {string} userId
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDisplayNameSync(userId) {
|
||||
if (_index === null) return `${userId.slice(0, 8)}…`;
|
||||
return _index.get(userId) ?? `${userId.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user UUID to a display name.
|
||||
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
||||
@@ -159,4 +172,4 @@ function isAvailable() {
|
||||
return addressBook.isSystemAvailable();
|
||||
}
|
||||
|
||||
export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
||||
export const systemUsers = { prefetch, getDisplayName, getDisplayNameSync, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { batchToolbar } from '../../features/files/batchToolbar.js';
|
||||
import { favorites } from '../../features/library/favorites.js';
|
||||
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
||||
@@ -21,6 +22,107 @@ import { systemUsers } from '../../model/systemUsers.js';
|
||||
|
||||
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
|
||||
|
||||
/**
|
||||
* @typedef {{ key: string, label: string, orderBy: string,
|
||||
* keyFn: (item: FileItem|FolderItem) => string|null,
|
||||
* labelFn?: (key: string) => string }} GroupByDef
|
||||
*/
|
||||
|
||||
/**
|
||||
* Group-by dimension definitions for this section.
|
||||
* Exported via `sharedWithMeView.groupByDefs` so `main.js` can populate
|
||||
* the dropdown dynamically without knowing the internals of this view.
|
||||
*
|
||||
* `keyFn` returns the grouping key (stable UUID for owner, or a
|
||||
* human-readable bucket label for shareDate — the bucket IS the key because
|
||||
* it is already derived from the date, so no separate `labelFn` is needed
|
||||
* for shareDate).
|
||||
*
|
||||
* @type {GroupByDef[]}
|
||||
*/
|
||||
const GROUP_BY_DEFS = [
|
||||
{
|
||||
key: 'type',
|
||||
get label() {
|
||||
return i18n.t('groupby.type', 'Type');
|
||||
},
|
||||
orderBy: 'type',
|
||||
// keyFn: folders get their own swimlane; files use the pre-computed
|
||||
// `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …).
|
||||
// The server orders by category_order (a pre-computed SMALLINT column)
|
||||
// so items within the same category arrive grouped — no client sort needed.
|
||||
keyFn: (item) => ('mime_type' in item ? /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
|
||||
labelFn: (key) => {
|
||||
// biome-ignore format: keep indentation
|
||||
/** @type {Record<string, string>} */
|
||||
const labels = {
|
||||
Folder: i18n.t('groupby.type.folders', 'Folders'),
|
||||
Image: i18n.t('category.images', 'Images'),
|
||||
Video: i18n.t('category.videos', 'Videos'),
|
||||
Audio: i18n.t('category.audio', 'Audio'),
|
||||
PDF: 'PDF',
|
||||
Document: i18n.t('category.documents', 'Documents'),
|
||||
Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'),
|
||||
Presentation: i18n.t('category.presentations', 'Presentations'),
|
||||
Archive: i18n.t('category.archives', 'Archives'),
|
||||
Code: i18n.t('category.code', 'Code'),
|
||||
Markdown: i18n.t('category.markdown', 'Markdown'),
|
||||
Text: i18n.t('category.text', 'Text'),
|
||||
Installer: i18n.t('category.installers', 'Installers')
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
// label is accessed via syncGroupByMenu → read at section-switch time,
|
||||
// when translations are guaranteed to be loaded.
|
||||
get label() {
|
||||
return i18n.t('groupby.owner', 'Owner');
|
||||
},
|
||||
orderBy: 'granted_by',
|
||||
// keyFn groups by UUID — stable and unique, avoids collisions between
|
||||
// users with the same display name.
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
|
||||
return r.owner_id || null;
|
||||
},
|
||||
// labelFn resolves UUID → display name from the pre-fetched cache.
|
||||
labelFn: (id) => systemUsers.getDisplayNameSync(id)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
get label() {
|
||||
return i18n.t('groupby.size', 'Size');
|
||||
},
|
||||
orderBy: 'size',
|
||||
// keyFn: the key IS the bucket label returned by sizeBucket(), so no
|
||||
// separate labelFn is needed (same pattern as shareDate).
|
||||
// Folders have no size — sizeBucket(-1) returns the "Folders" label.
|
||||
keyFn: (item) => {
|
||||
if (!('mime_type' in item)) return sizeBucket(-1);
|
||||
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
|
||||
return sizeBucket(r.size ?? 0);
|
||||
}
|
||||
// No labelFn: keyFn already returns the human-readable label.
|
||||
},
|
||||
{
|
||||
key: 'shareDate',
|
||||
get label() {
|
||||
return i18n.t('groupby.shareDate', 'Share date');
|
||||
},
|
||||
orderBy: 'granted_at',
|
||||
// keyFn returns the human-readable bucket label; the label IS the key
|
||||
// because consecutive items with the same bucket should be in one group.
|
||||
// sort_date is stored as unix seconds (number) in _mapItems().
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
|
||||
return r.sort_date ? normalizeDateBucket(r.sort_date) : null;
|
||||
}
|
||||
// No labelFn: keyFn already returns the human-readable label.
|
||||
}
|
||||
];
|
||||
|
||||
/** ID of the "Load more" wrapper injected below `.files-container`. */
|
||||
const LOAD_MORE_ID = 'swm-load-more-wrapper';
|
||||
|
||||
@@ -35,8 +137,36 @@ const sharedWithMeView = {
|
||||
/** @type {ResourceListComponent|null} */
|
||||
_component: null,
|
||||
|
||||
/**
|
||||
* Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active.
|
||||
* @type {string}
|
||||
*/
|
||||
_groupBy: '',
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The group-by dimension definitions for this section.
|
||||
* `main.js` reads this to populate the Group-by dropdown dynamically.
|
||||
* @returns {GroupByDef[]}
|
||||
*/
|
||||
get groupByDefs() {
|
||||
return GROUP_BY_DEFS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Change the active group-by dimension and reload from page 1.
|
||||
* Calling with the current key is a no-op.
|
||||
* @param {string} key '' | 'owner' | 'shareDate'
|
||||
*/
|
||||
setGroupBy(key) {
|
||||
if (this._groupBy === key) return;
|
||||
this._groupBy = key;
|
||||
this._nextCursor = null; // restart from first page
|
||||
this._component?.clear();
|
||||
this._loadPage();
|
||||
},
|
||||
|
||||
/**
|
||||
* (Re-)load from page 1 and render into the existing files container.
|
||||
* Called every time the user switches to this section.
|
||||
@@ -44,6 +174,7 @@ const sharedWithMeView = {
|
||||
async init() {
|
||||
this._nextCursor = null;
|
||||
this._loading = false;
|
||||
this._groupBy = '';
|
||||
|
||||
this._ensureLoadMoreButton();
|
||||
|
||||
@@ -60,47 +191,42 @@ const sharedWithMeView = {
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) {
|
||||
if (!this._component) {
|
||||
this._component = new ResourceListComponent(
|
||||
/** @type {HTMLElement} */ (filesList),
|
||||
{
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: true,
|
||||
showShareBadge: false,
|
||||
draggable: false,
|
||||
showContextMenu: true,
|
||||
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||||
isShared: () => false,
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onFavoriteToggle: async (item) => {
|
||||
const isFile = 'mime_type' in item;
|
||||
const type = isFile ? 'file' : 'folder';
|
||||
if (favorites.isFavorite(item.id, type)) {
|
||||
await favorites.removeFromFavorites(item.id, type);
|
||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||
} else {
|
||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||
}
|
||||
},
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile
|
||||
? (/** @type {FileItem} */ (sel)).folder_id || ''
|
||||
: (/** @type {FolderItem} */ (sel)).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||||
selectable: true,
|
||||
showFavorite: true,
|
||||
showOwner: true,
|
||||
showShareBadge: false,
|
||||
draggable: false,
|
||||
showContextMenu: true,
|
||||
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||||
isShared: () => false,
|
||||
onOpen: (item) => ui.openItem(item),
|
||||
onFavoriteToggle: async (item) => {
|
||||
const isFile = 'mime_type' in item;
|
||||
const type = isFile ? 'file' : 'folder';
|
||||
if (favorites.isFavorite(item.id, type)) {
|
||||
await favorites.removeFromFavorites(item.id, type);
|
||||
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||
} else {
|
||||
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||
this._component?.setFavoriteVisualState(item.id, type, true);
|
||||
}
|
||||
},
|
||||
onContextMenu: (item, e) => ui.showContextMenuForItem(item, e),
|
||||
onSelectionChange: (selectedItems) => {
|
||||
batchToolbar._selected.clear();
|
||||
for (const sel of selectedItems) {
|
||||
const isFile = 'mime_type' in sel;
|
||||
batchToolbar._selected.set(sel.id, {
|
||||
id: sel.id,
|
||||
name: sel.name,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || ''
|
||||
});
|
||||
}
|
||||
batchToolbar._syncUI();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
batchToolbar.setActiveComponent(this._component);
|
||||
}
|
||||
@@ -138,10 +264,18 @@ const sharedWithMeView = {
|
||||
const isFirstPage = this._nextCursor === null;
|
||||
|
||||
try {
|
||||
const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy);
|
||||
|
||||
// When no swimlane grouping is active, sort by resource name so the
|
||||
// list is alphabetical (same expectation as the Files section).
|
||||
// Group-by modes supply their own orderBy via the def.
|
||||
const orderBy = def?.orderBy ?? 'name';
|
||||
|
||||
const data = await grants.fetchSharedWithMe({
|
||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||
limit: 50,
|
||||
cursor: this._nextCursor ?? undefined
|
||||
cursor: this._nextCursor ?? undefined,
|
||||
orderBy
|
||||
});
|
||||
|
||||
this._nextCursor = data.next_cursor ?? null;
|
||||
@@ -157,12 +291,12 @@ const sharedWithMeView = {
|
||||
return;
|
||||
}
|
||||
|
||||
const { folders, files } = this._mapItems(data.items);
|
||||
const items = this._mapItems(data.items);
|
||||
|
||||
if (isFirstPage) {
|
||||
this._component?.render(folders, files);
|
||||
this._component?.render(items, def?.keyFn, def?.labelFn);
|
||||
} else {
|
||||
this._component?.append(folders, files);
|
||||
this._component?.append(items, def?.keyFn, def?.labelFn);
|
||||
}
|
||||
|
||||
// Wire owner tooltips after items are in the DOM
|
||||
@@ -185,35 +319,41 @@ const sharedWithMeView = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Map `SharedWithMeItem[]` to separate arrays for rendering.
|
||||
* Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in
|
||||
* **server-returned order**. The order must be preserved so that
|
||||
* swimlane grouping (group by owner / share date) works correctly when
|
||||
* the server interleaves files and folders by the sort key.
|
||||
*
|
||||
* Sets `owner_id` to `item.granted_by` so the component stamps
|
||||
* `data-owner-id` with the granter's user ID automatically.
|
||||
* Sets `sort_date` (unix seconds) to the grant date so the shareDate
|
||||
* `keyFn` buckets by when the share was created, not the resource's
|
||||
* own modification time.
|
||||
*
|
||||
* @param {SharedWithMeItem[]} items
|
||||
* @returns {{ folders: FolderItem[], files: FileItem[] }}
|
||||
* @returns {Array<FileItem|FolderItem>}
|
||||
*/
|
||||
_mapItems(items) {
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const result = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
/** @param {string} iso @returns {number} */
|
||||
const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.resource_type === 'folder') {
|
||||
const f = /** @type {FolderItem} */ (item.resource);
|
||||
folders.push(
|
||||
result.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
parent_id: f.parent_id ?? '',
|
||||
// Use granted_by as owner_id so the component populates
|
||||
// data-owner-id with the sharing user's ID.
|
||||
owner_id: item.granted_by,
|
||||
is_root: f.is_root ?? false,
|
||||
created_at: f.created_at,
|
||||
modified_at: f.modified_at,
|
||||
sort_date: grantedAtSecs(item.granted_at),
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: 'folder'
|
||||
@@ -221,21 +361,19 @@ const sharedWithMeView = {
|
||||
);
|
||||
} else if (item.resource_type === 'file') {
|
||||
const f = /** @type {FileItem} */ (item.resource);
|
||||
files.push(
|
||||
result.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
folder_id: f.folder_id ?? '',
|
||||
// Use granted_by as owner_id so the component populates
|
||||
// data-owner-id with the sharing user's ID.
|
||||
owner_id: item.granted_by,
|
||||
mime_type: f.mime_type,
|
||||
size: f.size,
|
||||
size_formatted: f.size_formatted,
|
||||
created_at: f.created_at,
|
||||
modified_at: f.modified_at,
|
||||
sort_date: f.modified_at,
|
||||
sort_date: grantedAtSecs(item.granted_at),
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: f.category
|
||||
@@ -244,7 +382,7 @@ const sharedWithMeView = {
|
||||
}
|
||||
}
|
||||
|
||||
return { folders, files };
|
||||
return result;
|
||||
},
|
||||
|
||||
// ── "Load more" button ────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user