web(review): switch cause tabs to PhotoGrid, add date-from-path suggestion

- CauseGroupCard now wraps PhotoGrid so selection, keyboard nav, and
  preview flow through the standard timeline plumbing. Per-tile hover
  Approve/Archive and the group-wide Approve all are gone — the bottom
  BulkActionBar's review-section Keep/Archive handle single + bulk.
- Low Resolution tab opts into a new PhotoTile dimensionBadge prop so
  WxH stays visible on each tile.
- New suggestDateFromPath util parses YYYY-MM-DD from filename or
  folder path. RightSidebar surfaces it as an amber Apply row above
  the Taken-at input whenever the photo lacks a trusted TakenAt.
- BulkActionBar gains a "Accept date & Keep" button (review section
  only) that patches each selected photo's TakenAt from its path
  suggestion when available, then approves.
- Drop the Same folder / Same camera / Same year strips and the
  RelatedStrip component from the metadata sidebar.

Also bundles in-progress Notes route + tile components and small
tweaks to LeftSidebar, DuplicatesView, CrossFolderGroupCard, and
photoprism.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 11:20:02 +02:00
parent 55c870c155
commit d1ddc48f81
15 changed files with 607 additions and 512 deletions

View File

@@ -179,7 +179,7 @@
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Cross-folder duplicate · ${group.files.length} copies`}
aria-label={`Duplicate group · ${group.files.length} copies`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"

View File

@@ -65,7 +65,7 @@
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Cross-folder scan failed'
: 'Duplicates scan failed'
);
}
});
@@ -91,7 +91,7 @@
<p>
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.
</p>
{/snippet}
</EmptyState>
@@ -105,9 +105,9 @@
</div>
{/if}
<!-- Cross-folder tab ----------------------------------------------- -->
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files the indexer dropped at index time. Found by scanning the
@@ -139,7 +139,7 @@
: 'unknown error'}
/>
{:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No cross-folder duplicates found">
<EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">

View File

@@ -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<PhotoWithNote[]>(() => ({
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 @@
</span>
</button>
{#if tagsExpanded}
<!--
Notes lives alongside the tag categories — same indent and row
chrome — but routes to /notes rather than /tags/*. Tucked at
the top of the expandable so it's the first thing the user
sees when opening Tags. Count badge renders once the shared
['photos', 'with-notes'] query has resolved.
-->
{@const notesActive = isNotesActive()}
{@const notesCount = notesQuery.data?.length}
<a
href="/notes"
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={notesActive}
class:text-primary-foreground={notesActive}
class:hover:bg-primary={notesActive}
style="padding-left: 36px;"
>
<span class="truncate">Notes</span>
{#if notesCount !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {notesActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{notesCount}
</span>
{/if}
</a>
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
<a
@@ -943,15 +989,6 @@
onclick={() => navigateTo('hidden')}
>
<span class="truncate">Hidden</span>
{#if hiddenBadge !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {hiddenActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{hiddenBadge}
</span>
{/if}
</button>
{/if}
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}

View File

@@ -1,171 +1,29 @@
<!--
One review-queue cause group rendered as a card. Mirrors StackGroupCard's
chrome (focusable container, ResizeObserver column tracking, keyboard
nav) but the per-tile semantics differ:
One review-queue cause group, rendered as a thin wrapper around
PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
main, BulkActionBar below, RightSidebar/BulkMetadataSidebar on the
right) — this component just adds the per-group header + suggestion
row above the grid, then delegates tiles to PhotoGrid so selection,
keyboard nav, and previews work the same way they do everywhere else.
- Click a tile → emits `select` so the parent can open the metadata
sidebar. Shift-click toggles bulk-select instead of opening.
- Header has `Approve all` + `Archive all` for the whole group.
- Suggestion line above the grid spotlights the likely-correct bulk
action with an inline button (per the plan).
- Keyboard: arrows move the focused tile; `S` approves the focused
tile, `A` archives it, `Enter` opens detail, `Esc` blurs.
The Low Resolution tab opts in to PhotoTile's dimension badge so the
user can spot under-2-MP photos without opening each tile.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
approvePhoto,
batchArchive
} from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import {
deriveCauses,
type ReviewGroup
} from '$lib/services/adapters/review';
import CauseBadges from './CauseBadges.svelte';
import { batchArchive } from '$lib/services/photoprism';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import { type ReviewGroup } from '$lib/services/adapters/review';
interface Props {
group: ReviewGroup;
autoFocus?: boolean;
/** Parent emits when the user picks a tile to inspect (Enter or
* plain click). Parent owns the RightSidebar mount. */
onSelect?: (photo: PpPhoto) => void;
}
let { group, autoFocus = false, onSelect }: Props = $props();
let { group }: Props = $props();
const qc = useQueryClient();
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let focusedIdx = $state(0);
let cols = $state(1);
let busy = $state(false);
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
});
// Match StackGroupCard's column-tracking trick so arrow Up/Down jump
// by row width.
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
});
function moveFocus(delta: number) {
if (group.photos.length === 0) return;
focusedIdx = Math.min(
Math.max(0, focusedIdx + delta),
group.photos.length - 1
);
}
function dims(p: PpPhoto): string {
const f = primaryFile(p);
const w = p.Width ?? f.Width;
const h = p.Height ?? f.Height;
if (!w || !h) return '';
return `${w}×${h}`;
}
function thumb(p: PpPhoto): string {
// list endpoint puts the hash on the photo itself; primaryFile is
// the fallback for detail responses.
const h = p.Hash ?? primaryFile(p).Hash;
return h ? thumbUrl(h, 'tile_500') : '';
}
async function approveOne(p: PpPhoto) {
if (busy) return;
busy = true;
try {
await approvePhoto(p.UID);
toast.success('Approved');
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Approve failed');
} finally {
busy = false;
}
}
async function archiveOne(p: PpPhoto) {
if (busy) return;
busy = true;
try {
await batchArchive([p.UID]);
toast.success('Archived');
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
} finally {
busy = false;
}
}
async function approveAll() {
if (busy || group.photos.length === 0) return;
if (!confirm(`Approve all ${group.photos.length} photos in "${group.meta.title}"?`)) return;
busy = true;
const total = group.photos.length;
let done = 0;
const toastId = toast.loading(`Approving 0 / ${total}…`);
try {
// PhotoPrism has no batch-approve, so fan out one-at-a-time.
// A small concurrency cap keeps the server responsive without
// stalling for very large groups.
const QUEUE = 4;
const uids = group.photos.map((p) => p.UID);
let idx = 0;
async function worker() {
while (idx < uids.length) {
const my = idx++;
try {
await approvePhoto(uids[my]);
} catch {
// Carry on — partial success is better than abort.
}
done++;
toast.loading(`Approving ${done} / ${total}…`, { id: toastId });
}
}
await Promise.all(Array.from({ length: Math.min(QUEUE, uids.length) }, worker));
toast.success(`Approved ${done} / ${total}`, { id: toastId });
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Approve all failed', {
id: toastId
});
} finally {
busy = false;
}
}
async function archiveAll() {
if (busy || group.photos.length === 0) return;
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
@@ -183,64 +41,14 @@
}
}
function onKeydown(e: KeyboardEvent) {
if (busy) return;
const p = group.photos[focusedIdx];
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
moveFocus(-1);
return;
case 'ArrowRight':
e.preventDefault();
moveFocus(1);
return;
case 'ArrowUp':
e.preventDefault();
moveFocus(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveFocus(cols);
return;
case 'Enter':
e.preventDefault();
if (p) onSelect?.(p);
return;
case 's':
case 'S':
e.preventDefault();
if (p) void approveOne(p);
return;
case 'a':
case 'A':
e.preventDefault();
if (p) void archiveOne(p);
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
}
}
function runSuggestion() {
if (group.meta.suggestedAction === 'approve') void approveAll();
else if (group.meta.suggestedAction === 'archive') void archiveAll();
// 'manual' suggestion has no button — the suggestion line is text-only.
// Only 'archive' suggestions are reachable through this button now —
// the page's BulkActionBar handles per-photo / multi-select Keep.
if (group.meta.suggestedAction === 'archive') void archiveAll();
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Cause group ${group.meta.title} with ${group.photos.length} photos`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"
>
<div class="space-y-2">
<header class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-foreground">
@@ -248,128 +56,30 @@
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.photos.length === 0}
onclick={approveAll}
title="Approve every photo in this group"
>
Approve all
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.photos.length === 0}
onclick={archiveAll}
title="Archive every photo in this group"
>
Archive all
</button>
</div>
</header>
<!-- Suggestion line — sits above the grid, surfaces the likely-correct
bulk action with an inline trigger. 'manual' causes get no
inline button; the user has to use the header bulk bar instead. -->
<!-- Suggestion line — surfaces the likely-correct bulk action. Only
'archive' renders a quick-button; 'manual' is text-only and
'approve' is unused today. Per-photo Keep / Archive comes from the
page's BulkActionBar (review section) once the user selects. -->
<div
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
>
<span>{group.meta.suggestion}</span>
{#if group.meta.suggestedAction !== 'manual'}
{#if group.meta.suggestedAction === 'archive'}
<button
type="button"
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={runSuggestion}
>
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'}
Archive all
</button>
{/if}
</div>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.photos as photo, i (photo.UID)}
{@const causes = deriveCauses(photo)}
{@const isFocused = i === focusedIdx}
<!-- Tile is a <div> with role=button so the inner per-tile
action buttons aren't nested inside another <button> (which
is invalid HTML and trips a11y linters). -->
<div
role="button"
tabindex="-1"
aria-label={`${photo.FileName ?? photo.Name ?? photo.UID} — press Enter to inspect`}
onclick={() => onSelect?.(photo)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect?.(photo);
}
}}
class:ring-2={isFocused}
class:ring-blue-500={isFocused}
class:ring-offset-2={isFocused}
class:ring-offset-background={isFocused}
class="group relative flex cursor-pointer flex-col overflow-hidden rounded-md border border-border bg-secondary text-left transition-shadow"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumb(photo)}
alt={photo.FileName ?? photo.Name ?? ''}
loading="lazy"
class="h-full w-full object-cover"
/>
{#if dims(photo)}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
>
{dims(photo)}
</span>
{/if}
<!-- Per-tile hover actions: stop propagation so a click
here doesn't also open the sidebar. -->
<div
class="absolute bottom-1.5 right-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
type="button"
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
onclick={(e) => {
e.stopPropagation();
void approveOne(photo);
}}
title="Approve (S)"
>
Approve
</button>
<button
type="button"
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
onclick={(e) => {
e.stopPropagation();
void archiveOne(photo);
}}
title="Archive (A)"
>
Archive
</button>
</div>
</div>
<div class="space-y-1 px-2 py-1.5">
<CauseBadges {causes} />
<div
class="truncate text-[10px] leading-tight text-muted-foreground"
title={photo.FileName ?? photo.Name ?? ''}
>
{photo.FileName ?? photo.Name ?? ''}
</div>
</div>
</div>
{/each}
</div>
<PhotoGrid
photos={group.photos}
dimensionBadge={group.cause === 'low_resolution'}
/>
</div>

View File

@@ -1,109 +0,0 @@
<!--
One horizontal strip of related-photo thumbnails for the metadata
sidebar. Used three times on the /review sidebar (folder / camera /
year). Self-fetches via the PhotoPrism DSL so each strip stays
independent.
The header is clickable: it navigates back to the timeline with the
same DSL applied as a `?q=` param, so the user can drill into the
full result set if they want to. Strips with zero hits collapse
silently — no header, no whitespace.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { listPhotos } from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
import { Loader2 } from 'lucide-svelte';
interface Props {
title: string;
/** PhotoPrism DSL fragment, e.g. `path:"2024/lyon"` or `year:2024`. */
q: string;
/** Cap on tiles rendered in the strip. Defaults to a small set
* that fits one row in a typical sidebar width. */
limit?: number;
/** UID to filter out — usually the photo whose sidebar this strip
* is on, so the user doesn't see itself in its own "related"
* list. */
excludeUid?: string;
}
let { title, q, limit = 12, excludeUid }: Props = $props();
const stripQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['related', q, limit],
queryFn: () => listPhotos({ q, count: limit + 1, order: 'newest' }),
// Strips are cheap to refetch; the data behind them changes
// rarely, but a stale-while-revalidate window keeps the sidebar
// snappy when the user clicks through similar photos.
staleTime: 60_000,
enabled: q.length > 0
}));
const photos = $derived(
(stripQuery.data ?? []).filter((p) => p.UID !== excludeUid).slice(0, limit)
);
function openTimeline() {
// Same `?q=` param the timeline already accepts (see filters store)
// — clicking the strip header pivots the main timeline into the
// same filtered scope so the user can browse the full set.
const params = new URLSearchParams({ q });
void goto(`/?${params.toString()}`, { keepFocus: true });
}
function openOne(uid: string) {
// Focus the picked photo so the sidebar re-renders against it.
// Useful for the "decide these together" workflow without leaving
// the review page.
setFocused(uid);
}
</script>
{#if stripQuery.isPending}
<div
class="flex items-center gap-1.5 text-[10px] text-muted-foreground/70"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2 class="h-2.5 w-2.5 animate-spin" aria-hidden="true" />
<span>Loading {title.toLowerCase()}</span>
</div>
{:else if stripQuery.isError}
<!-- Errors shouldn't break the sidebar; just hide the strip. -->
{null}
{:else if photos.length > 0}
<div class="space-y-1">
<button
type="button"
class="flex w-full items-baseline justify-between text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground"
onclick={openTimeline}
title={`Open the timeline filtered by ${q}`}
>
<span>{title}</span>
<span class="text-muted-foreground/70">({photos.length}+)</span>
</button>
<div class="flex gap-1 overflow-x-auto">
{#each photos as p (p.UID)}
<button
type="button"
class="h-12 w-12 shrink-0 overflow-hidden rounded border border-border bg-secondary hover:border-primary"
onclick={() => openOne(p.UID)}
title={p.FileName ?? p.Name ?? p.UID}
>
{#if p.Hash}
<img
src={thumbUrl(p.Hash, 'tile_100')}
alt=""
loading="lazy"
class="h-full w-full object-cover"
/>
{/if}
</button>
{/each}
</div>
</div>
{/if}

View File

@@ -39,15 +39,12 @@
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import RelatedStrip from './RelatedStrip.svelte';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props {
/** When true, append related-photo strips (folder/camera/year) below
* Keywords. Used by the /review route; left off on the timeline. */
showRelated?: boolean;
photo: PpPhoto;
}
let { photo, showRelated = false }: Props = $props();
let { photo }: Props = $props();
const qc = useQueryClient();
@@ -131,6 +128,25 @@
commit({ Caption: caption, CaptionSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Surfaced only when the photo's stored date is
// missing or untrusted — same heuristic the review adapter uses to
// bucket photos into `stripped_exif`. Tying it to the heuristic rather
// than a specific tab means the suggestion shows up wherever the user
// lands on a date-less photo (timeline drill-in, archive, etc.).
const needsDate = $derived(
!photo.TakenSrc || photo.TakenSrc === 'name' || !photo.TakenAt
);
const dateSuggestion = $derived(
suggestDateFromPath({ fileName: photo.FileName, path: photo.Path })
);
const showDateSuggestion = $derived(
needsDate && !!dateSuggestion && dateSuggestion !== takenAt
);
function applyDateSuggestion() {
if (!dateSuggestion) return;
takenAt = dateSuggestion;
commitTakenAt();
}
function commitTakenAt() {
if (!takenAt) return;
if (!isValidISODate(takenAt)) {
@@ -310,6 +326,29 @@
{/if}
</div>
<!-- Date suggestion derived from the file/folder path. Shown only
when the photo's stored date is missing or untrusted (the
`stripped_exif` heuristic). Amber styling marks it as
unconfirmed — clicking Apply commits as a manual TakenAt
edit. -->
{#if showDateSuggestion}
<div
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
>
<Folder class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">
Suggested from path: <span class="font-medium">{dateSuggestion}</span>
</span>
<button
type="button"
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
onclick={applyDateSuggestion}
>
Apply
</button>
</div>
{/if}
<!-- Taken at -->
<div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -373,25 +412,14 @@
</div>
</dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
mule-image's nomenclature). -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<!-- Tags — score, color label, keywords, and auto-labels grouped under
one collapsible section. Score + color are stored on the mule-
sidecar (PhotoPrism's PUT can't persist them); keywords live on
Details; auto-labels come from PhotoPrism's TF classifier and are
read-only. Open by default since these are the culling marks the
user reaches for first. -->
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
under one collapsible section. Note (PhotoPrism's Caption field,
labelled here to match mule-image's nomenclature) sits at the top
of the group since it's the most-edited per-photo field. Score +
color are stored on the mule-sidecar (PhotoPrism's PUT can't
persist them); keywords live on Details; auto-labels come from
PhotoPrism's TF classifier and are read-only. Open by default
since these are the culling marks the user reaches for first. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)}
@@ -405,6 +433,17 @@
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
@@ -504,35 +543,6 @@
</div>
</details>
<!-- Related strips (only the /review route opts in). The three
scopes match the three decisions the user usually makes here:
"did all these come from the same shoot?" (folder), "same
camera, EXIF-stripped together?" (camera), "right year?"
(year). Strips with zero hits collapse silently. -->
{#if showRelated}
<div class="space-y-2 border-t border-border pt-2">
<RelatedStrip
title="Same folder"
q={`path:"${photo.Path ?? ''}"`}
excludeUid={photo.UID}
/>
{#if photo.CameraID && photo.CameraID !== 1}
<RelatedStrip
title="Same camera"
q={`camera:${photo.CameraID}`}
excludeUid={photo.UID}
/>
{/if}
{#if photo.Year}
<RelatedStrip
title="Same year"
q={`year:${photo.Year}`}
excludeUid={photo.UID}
/>
{/if}
</div>
{/if}
<!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr

View File

@@ -7,11 +7,15 @@
batchArchive,
batchDelete,
batchRestore,
buildTakenAtPatch,
listHeaps,
removeFromHeap,
updatePhoto,
type PpAlbum
} from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import type { PpPhoto } from '$lib/types/photoprism';
import {
clearBulkToFirst,
clearSelection,
@@ -46,6 +50,43 @@
return [];
}
/** Walk every cache that might hold this UID's metadata: the timeline
* list (flat or infinite), the review-groups bucket, and the
* per-photo detail. Mirrors `gridKeyNav`'s `cachedPhoto` so the date-
* suggestion lookup behaves consistently with the rest of the
* selection plumbing. */
function cachedPhoto(uid: string): PpPhoto | undefined {
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === uid);
if (hit) return hit;
}
}
const review = qc.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
if (review) {
for (const group of review) {
const hit = group.photos?.find((p) => p.UID === uid);
if (hit) return hit;
}
}
return qc.getQueryData<PpPhoto>(['photo', uid]);
}
function suggestionFor(p: PpPhoto): string | null {
const needs = !p.TakenSrc || p.TakenSrc === 'name' || !p.TakenAt;
if (!needs) return null;
return suggestDateFromPath({ fileName: p.FileName, path: p.Path });
}
const targetCount = $derived(
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
);
@@ -55,6 +96,24 @@
// heap-adding / restoring. The S keybinding is rerouted to approve
// from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// "Accept date & Keep" surfaces only on review section, and only when
// at least one targeted photo has a derivable date suggestion that it
// would actually benefit from (no trusted TakenAt). Reads strictly from
// query caches — uids missing from cache count as "no suggestion".
const hasAnySuggestion = $derived.by(() => {
if (!isReview) return false;
const ids =
selection.ids.size > 0
? Array.from(selection.ids)
: selection.focused
? [selection.focused]
: [];
for (const id of ids) {
const p = cachedPhoto(id);
if (p && suggestionFor(p)) return true;
}
return false;
});
// Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived;
@@ -99,6 +158,34 @@
});
}
async function onAcceptDateAndKeep() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
// For each id: if there's a path-derived date and the photo lacks a
// trusted TakenAt, apply the date patch first; then approve. UIDs
// without a usable suggestion just get approved. Errors are tallied
// per-id rather than aborting the loop.
const { updated, errors } = await batchEdit(ids, async (id) => {
const p = cachedPhoto(id);
const iso = p ? suggestionFor(p) : null;
if (p && iso) {
await updatePhoto(p, buildTakenAtPatch(`${iso}T00:00:00Z`));
}
await approvePhoto(id);
return id;
});
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
} else {
toast.success(`Kept ${ids.length}`);
}
focusAfter(ids);
clearSelection();
void qc.invalidateQueries({ queryKey: ['review-groups'] });
});
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
@@ -239,6 +326,21 @@
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
{#if hasAnySuggestion}
<!-- Surfaces only when at least one selected photo has a
derivable date from its path AND lacks a trusted
TakenAt. Applies the suggested date patch then
approves in one go; uids without a usable suggestion
are just approved. -->
<button
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
disabled={busy}
onclick={onAcceptDateAndKeep}
title="Accept the date suggested from the file/folder path, then keep"
>
📅 Accept date & Keep
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}

View File

@@ -0,0 +1,81 @@
<!--
Notes-view flat grid — same skeleton as PhotoGrid, but each cell pairs
a photo with its note hint via NotesPhotoTile. Kept as a sibling
component (rather than threading a `note` slot through PhotoGrid) so
the Notes-view chrome stays out of the shared timeline/tags codepath.
The grid carries `data-photo-grid` and each tile (rendered inside
PhotoTile) carries `data-tile`+`data-uid` — same contract the
gridKeyNav action and shared selection helpers expect, so arrow-key
nav, range select, and bulk action bar work for free.
-->
<script lang="ts">
import { untrack } from 'svelte';
import {
isSelected,
selection,
setAnchor,
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview, view } from '$lib/stores/view.svelte';
import type { PhotoWithNote } from '$lib/services/photoprism';
import NotesPhotoTile from './NotesPhotoTile.svelte';
interface Props {
items: PhotoWithNote[];
columns?: string;
}
let { items, columns }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
const order = $derived(items.map((it) => it.photo.UID));
$effect(() => {
setOrder(order);
untrack(() => {
if (order.length === 0) {
setFocused(null);
selection.ids.clear();
return;
}
const cur = selection.focused;
if (cur && order.includes(cur)) return;
setFocused(order[0]);
setAnchor(order[0]);
selection.ids.clear();
});
});
function onClick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
function onDblclick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
openPreview();
}
</script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each items as item (item.photo.UID)}
{@const sel = isSelected(item.photo.UID) || selection.focused === item.photo.UID}
<NotesPhotoTile
photo={item.photo}
note={item.note}
selected={sel}
onClick={(e) => onClick(e, item.photo.UID)}
onDblclick={(e) => onDblclick(e, item.photo.UID)}
/>
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<!--
PhotoTile + footer card showing a hint of the photo's note. Only the
Notes view (/notes) uses this — every other surface keeps the bare
PhotoTile, so the note-card chrome doesn't leak into the timeline or
tag drill-ins.
Composition: square PhotoTile on top, presentational note strip
underneath. Clicks/dblclicks land on PhotoTile's own <button data-tile>
so selection/keyboard/preview behave exactly like every other tile.
-->
<script lang="ts">
import type { PpPhoto } from '$lib/types/photoprism';
import PhotoTile from './PhotoTile.svelte';
interface Props {
photo: PpPhoto;
selected: boolean;
note: string;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
}
let { photo, selected, note, onClick, onDblclick }: Props = $props();
</script>
<div class="flex h-full w-full flex-col">
<div class="aspect-square">
<PhotoTile {photo} {selected} {onClick} {onDblclick} />
</div>
<div
class="line-clamp-2 rounded-b-md border border-t-0 border-border bg-card px-2 py-1.5 text-[11px] leading-snug text-muted-foreground"
title={note}
>
{note}
</div>
</div>

View File

@@ -31,8 +31,12 @@
* `view.thumbnailSize` so drill-in grids honour the same XSXL
* preset the timeline uses. */
columns?: string;
/** Forwarded to every PhotoTile. The Low Resolution review tab
* opts in so users can spot pixel dimensions without opening
* each tile. */
dimensionBadge?: boolean;
}
let { photos, columns }: Props = $props();
let { photos, columns, dimensionBadge = false }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
@@ -91,6 +95,7 @@
<PhotoTile
{photo}
selected={sel}
{dimensionBadge}
onClick={(e) => onClick(e, photo.UID)}
onDblclick={(e) => onDblclick(e, photo.UID)}
/>

View File

@@ -22,11 +22,23 @@
selected: boolean;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
/** Opt-in `WxH` overlay in the top-right corner. Used by the Low
* Resolution review tab so the user can spot-check pixel dimensions
* without opening each tile. Default off so other surfaces stay
* uncluttered. */
dimensionBadge?: boolean;
}
let { photo, selected, onClick, onDblclick }: Props = $props();
let { photo, selected, onClick, onDblclick, dimensionBadge = false }: Props = $props();
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo));
const dims = $derived.by(() => {
if (!dimensionBadge) return '';
const f = primaryFile(photo);
const w = photo.Width ?? f.Width;
const h = photo.Height ?? f.Height;
return w && h ? `${w}×${h}` : '';
});
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
@@ -144,5 +156,11 @@
>VIDEO</span
>
{/if}
{#if dims}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
>{dims}</span
>
{/if}
</button>
</div>

View File

@@ -478,6 +478,33 @@ export interface AggregatedKeyword {
sampleHash: string;
}
/**
* Photos carrying a non-empty user note. mule-image's "Note" is
* PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which
* is a top-level scalar — present on the list response, so a single
* round-trip is enough.
*/
export interface PhotoWithNote {
photo: PpPhoto;
note: string;
}
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
const list = await listPhotos({ count: 1000, order: 'newest', merged: true });
const out: PhotoWithNote[] = [];
const seen = new Set<string>();
for (const p of list) {
// `merged: true` can repeat a photo across file-rows; dedupe by UID
// so the same tile doesn't render twice.
if (seen.has(p.UID)) continue;
seen.add(p.UID);
const note = p.Caption?.trim();
if (!note) continue;
out.push({ photo: p, note });
}
return out;
}
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
const list = await listPhotos({ count: 1000, merged: true });
const buckets = new Map<string, AggregatedKeyword>();

View File

@@ -0,0 +1,57 @@
/**
* Best-effort calendar date guessed from a photo's filename / parent
* folders. Returns `YYYY-MM-DD` only when year + month + day are all
* present and form a real date; returns `null` for year-only or
* unparseable inputs.
*
* Used by the EXIF Stripped review tab to pre-fill the metadata
* sidebar's date suggestion row.
*/
import { isValidISODate } from '$lib/services/photoprism';
interface Input {
fileName?: string;
path?: string;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
function tryDate(y: number, m: number, d: number): string | null {
if (y < 1900 || y > 2100) return null;
if (m < 1 || m > 12) return null;
if (d < 1 || d > 31) return null;
const iso = `${y}-${pad2(m)}-${pad2(d)}`;
return isValidISODate(iso) ? iso : null;
}
// `YYYY[sep]MM[sep]DD` where `sep` is an optional `-` or `_`. Anchored by
// non-digit boundaries on both sides so `IMG_20070612_135421.jpg` matches
// `20070612` cleanly without bleeding into the trailing timestamp.
const BASENAME_YMD = /(?<!\d)(\d{4})[-_]?(\d{2})[-_]?(\d{2})(?!\d)/;
// Path variant accepts `/` as a separator too — `2007/06/12`, `2007-06-12/`.
const PATH_YMD = /(?<!\d)(\d{4})[-_/](\d{2})[-_/](\d{2})(?!\d)/;
export function suggestDateFromPath(input: Input): string | null {
const fileName = (input.fileName ?? '').trim();
const path = (input.path ?? '').trim();
const fnMatch = fileName.match(BASENAME_YMD);
if (fnMatch) {
const iso = tryDate(Number(fnMatch[1]), Number(fnMatch[2]), Number(fnMatch[3]));
if (iso) return iso;
}
if (path) {
const pMatch = path.match(PATH_YMD);
if (pMatch) {
const iso = tryDate(Number(pMatch[1]), Number(pMatch[2]), Number(pMatch[3]));
if (iso) return iso;
}
}
return null;
}

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getPhoto,
listPhotosWithNotes,
type PhotoWithNote
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import { selection } from '$lib/stores/selection.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NotesPhotoGrid from '$lib/components/timeline/NotesPhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, MousePointerClick, StickyNote } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism';
// Photos with a non-empty Details.Notes. The query is shared by the
// LeftSidebar's count badge ($derived off the same key), so visiting
// /notes warms the badge and vice-versa. Keyed under the ['photos', …]
// prefix so the existing mutation invalidations cascade in.
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
queryKey: ['photos', 'with-notes'],
queryFn: listPhotosWithNotes,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const items = $derived<PhotoWithNote[]>(notesQuery.data ?? []);
const count = $derived(items.length);
// Right-sidebar metadata for the focused tile. Same wiring as the tag
// drill-in page so the metadata panel reads consistently.
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''],
queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0
}));
</script>
<Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Notes
</span>
{#if !notesQuery.isPending && !notesQuery.isError}
<span class="text-[11px] text-muted-foreground">
{count} photo{count === 1 ? '' : 's'}
</span>
{/if}
</Toolbar>
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if notesQuery.isPending}
<SkeletonGrid />
{:else if notesQuery.isError}
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load notes" />
{:else if items.length === 0}
<EmptyState
icon={StickyNote}
title="No photos with notes"
description="Add a note to a photo from its metadata sidebar and it will appear here."
/>
{:else}
<NotesPhotoGrid {items} />
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
<aside
class="relative h-full shrink-0 border-l border-border bg-card/30"
style="width: {view.rightSidebarWidth}px;"
>
<div class="h-full overflow-y-auto">
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
{/snippet}
</EmptyState>
{/if}
</div>
<div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'left',
getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize info panel"
>
<div
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
</div>

View File

@@ -115,7 +115,7 @@
count: g.photos.length as number | undefined
})),
{ id: 'stacks', label: 'Stacks', count: stacksCount },
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
{ id: 'cross-folder', label: 'Duplicates', count: crossFolderCount }
]);
const requestedTab = $derived(page.url.searchParams.get('tab'));
@@ -212,7 +212,7 @@
<p>
The indexer flags photos with a low quality score for human review. New
arrivals with missing EXIF, low resolution, or unknown cameras will land
here. The Stacks and Cross-folder tabs above stay available for
here. The Stacks and Duplicates tabs above stay available for
duplicate cleanup.
</p>
{/snippet}
@@ -235,7 +235,7 @@
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{/if}