The library stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies dropped at index time live under
- the Cross-folder tab.
+ the Duplicates tab.
Byte-identical files the indexer dropped at index time. Found by scanning the
@@ -139,7 +139,7 @@
: 'unknown error'}
/>
{:else if crossCount === 0}
-
+
{#snippet descriptionSnippet()}
{#if crossQuery.data}
diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte
index 18b7e9a..092a789 100644
--- a/web/src/lib/components/layout/LeftSidebar.svelte
+++ b/web/src/lib/components/layout/LeftSidebar.svelte
@@ -18,12 +18,14 @@
listFolderCounts,
listFolders,
listHeaps,
+ listPhotosWithNotes,
logout,
renameFolder,
renameHeap,
scanCrossFolderDuplicates,
triggerDownload,
type CrossFolderScanResult,
+ type PhotoWithNote,
type PpAlbum,
type PpClientConfig,
type PpFolder
@@ -133,16 +135,15 @@
}));
}
- // One query per badge. Admins with no BasePath skip these
+ // One query per badge. Admins with no BasePath skip this
// (enabled:false via `wantScoped`) and the configQuery numbers are
// used directly — same chrome as before that fix, no extra
- // 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');
+ // round-trip. Review and Hidden have no aggregate badge (pure
+ // toggles in the sidebar now, like Tags), so they don't appear here.
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
function bucketCount(
- key: 'hidden' | 'archived',
+ key: 'archived',
query: { data: number | undefined; isPending: boolean }
): number | undefined {
if (wantScoped) {
@@ -175,6 +176,17 @@
staleTime: 5 * 60_000
}));
+ // Notes-view badge. Cheap (one list round-trip, no fan-out) so we
+ // fetch eagerly — sharing the queryKey with /notes means the page hits
+ // the warm cache, and the ['photos', …] prefix lets existing mutation
+ // invalidations keep both in sync.
+ const notesQuery = createQuery(() => ({
+ queryKey: ['photos', 'with-notes'],
+ queryFn: listPhotosWithNotes,
+ enabled: isAuthenticated(),
+ staleTime: 60_000
+ }));
+
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
@@ -232,10 +244,9 @@
: (scopedRootCountQuery.data?.[''] ?? 0)
);
- // 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));
+ // Archive nav entry uses this derived value rather than peeking at
+ // configQuery directly so the scoped path is invisible to the
+ // manageViews[] declarations.
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
const createMut = createMutation(() => ({
@@ -347,16 +358,18 @@
}));
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.
+ // Stacks + Duplicates are always present on the /review tab strip
+ // regardless of count (the cross-folder scan is lazy from its own
+ // panel), so they tail every cause-tab list the sidebar renders.
+ // The 'cross-folder' tab id is kept internal/URL-stable; the label
+ // the user sees is "Duplicates".
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' }
+ { id: 'cross-folder', label: 'Duplicates' }
]);
const reviewActive = $derived(page.url.pathname === '/review');
@@ -537,9 +550,14 @@
// Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat
- // section/route ViewItem shape.
+ // section/route ViewItem shape. Notes lives under that expandable
+ // alongside the tag categories.
];
+ function isNotesActive(): boolean {
+ return page.url.pathname === '/notes';
+ }
+
// Review is rendered separately below as a pure expandable toggle
// (mirroring Tags — no /review landing entry from the sidebar,
// navigation only via subitems, with Hidden tucked in alongside the
@@ -862,6 +880,34 @@
{#if tagsExpanded}
+
+ {@const notesActive = isNotesActive()}
+ {@const notesCount = notesQuery.data?.length}
+
+ Notes
+ {#if notesCount !== undefined}
+
+ {notesCount}
+
+ {/if}
+
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
navigateTo('hidden')}
>
Hidden
- {#if hiddenBadge !== undefined}
-
- {hiddenBadge}
-
- {/if}
{/if}
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
diff --git a/web/src/lib/components/review/CauseGroupCard.svelte b/web/src/lib/components/review/CauseGroupCard.svelte
index 3b9e338..71f6009 100644
--- a/web/src/lib/components/review/CauseGroupCard.svelte
+++ b/web/src/lib/components/review/CauseGroupCard.svelte
@@ -1,171 +1,29 @@
-
-
-