81a93a489b
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists. Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets. Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
593 lines
24 KiB
JavaScript
593 lines
24 KiB
JavaScript
// @ts-check
|
||
|
||
/**
|
||
* OxiCloud – Files section view.
|
||
*
|
||
* Orchestrates the main Files section:
|
||
* - 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
|
||
* - `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 { shareModal } from '../components/shareModal.js';
|
||
import { normalizeDateBucket, sizeBucket } from '../core/formatters.js';
|
||
import { i18n } from '../core/i18n.js';
|
||
import * as viewPrefs from '../core/viewPrefs.js';
|
||
import { batchToolbar } from '../features/files/batchToolbar.js';
|
||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||
import { favorites } from '../features/library/favorites.js';
|
||
import { fetchResourcesPage, rebuildBreadCrumb } from '../model/filesModel.js';
|
||
import { grants } from '../model/grants.js';
|
||
import { attachInfiniteScroll } from '../utils/infiniteScroll.js';
|
||
import { resolveHomeFolder } from './authSession.js';
|
||
import { updateHistory } from './main.js';
|
||
import { app } from './state.js';
|
||
import { ui } from './ui.js';
|
||
import { uiNotifications } from './uiNotifications.js';
|
||
|
||
/** @import {FileItem, FolderItem} from '../core/types.js' */
|
||
|
||
/**
|
||
* @typedef {{ key: string, label: string, icon?: 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: '',
|
||
get label() {
|
||
return i18n.t('files.name', 'Name');
|
||
},
|
||
icon: 'fas fa-arrow-up-a-z',
|
||
orderBy: 'name'
|
||
// no keyFn → flat list.
|
||
},
|
||
{
|
||
key: 'type',
|
||
get label() {
|
||
return i18n.t('groupby.type', 'Type');
|
||
},
|
||
icon: 'fas fa-layer-group',
|
||
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: 'size',
|
||
get label() {
|
||
return i18n.t('groupby.size', 'Size');
|
||
},
|
||
icon: 'fas fa-layer-group',
|
||
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);
|
||
}
|
||
},
|
||
{
|
||
key: 'modifiedAt',
|
||
get label() {
|
||
return i18n.t('groupby.modifiedAt', 'Modified date');
|
||
},
|
||
icon: 'fas fa-layer-group',
|
||
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');
|
||
},
|
||
icon: 'fas fa-layer-group',
|
||
orderBy: 'created_at',
|
||
keyFn: (item) => {
|
||
const r = /** @type {Record<string, number>} */ (/** @type {unknown} */ (item));
|
||
return r.created_at ? normalizeDateBucket(r.created_at) : null;
|
||
}
|
||
}
|
||
];
|
||
|
||
// ── 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 `_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;
|
||
|
||
/**
|
||
* Flat column sort set by clicking a list-view header. When non-empty it
|
||
* overrides the group-by's `orderBy` and forces `_groupBy = ''`, so the list
|
||
* is a flat sort (no swimlanes) — the Drive/Finder column-sort behaviour.
|
||
* '' = follow the active group-by.
|
||
* @type {string}
|
||
*/
|
||
let _sortField = '';
|
||
|
||
/** The order field the server is currently sorting by (group-by or flat). */
|
||
function _currentOrderBy() {
|
||
if (_sortField) return _sortField;
|
||
return GROUP_BY_DEFS.find((d) => d.key === _groupBy)?.orderBy ?? 'name';
|
||
}
|
||
|
||
// ── 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) {
|
||
// Re-pick the same group while a flat column-sort is active → still
|
||
// exit flat-sort and return to grouping, so don't early-return then.
|
||
if (_groupBy === key && !_sortField) return;
|
||
_groupBy = key;
|
||
_sortField = ''; // grouping takes over → follow the group's order field
|
||
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
|
||
_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;
|
||
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
|
||
_nextCursor = null;
|
||
_component?.clear();
|
||
_loadPage({ isFirstPage: true });
|
||
},
|
||
|
||
/**
|
||
* Flat-sort the list by a column field (clicking a list-view header).
|
||
* Drops any grouping (no swimlanes); re-picking the active field flips the
|
||
* direction. Synchronous state update — read `currentSort` right after to
|
||
* refresh the header arrow, the async reload follows.
|
||
* @param {string} field 'name' | 'type' | 'size' | 'modified_at'
|
||
*/
|
||
setSortField(field) {
|
||
const sameField = _currentOrderBy() === field;
|
||
_reversed = sameField ? !_reversed : false;
|
||
_sortField = field;
|
||
_groupBy = '';
|
||
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
|
||
_nextCursor = null;
|
||
_component?.clear();
|
||
_loadPage({ isFirstPage: true });
|
||
},
|
||
|
||
/** Current effective sort `{ field, reversed }` — for the column-header arrows. */
|
||
get currentSort() {
|
||
return { field: _currentOrderBy(), reversed: _reversed };
|
||
}
|
||
};
|
||
|
||
// ── Component factory ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return (creating on first call) the `ResourceListComponent` bound to
|
||
* `#files-list`. The element must already be in the DOM.
|
||
* @returns {ResourceListComponent|null}
|
||
*/
|
||
function _ensureComponent() {
|
||
const filesList = document.getElementById('files-list');
|
||
if (!filesList) return null;
|
||
|
||
if (!_component) {
|
||
_component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), {
|
||
selectable: true,
|
||
showFavorite: true,
|
||
showOwner: true,
|
||
showShareBadge: true,
|
||
draggable: true,
|
||
showContextMenu: true,
|
||
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||
isShared: (id, type) => grants.getOutgoingGrantsFor(type, id).length > 0,
|
||
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, item.name);
|
||
_component?.setFavoriteVisualState(item.id, type, false);
|
||
} else {
|
||
await favorites.addToFavorites(item.id, item.name, type, null);
|
||
_component?.setFavoriteVisualState(item.id, type, true);
|
||
}
|
||
},
|
||
onShareBadgeClick: (item) => {
|
||
const isFile = 'mime_type' in item;
|
||
shareModal.open(item, isFile ? 'file' : 'folder', () => {
|
||
grants.fetchOutgoingGrants().then(() => refreshSharedBadges());
|
||
});
|
||
},
|
||
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();
|
||
}
|
||
});
|
||
|
||
// Wire drag-and-drop on the container once the component is created.
|
||
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);
|
||
|
||
attachInfiniteScroll(wrapper, () => _loadPage({ isFirstPage: false }));
|
||
}
|
||
|
||
/**
|
||
* @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);
|
||
// Flat column-sort (`_sortField`) overrides the group's order field. When
|
||
// it's set, `_groupBy` is '' so `def` has no keyFn → a flat list.
|
||
const orderBy = _currentOrderBy();
|
||
|
||
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);
|
||
// Keep the list-view column-header arrow in sync however the sort
|
||
// changed — header click, toolbar group-by, or direction toggle.
|
||
// No-op in grid view (no sortable headers present).
|
||
ui.updateListHeaderSort?.();
|
||
} 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
|
||
* item is already in the list.
|
||
*
|
||
* Called by `fileOperations.js` and `search.js`.
|
||
*
|
||
* @param {FileItem|FolderItem} item
|
||
*/
|
||
function addItem(item) {
|
||
const component = _ensureComponent();
|
||
if (!component) return;
|
||
// Hide the empty-state placeholder if it's currently showing —
|
||
// creating a folder in an empty directory should reveal the new
|
||
// row, not display both states side-by-side. (The component will
|
||
// un-hide `#files-list` itself when the item is inserted.)
|
||
document.getElementById('files-container-error')?.classList.add('hidden');
|
||
// Hand the item to the component so it can place it in the right
|
||
// swimlane (when the current view is grouped) and pulse-highlight
|
||
// + smooth-scroll it into view. We deliberately do NOT call
|
||
// `ui.resetFilesList()` here — that wipes the rendered DOM,
|
||
// defeating the whole point of an optimistic single-item insert.
|
||
component.addItem(item, { scroll: true, highlight: true });
|
||
}
|
||
|
||
/**
|
||
* Load and render the contents of `app.currentPath`, rebuilding the
|
||
* breadcrumb and updating browser history.
|
||
*
|
||
* @param {Object} [options]
|
||
* @param {boolean} [options.insertHistory=true]
|
||
* @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;
|
||
}
|
||
|
||
// Reset cursor on navigation; restore saved group-by/direction preferences.
|
||
_nextCursor = null;
|
||
const _savedPrefs = viewPrefs.load('files');
|
||
_groupBy = _savedPrefs.groupBy;
|
||
_reversed = _savedPrefs.reversed;
|
||
|
||
// Delay the loading skeleton so fast loads avoid a flash of placeholders.
|
||
// Skeleton cards/rows hold the layout (no empty-list flash) and mirror the
|
||
// current view; reduced-motion is honoured via the global guard.
|
||
const spinnerTimeout = setTimeout(() => {
|
||
const isList = app.currentView === 'list';
|
||
const cell = isList
|
||
? '<div class="skeleton-row"><div class="skeleton skeleton-icon"></div><div class="skeleton skeleton-line skeleton-line--medium"></div><div class="skeleton skeleton-line skeleton-line--short"></div></div>'
|
||
: '<div class="skeleton-card"><div class="skeleton skeleton-thumb"></div><div class="skeleton skeleton-line skeleton-line--medium"></div><div class="skeleton skeleton-line skeleton-line--short"></div></div>';
|
||
const wrapper = isList ? 'files-skeleton' : 'files-grid-view files-skeleton';
|
||
ui.showError(
|
||
`<div class="${wrapper}" role="status" aria-busy="true" aria-label="${i18n.t('files.loading')}">${cell.repeat(8)}</div>`
|
||
);
|
||
}, 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();
|
||
|
||
// External users have no home folder. If they land on /files
|
||
// without a specific folder id in the URL, redirect them to
|
||
// /#/sharedwithme — their actual landing page. This guards
|
||
// against `fetchResourcesPage('')` building `/api/folders//resources`.
|
||
if (app.isExternalUser && (!app.currentPath || app.currentPath === '')) {
|
||
clearTimeout(spinnerTimeout);
|
||
_loading = false;
|
||
window.location.hash = '#/sharedwithme';
|
||
return;
|
||
}
|
||
|
||
// Resolve path to home folder when none is set
|
||
if (!app.currentPath || app.currentPath === '') {
|
||
if (app.userHomeFolderId) {
|
||
app.currentPath = app.userHomeFolderId;
|
||
app.breadcrumbPath = [];
|
||
console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`);
|
||
} else {
|
||
console.warn('No home folder id — this should not normally happen');
|
||
}
|
||
}
|
||
|
||
// Rebuild the breadcrumb concurrently with the first page fetch —
|
||
// the listing does not depend on it, and awaiting the ancestor
|
||
// chain first added one round-trip per depth level before the
|
||
// content even started loading. Crumbs, history and title update
|
||
// when it resolves (skipped when superseded by a newer navigation).
|
||
const requestedPath = app.currentPath;
|
||
const breadcrumbReady = rebuildBreadCrumb().then((committed) => {
|
||
if (!committed) return;
|
||
ui.updateBreadcrumb();
|
||
updateHistory(options.insertHistory ?? true);
|
||
});
|
||
|
||
clearTimeout(spinnerTimeout);
|
||
|
||
// Prepare the container (shows #files-list, hides error panel)
|
||
ui.resetFilesList();
|
||
|
||
const component = _ensureComponent();
|
||
if (!component) return;
|
||
|
||
batchToolbar.clear();
|
||
batchToolbar.init();
|
||
batchToolbar.setActiveComponent(component);
|
||
|
||
// Hand off to _loadPage (re-use cursor/groupBy state just reset above).
|
||
_loading = false; // _loadPage sets its own guard
|
||
await _loadPage({ isFirstPage: true });
|
||
await breadcrumbReady;
|
||
|
||
// rebuildBreadCrumb falls back to the home folder when the
|
||
// requested folder is inaccessible (deleted / permission revoked).
|
||
// The old sequential flow got the home listing for free; reload to
|
||
// match it.
|
||
if (app.currentPath !== requestedPath) {
|
||
ui.resetFilesList();
|
||
_nextCursor = null;
|
||
await _loadPage({ isFirstPage: true });
|
||
}
|
||
|
||
// 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) {
|
||
// 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 in first page — skipping auto-open`);
|
||
app.viewFile = null;
|
||
updateHistory(false);
|
||
}
|
||
}
|
||
} catch (/** @type {any} */ err) {
|
||
clearTimeout(spinnerTimeout);
|
||
if (err?.status === 403) {
|
||
ui.showError(`<p>${i18n.t('errors.forbidden', 'Could not load files')}</p>`);
|
||
} else {
|
||
console.error('Error loading folders:', err);
|
||
uiNotifications.show('Error', 'Could not load files and folders');
|
||
}
|
||
} finally {
|
||
_loading = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Replace the list contents with a whole result set in one batched render
|
||
* (single DocumentFragment pass — no per-item DOM scans, smooth-scrolls or
|
||
* highlight pulses). Used by search to display results; optimistic
|
||
* single-item inserts should keep using `addItem`.
|
||
*
|
||
* @param {Array<FileItem|FolderItem>} items
|
||
*/
|
||
function renderItems(items) {
|
||
const component = _ensureComponent();
|
||
if (!component) return;
|
||
document.getElementById('files-container-error')?.classList.add('hidden');
|
||
component.render(items);
|
||
}
|
||
|
||
/**
|
||
* Re-evaluate the shared badge for every item currently rendered in the Files list.
|
||
* Call this after the outgoing grants cache has been refreshed.
|
||
*/
|
||
function refreshSharedBadges() {
|
||
_component?.refreshSharedBadges();
|
||
}
|
||
|
||
export { addItem, filesView, loadFiles, refreshSharedBadges, renderItems };
|