Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
15 changed files with 607 additions and 512 deletions
Showing only changes of commit d1ddc48f81 - Show all commits

View File

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

View File

@@ -65,7 +65,7 @@
toast.error( toast.error(
crossQuery.error instanceof Error crossQuery.error instanceof Error
? crossQuery.error.message ? crossQuery.error.message
: 'Cross-folder scan failed' : 'Duplicates scan failed'
); );
} }
}); });
@@ -91,7 +91,7 @@
<p> <p>
The library stacks byte-identical (or EXIF-identical) files. If you don't have 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 any, this tab stays empty. Cross-folder copies dropped at index time live under
the Cross-folder tab. the Duplicates tab.
</p> </p>
{/snippet} {/snippet}
</EmptyState> </EmptyState>
@@ -105,9 +105,9 @@
</div> </div>
{/if} {/if}
<!-- Cross-folder tab ----------------------------------------------- --> <!-- Duplicates tab (cross-folder scan) ----------------------------- -->
{#if activeTab === 'cross-folder'} {#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"> <header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground"> <p class="text-[11px] text-muted-foreground">
Byte-identical files the indexer dropped at index time. Found by scanning the Byte-identical files the indexer dropped at index time. Found by scanning the
@@ -139,7 +139,7 @@
: 'unknown error'} : 'unknown error'}
/> />
{:else if crossCount === 0} {:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No cross-folder duplicates found"> <EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()} {#snippet descriptionSnippet()}
{#if crossQuery.data} {#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70"> <p class="text-[10px] text-muted-foreground/70">

View File

@@ -18,12 +18,14 @@
listFolderCounts, listFolderCounts,
listFolders, listFolders,
listHeaps, listHeaps,
listPhotosWithNotes,
logout, logout,
renameFolder, renameFolder,
renameHeap, renameHeap,
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
triggerDownload, triggerDownload,
type CrossFolderScanResult, type CrossFolderScanResult,
type PhotoWithNote,
type PpAlbum, type PpAlbum,
type PpClientConfig, type PpClientConfig,
type PpFolder 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 // (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. Review has no aggregate badge (it's a pure toggle in // round-trip. Review and Hidden have no aggregate badge (pure
// the sidebar now, like Tags), so it doesn't appear here. // toggles in the sidebar now, like Tags), so they don't appear here.
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
const archivedCountQuery = scopedCountQuery('archived', 'archived:true'); const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
function bucketCount( function bucketCount(
key: 'hidden' | 'archived', key: 'archived',
query: { data: number | undefined; isPending: boolean } query: { data: number | undefined; isPending: boolean }
): number | undefined { ): number | undefined {
if (wantScoped) { if (wantScoped) {
@@ -175,6 +176,17 @@
staleTime: 5 * 60_000 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( const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path)) buildTree((foldersQuery.data ?? []).map((f) => f.Path))
); );
@@ -232,10 +244,9 @@
: (scopedRootCountQuery.data?.[''] ?? 0) : (scopedRootCountQuery.data?.[''] ?? 0)
); );
// Hidden / Archive nav entries use these derived values rather than // Archive nav entry uses this derived value rather than peeking at
// peeking at configQuery directly so the scoped path is invisible to // configQuery directly so the scoped path is invisible to the
// the manageViews[] declarations. // manageViews[] declarations.
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery)); const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
const createMut = createMutation(() => ({ const createMut = createMutation(() => ({
@@ -347,16 +358,18 @@
})); }));
type ReviewTabId = CauseKey | 'stacks' | 'cross-folder'; type ReviewTabId = CauseKey | 'stacks' | 'cross-folder';
// Stacks + Cross-folder are always present on the /review tab strip // Stacks + Duplicates are always present on the /review tab strip
// regardless of count (cross-folder's scan is lazy from its own panel), // regardless of count (the cross-folder scan is lazy from its own
// so they tail every cause-tab list the sidebar renders. // 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 }[]>([ const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([
...(reviewGroupsQuery.data ?? []).map((g) => ({ ...(reviewGroupsQuery.data ?? []).map((g) => ({
id: g.cause as ReviewTabId, id: g.cause as ReviewTabId,
label: g.meta.title label: g.meta.title
})), })),
{ id: 'stacks', label: 'Stacks' }, { id: 'stacks', label: 'Stacks' },
{ id: 'cross-folder', label: 'Cross-folder' } { id: 'cross-folder', label: 'Duplicates' }
]); ]);
const reviewActive = $derived(page.url.pathname === '/review'); const reviewActive = $derived(page.url.pathname === '/review');
@@ -537,9 +550,14 @@
// Tags is rendered as a bespoke expandable block below the // Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/ // `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat // 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 // Review is rendered separately below as a pure expandable toggle
// (mirroring Tags — no /review landing entry from the sidebar, // (mirroring Tags — no /review landing entry from the sidebar,
// navigation only via subitems, with Hidden tucked in alongside the // navigation only via subitems, with Hidden tucked in alongside the
@@ -862,6 +880,34 @@
</span> </span>
</button> </button>
{#if tagsExpanded} {#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)} {#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)} {@const active = isTagCategoryActive(cat)}
<a <a
@@ -943,15 +989,6 @@
onclick={() => navigateTo('hidden')} onclick={() => navigateTo('hidden')}
> >
<span class="truncate">Hidden</span> <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> </button>
{/if} {/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}`)}

View File

@@ -1,171 +1,29 @@
<!-- <!--
One review-queue cause group rendered as a card. Mirrors StackGroupCard's One review-queue cause group, rendered as a thin wrapper around
chrome (focusable container, ResizeObserver column tracking, keyboard PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
nav) but the per-tile semantics differ: 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 The Low Resolution tab opts in to PhotoTile's dimension badge so the
sidebar. Shift-click toggles bulk-select instead of opening. user can spot under-2-MP photos without opening each tile.
- 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.
--> -->
<script lang="ts"> <script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import { batchArchive } from '$lib/services/photoprism';
approvePhoto, import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
batchArchive import { type ReviewGroup } from '$lib/services/adapters/review';
} 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';
interface Props { interface Props {
group: ReviewGroup; 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(); 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); 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() { async function archiveAll() {
if (busy || group.photos.length === 0) return; if (busy || group.photos.length === 0) return;
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`)) 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() { function runSuggestion() {
if (group.meta.suggestedAction === 'approve') void approveAll(); // Only 'archive' suggestions are reachable through this button now —
else if (group.meta.suggestedAction === 'archive') void archiveAll(); // the page's BulkActionBar handles per-photo / multi-select Keep.
// 'manual' suggestion has no button — the suggestion line is text-only. if (group.meta.suggestedAction === 'archive') void archiveAll();
} }
</script> </script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div class="space-y-2">
<!-- 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"
>
<header class="flex items-center justify-between gap-3"> <header class="flex items-center justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<div class="text-sm font-medium text-foreground"> <div class="text-sm font-medium text-foreground">
@@ -248,128 +56,30 @@
<span class="ml-1 text-muted-foreground">({group.photos.length})</span> <span class="ml-1 text-muted-foreground">({group.photos.length})</span>
</div> </div>
</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> </header>
<!-- Suggestion line — sits above the grid, surfaces the likely-correct <!-- Suggestion line — surfaces the likely-correct bulk action. Only
bulk action with an inline trigger. 'manual' causes get no 'archive' renders a quick-button; 'manual' is text-only and
inline button; the user has to use the header bulk bar instead. --> 'approve' is unused today. Per-photo Keep / Archive comes from the
page's BulkActionBar (review section) once the user selects. -->
<div <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" 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> <span>{group.meta.suggestion}</span>
{#if group.meta.suggestedAction !== 'manual'} {#if group.meta.suggestedAction === 'archive'}
<button <button
type="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" 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} disabled={busy}
onclick={runSuggestion} onclick={runSuggestion}
> >
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'} Archive all
</button> </button>
{/if} {/if}
</div> </div>
<div <PhotoGrid
bind:this={gridEl} photos={group.photos}
class="grid gap-2" dimensionBadge={group.cause === 'low_resolution'}
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>
</div> </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 { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups'; import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import RelatedStrip from './RelatedStrip.svelte'; import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props { 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; photo: PpPhoto;
} }
let { photo, showRelated = false }: Props = $props(); let { photo }: Props = $props();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -131,6 +128,25 @@
commit({ Caption: caption, CaptionSrc: 'manual' }); commit({ Caption: caption, CaptionSrc: 'manual' });
} }
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt)); 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() { function commitTakenAt() {
if (!takenAt) return; if (!takenAt) return;
if (!isValidISODate(takenAt)) { if (!isValidISODate(takenAt)) {
@@ -310,6 +326,29 @@
{/if} {/if}
</div> </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 --> <!-- Taken at -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -373,25 +412,14 @@
</div> </div>
</dl> </dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match <!-- Tags — note, score, color label, keywords, and auto-labels grouped
mule-image's nomenclature). --> under one collapsible section. Note (PhotoPrism's Caption field,
<div class="space-y-1"> labelled here to match mule-image's nomenclature) sits at the top
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div> of the group since it's the most-edited per-photo field. Score +
<textarea color are stored on the mule-sidecar (PhotoPrism's PUT can't
rows="2" persist them); keywords live on Details; auto-labels come from
placeholder="Add a note…" PhotoPrism's TF classifier and are read-only. Open by default
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" since these are the culling marks the user reaches for first. -->
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. -->
<details <details
class="rounded border border-border" class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)} open={getMetadataSectionOpen('tags', true)}
@@ -405,6 +433,17 @@
</span> </span>
</summary> </summary>
<div class="space-y-2 p-2 pt-1"> <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="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div> <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"> <div class="flex items-center gap-0.5" role="group" aria-label="Rating">
@@ -504,35 +543,6 @@
</div> </div>
</details> </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 <!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store. choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr Avoid data-driven defaults here — they make the `open` attr

View File

@@ -7,11 +7,15 @@
batchArchive, batchArchive,
batchDelete, batchDelete,
batchRestore, batchRestore,
buildTakenAtPatch,
listHeaps, listHeaps,
removeFromHeap, removeFromHeap,
updatePhoto,
type PpAlbum type PpAlbum
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch'; import { batchEdit } from '$lib/services/batch';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import type { PpPhoto } from '$lib/types/photoprism';
import { import {
clearBulkToFirst, clearBulkToFirst,
clearSelection, clearSelection,
@@ -46,6 +50,43 @@
return []; 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( const targetCount = $derived(
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0 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 // heap-adding / restoring. The S keybinding is rerouted to approve
// from gridKeyNav for the same reason. // from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review'); 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 // Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed // to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived; // 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() { async function onArchive() {
const ids = snapshotIds(); const ids = snapshotIds();
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -239,6 +326,21 @@
✓ Keep ✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd> <kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button> </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 <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" 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} 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 * `view.thumbnailSize` so drill-in grids honour the same XSXL
* preset the timeline uses. */ * preset the timeline uses. */
columns?: string; 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( const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))` columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
); );
@@ -91,6 +95,7 @@
<PhotoTile <PhotoTile
{photo} {photo}
selected={sel} selected={sel}
{dimensionBadge}
onClick={(e) => onClick(e, photo.UID)} onClick={(e) => onClick(e, photo.UID)}
onDblclick={(e) => onDblclick(e, photo.UID)} onDblclick={(e) => onDblclick(e, photo.UID)}
/> />

View File

@@ -22,11 +22,23 @@
selected: boolean; selected: boolean;
onClick: (e: MouseEvent) => void; onClick: (e: MouseEvent) => void;
onDblclick: (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 hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo)); 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 // 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 // video when you hover the tile in the grid. We wait HOVER_DELAY ms
@@ -144,5 +156,11 @@
>VIDEO</span >VIDEO</span
> >
{/if} {/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> </button>
</div> </div>

View File

@@ -478,6 +478,33 @@ export interface AggregatedKeyword {
sampleHash: string; 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[]> { export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
const list = await listPhotos({ count: 1000, merged: true }); const list = await listPhotos({ count: 1000, merged: true });
const buckets = new Map<string, AggregatedKeyword>(); 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 count: g.photos.length as number | undefined
})), })),
{ id: 'stacks', label: 'Stacks', count: stacksCount }, { 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')); const requestedTab = $derived(page.url.searchParams.get('tab'));
@@ -212,7 +212,7 @@
<p> <p>
The indexer flags photos with a low quality score for human review. New The indexer flags photos with a low quality score for human review. New
arrivals with missing EXIF, low resolution, or unknown cameras will land 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. duplicate cleanup.
</p> </p>
{/snippet} {/snippet}
@@ -235,7 +235,7 @@
{#if selection.ids.size >= 2} {#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} /> <BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated /> <RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" /> <InlineLoader size="sm" label="Loading metadata…" />
{/if} {/if}