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=<id>). 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) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,11 @@
|
|||||||
listDuplicateGroups,
|
listDuplicateGroups,
|
||||||
type DuplicateGroup
|
type DuplicateGroup
|
||||||
} from '$lib/services/adapters/duplicates';
|
} from '$lib/services/adapters/duplicates';
|
||||||
|
import {
|
||||||
|
listReviewGroups,
|
||||||
|
type CauseKey,
|
||||||
|
type ReviewGroup
|
||||||
|
} from '$lib/services/adapters/review';
|
||||||
import {
|
import {
|
||||||
filters,
|
filters,
|
||||||
setFolderPath,
|
setFolderPath,
|
||||||
@@ -131,13 +136,13 @@
|
|||||||
// One query per badge. Admins with no BasePath skip these
|
// One query per badge. Admins with no BasePath skip these
|
||||||
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||||
// used directly — same chrome as before that fix, no extra
|
// used directly — same chrome as before that fix, no extra
|
||||||
// round-trips.
|
// round-trips. Review has no aggregate badge (it's a pure toggle in
|
||||||
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
// the sidebar now, like Tags), so it doesn't appear here.
|
||||||
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
||||||
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
||||||
|
|
||||||
function bucketCount(
|
function bucketCount(
|
||||||
key: 'review' | 'hidden' | 'archived',
|
key: 'hidden' | 'archived',
|
||||||
query: { data: number | undefined; isPending: boolean }
|
query: { data: number | undefined; isPending: boolean }
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
if (wantScoped) {
|
if (wantScoped) {
|
||||||
@@ -227,10 +232,9 @@
|
|||||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Review / Hidden / Archive nav entries use these derived values
|
// Hidden / Archive nav entries use these derived values rather than
|
||||||
// rather than peeking at configQuery directly so the scoped path
|
// peeking at configQuery directly so the scoped path is invisible to
|
||||||
// is invisible to the manageViews[] declarations.
|
// the manageViews[] declarations.
|
||||||
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
|
||||||
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
||||||
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
||||||
|
|
||||||
@@ -316,6 +320,51 @@
|
|||||||
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
|
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<ReviewGroup[]>(() => ({
|
||||||
|
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<TagCategory, string> = {
|
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||||
labels: 'Labels',
|
labels: 'Labels',
|
||||||
keywords: 'Keywords',
|
keywords: 'Keywords',
|
||||||
@@ -491,19 +540,11 @@
|
|||||||
// section/route ViewItem shape.
|
// 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[] = [
|
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: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
|
||||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
||||||
];
|
];
|
||||||
@@ -848,6 +889,44 @@
|
|||||||
Manage
|
Manage
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
|
Review expandable. Mirrors the Tags affordance — pure toggle
|
||||||
|
with no landing page; the only way into a tab is to expand and
|
||||||
|
pick a subitem. Cause buckets are dynamic (only buckets with
|
||||||
|
hits show up); Stacks/Cross-folder are always present.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
style="padding-left: 4px;"
|
||||||
|
onclick={toggleReview}
|
||||||
|
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
|
||||||
|
aria-expanded={reviewExpanded}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{reviewExpanded ? '▾' : '▸'}
|
||||||
|
</span>
|
||||||
|
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||||
|
<span class="truncate">Review</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if reviewExpanded}
|
||||||
|
{#each reviewTabs as t (t.id)}
|
||||||
|
{@const active = isReviewTabActive(t.id)}
|
||||||
|
<a
|
||||||
|
href={`/review?tab=${t.id}`}
|
||||||
|
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
style="padding-left: 36px;"
|
||||||
|
>
|
||||||
|
<span class="truncate">{t.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||||
{@render viewRow(v)}
|
{@render viewRow(v)}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
approve). The previous section is restored on unmount.
|
approve). The previous section is restored on unmount.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
@@ -138,49 +137,18 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
||||||
|
const activeTabSpec = $derived(tabs.find((t) => t.id === activeTab));
|
||||||
function setTab(id: Tab) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
// First cause tab (if any) is the default — same convention as
|
|
||||||
// the old /review behaviour, so back-from-cross-folder lands on
|
|
||||||
// the user's review queue rather than the empty Stacks panel.
|
|
||||||
const defaultId = tabs[0]?.id;
|
|
||||||
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
|
|
||||||
void goto(`/review${params.size ? '?' + params : ''}`, {
|
|
||||||
keepFocus: true,
|
|
||||||
noScroll: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
Review
|
Review
|
||||||
</span>
|
</span>
|
||||||
{#if tabs.length > 0}
|
{#if activeTabSpec}
|
||||||
<div class="flex items-center gap-1">
|
<span class="text-[11px] font-medium">{activeTabSpec.label}</span>
|
||||||
{#each tabs as t (t.id)}
|
{#if activeTabSpec.count !== undefined}
|
||||||
<button
|
<span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
|
||||||
type="button"
|
{/if}
|
||||||
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
|
||||||
onclick={() => setTab(t.id)}
|
|
||||||
>
|
|
||||||
<span>{t.label}</span>
|
|
||||||
{#if t.count !== undefined}
|
|
||||||
<span
|
|
||||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
|
||||||
t.id
|
|
||||||
? 'bg-primary/15 text-primary'
|
|
||||||
: 'bg-secondary text-muted-foreground'}"
|
|
||||||
>
|
|
||||||
{t.count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user