2026-05-22 10:15:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-05-26 23:21:58 +02:00
|
|
|
|
* 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.
|
2026-05-22 10:15:25 +02:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { ui } from '../../app/ui.js';
|
2026-05-26 23:21:58 +02:00
|
|
|
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
2026-05-28 12:07:56 +02:00
|
|
|
|
import { createUserVignette } from '../../components/userVignette.js';
|
2026-05-27 00:32:10 +02:00
|
|
|
|
import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js';
|
|
|
|
|
|
import { i18n } from '../../core/i18n.js';
|
2026-05-28 11:12:36 +02:00
|
|
|
|
import * as viewPrefs from '../../core/viewPrefs.js';
|
2026-05-26 22:59:43 +02:00
|
|
|
|
import { batchToolbar } from '../../features/files/batchToolbar.js';
|
2026-05-28 10:51:50 +02:00
|
|
|
|
import * as itemTooltip from '../../features/itemTooltip.js';
|
2026-05-26 23:21:58 +02:00
|
|
|
|
import { favorites } from '../../features/library/favorites.js';
|
2026-05-22 10:15:25 +02:00
|
|
|
|
import { grants } from '../../model/grants.js';
|
|
|
|
|
|
import { systemUsers } from '../../model/systemUsers.js';
|
|
|
|
|
|
|
|
|
|
|
|
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
|
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @typedef {{ key: string, label: string, orderBy: string,
|
|
|
|
|
|
* keyFn: (item: FileItem|FolderItem) => string|null,
|
2026-05-28 12:07:56 +02:00
|
|
|
|
* labelFn?: (key: string) => string,
|
|
|
|
|
|
* headerNodeFn?: (key: string) => HTMLElement }} GroupByDef
|
2026-05-27 00:32:10 +02:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 = [
|
2026-05-28 12:07:56 +02:00
|
|
|
|
{
|
|
|
|
|
|
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')
|
|
|
|
|
|
},
|
2026-05-27 00:32:10 +02:00
|
|
|
|
{
|
|
|
|
|
|
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.
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
/** 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,
|
|
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
/** @type {ResourceListComponent|null} */
|
|
|
|
|
|
_component: null,
|
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active.
|
|
|
|
|
|
* @type {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
_groupBy: '',
|
|
|
|
|
|
|
2026-05-28 00:44:20 +02:00
|
|
|
|
/** Whether the current sort order is reversed. */
|
|
|
|
|
|
_reversed: false,
|
|
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-05-28 11:12:36 +02:00
|
|
|
|
viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view);
|
2026-05-27 00:32:10 +02:00
|
|
|
|
this._nextCursor = null; // restart from first page
|
|
|
|
|
|
this._component?.clear();
|
|
|
|
|
|
this._loadPage();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-28 00:44:20 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-05-28 11:12:36 +02:00
|
|
|
|
viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view);
|
2026-05-28 00:44:20 +02:00
|
|
|
|
this._nextCursor = null;
|
|
|
|
|
|
this._component?.clear();
|
|
|
|
|
|
this._loadPage();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* (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;
|
2026-05-28 11:12:36 +02:00
|
|
|
|
const _savedPrefs = viewPrefs.load('sharedwithme');
|
|
|
|
|
|
this._groupBy = _savedPrefs.groupBy;
|
|
|
|
|
|
this._reversed = _savedPrefs.reversed;
|
2026-05-22 10:15:25 +02:00
|
|
|
|
|
|
|
|
|
|
this._ensureLoadMoreButton();
|
|
|
|
|
|
|
|
|
|
|
|
// Start fetching system users in background so tooltips resolve instantly
|
|
|
|
|
|
// by the time the user hovers over an item.
|
|
|
|
|
|
systemUsers.prefetch();
|
|
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
// Standard files-view setup: clear list, show container
|
2026-05-22 10:15:25 +02:00
|
|
|
|
ui.resetFilesList();
|
2026-05-26 22:59:43 +02:00
|
|
|
|
batchToolbar.init();
|
2026-05-22 10:15:25 +02:00
|
|
|
|
ui.updateBreadcrumb();
|
|
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
// Create (or re-use) the component bound to #files-list.
|
|
|
|
|
|
const filesList = document.getElementById('files-list');
|
|
|
|
|
|
if (filesList) {
|
|
|
|
|
|
if (!this._component) {
|
2026-05-27 00:32:10 +02:00
|
|
|
|
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)) {
|
2026-05-28 10:08:38 +02:00
|
|
|
|
await favorites.removeFromFavorites(item.id, type, item.name);
|
2026-05-27 00:32:10 +02:00
|
|
|
|
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 || ''
|
|
|
|
|
|
});
|
2026-05-26 23:21:58 +02:00
|
|
|
|
}
|
2026-05-27 00:32:10 +02:00
|
|
|
|
batchToolbar._syncUI();
|
2026-05-26 23:21:58 +02:00
|
|
|
|
}
|
2026-05-27 00:32:10 +02:00
|
|
|
|
});
|
2026-05-26 23:21:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
batchToolbar.setActiveComponent(this._component);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
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');
|
|
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
batchToolbar.setActiveComponent(null);
|
|
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
const filesList = document.getElementById('files-list');
|
2026-05-28 10:51:50 +02:00
|
|
|
|
if (filesList) itemTooltip.destroy(filesList);
|
2026-05-22 10:15:25 +02:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Fetch one page, map items → FileItem / FolderItem, render them, then
|
2026-05-26 23:21:58 +02:00
|
|
|
|
* wire the owner tooltip.
|
2026-05-22 10:15:25 +02:00
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
async _loadPage() {
|
|
|
|
|
|
if (this._loading) return;
|
|
|
|
|
|
this._loading = true;
|
|
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
try {
|
2026-05-27 00:32:10 +02:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
const data = await grants.fetchSharedWithMe({
|
|
|
|
|
|
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
|
|
|
|
|
limit: 50,
|
2026-05-27 00:32:10 +02:00
|
|
|
|
cursor: this._nextCursor ?? undefined,
|
2026-05-28 00:44:20 +02:00
|
|
|
|
orderBy,
|
|
|
|
|
|
reverse: this._reversed
|
2026-05-22 10:15:25 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this._nextCursor = data.next_cursor ?? null;
|
|
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
if (data.items.length === 0 && isFirstPage) {
|
2026-05-22 10:15:25 +02:00
|
|
|
|
// First page came back empty
|
|
|
|
|
|
ui.showError(`
|
2026-05-25 23:17:21 +02:00
|
|
|
|
<i class="fas fa-share-alt empty-state-icon"></i>
|
2026-05-22 10:15:25 +02:00
|
|
|
|
<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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
|
const items = this._mapItems(data.items);
|
2026-05-22 10:15:25 +02:00
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
if (isFirstPage) {
|
2026-05-28 12:07:56 +02:00
|
|
|
|
this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
|
2026-05-26 23:21:58 +02:00
|
|
|
|
} else {
|
2026-05-28 12:07:56 +02:00
|
|
|
|
this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn);
|
2026-05-22 10:15:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
|
// Wire owner tooltips after items are in the DOM
|
|
|
|
|
|
const filesList = document.getElementById('files-list');
|
2026-05-28 10:51:50 +02:00
|
|
|
|
if (filesList) itemTooltip.init(filesList);
|
2026-05-26 23:21:58 +02:00
|
|
|
|
|
2026-05-25 22:34:45 +02:00
|
|
|
|
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
2026-05-27 22:51:15 +02:00
|
|
|
|
await this._component?.resolveOwnerCells();
|
2026-05-25 22:34:45 +02:00
|
|
|
|
|
2026-05-22 10:15:25 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-27 00:32:10 +02:00
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-05-26 23:21:58 +02:00
|
|
|
|
* Sets `owner_id` to `item.granted_by` so the component stamps
|
|
|
|
|
|
* `data-owner-id` with the granter's user ID automatically.
|
2026-05-27 00:32:10 +02:00
|
|
|
|
* 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.
|
2026-05-22 10:15:25 +02:00
|
|
|
|
*
|
|
|
|
|
|
* @param {SharedWithMeItem[]} items
|
2026-05-27 00:32:10 +02:00
|
|
|
|
* @returns {Array<FileItem|FolderItem>}
|
2026-05-22 10:15:25 +02:00
|
|
|
|
*/
|
|
|
|
|
|
_mapItems(items) {
|
2026-05-27 00:32:10 +02:00
|
|
|
|
/** @type {Array<FileItem|FolderItem>} */
|
|
|
|
|
|
const result = [];
|
2026-05-22 10:15:25 +02:00
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
|
/** @param {string} iso @returns {number} */
|
|
|
|
|
|
const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000);
|
2026-05-22 10:15:25 +02:00
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
2026-05-26 17:52:28 +02:00
|
|
|
|
if (item.resource_type === 'folder') {
|
|
|
|
|
|
const f = /** @type {FolderItem} */ (item.resource);
|
2026-05-27 00:32:10 +02:00
|
|
|
|
result.push(
|
2026-05-22 10:15:25 +02:00
|
|
|
|
/** @type {FolderItem} */ ({
|
|
|
|
|
|
id: f.id,
|
|
|
|
|
|
name: f.name,
|
|
|
|
|
|
path: f.path ?? '',
|
|
|
|
|
|
parent_id: f.parent_id ?? '',
|
2026-05-26 23:21:58 +02:00
|
|
|
|
owner_id: item.granted_by,
|
2026-05-22 10:15:25 +02:00
|
|
|
|
is_root: f.is_root ?? false,
|
|
|
|
|
|
created_at: f.created_at,
|
|
|
|
|
|
modified_at: f.modified_at,
|
2026-05-27 00:32:10 +02:00
|
|
|
|
sort_date: grantedAtSecs(item.granted_at),
|
2026-05-22 10:15:25 +02:00
|
|
|
|
icon_class: f.icon_class,
|
|
|
|
|
|
icon_special_class: f.icon_special_class ?? '',
|
|
|
|
|
|
category: 'folder'
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
2026-05-26 17:52:28 +02:00
|
|
|
|
} else if (item.resource_type === 'file') {
|
|
|
|
|
|
const f = /** @type {FileItem} */ (item.resource);
|
2026-05-27 00:32:10 +02:00
|
|
|
|
result.push(
|
2026-05-22 10:15:25 +02:00
|
|
|
|
/** @type {FileItem} */ ({
|
|
|
|
|
|
id: f.id,
|
|
|
|
|
|
name: f.name,
|
|
|
|
|
|
path: f.path ?? '',
|
|
|
|
|
|
folder_id: f.folder_id ?? '',
|
2026-05-26 23:21:58 +02:00
|
|
|
|
owner_id: item.granted_by,
|
2026-05-22 10:15:25 +02:00
|
|
|
|
mime_type: f.mime_type,
|
|
|
|
|
|
size: f.size,
|
|
|
|
|
|
size_formatted: f.size_formatted,
|
|
|
|
|
|
created_at: f.created_at,
|
|
|
|
|
|
modified_at: f.modified_at,
|
2026-05-27 00:32:10 +02:00
|
|
|
|
sort_date: grantedAtSecs(item.granted_at),
|
2026-05-22 10:15:25 +02:00
|
|
|
|
icon_class: f.icon_class,
|
|
|
|
|
|
icon_special_class: f.icon_special_class ?? '',
|
|
|
|
|
|
category: f.category
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
|
return result;
|
2026-05-22 10:15:25 +02:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ── "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 };
|