Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list

feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
Dionisio Pozo
2026-07-22 06:51:28 +02:00
committed by GitHub
50 changed files with 1140 additions and 287 deletions
+10 -19
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteMap } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import Button from '$lib/components/Button.svelte';
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
@@ -50,22 +50,15 @@
// action surfaces don't filter, algorithmic surfaces do.
//
// ResourceList consumes raw `FileItem | FolderItem`; the favorites
// envelope contributes `favorited_at` via `date` in contextMap. All
// items on this page are favorites — pass every id in `favoriteIds`
// so the star widget lights up universally.
// envelope contributes `favorited_at` via `date` in contextMap.
// Every item on this page has `is_favorite: true` by construction
// (the listing SQL hardcodes `TRUE AS is_favorite`), so the star
// widget lights up universally without any shadow set here.
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
// on every infinite-scroll page.
const contextMap = new SvelteMap<string, ItemContext>();
// Persistent reactive set, updated in place per page (add the fresh page's
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
// whole accumulated list on every infinite-scroll page — that was O(N²)
// across a drain and, being a new instance each page, invalidated every
// mounted star reader. Every item on this page is a favorite, and removed
// items are no longer rendered, so the set only needs to be a superset of
// the displayed ids (benches/ROUND14.md §F2, mirrors recent's shipped shape).
const favoriteIds = new SvelteSet<string>();
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
@@ -114,10 +107,6 @@
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
// Keep the persistent favoriteIds set in sync incrementally: clear on
// reset, then add only this page's ids (benches/ROUND14.md §F2).
if (reset) favoriteIds.clear();
for (const it of page.items) favoriteIds.add(it.resource.id);
primeContextPage(contextMap, reset, page.items, (it) => [
it.resource.id,
{ date: it.favorited_at }
@@ -164,7 +153,6 @@
try {
await removeFavorite(kind, item.id);
raw = raw.filter((i) => i.resource.id !== item.id);
favoriteIds.delete(item.id);
} catch (e) {
errorToast(e);
}
@@ -317,7 +305,6 @@
title={t('nav.favorites', 'Favorites')}
{items}
{contextMap}
{favoriteIds}
resolveOwnerName={(id) => owners.name(id)}
{loading}
{error}
@@ -328,6 +315,10 @@
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={unfavorite}
onshared={(item) => {
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
shareOpen = true;
}}
showOwner
showPath
dateLabel={t('files.col_added', 'Added')}
@@ -108,7 +108,7 @@
return drivesStore.findByRootFolderId(pathSegments[0] ?? null);
});
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
let listing = $state<FolderListing>({ folders: [], files: [] });
// Server-order accumulator — items in the exact sequence the backend
// returned across pages, honouring `sortField`+`reversed` on the wire.
// Under order_by=name/type/size the server puts folders first then files;
@@ -176,14 +176,13 @@
let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite + shared badge sets for the current folder, seeded directly from
// the listing response (server-computed, scoped to these items — no extra
// per-navigation fetch) and updated optimistically on mutation.
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying
// the whole set, and every other present-key `.has()` reader is spared
// (measured in selectionPatterns.bench.test.ts).
const favoriteIds = new SvelteSet<string>();
const sharedIds = new SvelteSet<string>();
// Favorite / shared state now lives inline on every `FileItem` /
// `FolderItem` DTO (`is_favorite`, `is_shared` — see
// `frontend/src/lib/api/types.ts`). Populated by the backend
// listing SQL (per-row `EXISTS`) and single-item enrichment
// helper. The row-badge snippet and star gate read these
// fields directly; the toggle path mutates the item in place
// inside `orderedItems`. No more `SvelteSet` shadowing.
function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
@@ -203,17 +202,20 @@
}
async function toggleFavorite(kind: ItemType, id: string) {
const isFav = favoriteIds.has(id);
// Optimistic toggle, reverted on failure.
if (isFav) favoriteIds.delete(id);
else favoriteIds.add(id);
// Server-authoritative `is_favorite` lives on every
// `FileItem`/`FolderItem` DTO. Read → optimistic flip → server
// call → revert on failure. All state changes happen in-place
// on the item inside `orderedItems`; there is no shadow set.
const item = orderedItems.find((it) => it.id === id);
if (!item) return; // row scrolled off / navigated away mid-click
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try {
if (isFav) await removeFavorite(kind, id);
if (wasFav) await removeFavorite(kind, id);
else await addFavorite(kind, id);
} catch (e) {
errorToast(e);
if (isFav) favoriteIds.add(id);
else favoriteIds.delete(id);
item.is_favorite = wasFav;
}
}
@@ -295,7 +297,7 @@
// Reset paging state: previous folder's cursor is meaningless here,
// and mixing its rows with the new folder's would flash a wrong list.
pageCursor = undefined;
listing = { folders: [], files: [], favoriteIds: [], sharedIds: [] };
listing = { folders: [], files: [] };
orderedItems = [];
loading = true;
@@ -336,17 +338,13 @@
if (reset) {
listing = {
folders: page.folders,
files: page.files,
favoriteIds: [],
sharedIds: []
files: page.files
};
orderedItems = page.items;
} else {
listing = {
folders: [...listing.folders, ...page.folders],
files: [...listing.files, ...page.files],
favoriteIds: listing.favoriteIds,
sharedIds: listing.sharedIds
files: [...listing.files, ...page.files]
};
orderedItems = [...orderedItems, ...page.items];
}
@@ -1009,7 +1007,13 @@
/** Batch add the selection to favorites — single /api/favorites/batch call. */
async function batchFavorites() {
const items = selectionTargets().filter((it) => !favoriteIds.has(it.id));
// Build an id → item index so the "already favorite" filter is
// O(1) per selection member instead of an O(N·M) scan. Reused
// after success to flip `is_favorite` in place on each row.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, FileItem | FolderItem>();
for (const it of orderedItems) byId.set(it.id, it);
const items = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false));
if (items.length === 0) {
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
clearSelection();
@@ -1025,7 +1029,10 @@
})
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
for (const it of items) favoriteIds.add(it.id);
for (const it of items) {
const row = byId.get(it.id);
if (row) row.is_favorite = true;
}
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection();
} catch (e) {
@@ -1779,7 +1786,6 @@
<ResourceList
title={t('nav.files', 'Files')}
items={rlItems}
{favoriteIds}
emptyText={hiddenCount > 0
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
: t('files.empty_title', 'This folder is empty')}
@@ -1812,6 +1818,7 @@
}}
onopen={rlOnOpen}
onfavorite={rlOnFavorite}
onshared={(item) => openShare(isFile(item) ? 'file' : 'folder', item.id, item.name)}
oncontextmenu={rlOnContextMenu}
onselectionchange={(ids) => replaceSet(selected, ids)}
isDraggable={rlIsDraggable}
@@ -1993,19 +2000,6 @@
<span>{t('common.delete', 'Delete')}</span>
</button>
{/snippet}
{#snippet rowBadge(item)}
{#if favoriteIds.has(item.id)}
<span class="item-badge item-badge--fav" title={t('files.favorited', 'Favorite')}>
<Icon name="star" />
</span>
{/if}
{#if sharedIds.has(item.id)}
<span class="file-badge file-badge-shared" title={t('files.shared', 'Shared')}>
<Icon name="oxiexport" />
</span>
{/if}
{/snippet}
</ResourceList>
</div>
@@ -2024,7 +2018,16 @@
{/if}
{#if shareDialog.component}
{@const ShareDialog = shareDialog.component}
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} />
<ShareDialog
bind:open={shareOpen}
item={actionTarget}
onshared={(id) => {
// Optimistic in-place flip so the shared chip appears on
// the row without waiting for the next listing refetch.
const row = orderedItems.find((it) => it.id === id);
if (row) row.is_shared = true;
}}
/>
{/if}
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
@@ -2182,7 +2185,7 @@
}}
>
<Icon name="star" />
{favoriteIds.has(ctxTarget.id)
{orderedItems.find((it) => it.id === ctxTarget!.id)?.is_favorite
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
</button>
@@ -2214,27 +2217,21 @@
min-height: 100%;
}
.item-badge {
display: inline-flex;
align-items: center;
margin-left: var(--space-1);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.item-badge--fav {
color: var(--color-warning-text, var(--color-accent));
}
/* Scrim + menu must sit ABOVE `.page-sticky-header` (breadcrumb +
action bar, `z-index: var(--z-sticky)` = 100) so a click on the
breadcrumb or the action bar closes the menu. Otherwise the
sticky header covers the scrim and swallows the outside-click.
Aligns with ResourceList's built-in menu (`.rl-ctx-scrim` uses
`1000` / `.rl-ctx-menu` uses `1001`). */
.ctx-scrim {
position: fixed;
inset: 0;
z-index: 90;
z-index: 1000;
}
.ctx-menu {
position: fixed;
z-index: 100;
z-index: 1001;
min-width: 12rem;
padding: var(--space-1);
background: var(--color-bg-surface);
+30 -12
View File
@@ -16,6 +16,7 @@
import {
addFavorite,
dateBucket,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel
@@ -314,24 +315,36 @@
}
},
{
// "Add to favorites" — /recent doesn't track per-row favorite
// state (the star widget was replaced by the broom), so the
// entry always reads "Add" and the backend swallows duplicate
// adds idempotently. If the user wants to un-favorite, they
// navigate to /favorites and use the row menu there. Placed
// between Move and Rename to match the canonical context-menu
// order on `/files`.
key: 'favorite',
key: 'favorite_add',
label: t('files.favorite', 'Add favorite'),
icon: 'star',
run: (item) => {
void addFavorite(kindOf(item), item.id).catch(errorToast);
}
visible: (item) => !item.is_favorite,
run: toggleFavorite
},
{
key: 'favorite_remove',
label: t('files.unfavorite', 'Remove favorite'),
icon: 'star',
visible: (item) => item.is_favorite,
run: toggleFavorite
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
async function toggleFavorite(item: FileItem | FolderItem) {
const kind = kindOf(item);
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try {
if (wasFav) await removeFavorite(kind, item.id);
else await addFavorite(kind, item.id);
} catch (e) {
errorToast(e);
item.is_favorite = wasFav;
}
}
// ── Selection + batch ─────────────────────────────────────────────────────
// Selected items arrive via the batchActions snippet param —
// ResourceList already derives them (O(selection), not O(N)); a
@@ -372,6 +385,11 @@
hasMore={!!cursor}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
onshared={(item) => {
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
shareOpen = true;
}}
showOwner
showPath
dateLabel={t('files.col_opened', 'Opened')}
@@ -436,7 +454,7 @@
buttons at the row's action-cell.
-->
<button
class="btn-action"
class="btn-action btn-action--hover"
data-testid={`recent-remove-btn-${item.id}`}
title={t('recent.remove_item', 'Remove from recent')}
aria-label={t('recent.remove_item', 'Remove from recent')}
@@ -8,6 +8,7 @@
import {
addFavorite,
dateBucket,
removeFavorite,
resolveOwnerName,
typeLabel
} from '$lib/api/endpoints/favorites';
@@ -161,11 +162,23 @@
// folders show "Download as ZIP" (server-side archive). Two entries
// with `visible?` predicates rather than one label that changes,
// so the `.icon` reads correctly per kind too.
//
// The favorite entry stays "Add to favorites" only: un-favoriting
// from here would need per-row favorite-state tracking which this
// view doesn't carry — users toggle off from /favorites' own row
// menu. Backend swallows duplicate `addFavorite` calls idempotently.
async function toggleFavorite(item: FileItem | FolderItem) {
const kind = isFile(item) ? 'file' : 'folder';
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try {
if (wasFav) await removeFavorite(kind, item.id);
else await addFavorite(kind, item.id);
} catch (e) {
errorToast(e);
item.is_favorite = wasFav;
}
}
// Favorite entry mirrors the star toggle in the row action cell —
// same wording, same behavior. Context-menu label flips based on
// `item.is_favorite` so keyboard users get the same state read as
// the button-hovering ones.
const contextActions: ContextAction[] = [
{
key: 'download',
@@ -182,12 +195,18 @@
run: downloadItem
},
{
key: 'favorite',
key: 'favorite_add',
label: t('files.favorite', 'Add favorite'),
icon: 'star',
run: (item) => {
void addFavorite(isFile(item) ? 'file' : 'folder', item.id).catch(errorToast);
}
visible: (item) => !item.is_favorite,
run: toggleFavorite
},
{
key: 'favorite_remove',
label: t('files.unfavorite', 'Remove favorite'),
icon: 'star',
visible: (item) => item.is_favorite,
run: toggleFavorite
}
];
@@ -235,6 +254,7 @@
bind:reversed
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);