Files
Oxicloud/static/js/views/sharedWithMe/sharedWithMeView.js
T

449 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OxiCloud – "Shared with me" view.
*
* Renders files and folders that other users have explicitly granted the
* current user access to, using the cursor-paginated
* `GET /api/grants/incoming/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.
*/
import { ui } from '../../app/ui.js';
import { ResourceListComponent } from '../../components/resourceList.js';
import { createUserVignette } from '../../components/userVignette.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 { grants } from '../../model/grants.js';
import { systemUsers } from '../../model/systemUsers.js';
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
/**
* @typedef {{ key: string, label: string, orderBy: string,
* keyFn: (item: FileItem|FolderItem) => string|null,
* labelFn?: (key: string) => string,
* headerNodeFn?: (key: string) => HTMLElement }} GroupByDef
*/
/**
* Group-by dimension definitions for this section.
* Exported via `sharedWithMeView.groupByDefs` so `main.js` can populate
* the dropdown dynamically without knowing the internals of this view.
*
* `keyFn` returns the grouping key (stable UUID for owner, or a
* human-readable bucket label for shareDate — the bucket IS the key because
* it is already derived from the date, so no separate `labelFn` is needed
* for shareDate).
*
* @type {GroupByDef[]}
*/
const GROUP_BY_DEFS = [
{
key: 'owner',
// label is accessed via syncGroupByMenu → read at section-switch time,
// when translations are guaranteed to be loaded.
get label() {
return i18n.t('groupby.owner', 'Owner');
},
orderBy: 'granted_by',
// keyFn groups by UUID — stable and unique, avoids collisions between
// users with the same display name.
keyFn: (item) => {
const r = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item));
return r.owner_id || null;
},
// labelFn resolves UUID → display name from the pre-fetched cache.
labelFn: (id) => systemUsers.getDisplayNameSync(id),
headerNodeFn: (id) => createUserVignette(id, 'sm')
},
{
key: 'type',
get label() {
return i18n.t('groupby.type', 'Type');
},
orderBy: 'type',
// keyFn: folders get their own swimlane; files use the pre-computed
// `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …).
// The server orders by category_order (a pre-computed SMALLINT column)
// so items within the same category arrive grouped — no client sort needed.
keyFn: (item) => ('mime_type' in item ? /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'),
labelFn: (key) => {
// biome-ignore format: keep indentation
/** @type {Record<string, string>} */
const labels = {
Folder: i18n.t('groupby.type.folders', 'Folders'),
Image: i18n.t('category.images', 'Images'),
Video: i18n.t('category.videos', 'Videos'),
Audio: i18n.t('category.audio', 'Audio'),
PDF: 'PDF',
Document: i18n.t('category.documents', 'Documents'),
Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'),
Presentation: i18n.t('category.presentations', 'Presentations'),
Archive: i18n.t('category.archives', 'Archives'),
Code: i18n.t('category.code', 'Code'),
Markdown: i18n.t('category.markdown', 'Markdown'),
Text: i18n.t('category.text', 'Text'),
Installer: i18n.t('category.installers', 'Installers')
};
return labels[key] ?? key;
}
},
{
key: 'size',
get label() {
return i18n.t('groupby.size', 'Size');
},
orderBy: 'size',
// keyFn: the key IS the bucket label returned by sizeBucket(), so no
// separate labelFn is needed (same pattern as shareDate).
// Folders have no size — sizeBucket(-1) returns the "Folders" label.
keyFn: (item) => {
if (!('mime_type' in item)) return sizeBucket(-1);
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
return sizeBucket(r.size ?? 0);
}
// No labelFn: keyFn already returns the human-readable label.
},
{
key: 'shareDate',
get label() {
return i18n.t('groupby.shareDate', 'Share date');
},
orderBy: 'granted_at',
// keyFn returns the human-readable bucket label; the label IS the key
// because consecutive items with the same bucket should be in one group.
// sort_date is stored as unix seconds (number) in _mapItems().
keyFn: (item) => {
const r = /** @type {Record<string,number>} */ (/** @type {unknown} */ (item));
return r.sort_date ? normalizeDateBucket(r.sort_date) : null;
}
// No labelFn: keyFn already returns the human-readable label.
}
];
/** ID of the "Load more" wrapper injected below `.files-container`. */
const LOAD_MORE_ID = 'swm-load-more-wrapper';
const sharedWithMeView = {
// ── State ─────────────────────────────────────────────────────────────────
/** @type {string|null} */
_nextCursor: null,
_loading: false,
/** @type {ResourceListComponent|null} */
_component: null,
/**
* Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active.
* @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 '' | 'owner' | 'shareDate'
*/
setGroupBy(key) {
if (this._groupBy === key) return;
this._groupBy = key;
viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view);
this._nextCursor = null; // restart from first page
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('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view);
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.
*/
async init() {
this._nextCursor = null;
this._loading = false;
const _savedPrefs = viewPrefs.load('sharedwithme');
this._groupBy = _savedPrefs.groupBy;
this._reversed = _savedPrefs.reversed;
this._ensureLoadMoreButton();
// Start fetching system users in background so tooltips resolve instantly
// by the time the user hovers over an item.
systemUsers.prefetch();
// Standard files-view setup: clear list, show container
ui.resetFilesList();
batchToolbar.init();
ui.updateBreadcrumb();
// Create (or re-use) the component bound to #files-list.
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, then
* wire the owner tooltip.
* @returns {Promise<void>}
*/
async _loadPage() {
if (this._loading) return;
this._loading = true;
// Remember whether this is a fresh first-page load (cursor was null on
// entry) so we know whether to replace or append items.
const isFirstPage = this._nextCursor === null;
try {
const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy);
// When no swimlane grouping is active, sort by resource name so the
// list is alphabetical (same expectation as the Files section).
// Group-by modes supply their own orderBy via the def.
const orderBy = def?.orderBy ?? 'name';
const data = await grants.fetchSharedWithMe({
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
limit: 50,
cursor: this._nextCursor ?? undefined,
orderBy,
reverse: this._reversed
});
this._nextCursor = data.next_cursor ?? null;
if (data.items.length === 0 && isFirstPage) {
// First page came back empty
ui.showError(`
<i class="fas fa-share-alt empty-state-icon"></i>
<p>${i18n.t('sharedwithme_emptyStateTitle', 'Nothing shared with you yet')}</p>
<p>${i18n.t('sharedwithme_emptyStateDesc', 'Items shared with you by other users will appear here')}</p>
`);
this._setLoadMoreVisible(false);
return;
}
const items = this._mapItems(data.items);
if (isFirstPage) {
this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
} else {
this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
}
// Wire owner tooltips after items are in the DOM
const filesList = document.getElementById('files-list');
if (filesList) itemTooltip.init(filesList);
// Fill the Owner column cells (idempotent: skips already-resolved rows).
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('sharedWithMeView: load error', err);
} finally {
this._loading = false;
}
},
/**
* Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in
* **server-returned order**. The order must be preserved so that
* swimlane grouping (group by owner / share date) works correctly when
* the server interleaves files and folders by the sort key.
*
* Sets `owner_id` to `item.granted_by` so the component stamps
* `data-owner-id` with the granter's user ID automatically.
* Sets `sort_date` (unix seconds) to the grant date so the shareDate
* `keyFn` buckets by when the share was created, not the resource's
* own modification time.
*
* @param {SharedWithMeItem[]} items
* @returns {Array<FileItem|FolderItem>}
*/
_mapItems(items) {
/** @type {Array<FileItem|FolderItem>} */
const result = [];
/** @param {string} iso @returns {number} */
const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
for (const item of items) {
if (item.resource_type === 'folder') {
const f = /** @type {FolderItem} */ (item.resource);
result.push(
/** @type {FolderItem} */ ({
id: f.id,
name: f.name,
path: f.path ?? '',
parent_id: f.parent_id ?? '',
owner_id: item.granted_by,
is_root: f.is_root ?? false,
created_at: f.created_at,
modified_at: f.modified_at,
sort_date: grantedAtSecs(item.granted_at),
icon_class: f.icon_class,
icon_special_class: f.icon_special_class ?? '',
category: 'folder'
})
);
} 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: item.granted_by,
mime_type: f.mime_type,
size: f.size,
size_formatted: f.size_formatted,
created_at: f.created_at,
modified_at: f.modified_at,
sort_date: grantedAtSecs(item.granted_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 = 'swm-load-more';
btn.className = 'button secondary';
btn.textContent = i18n.t('sharedwithme_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 { sharedWithMeView };