feat(folders): add curser and the normalized way to get foler's item list. add reverse order
This commit is contained in:
+280
-28
@@ -4,21 +4,23 @@
|
||||
* OxiCloud – Files section view.
|
||||
*
|
||||
* Orchestrates the main Files section:
|
||||
* - Data fetching via `filesModel`
|
||||
* - Rendering via a `ResourceListComponent` instance
|
||||
* - Data fetching via `filesModel` (cursor-paginated `/api/folders/{id}/resources`)
|
||||
* - Rendering via a `ResourceListComponent` instance with optional swimlane grouping
|
||||
* - Drag-and-drop initialisation (delegated to `ui.initDragDrop`)
|
||||
*
|
||||
* Exports `loadFiles` (navigation & deep-link entry-point) and `addItem`
|
||||
* (post-upload / post-create optimistic UI updates used by fileOperations
|
||||
* and search).
|
||||
* Exports:
|
||||
* - `loadFiles` – navigation & deep-link entry-point
|
||||
* - `addItem` – post-upload / post-create optimistic UI updates
|
||||
* - `filesView` – group-by controller consumed by `navigation.js` / `main.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 { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { fetchListing, rebuildBreadCrumb } from '../model/filesModel.js';
|
||||
import { fetchResourcesPage, rebuildBreadCrumb } from '../model/filesModel.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { resolveHomeFolder } from './authSession.js';
|
||||
import { updateHistory } from './main.js';
|
||||
@@ -28,12 +30,160 @@ import { uiNotifications } from './uiNotifications.js';
|
||||
|
||||
/** @import {FileItem, FolderItem} 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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Group-by dimension definitions for the Files section.
|
||||
* Mirrors the same shape used by `sharedWithMeView.groupByDefs` so `main.js`
|
||||
* can drive the group-by dropdown generically.
|
||||
*
|
||||
* @type {GroupByDef[]}
|
||||
*/
|
||||
const GROUP_BY_DEFS = [
|
||||
{
|
||||
key: 'type',
|
||||
get label() {
|
||||
return i18n.t('groupby.type', 'Type');
|
||||
},
|
||||
orderBy: 'type',
|
||||
// Folders → 'Folder'; files → their pre-computed category string.
|
||||
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: 'modifiedAt',
|
||||
get label() {
|
||||
return i18n.t('groupby.modifiedAt', 'Modified date');
|
||||
},
|
||||
orderBy: 'modified_at',
|
||||
// keyFn returns the human-readable bucket; the bucket IS the key.
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string, number>} */ (/** @type {unknown} */ (item));
|
||||
return r.modified_at ? normalizeDateBucket(r.modified_at) : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
get label() {
|
||||
return i18n.t('groupby.createdAt', 'Created date');
|
||||
},
|
||||
orderBy: 'created_at',
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string, number>} */ (/** @type {unknown} */ (item));
|
||||
return r.created_at ? normalizeDateBucket(r.created_at) : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
get label() {
|
||||
return i18n.t('groupby.size', 'Size');
|
||||
},
|
||||
orderBy: 'size',
|
||||
// sizeBucket(-1) → "Folders" sentinel; no labelFn needed.
|
||||
keyFn: (item) => {
|
||||
if (!('mime_type' in item)) return sizeBucket(-1);
|
||||
const r = /** @type {Record<string, number>} */ (/** @type {unknown} */ (item));
|
||||
return sizeBucket(r.size ?? 0);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ── Module-level state ────────────────────────────────────────────────────────
|
||||
|
||||
/** ID of the "Load more" wrapper injected below `.files-container`. */
|
||||
const LOAD_MORE_ID = 'files-load-more-wrapper';
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
let _component = null;
|
||||
|
||||
/** Guard against concurrent `loadFiles` calls. */
|
||||
/** Guard against concurrent `_loadPage` calls. */
|
||||
let _loading = false;
|
||||
|
||||
/** Opaque cursor for the next page; `null` on first page or when exhausted. */
|
||||
let _nextCursor = /** @type {string|null} */ (null);
|
||||
|
||||
/**
|
||||
* Active group-by key: '' = no grouping (name order), or one of the keys
|
||||
* from GROUP_BY_DEFS.
|
||||
* @type {string}
|
||||
*/
|
||||
let _groupBy = '';
|
||||
|
||||
/** Whether the current sort order is reversed. */
|
||||
let _reversed = false;
|
||||
|
||||
// ── Group-by controller (public API, consumed by navigation.js / main.js) ───
|
||||
|
||||
/**
|
||||
* Controller object registered with `setGroupByView()` by navigation.js when
|
||||
* the Files section is active. Exposes the same interface as
|
||||
* `sharedWithMeView` so the generic group-by infrastructure in `main.js`
|
||||
* drives both sections identically.
|
||||
*/
|
||||
const filesView = {
|
||||
/**
|
||||
* 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 '' | 'type' | 'modifiedAt' | 'createdAt' | 'size'
|
||||
*/
|
||||
setGroupBy(key) {
|
||||
if (_groupBy === key) return;
|
||||
_groupBy = key;
|
||||
_nextCursor = null;
|
||||
_component?.clear();
|
||||
_loadPage({ isFirstPage: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the sort direction and reload from page 1.
|
||||
* Calling with the current value is a no-op.
|
||||
* @param {boolean} reversed
|
||||
*/
|
||||
setDirection(reversed) {
|
||||
if (_reversed === reversed) return;
|
||||
_reversed = reversed;
|
||||
_nextCursor = null;
|
||||
_component?.clear();
|
||||
_loadPage({ isFirstPage: true });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Component factory ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return (creating on first call) the `ResourceListComponent` bound to
|
||||
* `#files-list`. The element must already be in the DOM.
|
||||
@@ -85,9 +235,99 @@ function _ensureComponent() {
|
||||
ui.initDragDrop(/** @type {HTMLElement} */ (filesList));
|
||||
}
|
||||
|
||||
_ensureLoadMoreButton();
|
||||
|
||||
return _component;
|
||||
}
|
||||
|
||||
// ── "Load more" button ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the "Load more" wrapper once and attach it below `.files-container`.
|
||||
* Subsequent calls are no-ops.
|
||||
*/
|
||||
function _ensureLoadMoreButton() {
|
||||
if (document.getElementById(LOAD_MORE_ID)) return;
|
||||
|
||||
const filesContainer = document.querySelector('.files-container');
|
||||
if (!filesContainer) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = LOAD_MORE_ID;
|
||||
wrapper.className = 'swm-load-more-wrapper hidden';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'files-load-more';
|
||||
btn.className = 'button secondary';
|
||||
btn.textContent = i18n.t('files.loadMore', 'Load more');
|
||||
btn.addEventListener('click', () => {
|
||||
_loadPage({ isFirstPage: false });
|
||||
});
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
filesContainer.after(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
function _setLoadMoreVisible(visible) {
|
||||
const w = document.getElementById(LOAD_MORE_ID);
|
||||
if (w) w.classList.toggle('hidden', !visible);
|
||||
}
|
||||
|
||||
// ── Page loader ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch one cursor page and render it.
|
||||
* @param {{ isFirstPage?: boolean }} [opts]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function _loadPage({ isFirstPage = false } = {}) {
|
||||
if (_loading) return;
|
||||
_loading = true;
|
||||
|
||||
try {
|
||||
const def = GROUP_BY_DEFS.find((d) => d.key === _groupBy);
|
||||
const orderBy = def?.orderBy ?? 'name';
|
||||
|
||||
const { items, nextCursor } = await fetchResourcesPage(app.currentPath, {
|
||||
cursor: _nextCursor,
|
||||
orderBy,
|
||||
limit: 50,
|
||||
reverse: _reversed
|
||||
});
|
||||
|
||||
_nextCursor = nextCursor;
|
||||
|
||||
if (items.length === 0 && isFirstPage) {
|
||||
ui.showEmptyList();
|
||||
_setLoadMoreVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirstPage) {
|
||||
_component?.render(items, def?.keyFn, def?.labelFn);
|
||||
} else {
|
||||
_component?.append(items, def?.keyFn, def?.labelFn);
|
||||
}
|
||||
|
||||
await _component?.resolveOwnerCells();
|
||||
_setLoadMoreVisible(!!nextCursor);
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.status === 403) {
|
||||
ui.showError(`<p>${i18n.t('errors.forbidden', 'Could not load files')}</p>`);
|
||||
} else {
|
||||
console.error('filesView: load error', err);
|
||||
uiNotifications.show('Error', 'Could not load files and folders');
|
||||
}
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append a single item to the current view (post-upload / post-create
|
||||
* optimistic update). No-op when the Files section is not active or the
|
||||
@@ -111,14 +351,18 @@ function addItem(item) {
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.insertHistory=true]
|
||||
* @param {boolean} [options.forceRefresh=false]
|
||||
* @param {boolean} [options.forceRefresh=false] (legacy — kept for callers; ignored internally)
|
||||
*/
|
||||
async function loadFiles(options = { insertHistory: true }) {
|
||||
if (_loading) {
|
||||
console.log('A file load is already in progress, ignoring request');
|
||||
return;
|
||||
}
|
||||
_loading = true;
|
||||
|
||||
// Reset cursor, groupBy, and direction on navigation to a different folder.
|
||||
_nextCursor = null;
|
||||
_groupBy = '';
|
||||
_reversed = false;
|
||||
|
||||
// Delay spinner so fast loads avoid the flash
|
||||
const spinnerTimeout = setTimeout(() => {
|
||||
@@ -130,6 +374,10 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
`);
|
||||
}, 100);
|
||||
|
||||
// A temporary guard: _loadPage sets _loading itself, but we need to
|
||||
// block re-entrant loadFiles() calls during the setup below.
|
||||
_loading = true;
|
||||
|
||||
try {
|
||||
if (!app.userHomeFolderId) await resolveHomeFolder();
|
||||
|
||||
@@ -148,10 +396,6 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
ui.updateBreadcrumb();
|
||||
updateHistory(options.insertHistory ?? true);
|
||||
|
||||
const { folders, files } = await fetchListing(app.currentPath, {
|
||||
forceRefresh: options.forceRefresh ?? false
|
||||
});
|
||||
|
||||
clearTimeout(spinnerTimeout);
|
||||
|
||||
// Prepare the container (shows #files-list, hides error panel)
|
||||
@@ -164,23 +408,31 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
batchToolbar.init();
|
||||
batchToolbar.setActiveComponent(component);
|
||||
|
||||
if (folders.length === 0 && files.length === 0) {
|
||||
ui.showEmptyList();
|
||||
} else {
|
||||
component.render([...folders, ...files]);
|
||||
await component.resolveOwnerCells();
|
||||
}
|
||||
// Hand off to _loadPage (re-use cursor/groupBy state just reset above).
|
||||
_loading = false; // _loadPage sets its own guard
|
||||
await _loadPage({ isFirstPage: true });
|
||||
|
||||
console.log(`Loaded ${folders.length} folders and ${files.length} files`);
|
||||
|
||||
// Deep-link: open a specific file if requested via app.viewFile
|
||||
// Deep-link: open a specific file if requested via app.viewFile.
|
||||
// We don't have a flat file list anymore (cursor pages), so only try
|
||||
// to open it if it was already rendered (first page).
|
||||
if (app.viewFile) {
|
||||
const fileFound = files.find((f) => f.id === app.viewFile) ?? null;
|
||||
if (fileFound) {
|
||||
console.log(`file ${app.viewFile} found, calling viewer`);
|
||||
await inlineViewer.openFile(fileFound);
|
||||
// Find the item among all rendered cards via the DOM attribute.
|
||||
const rendered = document.querySelector(`[data-id="${app.viewFile}"][data-type="file"]`);
|
||||
if (rendered) {
|
||||
// The component's item list may be sparse; ask for a fresh fetch.
|
||||
const fileRes = await fetch(`/api/files/${app.viewFile}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (fileRes.ok) {
|
||||
const fileFound = /** @type {FileItem} */ (await fileRes.json());
|
||||
await inlineViewer.openFile(fileFound);
|
||||
} else {
|
||||
app.viewFile = null;
|
||||
updateHistory(false);
|
||||
}
|
||||
} else {
|
||||
console.log(`file ${app.viewFile} not found`);
|
||||
console.log(`file ${app.viewFile} not in first page — skipping auto-open`);
|
||||
app.viewFile = null;
|
||||
updateHistory(false);
|
||||
}
|
||||
@@ -198,4 +450,4 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
}
|
||||
}
|
||||
|
||||
export { addItem, loadFiles };
|
||||
export { addItem, filesView, loadFiles };
|
||||
|
||||
+17
-2
@@ -89,6 +89,10 @@ const _toggleButtons = `
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span class="group-by-label"></span>
|
||||
</button>
|
||||
<button class="toggle-btn sort-dir-btn" id="sort-dir-btn"
|
||||
title="Sort direction" data-i18n-title="sortdir.title">
|
||||
<i class="fas fa-arrow-up" id="sort-dir-icon"></i>
|
||||
</button>
|
||||
<div class="group-by-menu hidden" id="group-by-menu"></div>
|
||||
</div>
|
||||
<span class="view-toggle-separator hidden" id="group-by-separator"></span>
|
||||
@@ -206,14 +210,14 @@ function setActionsBarMode(mode, force = false) {
|
||||
/**
|
||||
* 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}
|
||||
* @type {{ setGroupBy: (key: string) => void, setDirection: (reversed: boolean) => 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
|
||||
* @param {{ setGroupBy: (key: string) => void, setDirection: (reversed: boolean) => void } | null} view
|
||||
*/
|
||||
function setGroupByView(view) {
|
||||
_groupByView = view;
|
||||
@@ -247,6 +251,8 @@ function syncGroupByMenu(defs = []) {
|
||||
btn?.classList.remove('active');
|
||||
const lbl = btn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = '';
|
||||
// Reset direction button to ascending (↑)
|
||||
document.getElementById('sort-dir-btn')?.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,10 +295,19 @@ function setupActionsBarDelegation() {
|
||||
groupByBtn?.classList.toggle('active', key !== '');
|
||||
const lbl = groupByBtn?.querySelector('.group-by-label');
|
||||
if (lbl) lbl.textContent = key !== '' ? (btn.textContent ?? '') : '';
|
||||
// Changing order-by dimension resets direction to ascending
|
||||
_groupByView?.setDirection(false);
|
||||
document.getElementById('sort-dir-btn')?.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (btn.id) {
|
||||
case 'sort-dir-btn': {
|
||||
const nowReversed = !btn.classList.contains('active');
|
||||
_groupByView?.setDirection(nowReversed);
|
||||
btn.classList.toggle('active', nowReversed);
|
||||
return;
|
||||
}
|
||||
case 'group-by-btn':
|
||||
document.getElementById('group-by-menu')?.classList.toggle('hidden');
|
||||
return;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { photosView } from '../features/library/photos.js';
|
||||
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 { filesView, loadFiles } from './filesView.js';
|
||||
import { setActionsBarMode, setGroupByView, syncGroupByMenu } from './main.js';
|
||||
import { app, appElements } from './state.js';
|
||||
import { loadTrashItems } from './trashView.js';
|
||||
@@ -237,8 +237,8 @@ function switchToFilesSection() {
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
setGroupByView(filesView);
|
||||
syncGroupByMenu(filesView.groupByDefs);
|
||||
|
||||
// Show owner column in the Files section
|
||||
ui.setOwnerColumnVisible(true);
|
||||
@@ -432,8 +432,8 @@ function switchToMusicSection() {
|
||||
function activateFilesUI() {
|
||||
setCurrentSection('files');
|
||||
setActionsBarMode('files', true);
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
setGroupByView(filesView);
|
||||
syncGroupByMenu(filesView.groupByDefs);
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.remove('hidden');
|
||||
toggleFileContainer(true);
|
||||
|
||||
@@ -25,6 +25,23 @@ const OxiIcons = {
|
||||
512,
|
||||
'M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 96-96 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6l0-32 96 0 0 96-32 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0 0-96 96 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 32-96 0 0-96 32 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64z'
|
||||
],
|
||||
'arrow-up': [
|
||||
512,
|
||||
'M214.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 109.3 160 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-370.7 105.4 105.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z'
|
||||
],
|
||||
'arrow-down': [
|
||||
512,
|
||||
'M169.4 502.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 402.7 224 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 370.7-105.4-105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z'
|
||||
],
|
||||
'arrow-down-short-wide': [
|
||||
576,
|
||||
'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z'
|
||||
],
|
||||
'arrow-down-wide-short': [
|
||||
576,
|
||||
'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L320 96z'
|
||||
],
|
||||
|
||||
ban: [
|
||||
512,
|
||||
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z'
|
||||
|
||||
@@ -107,4 +107,72 @@ async function fetchListing(folderId, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export { fetchListing, getFolder, rebuildBreadCrumb };
|
||||
/**
|
||||
* Map one tagged resource item from `/api/folders/{id}/resources` into the
|
||||
* canonical `FileItem` / `FolderItem` shape used by `ResourceListComponent`.
|
||||
*
|
||||
* @param {{ resource_type: string, resource: Record<string, unknown> }} tagged
|
||||
* @returns {FileItem|FolderItem}
|
||||
*/
|
||||
function _mapResourceItem(tagged) {
|
||||
const r = tagged.resource;
|
||||
if (tagged.resource_type === 'folder') {
|
||||
return /** @type {FolderItem} */ ({
|
||||
id: String(r.id ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
path: String(r.path ?? ''),
|
||||
parent_id: r.parent_id != null ? String(r.parent_id) : '',
|
||||
owner_id: r.owner_id != null ? String(r.owner_id) : '',
|
||||
created_at: /** @type {number} */ (r.created_at),
|
||||
modified_at: /** @type {number} */ (r.modified_at),
|
||||
is_root: Boolean(r.is_root),
|
||||
icon_class: String(r.icon_class ?? 'fas fa-folder'),
|
||||
icon_special_class: String(r.icon_special_class ?? 'folder-icon'),
|
||||
category: String(r.category ?? 'Folder')
|
||||
});
|
||||
}
|
||||
return /** @type {FileItem} */ ({
|
||||
id: String(r.id ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
path: String(r.path ?? ''),
|
||||
folder_id: r.folder_id != null ? String(r.folder_id) : '',
|
||||
owner_id: r.owner_id != null ? String(r.owner_id) : '',
|
||||
mime_type: String(r.mime_type ?? ''),
|
||||
size: /** @type {number} */ (r.size),
|
||||
size_formatted: String(r.size_formatted ?? ''),
|
||||
created_at: /** @type {number} */ (r.created_at),
|
||||
modified_at: /** @type {number} */ (r.modified_at),
|
||||
icon_class: String(r.icon_class ?? ''),
|
||||
icon_special_class: String(r.icon_special_class ?? ''),
|
||||
category: String(r.category ?? '')
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one cursor page from `GET /api/folders/{id}/resources`.
|
||||
*
|
||||
* @param {string} folderId
|
||||
* @param {{ cursor?: string|null, orderBy?: string, limit?: number, reverse?: boolean }} [opts]
|
||||
* @returns {Promise<{ items: Array<FileItem|FolderItem>, nextCursor: string|null }>}
|
||||
*/
|
||||
async function fetchResourcesPage(folderId, { cursor = null, orderBy = 'name', limit = 50, reverse = false } = {}) {
|
||||
const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (reverse) params.set('reverse', 'true');
|
||||
|
||||
const res = await fetch(`/api/folders/${folderId}/resources?${params}`, NO_CACHE);
|
||||
if (!res.ok) {
|
||||
const err = /** @type {any} */ (new Error(`fetchResourcesPage: ${res.status}`));
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const items = /** @type {Array<{ resource_type: string, resource: Record<string, unknown> }>} */ (Array.isArray(data.items) ? data.items : []).map(
|
||||
_mapResourceItem
|
||||
);
|
||||
|
||||
return { items, nextCursor: data.next_cursor ?? null };
|
||||
}
|
||||
|
||||
export { fetchListing, fetchResourcesPage, getFolder, rebuildBreadCrumb };
|
||||
|
||||
@@ -89,15 +89,17 @@ const grants = {
|
||||
* @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').
|
||||
* @param {boolean} [opts.reverse] - Reverse the sort order (default: false).
|
||||
* @returns {Promise<SharedWithMeResponse>}
|
||||
*/
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) {
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy, reverse = false } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
resource_types: resourceTypes.join(',')
|
||||
});
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (orderBy) params.set('sort_by', orderBy);
|
||||
if (reverse) params.set('reverse', 'true');
|
||||
|
||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
||||
|
||||
|
||||
@@ -143,6 +143,9 @@ const sharedWithMeView = {
|
||||
*/
|
||||
_groupBy: '',
|
||||
|
||||
/** Whether the current sort order is reversed. */
|
||||
_reversed: false,
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -167,6 +170,19 @@ const sharedWithMeView = {
|
||||
this._loadPage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the sort direction and reload from page 1.
|
||||
* Calling with the current value is a no-op.
|
||||
* @param {boolean} reversed
|
||||
*/
|
||||
setDirection(reversed) {
|
||||
if (this._reversed === reversed) return;
|
||||
this._reversed = reversed;
|
||||
this._nextCursor = null;
|
||||
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.
|
||||
@@ -175,6 +191,7 @@ const sharedWithMeView = {
|
||||
this._nextCursor = null;
|
||||
this._loading = false;
|
||||
this._groupBy = '';
|
||||
this._reversed = false;
|
||||
|
||||
this._ensureLoadMoreButton();
|
||||
|
||||
@@ -275,7 +292,8 @@ const sharedWithMeView = {
|
||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||
limit: 50,
|
||||
cursor: this._nextCursor ?? undefined,
|
||||
orderBy
|
||||
orderBy,
|
||||
reverse: this._reversed
|
||||
});
|
||||
|
||||
this._nextCursor = data.next_cursor ?? null;
|
||||
|
||||
Reference in New Issue
Block a user