feat(resources): add cursor, group by on /api/favorites/resources

This commit is contained in:
Edouard Vanbelle
2026-05-28 10:08:38 +02:00
parent 5790a1459f
commit 607ae5e6df
15 changed files with 1265 additions and 209 deletions
+1 -1
View File
@@ -208,7 +208,7 @@ function _ensureComponent() {
const isFile = 'mime_type' in item;
const type = isFile ? 'file' : 'folder';
if (favorites.isFavorite(item.id, type)) {
await favorites.removeFromFavorites(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);
+14 -18
View File
@@ -9,6 +9,7 @@ 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 { sharedView } from '../views/shared/sharedView.js';
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
import { filesView, loadFiles } from './filesView.js';
@@ -164,6 +165,11 @@ function setCurrentSection(section) {
sharedWithMeView.hide();
}
// Hide favoritesView "Load more" button when leaving the favorites section
if (section !== 'favorites' && favoritesView) {
favoritesView.hide();
}
// Reset owner column — sections that need it re-enable it explicitly below.
ui.setOwnerColumnVisible(false);
@@ -273,8 +279,8 @@ function switchToFavoritesSection() {
// Set actions bar mode
setActionsBarMode('favorites');
setGroupByView(null);
syncGroupByMenu([]);
setGroupByView(favoritesView);
syncGroupByMenu(favoritesView.groupByDefs);
// Show the Owner column — names are resolved async after render.
ui.setOwnerColumnVisible(true);
@@ -289,23 +295,13 @@ function switchToFavoritesSection() {
// ensure correct view
syncViewContainers();
//reset files view + remove any error
ui.resetFilesList();
if (favorites) {
// temp solution
sharedView.loadItems().then(() => {
favorites.displayFavorites();
});
} else {
console.error('Favorites module not loaded or initialized');
ui.showError(`
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
<p>Error loading the favorites module</p>
`);
}
if (batchToolbar) batchToolbar.clear();
// Prefetch isFavorite cache in background (non-blocking)
favorites.init();
// Load and render via the cursor-paginated view
favoritesView.init();
}
function switchToRecentFilesSection() {
+16
View File
@@ -333,6 +333,22 @@
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
*/
/**
* One item returned by `GET /api/favorites/resources`.
* `resource_type` discriminates the shape of `resource`.
* @typedef {Object} FavoritesResourceItem
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
* @property {string} favorited_at - ISO-8601 timestamp when the item was starred.
* @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type.
*/
/**
* Response for `GET /api/favorites/resources`.
* @typedef {Object} FavoritesResourcesResponse
* @property {FavoritesResourceItem[]} items
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
*/
/**
* @typedef {Object} ContactEmail
* @property {string} email
+2 -6
View File
@@ -508,12 +508,8 @@ const batchToolbar = {
const data = await response.json();
const inserted = data.stats?.inserted || 0;
// Replace cache directly from response (no extra GET)
if (data.favorites && favorites._replaceCacheFromResponse) {
favorites._replaceCacheFromResponse(data.favorites);
} else {
await favorites._fetchFromServer();
}
// Re-fetch the isFavorite cache from the server.
await favorites._fetchFromServer();
this.clear();
loadFiles();
+2 -2
View File
@@ -122,7 +122,7 @@ const contextMenus = {
// Check if folder is already in favorites to toggle
if (favorites?.isFavorite(folder.id, 'folder')) {
// Remove from favorites
const ok = await favorites.removeFromFavorites(folder.id, 'folder');
const ok = await favorites.removeFromFavorites(folder.id, 'folder', folder.name);
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
ui.setFavoriteVisualState(folder.id, 'folder', false);
}
@@ -244,7 +244,7 @@ const contextMenus = {
// Check if file is already in favorites to toggle
if (favorites?.isFavorite(file.id, 'file')) {
// Remove from favorites
const ok = await favorites.removeFromFavorites(file.id, 'file');
const ok = await favorites.removeFromFavorites(file.id, 'file', file.name);
if (ok && ui && typeof ui.setFavoriteVisualState === 'function') {
ui.setFavoriteVisualState(file.id, 'file', false);
}
+64 -173
View File
@@ -1,30 +1,28 @@
/**
* OxiCloud - Favorites Module (server-authoritative)
*
* Source of truth: GET /api/favorites (enriched with name/size/mime via SQL JOIN).
* Local in-memory cache (`_cache`) keeps `isFavorite()` synchronous for the
* rendering path so star icons can be painted without a round-trip.
* Source of truth: GET /api/favorites/resources (cursor-paginated).
* The in-memory cache (`_cache`) is a Set of "type:id" keys that keeps
* `isFavorite()` synchronous so star icons are painted without a round-trip.
*
* Display is handled by `views/favorites/favoritesView.js`.
*/
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 pathTooltip from '../pathTooltip.js';
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
import { fetchFavoritesPage } from '../../model/favoritesModel.js';
const favorites = {
/** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
_cache: new Map(),
/**
* Set of "type:id" cache keys. A Set is enough — we only need O(1) lookups.
* @type {Set<string>}
*/
_cache: new Set(),
/** Whether the initial fetch from the server has completed */
/** Whether the initial fetch from the server has completed. */
_ready: false,
/** @type {ResourceListComponent|null} */
_component: null,
// ───────────────────── helpers ─────────────────────
_authHeaders() {
@@ -34,56 +32,43 @@ const favorites = {
/**
* @param {string} id
* @param {string} type
* @returns {string}
*/
_cacheKey(id, type) {
return `${type}:${id}`;
},
/**
* Replace the entire in-memory cache from an array of FavoriteItemDto
* objects (as returned by the batch endpoint). Avoids an extra
* GET /api/favorites round-trip.
* @param {any[]} items
*/
_replaceCacheFromResponse(items) {
this._cache.clear();
for (const item of items) {
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
}
this._ready = true;
console.log(`Favorites cache replaced from response: ${this._cache.size} items`);
},
// ───────────────────── lifecycle ─────────────────────
/**
* Initialise the module: fetch the full list from the server and populate
* the in-memory cache. Called once from app.js on startup.
* Initialise the module: fetch the full favorites list from the server and
* populate the in-memory cache. Called from navigation.js every time the
* Favorites section is entered (non-blocking — the view loads in parallel).
*/
async init() {
console.log('Initializing favorites module (server-authoritative)');
await this._fetchFromServer();
},
/**
* Fetch favourites from the backend and rebuild the cache.
* Fetch all favorited resource IDs from the server and rebuild the cache.
* Paginates through `GET /api/favorites/resources` until exhausted.
*/
async _fetchFromServer() {
try {
const response = await fetch('/api/favorites', {
headers: this._authHeaders()
});
if (!response.ok) {
console.warn(`Favorites API returned ${response.status}`);
return;
}
/** @type {FavoriteItem[]} */
const items = await response.json();
this._cache.clear();
for (const item of items) {
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
let cursor = /** @type {string|undefined} */ (undefined);
// Paginate with the max page size so most users need only one request.
while (true) {
const data = await fetchFavoritesPage({ limit: 200, cursor, orderBy: 'name' });
for (const item of data.items) {
// `item.resource.id` works for both FileItem (id) and FolderItem (id).
const r = /** @type {Record<string, string>} */ (/** @type {unknown} */ (item.resource));
this._cache.add(this._cacheKey(r.id, item.resource_type));
}
if (!data.next_cursor) break;
cursor = data.next_cursor;
}
this._ready = true;
@@ -96,20 +81,22 @@ const favorites = {
// ───────────────────── public API ─────────────────────
/**
* Synchronous check used by ui.js to paint star icons.
* Synchronous check used by the rendering layer to paint star icons.
* @param {string} id
* @param {string} type
* @returns {boolean}
*/
isFavorite(id, type) {
return this._cache.has(this._cacheKey(id, type));
},
/**
* Add an item to favourites (server-first).
* Add an item to favourites (server-first, then update local cache).
* @param {string} id
* @param {string} name
* @param {string} type
* @param {string | null} _parentId
* @param {string | null} _parentId - unused, kept for call-site compatibility
* @returns {Promise<boolean>}
*/
async addToFavorites(id, name, type, _parentId) {
try {
@@ -122,10 +109,9 @@ const favorites = {
throw new Error(`Server returned ${response.status}`);
}
// Refresh cache from server to get enriched data
await this._fetchFromServer();
// Optimistically update local cache without a full re-fetch.
this._cache.add(this._cacheKey(id, type));
// Notify user
if (ui?.showNotification) {
ui.showNotification(i18n.t('favorites.added_title'), `"${name}" ${i18n.t('favorites.added_msg')}`);
}
@@ -138,16 +124,14 @@ const favorites = {
},
/**
* Remove an item from favourites (server-first).
* Remove an item from favourites (server-first, then update local cache).
* @param {string} id
* @param {string} type
* @param {string} [name] - Display name for the notification; falls back to `id`.
* @returns {Promise<boolean>}
*/
async removeFromFavorites(id, type) {
async removeFromFavorites(id, type, name = id) {
try {
// Remember name for notification before removing from cache
const cached = this._cache.get(this._cacheKey(id, type));
const itemName = cached?.item_name || id;
const response = await fetch(`/api/favorites/${type}/${id}`, {
method: 'DELETE',
headers: this._authHeaders()
@@ -157,11 +141,10 @@ const favorites = {
throw new Error(`Server returned ${response.status}`);
}
// Remove from local cache
this._cache.delete(this._cacheKey(id, type));
if (ui?.showNotification) {
ui.showNotification(i18n.t('favorites.removed_title'), `"${itemName}" ${i18n.t('favorites.removed_msg')}`);
ui.showNotification(i18n.t('favorites.removed_title'), `"${name}" ${i18n.t('favorites.removed_msg')}`);
}
return true;
@@ -171,124 +154,32 @@ const favorites = {
}
},
// ───────────────────── display ─────────────────────
/**
* Render the favourites view. All data comes from the in-memory cache
* (which was populated from the enriched backend response — zero extra
* fetches).
* Batch-add multiple items to favourites in a single server call.
* Re-fetches the cache after success to stay consistent.
*
* @param {Array<{item_id: string, item_type: string}>} items
* @returns {Promise<boolean>}
*/
async displayFavorites() {
async batchAdd(items) {
try {
const response = await fetch('/api/favorites/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this._authHeaders() },
body: JSON.stringify({ items })
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
// Re-fetch the full cache so the Set reflects the latest server state.
await this._fetchFromServer();
ui.resetFilesList();
batchToolbar.init();
ui.updateBreadcrumb();
if (this._cache.size === 0) {
ui.showError(`
<i class="fas fa-star empty-state-icon"></i>
<p>${i18n.t('favorites.empty_state')}</p>
<p>${i18n.t('favorites.empty_hint')}</p>
`);
return;
}
/** @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') {
items.push(
/** @type {FolderItem} */ ({
id: item.item_id,
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
category: 'folder',
created_at: item.created_at,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: item.owner_id ?? '',
is_root: false
})
);
} else {
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.modified_at || item.created_at,
path: item.item_path || '',
owner_id: item.owner_id ?? '',
created_at: item.created_at,
sort_date: item.created_at
})
);
}
}
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();
}
});
}
batchToolbar.setActiveComponent(this._component);
this._component.render(items);
pathTooltip.init(filesList);
}
await this._component?.resolveOwnerCells();
} catch (error) {
console.error('Error displaying favorites:', error);
if (ui?.showNotification) {
ui.showNotification('Error', 'Error loading favorite items');
}
return true;
} catch (err) {
console.error('Error in batchAdd:', err);
return false;
}
}
};
+51
View File
@@ -0,0 +1,51 @@
/**
* OxiCloud – Favorites resource model.
*
* Thin fetch wrapper for `GET /api/favorites/resources` (cursor-paginated).
* The old `GET /api/favorites` endpoint is kept for the isFavorite cache in
* `features/library/favorites.js` — this module only handles the new endpoint.
*/
/** @import {FileItem, FolderItem, ResourceTypeEnum} from '../core/types.js' */
/**
* @typedef {Object} FavoritesResourceItem
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
* @property {string} favorited_at - ISO-8601 timestamp
* @property {FileItem|FolderItem} resource - Full resource details
*/
/**
* @typedef {Object} FavoritesResourcesResponse
* @property {FavoritesResourceItem[]} items
* @property {string|undefined} [next_cursor]
*/
/**
* Fetch one page of the current user's favorited resources.
*
* @param {{
* cursor?: string,
* orderBy?: string,
* limit?: number,
* reverse?: boolean,
* resourceTypes?: ResourceTypeEnum[],
* }} [opts]
* @returns {Promise<FavoritesResourcesResponse>}
*/
async function fetchFavoritesPage({ cursor, orderBy = 'name', 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/favorites/resources?${params}`);
if (!res.ok) {
const err = new Error(`Failed to fetch favorites: HTTP ${res.status}`);
/** @type {any} */ (err).status = res.status;
throw err;
}
return res.json();
}
export { fetchFavoritesPage };
+422
View File
@@ -0,0 +1,422 @@
/**
* OxiCloud – Favorites view.
*
* Renders files and folders the current user has starred, using the
* cursor-paginated `GET /api/favorites/resources` endpoint.
*
* Uses `ResourceListComponent` so the grid ↔ list toggle and all card
* components work out of the box. A "Load more" button is injected below
* the files container for cursor-based pagination.
*
* Public API mirrors `sharedWithMeView`:
* - `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; resets state + 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 { batchToolbar } from '../../features/files/batchToolbar.js';
import * as itemTooltip from '../../features/itemTooltip.js';
import { favorites } from '../../features/library/favorites.js';
import { fetchFavoritesPage } from '../../model/favoritesModel.js';
import { systemUsers } from '../../model/systemUsers.js';
/** @import {FavoritesResourceItem, 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 the Favorites section.
* The empty-key entry (no grouping) is the default; it sorts by `name`.
*
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: 'type',
get label() {
return i18n.t('groupby.type', 'Type');
},
orderBy: 'type',
// keyFn: folders → 'Folder' swimlane; files → their `category` field.
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: 'favoriteDate',
get label() {
return i18n.t('groupby.favoriteDate', 'Favorite date');
},
orderBy: 'favorited_at',
// sort_date is stored as unix seconds in _mapItems().
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',
// modified_at is a unix seconds timestamp on FileItem/FolderItem.
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',
// 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);
}
},
{
key: 'owner',
get label() {
return i18n.t('groupby.owner', 'Owner');
},
orderBy: 'owner',
// keyFn groups by UUID — stable, avoids collisions on identical display names.
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 = 'fav-load-more-wrapper';
const favoritesView = {
// ── State ─────────────────────────────────────────────────────────────────
/** @type {string|null} */
_nextCursor: null,
_loading: false,
/** @type {ResourceListComponent|null} */
_component: null,
/**
* Active group-by key. '' = no grouping (sorted by name).
* @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.
* @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;
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;
this._nextCursor = null;
this._component?.clear();
this._loadPage();
},
/**
* (Re-)enter the Favorites section: reset state, create / reuse the
* component, and load page 1.
*/
async init() {
this._nextCursor = null;
this._loading = false;
this._groupBy = '';
this._reversed = false;
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);
const orderBy = def?.orderBy ?? 'name';
const data = await fetchFavoritesPage({
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-star empty-state-icon"></i>
<p>${i18n.t('favorites.empty_state', 'No favorites yet')}</p>
<p>${i18n.t('favorites.empty_hint', 'Star files and folders to find them here quickly')}</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('favoritesView: load error', err);
} finally {
this._loading = false;
}
},
/**
* Map `FavoritesResourceItem[]` → a flat `(FileItem|FolderItem)[]` preserving
* server order. Sets `sort_date` (unix seconds) to the favorite date so the
* `favoriteDate` keyFn can bucket by when the item was starred.
*
* @param {FavoritesResourceItem[]} 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 = favorited_at (unix seconds) for the favoriteDate keyFn
sort_date: toSecs(item.favorited_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.favorited_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 = 'fav-load-more';
btn.className = 'button secondary';
btn.textContent = i18n.t('favorites.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 { favoritesView };
@@ -222,7 +222,7 @@ const sharedWithMeView = {
const isFile = 'mime_type' in item;
const type = isFile ? 'file' : 'folder';
if (favorites.isFavorite(item.id, type)) {
await favorites.removeFromFavorites(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);