Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
2 changed files with 104 additions and 57 deletions
Showing only changes of commit 981328faff - Show all commits

View File

@@ -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<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> = {
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
</span>
</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}`)}
{@render viewRow(v)}
{/each}

View File

@@ -15,7 +15,6 @@
approve). The previous section is restored on unmount.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
import {
@@ -138,49 +137,18 @@
});
const activeGroup = $derived(groups.find((g) => g.cause === 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
});
}
const activeTabSpec = $derived(tabs.find((t) => t.id === activeTab));
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Review
</span>
{#if tabs.length > 0}
<div class="flex items-center gap-1">
{#each tabs as t (t.id)}
<button
type="button"
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 activeTabSpec}
<span class="text-[11px] font-medium">{activeTabSpec.label}</span>
{#if activeTabSpec.count !== undefined}
<span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
{/if}
{/if}
{#snippet trailing()}
<div