feat(recent): add cursor + groupby support
This commit is contained in:
@@ -17,6 +17,7 @@ import { favorites } from '../features/library/favorites.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { recentView } from '../views/recent/recentView.js';
|
||||
import { sharedView } from '../views/shared/sharedView.js';
|
||||
import { checkAuthentication } from './authSession.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
@@ -345,8 +346,8 @@ function setupActionsBarDelegation() {
|
||||
break;
|
||||
case 'clear-recent-btn':
|
||||
if (recent) {
|
||||
recent.clearRecentFiles();
|
||||
recent.displayRecentFiles();
|
||||
await recent.clearRecentFiles();
|
||||
await recentView.init();
|
||||
ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
|
||||
}
|
||||
break;
|
||||
|
||||
+18
-18
@@ -10,8 +10,8 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { musicView } from '../features/library/music.js';
|
||||
import { photosView } from '../features/library/photos.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { favoritesView } from '../views/favorites/favoritesView.js';
|
||||
import { recentView } from '../views/recent/recentView.js';
|
||||
import { sharedView } from '../views/shared/sharedView.js';
|
||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||
import { filesView, loadFiles } from './filesView.js';
|
||||
@@ -180,6 +180,11 @@ function setCurrentSection(section) {
|
||||
favoritesView.hide();
|
||||
}
|
||||
|
||||
// Hide recentView "Load more" button when leaving the recent section
|
||||
if (section !== 'recent' && recentView) {
|
||||
recentView.hide();
|
||||
}
|
||||
|
||||
// Reset owner column — sections that need it re-enable it explicitly below.
|
||||
ui.setOwnerColumnVisible(false);
|
||||
|
||||
@@ -332,10 +337,17 @@ function switchToFavoritesSection() {
|
||||
function switchToRecentFilesSection() {
|
||||
if (!setCurrentSection('recent')) return;
|
||||
|
||||
// Set actions bar mode
|
||||
// Set actions bar mode with group-by support
|
||||
setActionsBarMode('recent');
|
||||
setGroupByView(null);
|
||||
syncGroupByMenu([]);
|
||||
setGroupByView(recentView);
|
||||
syncGroupByMenu(recentView.groupByDefs);
|
||||
|
||||
// Restore the saved group-by selection in the dropdown.
|
||||
const recentPrefs = viewPrefs.load('recent');
|
||||
applyGroupByMenuState(recentPrefs.groupBy, recentPrefs.reversed);
|
||||
|
||||
// Show the Owner column
|
||||
ui.setOwnerColumnVisible(true);
|
||||
|
||||
// Hide breadcrumb (only shown in Files view)
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
@@ -348,21 +360,9 @@ function switchToRecentFilesSection() {
|
||||
restoreView('recent');
|
||||
syncViewContainers();
|
||||
|
||||
//reset files view + remove any error
|
||||
ui.resetFilesList();
|
||||
|
||||
if (recent) {
|
||||
sharedView.loadItems().then(() => {
|
||||
recent.displayRecentFiles();
|
||||
});
|
||||
} else {
|
||||
console.error('Recent files module not loaded or initialized');
|
||||
ui.showError(`
|
||||
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
|
||||
<p>Error loading the recent module</p>
|
||||
`);
|
||||
}
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
|
||||
recentView.init();
|
||||
}
|
||||
|
||||
function switchToPhotosSection() {
|
||||
|
||||
@@ -41,9 +41,7 @@ function applyGroupByMenuState(groupBy, reversed) {
|
||||
if (groupBy === '') {
|
||||
lbl.textContent = '';
|
||||
} else {
|
||||
const activeOpt = /** @type {HTMLElement|null} */ (
|
||||
document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`)
|
||||
);
|
||||
const activeOpt = /** @type {HTMLElement|null} */ (document.querySelector(`.group-by-option[data-group-by="${CSS.escape(groupBy)}"]`));
|
||||
lbl.textContent = activeOpt?.textContent ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* OxiCloud - Recent Files Module (server-authoritative)
|
||||
*
|
||||
* Source of truth: GET /api/recent (enriched with name/size/mime via SQL JOIN).
|
||||
* File-access events are forwarded to the backend with POST /api/recent/{type}/{id}.
|
||||
* No localStorage usage — the server persists and prunes recent items.
|
||||
* Records file-access events via POST /api/recent/{type}/{id} and exposes
|
||||
* `clearRecentFiles()` for the clear-all action.
|
||||
*
|
||||
* Display is now handled by `recentView.js` using the cursor-paginated
|
||||
* `GET /api/recent/resources` endpoint.
|
||||
*/
|
||||
|
||||
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 { batchToolbar } from '../files/batchToolbar.js';
|
||||
import * as itemTooltip from '../itemTooltip.js';
|
||||
|
||||
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
|
||||
/** @import {ItemTypeEnum} from '../../core/types.js' */
|
||||
|
||||
const recent = {
|
||||
/** Maximum items to request from the server */
|
||||
MAX_RECENT_FILES: 20,
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
_component: null,
|
||||
|
||||
// ───────────────────── helpers ─────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
@@ -31,10 +24,9 @@ const recent = {
|
||||
// ───────────────────── lifecycle ─────────────────────
|
||||
|
||||
/**
|
||||
* Initialise the module. Called once from app.js on startup.
|
||||
* Initialise the module. Called once from app.js on startup.
|
||||
*/
|
||||
init() {
|
||||
console.log('Initializing recent files module (server-authoritative)');
|
||||
this.setupEventListeners();
|
||||
},
|
||||
|
||||
@@ -83,139 +75,6 @@ const recent = {
|
||||
} catch (err) {
|
||||
console.error('Error clearing recent files:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch and display recent files. Data comes directly from the
|
||||
* enriched backend response — zero extra per-item fetches.
|
||||
*/
|
||||
async displayRecentFiles() {
|
||||
try {
|
||||
const response = await fetch(`/api/recent?limit=${this.MAX_RECENT_FILES}`, {
|
||||
headers: this._authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
|
||||
const recentItems = /** @type {RecentItem[]} */ (await response.json());
|
||||
|
||||
// resetFilesList injects the standard list-header with the
|
||||
// Modified column label; we swap the last header cell to "Accessed".
|
||||
ui.resetFilesList();
|
||||
|
||||
const filesList = document.getElementById('files-list');
|
||||
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')
|
||||
);
|
||||
if (dateHeader) {
|
||||
dateHeader.removeAttribute('data-i18n');
|
||||
dateHeader.setAttribute('data-i18n', 'recent.accessed');
|
||||
dateHeader.textContent = i18n.t('recent.accessed', 'Accessed');
|
||||
}
|
||||
}
|
||||
|
||||
batchToolbar.clear();
|
||||
batchToolbar.init();
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
if (recentItems.length === 0) {
|
||||
ui.showError(`
|
||||
<i class="fas fa-clock empty-state-icon"></i>
|
||||
<p>${i18n.t('recent.empty_state')}</p>
|
||||
<p>${i18n.t('recent.empty_hint')}</p>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const items = [];
|
||||
|
||||
for (const item of recentItems) {
|
||||
const isFolder = item.item_type === 'folder';
|
||||
if (isFolder) {
|
||||
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);
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
batchToolbar.setActiveComponent(this._component);
|
||||
this._component.render(items);
|
||||
itemTooltip.init(filesList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error displaying recent files:', error);
|
||||
if (ui?.showNotification) {
|
||||
ui.showNotification('Error', 'Error loading recent files');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* OxiCloud – Recent resources model.
|
||||
*
|
||||
* Thin fetch wrapper for `GET /api/recent/resources` (cursor-paginated).
|
||||
* The old `GET /api/recent` endpoint is kept for backward compat — this
|
||||
* module only handles the new endpoint.
|
||||
*/
|
||||
|
||||
/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../core/types.js' */
|
||||
|
||||
/**
|
||||
* @typedef {Object} RecentResourceItem
|
||||
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
|
||||
* @property {string} accessed_at - ISO-8601 timestamp
|
||||
* @property {FileItem|FolderItem} resource - Full resource details
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RecentResourcesResponse
|
||||
* @property {RecentResourceItem[]} items
|
||||
* @property {string|undefined} [next_cursor]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch one page of the current user's recently accessed resources.
|
||||
*
|
||||
* @param {{
|
||||
* cursor?: string,
|
||||
* orderBy?: string,
|
||||
* limit?: number,
|
||||
* reverse?: boolean,
|
||||
* resourceTypes?: ResourceTypeEnum[],
|
||||
* }} [opts]
|
||||
* @returns {Promise<RecentResourcesResponse>}
|
||||
*/
|
||||
async function fetchRecentPage({ cursor, orderBy = 'accessed_at', limit = 50, reverse = false, resourceTypes } = {}) {
|
||||
const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (reverse) params.set('reverse', 'true');
|
||||
if (resourceTypes?.length) params.set('resource_types', resourceTypes.join(','));
|
||||
|
||||
const res = await fetch(`/api/recent/resources?${params}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = /** @type {any} */ (new Error(`GET /api/recent/resources failed: ${res.status}`));
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return /** @type {Promise<RecentResourcesResponse>} */ (res.json());
|
||||
}
|
||||
|
||||
export { fetchRecentPage };
|
||||
@@ -0,0 +1,433 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* OxiCloud – Recent view.
|
||||
*
|
||||
* Renders files and folders the current user has recently accessed, using the
|
||||
* cursor-paginated `GET /api/recent/resources` endpoint.
|
||||
*
|
||||
* Default sort: `accessed_at` DESC (most recently accessed first, no swimlanes).
|
||||
* The user can pick any group-by from the dropdown; viewPrefs persists the choice.
|
||||
*
|
||||
* Public API mirrors `favoritesView`:
|
||||
* - `groupByDefs` — array of group-by dimension definitions
|
||||
* - `setGroupBy(key)` — change active dimension + reload from page 1
|
||||
* - `setDirection(reversed)` — flip sort direction + reload from page 1
|
||||
* - `init()` — (re-)enter the section; restores prefs + loads page 1
|
||||
* - `hide()` — called when leaving this section
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { ResourceListComponent } from '../../components/resourceList.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 * as itemTooltip from '../../features/itemTooltip.js';
|
||||
import { favorites } from '../../features/library/favorites.js';
|
||||
import { fetchRecentPage } from '../../model/recentModel.js';
|
||||
import { systemUsers } from '../../model/systemUsers.js';
|
||||
|
||||
/** @import {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
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RecentResourceItem
|
||||
* @property {ResourceTypeEnum} resource_type
|
||||
* @property {string} accessed_at
|
||||
* @property {FileItem|FolderItem} resource
|
||||
*/
|
||||
|
||||
/**
|
||||
* Group-by dimension definitions for the Recent section.
|
||||
*
|
||||
* When `_groupBy === ''` (None selected), items are sorted by `accessed_at` DESC —
|
||||
* the natural expectation for a "Recent" section. "None" = flat chronological feed.
|
||||
*
|
||||
* @type {GroupByDef[]}
|
||||
*/
|
||||
const GROUP_BY_DEFS = [
|
||||
{
|
||||
key: 'type',
|
||||
get label() {
|
||||
return i18n.t('groupby.type', 'Type');
|
||||
},
|
||||
orderBy: 'type',
|
||||
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: 'accessedAt',
|
||||
get label() {
|
||||
return i18n.t('groupby.accessedAt', 'Accessed date');
|
||||
},
|
||||
orderBy: 'accessed_at',
|
||||
// sort_date is unix seconds set in _mapItems(); keyFn returns the bucket label.
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
|
||||
return r.sort_date ? normalizeDateBucket(r.sort_date) : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'modifiedAt',
|
||||
get label() {
|
||||
return i18n.t('groupby.modifiedAt', 'Modified date');
|
||||
},
|
||||
orderBy: 'modified_at',
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
|
||||
return r.modified_at ? normalizeDateBucket(r.modified_at) : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
get label() {
|
||||
return i18n.t('groupby.size', 'Size');
|
||||
},
|
||||
orderBy: 'size',
|
||||
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: 'owner',
|
||||
get label() {
|
||||
return i18n.t('groupby.owner', 'Owner');
|
||||
},
|
||||
orderBy: 'owner',
|
||||
keyFn: (item) => {
|
||||
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
|
||||
return r.owner_id || null;
|
||||
},
|
||||
labelFn: (id) => systemUsers.getDisplayNameSync(id)
|
||||
}
|
||||
];
|
||||
|
||||
/** ID of the "Load more" wrapper injected below `.files-container`. */
|
||||
const LOAD_MORE_ID = 'recent-load-more-wrapper';
|
||||
|
||||
const recentView = {
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @type {string|null} */
|
||||
_nextCursor: null,
|
||||
|
||||
_loading: false,
|
||||
|
||||
/** @type {ResourceListComponent|null} */
|
||||
_component: null,
|
||||
|
||||
/**
|
||||
* Active group-by key. '' = no grouping (sorted by accessed_at DESC).
|
||||
* @type {string}
|
||||
*/
|
||||
_groupBy: '',
|
||||
|
||||
/** Whether the current sort order is reversed. */
|
||||
_reversed: false,
|
||||
|
||||
// ── 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
|
||||
*/
|
||||
setGroupBy(key) {
|
||||
if (this._groupBy === key) return;
|
||||
this._groupBy = key;
|
||||
viewPrefs.save('recent', this._groupBy, this._reversed, viewPrefs.load('recent').view);
|
||||
this._nextCursor = null;
|
||||
this._component?.clear();
|
||||
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;
|
||||
viewPrefs.save('recent', this._groupBy, this._reversed, viewPrefs.load('recent').view);
|
||||
this._nextCursor = null;
|
||||
this._component?.clear();
|
||||
this._loadPage();
|
||||
},
|
||||
|
||||
/**
|
||||
* (Re-)enter the Recent section: restore saved prefs, create / reuse the
|
||||
* component, and load page 1.
|
||||
*/
|
||||
async init() {
|
||||
this._nextCursor = null;
|
||||
this._loading = false;
|
||||
const savedPrefs = viewPrefs.load('recent');
|
||||
this._groupBy = savedPrefs.groupBy;
|
||||
this._reversed = savedPrefs.reversed;
|
||||
|
||||
this._ensureLoadMoreButton();
|
||||
|
||||
// Prefetch system users so owner tooltips resolve without delay.
|
||||
systemUsers.prefetch();
|
||||
|
||||
ui.resetFilesList();
|
||||
batchToolbar.init();
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
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, item.name);
|
||||
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);
|
||||
}
|
||||
|
||||
await this._loadPage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the "Load more" button when leaving this section.
|
||||
* The files container itself is managed by navigation.js.
|
||||
*/
|
||||
hide() {
|
||||
const w = document.getElementById(LOAD_MORE_ID);
|
||||
if (w) w.classList.add('hidden');
|
||||
|
||||
batchToolbar.setActiveComponent(null);
|
||||
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) itemTooltip.destroy(filesList);
|
||||
},
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch one page, map items → FileItem / FolderItem, render them.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _loadPage() {
|
||||
if (this._loading) return;
|
||||
this._loading = true;
|
||||
|
||||
const isFirstPage = this._nextCursor === null;
|
||||
|
||||
try {
|
||||
const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy);
|
||||
// When no group-by is active, sort by accessed_at DESC (most recent first).
|
||||
const orderBy = def?.orderBy ?? 'accessed_at';
|
||||
|
||||
const data = await fetchRecentPage({
|
||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||
limit: 50,
|
||||
cursor: this._nextCursor ?? undefined,
|
||||
orderBy,
|
||||
reverse: this._reversed
|
||||
});
|
||||
|
||||
this._nextCursor = data.next_cursor ?? null;
|
||||
|
||||
if (data.items.length === 0 && isFirstPage) {
|
||||
ui.showError(`
|
||||
<i class="fas fa-clock empty-state-icon"></i>
|
||||
<p>${i18n.t('recent.empty_state', 'No recent files')}</p>
|
||||
<p>${i18n.t('recent.empty_hint', 'Files you open will appear here')}</p>
|
||||
`);
|
||||
this._setLoadMoreVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = this._mapItems(data.items);
|
||||
|
||||
if (isFirstPage) {
|
||||
this._component?.render(items, def?.keyFn, def?.labelFn);
|
||||
} else {
|
||||
this._component?.append(items, def?.keyFn, def?.labelFn);
|
||||
}
|
||||
|
||||
// Wire unified item tooltip (owner + path) after items are in the DOM.
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) itemTooltip.init(filesList);
|
||||
|
||||
await this._component?.resolveOwnerCells();
|
||||
|
||||
this._setLoadMoreVisible(!!this._nextCursor);
|
||||
} catch (err) {
|
||||
ui.showError(`
|
||||
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
|
||||
<p>${i18n.t('errors_loadFailed', 'Failed to load items')}</p>
|
||||
`);
|
||||
console.error('recentView: load error', err);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Map `RecentResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving
|
||||
* server order. Sets `sort_date` (unix seconds) to the `accessed_at` date
|
||||
* so the `accessedAt` keyFn can bucket by when the item was accessed.
|
||||
*
|
||||
* @param {RecentResourceItem[]} items
|
||||
* @returns {Array<FileItem|FolderItem>}
|
||||
*/
|
||||
_mapItems(items) {
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const result = [];
|
||||
|
||||
/** @param {string} iso @returns {number} unix seconds */
|
||||
const toSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.resource_type === 'folder') {
|
||||
const f = /** @type {FolderItem} */ (item.resource);
|
||||
result.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
parent_id: f.parent_id ?? '',
|
||||
owner_id: f.owner_id ?? '',
|
||||
is_root: f.is_root ?? false,
|
||||
created_at: f.created_at,
|
||||
modified_at: f.modified_at,
|
||||
// sort_date = accessed_at (unix seconds) for the accessedAt keyFn
|
||||
sort_date: toSecs(item.accessed_at),
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: 'Folder'
|
||||
})
|
||||
);
|
||||
} else if (item.resource_type === 'file') {
|
||||
const f = /** @type {FileItem} */ (item.resource);
|
||||
result.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
folder_id: f.folder_id ?? '',
|
||||
owner_id: f.owner_id ?? '',
|
||||
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: toSecs(item.accessed_at),
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: f.category
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
// ── "Load more" button ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the "Load more" wrapper once and attach it below `.files-container`.
|
||||
* Subsequent calls are no-ops.
|
||||
*/
|
||||
_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 = 'recent-load-more';
|
||||
btn.className = 'button secondary';
|
||||
btn.textContent = i18n.t('recent.loadMore', 'Load more');
|
||||
btn.addEventListener('click', () => this._loadPage());
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
filesContainer.after(wrapper);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
_setLoadMoreVisible(visible) {
|
||||
const w = document.getElementById(LOAD_MORE_ID);
|
||||
if (w) w.classList.toggle('hidden', !visible);
|
||||
}
|
||||
};
|
||||
|
||||
export { recentView };
|
||||
Reference in New Issue
Block a user