From 981328faffcb2d80f68ca8d169626bf706c93f0b Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 20 May 2026 10:42:43 +0200 Subject: [PATCH] web(review): expandable Review group in sidebar, tabs become subitems Mirrors the Tags affordance: chevron-only toggle, no /review landing entry, navigation only via subitems (cause buckets + Stacks + Cross-folder linked as /review?tab=). Cause list reuses the review-groups query so empty buckets stay hidden. The /review toolbar drops the pill row and shows the active tab as a breadcrumb segment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/components/layout/LeftSidebar.svelte | 117 +++++++++++++++--- web/src/routes/review/+page.svelte | 44 +------ 2 files changed, 104 insertions(+), 57 deletions(-) diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 29e8bc8..e5ee0af 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -32,6 +32,11 @@ listDuplicateGroups, type DuplicateGroup } from '$lib/services/adapters/duplicates'; + import { + listReviewGroups, + type CauseKey, + type ReviewGroup + } from '$lib/services/adapters/review'; import { filters, setFolderPath, @@ -131,13 +136,13 @@ // One query per badge. Admins with no BasePath skip these // (enabled:false via `wantScoped`) and the configQuery numbers are // used directly — same chrome as before that fix, no extra - // round-trips. - const reviewCountQuery = scopedCountQuery('review', 'review:true'); + // round-trips. Review has no aggregate badge (it's a pure toggle in + // the sidebar now, like Tags), so it doesn't appear here. const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true'); const archivedCountQuery = scopedCountQuery('archived', 'archived:true'); function bucketCount( - key: 'review' | 'hidden' | 'archived', + key: 'hidden' | 'archived', query: { data: number | undefined; isPending: boolean } ): number | undefined { if (wantScoped) { @@ -227,10 +232,9 @@ : (scopedRootCountQuery.data?.[''] ?? 0) ); - // Review / Hidden / Archive nav entries use these derived values - // rather than peeking at configQuery directly so the scoped path - // is invisible to the manageViews[] declarations. - const reviewBadge = $derived(bucketCount('review', reviewCountQuery)); + // Hidden / Archive nav entries use these derived values rather than + // peeking at configQuery directly so the scoped path is invisible to + // the manageViews[] declarations. const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery)); const archivedBadge = $derived(bucketCount('archived', archivedCountQuery)); @@ -316,6 +320,51 @@ if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0'); } + // Review-submenu collapse state. Mirrors `tagsExpanded` so the Review + // row in Manage can expose the same set of tabs the /review page shows + // (cause groups + duplicates panels). Defaults to collapsed. + const REVIEW_OPEN_KEY = 'mule_review_expanded'; + let reviewExpanded = $state(loadReviewExpanded()); + function loadReviewExpanded(): boolean { + if (!browser) return false; + return localStorage.getItem(REVIEW_OPEN_KEY) === '1'; + } + function toggleReview() { + reviewExpanded = !reviewExpanded; + if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0'); + } + + // Cause-tab list is dynamic (only buckets with hits show up on /review), + // so the sidebar mirrors that by reusing the same query. Gated on + // `reviewExpanded` to avoid paying the /photos round-trip for users who + // never expand the section; the queryKey is shared with the /review page + // so visiting that route warms the cache for free. + const reviewGroupsQuery = createQuery(() => ({ + queryKey: ['review-groups'], + queryFn: listReviewGroups, + enabled: isAuthenticated() && reviewExpanded, + staleTime: 30_000 + })); + + type ReviewTabId = CauseKey | 'stacks' | 'cross-folder'; + // Stacks + Cross-folder are always present on the /review tab strip + // regardless of count (cross-folder's scan is lazy from its own panel), + // so they tail every cause-tab list the sidebar renders. + const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([ + ...(reviewGroupsQuery.data ?? []).map((g) => ({ + id: g.cause as ReviewTabId, + label: g.meta.title + })), + { id: 'stacks', label: 'Stacks' }, + { id: 'cross-folder', label: 'Cross-folder' } + ]); + + const reviewActive = $derived(page.url.pathname === '/review'); + function isReviewTabActive(id: ReviewTabId): boolean { + if (!reviewActive) return false; + return page.url.searchParams.get('tab') === id; + } + const TAG_CATEGORY_LABELS: Record = { labels: 'Labels', keywords: 'Keywords', @@ -491,19 +540,11 @@ // section/route ViewItem shape. ]; + // Review is rendered separately below as a pure expandable toggle + // (mirroring Tags — no /review landing entry from the sidebar, + // navigation only via subitems). This list carries the flat Manage + // entries that follow it. const manageViews: ViewItem[] = [ - { - kind: 'route', - href: '/review', - label: 'Review', - getCount: () => { - if (reviewBadge === undefined) return undefined; - // The two duplicates queries are library-wide; only admins - // without a BasePath roll them into the Review badge. - if (wantScoped) return reviewBadge; - return reviewBadge + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0); - } - }, { kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge }, { kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge } ]; @@ -848,6 +889,44 @@ Manage + + + {#if reviewExpanded} + {#each reviewTabs as t (t.id)} + {@const active = isReviewTabActive(t.id)} + + {t.label} + + {/each} + {/if} {#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)} {@render viewRow(v)} {/each} diff --git a/web/src/routes/review/+page.svelte b/web/src/routes/review/+page.svelte index 88c97b9..cc209bd 100644 --- a/web/src/routes/review/+page.svelte +++ b/web/src/routes/review/+page.svelte @@ -15,7 +15,6 @@ approve). The previous section is restored on unmount. --> Review - {#if tabs.length > 0} -
- {#each tabs as t (t.id)} - - {/each} -
+ {#if activeTabSpec} + {activeTabSpec.label} + {#if activeTabSpec.count !== undefined} + {activeTabSpec.count} + {/if} {/if} {#snippet trailing()}