Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list
feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user