Mulimage 2.0 #1
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
81
web/src/lib/components/timeline/NotesPhotoGrid.svelte
Normal file
81
web/src/lib/components/timeline/NotesPhotoGrid.svelte
Normal 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>
|
||||
35
web/src/lib/components/timeline/NotesPhotoTile.svelte
Normal file
35
web/src/lib/components/timeline/NotesPhotoTile.svelte
Normal 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>
|
||||
@@ -31,8 +31,12 @@
|
||||
* `view.thumbnailSize` so drill-in grids honour the same XS–XL
|
||||
* 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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
57
web/src/lib/utils/suggestDateFromPath.ts
Normal file
57
web/src/lib/utils/suggestDateFromPath.ts
Normal 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;
|
||||
}
|
||||
122
web/src/routes/notes/+page.svelte
Normal file
122
web/src/routes/notes/+page.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user