feat(web): fold duplicates+inbox into /review; sidebar UX cleanup
- /duplicates and /inbox routes removed and folded into /review as additional tabs alongside cause tabs; /duplicates keeps a redirect for bookmarks. - LeftSidebar: drop import/inbox tile and favorites; show per-user BasePath label at the folder root. - RightSidebar: split file header into read-only path over editable basename (matches sidecar rename contract); date field switches to plain-text ISO YYYY-MM-DD (no native datetime picker) with strict validation and revert-on-invalid-blur; preserves original hour. - BulkMetadataSidebar: same ISO-only date input with invalid-state styling and apply-button gating. - BulkActionBar: drop redundant Restore and Undo buttons; ⌘Z still reachable via gridKeyNav. - gridKeyNav: remove favorite toggle (F) alongside the favorites view retirement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,7 @@ import {
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
likePhoto,
|
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
unlikePhoto,
|
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
@@ -55,7 +53,7 @@ export interface GridKeyNavParams {
|
|||||||
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
|
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
|
||||||
* - Click + shift/ctrl click selection mutations
|
* - Click + shift/ctrl click selection mutations
|
||||||
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
||||||
* x archive-toggle, u restore, f favorite-toggle, s + (1–9) add to
|
* x archive-toggle, u restore, s + (1–9) add to
|
||||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||||
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
||||||
* esc clears, ⌘Z undoes, ⌘A selects all visible.
|
* esc clears, ⌘Z undoes, ⌘A selects all visible.
|
||||||
@@ -302,46 +300,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
toast.success(`Kept ${ids.length}`);
|
toast.success(`Kept ${ids.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
|
||||||
* target's cached `Favorite` to decide direction so a mixed selection
|
|
||||||
* resolves to "make them all favorited" when the first isn't, mirroring
|
|
||||||
* the way `toggleArchive('toggle')` works. */
|
|
||||||
async function toggleFavoriteOnTargets() {
|
|
||||||
const ids = cullTargets();
|
|
||||||
if (ids.length === 0) {
|
|
||||||
toast.message('Nothing to favorite', {
|
|
||||||
description: 'Click a photo or select some first'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const first = cachedPhoto(ids[0]);
|
|
||||||
const next = !(first?.Favorite ?? false);
|
|
||||||
const { updated, errors } = await batchEdit(ids, (id) =>
|
|
||||||
next ? likePhoto(id) : unlikePhoto(id)
|
|
||||||
);
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
for (const id of ids) {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
|
||||||
}
|
|
||||||
const verb = next ? 'Favorited' : 'Unfavorited';
|
|
||||||
if (errors.length) {
|
|
||||||
// Surface the actual first error message — silent failures here are
|
|
||||||
// the #1 reason `f` "doesn't work" (e.g. permission, network, 404).
|
|
||||||
toast.error(`${verb} ${updated.length}; ${errors.length} failed`, {
|
|
||||||
description: errors[0].message
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(`${verb} ${ids.length}`);
|
|
||||||
pushUndo(`${verb} ${ids.length}`, async () => {
|
|
||||||
await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id)));
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
for (const id of ids) {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||||
// Press S: arm a short timer. A digit 1–9 within the window adds the
|
// Press S: arm a short timer. A digit 1–9 within the window adds the
|
||||||
// cull targets to the Nth heap in the heap list. Any other key cancels
|
// cull targets to the Nth heap in the heap list. Any other key cancels
|
||||||
@@ -548,12 +506,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void toggleArchive('restore');
|
void toggleArchive('restore');
|
||||||
return;
|
return;
|
||||||
case 'f':
|
|
||||||
case 'F':
|
|
||||||
if (meta || shift) return;
|
|
||||||
e.preventDefault();
|
|
||||||
void toggleFavoriteOnTargets();
|
|
||||||
return;
|
|
||||||
case 's':
|
case 's':
|
||||||
case 'S':
|
case 'S':
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
|
|||||||
@@ -63,7 +63,6 @@
|
|||||||
'default',
|
'default',
|
||||||
'browse',
|
'browse',
|
||||||
'albums',
|
'albums',
|
||||||
'favorites',
|
|
||||||
'calendar',
|
'calendar',
|
||||||
'moments',
|
'moments',
|
||||||
'people',
|
'people',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
duplicateHeap,
|
duplicateHeap,
|
||||||
getAllMarks,
|
getAllMarks,
|
||||||
getConfig,
|
getConfig,
|
||||||
getImportInfo,
|
|
||||||
heapDownloadUrl,
|
heapDownloadUrl,
|
||||||
listFolderCounts,
|
listFolderCounts,
|
||||||
listFolders,
|
listFolders,
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
scanCrossFolderDuplicates,
|
scanCrossFolderDuplicates,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
type CrossFolderScanResult,
|
type CrossFolderScanResult,
|
||||||
type ImportInfo,
|
|
||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
type PpClientConfig,
|
type PpClientConfig,
|
||||||
@@ -73,27 +71,17 @@
|
|||||||
enabled: isAuthenticated()
|
enabled: isAuthenticated()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
|
|
||||||
// 60s — the inbox only changes when files are uploaded or the indexer
|
|
||||||
// runs, neither of which happens often enough to justify a tighter cadence.
|
|
||||||
const importQuery = createQuery<ImportInfo>(() => ({
|
|
||||||
queryKey: ['import'],
|
|
||||||
queryFn: getImportInfo,
|
|
||||||
enabled: isAuthenticated(),
|
|
||||||
staleTime: 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
// View counts come from PhotoPrism's `/config` response, which carries a
|
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||||
// precomputed counter for every common bucket (all/favorites/archived/
|
// precomputed counter for every common bucket (all/archived/labels/
|
||||||
// labels/places/…) updated incrementally on every mutation. Cheap to
|
// places/…) updated incrementally on every mutation. Cheap to refetch,
|
||||||
// refetch, and gives us a stable total — `/photos` only returns
|
// and gives us a stable total — `/photos` only returns per-page row
|
||||||
// per-page row counts via `X-Count`, never a total.
|
// counts via `X-Count`, never a total.
|
||||||
//
|
//
|
||||||
// The key sits under the `['photos', …]` prefix so it inherits the
|
// The key sits under the `['photos', …]` prefix so it inherits the
|
||||||
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
|
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
|
||||||
// across mutations (favorite, archive, restore, delete, heap add) — the
|
// across mutations (archive, restore, delete, heap add) — the counter
|
||||||
// counter map refreshes whenever the photo list does. Marks-derived
|
// map refreshes whenever the photo list does. Marks-derived counts
|
||||||
// counts (ratings/colors) react through the shared `['marks']` cache.
|
// (ratings/colors) react through the shared `['marks']` cache.
|
||||||
const configQuery = createQuery<PpClientConfig>(() => ({
|
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||||
queryKey: ['photos', 'config'],
|
queryKey: ['photos', 'config'],
|
||||||
queryFn: getConfig,
|
queryFn: getConfig,
|
||||||
@@ -267,6 +255,13 @@
|
|||||||
const rootActive = $derived(filters.folderPath === '/');
|
const rootActive = $derived(filters.folderPath === '/');
|
||||||
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
||||||
|
|
||||||
|
// Root-folder label. Every account (admins included) gets a
|
||||||
|
// BasePath named after them on disk, so surface that identity
|
||||||
|
// here instead of an opaque "/".
|
||||||
|
const rootLabel = $derived(
|
||||||
|
session.user?.DisplayName?.trim() || session.user?.Name || '/'
|
||||||
|
);
|
||||||
|
|
||||||
async function onSignOut() {
|
async function onSignOut() {
|
||||||
await logout();
|
await logout();
|
||||||
await goto('/login', { replaceState: true });
|
await goto('/login', { replaceState: true });
|
||||||
@@ -390,9 +385,9 @@
|
|||||||
// "everything visible in the main timeline" tally), so it matches what
|
// "everything visible in the main timeline" tally), so it matches what
|
||||||
// the All photos view actually renders. `places` is the count of
|
// the All photos view actually renders. `places` is the count of
|
||||||
// geocoded locations — semantically what the Map view groups by.
|
// geocoded locations — semantically what the Map view groups by.
|
||||||
// Duplicates sums stacks + cross-folder groups; cross-folder only
|
// Review rolls in the duplicates tabs hosted under /review — stacks
|
||||||
// contributes once its tab has been visited (the scan is opt-in
|
// always contributes; cross-folder only contributes once its tab has
|
||||||
// per-visit, not eager from the sidebar).
|
// been opened (the scan is lazy, not eager from the sidebar).
|
||||||
type ViewItem =
|
type ViewItem =
|
||||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||||
@@ -402,8 +397,6 @@
|
|||||||
// separate "everything regardless of folder" destination would just
|
// separate "everything regardless of folder" destination would just
|
||||||
// duplicate it for users whose photos live under the root.
|
// duplicate it for users whose photos live under the root.
|
||||||
const views: ViewItem[] = [
|
const views: ViewItem[] = [
|
||||||
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
|
|
||||||
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
|
|
||||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
|
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
|
||||||
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
|
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
|
||||||
// the badge shows total labels + keywords + ratings + colors so the
|
// the badge shows total labels + keywords + ratings + colors so the
|
||||||
@@ -421,15 +414,14 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const manageViews: ViewItem[] = [
|
const manageViews: ViewItem[] = [
|
||||||
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
|
||||||
{
|
{
|
||||||
kind: 'route',
|
kind: 'route',
|
||||||
href: '/duplicates',
|
href: '/review',
|
||||||
label: 'Duplicates',
|
label: 'Review',
|
||||||
getCount: () => {
|
getCount: () => {
|
||||||
const stacks = stacksQuery.data?.length;
|
const review = configQuery.data?.count?.review;
|
||||||
if (stacks === undefined) return undefined;
|
if (review === undefined) return undefined;
|
||||||
return stacks + (crossFolderQuery.data?.groups.length ?? 0);
|
return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
||||||
@@ -560,9 +552,9 @@
|
|||||||
class="flex min-w-0 flex-1 items-center text-left"
|
class="flex min-w-0 flex-1 items-center text-left"
|
||||||
class:px-1={hasSubfolders}
|
class:px-1={hasSubfolders}
|
||||||
onclick={() => pickFolder('/')}
|
onclick={() => pickFolder('/')}
|
||||||
title="Photos directly under originals/"
|
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||||
>
|
>
|
||||||
<span class="truncate">/</span>
|
<span class="truncate">{rootLabel}</span>
|
||||||
{#if configQuery.data}
|
{#if configQuery.data}
|
||||||
<span
|
<span
|
||||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import {
|
import {
|
||||||
buildTakenAtPatch,
|
buildTakenAtPatch,
|
||||||
bulkSetMarks,
|
bulkSetMarks,
|
||||||
|
isValidISODate,
|
||||||
type PhotoMark,
|
type PhotoMark,
|
||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type UpdatePhotoBody
|
type UpdatePhotoBody
|
||||||
@@ -56,12 +57,12 @@
|
|||||||
noteDraft = '';
|
noteDraft = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dateDraftValid = $derived(dateDraft === '' || isValidISODate(dateDraft));
|
||||||
async function applyDate() {
|
async function applyDate() {
|
||||||
if (busy || !dateDraft) return;
|
if (busy || !dateDraft || !isValidISODate(dateDraft)) return;
|
||||||
// datetime-local omits the timezone; treat the input as UTC (same
|
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
|
||||||
// convention as the single-photo sidebar) and let PhotoPrism's
|
// fill the local timezone field downstream.
|
||||||
// backwrite stamp the local timezone field downstream.
|
const iso = `${dateDraft}T00:00:00Z`;
|
||||||
const iso = `${dateDraft}:00Z`;
|
|
||||||
await withBusy(() =>
|
await withBusy(() =>
|
||||||
patchTargets(
|
patchTargets(
|
||||||
ids,
|
ids,
|
||||||
@@ -200,14 +201,18 @@
|
|||||||
<Calendar class="h-3 w-3" /> Date taken
|
<Calendar class="h-3 w-3" /> Date taken
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="text"
|
||||||
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
inputmode="numeric"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
pattern="\d{4}-\d{2}-\d{2}"
|
||||||
|
aria-invalid={!dateDraftValid}
|
||||||
|
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
||||||
bind:value={dateDraft}
|
bind:value={dateDraft}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
disabled={busy || !dateDraft}
|
disabled={busy || !dateDraft || !dateDraftValid}
|
||||||
onclick={applyDate}
|
onclick={applyDate}
|
||||||
>
|
>
|
||||||
Apply date to {ids.length}
|
Apply date to {ids.length}
|
||||||
|
|||||||
@@ -13,10 +13,8 @@
|
|||||||
Calendar,
|
Calendar,
|
||||||
Camera,
|
Camera,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Heart,
|
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -26,10 +24,9 @@
|
|||||||
import {
|
import {
|
||||||
buildTakenAtPatch,
|
buildTakenAtPatch,
|
||||||
getAllMarks,
|
getAllMarks,
|
||||||
likePhoto,
|
isValidISODate,
|
||||||
renameOnDisk,
|
renameOnDisk,
|
||||||
setMark,
|
setMark,
|
||||||
unlikePhoto,
|
|
||||||
updatePhoto,
|
updatePhoto,
|
||||||
type PhotoMark,
|
type PhotoMark,
|
||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
@@ -52,7 +49,7 @@
|
|||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
let filename = $state('');
|
let basename = $state('');
|
||||||
let caption = $state('');
|
let caption = $state('');
|
||||||
let takenAt = $state('');
|
let takenAt = $state('');
|
||||||
let lat = $state('');
|
let lat = $state('');
|
||||||
@@ -60,18 +57,22 @@
|
|||||||
let country = $state('');
|
let country = $state('');
|
||||||
let keywords = $state<string[]>([]);
|
let keywords = $state<string[]>([]);
|
||||||
let keywordDraft = $state('');
|
let keywordDraft = $state('');
|
||||||
let subject = $state('');
|
|
||||||
let artist = $state('');
|
|
||||||
let copyright = $state('');
|
|
||||||
let license = $state('');
|
|
||||||
let notes = $state('');
|
|
||||||
let renaming = $state(false);
|
let renaming = $state(false);
|
||||||
|
|
||||||
|
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
|
||||||
|
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
||||||
|
* basename — it preserves the directory on disk — so we mirror that
|
||||||
|
* contract in the UI by exposing just the basename for edit. */
|
||||||
|
function splitName(full: string): { dir: string; base: string } {
|
||||||
|
const i = full.lastIndexOf('/');
|
||||||
|
return i < 0 ? { dir: '', base: full } : { dir: full.slice(0, i), base: full.slice(i + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const pf = primaryFile(photo);
|
const pf = primaryFile(photo);
|
||||||
filename = pf.Name ?? '';
|
basename = splitName(pf.Name ?? '').base;
|
||||||
caption = photo.Caption ?? '';
|
caption = photo.Caption ?? '';
|
||||||
takenAt = (photo.TakenAt ?? '').slice(0, 16);
|
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||||
lat = photo.Lat ? String(photo.Lat) : '';
|
lat = photo.Lat ? String(photo.Lat) : '';
|
||||||
lng = photo.Lng ? String(photo.Lng) : '';
|
lng = photo.Lng ? String(photo.Lng) : '';
|
||||||
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
||||||
@@ -80,11 +81,6 @@
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map((k) => k.trim())
|
.map((k) => k.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
subject = det.Subject ?? '';
|
|
||||||
artist = det.Artist ?? '';
|
|
||||||
copyright = det.Copyright ?? '';
|
|
||||||
license = det.License ?? '';
|
|
||||||
notes = det.Notes ?? '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchMutation = createMutation(() => ({
|
const patchMutation = createMutation(() => ({
|
||||||
@@ -100,32 +96,15 @@
|
|||||||
toast.error(err instanceof Error ? err.message : 'Save failed')
|
toast.error(err instanceof Error ? err.message : 'Save failed')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const favoriteMutation = createMutation(() => ({
|
|
||||||
mutationFn: async (next: boolean) => {
|
|
||||||
if (next) await likePhoto(photo.UID);
|
|
||||||
else await unlikePhoto(photo.UID);
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
onSuccess: (next) => {
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
pushUndo(next ? 'Favorited' : 'Unfavorited', async () => {
|
|
||||||
if (next) await unlikePhoto(photo.UID);
|
|
||||||
else await likePhoto(photo.UID);
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
function commit(patch: UpdatePhotoBody) {
|
function commit(patch: UpdatePhotoBody) {
|
||||||
patchMutation.mutate(patch);
|
patchMutation.mutate(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commitFilename() {
|
async function commitFilename() {
|
||||||
const pf = primaryFile(photo);
|
const pf = primaryFile(photo);
|
||||||
const next = filename.trim();
|
const { base: currentBase } = splitName(pf.Name ?? '');
|
||||||
if (!next || next === pf.Name) return;
|
const next = basename.trim();
|
||||||
|
if (!next || next === currentBase) return;
|
||||||
renaming = true;
|
renaming = true;
|
||||||
try {
|
try {
|
||||||
const result = await renameOnDisk(photo.UID, next);
|
const result = await renameOnDisk(photo.UID, next);
|
||||||
@@ -139,7 +118,7 @@
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Rename failed');
|
toast.error(err instanceof Error ? err.message : 'Rename failed');
|
||||||
filename = pf.Name ?? '';
|
basename = currentBase;
|
||||||
} finally {
|
} finally {
|
||||||
renaming = false;
|
renaming = false;
|
||||||
}
|
}
|
||||||
@@ -149,9 +128,19 @@
|
|||||||
if (caption === (photo.Caption ?? '')) return;
|
if (caption === (photo.Caption ?? '')) return;
|
||||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||||
}
|
}
|
||||||
|
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||||
function commitTakenAt() {
|
function commitTakenAt() {
|
||||||
if (!takenAt) return;
|
if (!takenAt) return;
|
||||||
const iso = `${takenAt}:00Z`;
|
if (!isValidISODate(takenAt)) {
|
||||||
|
// Revert to the photo's stored date so the field doesn't sit in
|
||||||
|
// a broken state once focus leaves it.
|
||||||
|
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep the original time-of-day so date-only edits don't clobber the
|
||||||
|
// hour. Fall back to midnight UTC when the photo has no prior TakenAt.
|
||||||
|
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
||||||
|
const iso = `${takenAt}${tail}`;
|
||||||
if (iso === photo.TakenAt) return;
|
if (iso === photo.TakenAt) return;
|
||||||
commit(buildTakenAtPatch(iso));
|
commit(buildTakenAtPatch(iso));
|
||||||
}
|
}
|
||||||
@@ -170,7 +159,7 @@
|
|||||||
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetailsKey = 'Keywords' | 'Subject' | 'Artist' | 'Copyright' | 'License' | 'Notes';
|
type DetailsKey = 'Keywords';
|
||||||
function commitDetails(field: DetailsKey, value: string) {
|
function commitDetails(field: DetailsKey, value: string) {
|
||||||
const prev = (photo.Details ?? {})[field] ?? '';
|
const prev = (photo.Details ?? {})[field] ?? '';
|
||||||
if (value === prev) return;
|
if (value === prev) return;
|
||||||
@@ -189,14 +178,6 @@
|
|||||||
commitDetails('Keywords', keywords.join(', '));
|
commitDetails('Keywords', keywords.join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePrivate() {
|
|
||||||
const prev = photo.Private ?? false;
|
|
||||||
commit({ Private: !prev });
|
|
||||||
pushUndo(prev ? 'Made public' : 'Made private', () => {
|
|
||||||
commit({ Private: prev });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
|
// Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
|
||||||
// silently drops these fields. One query holds the whole map; mutations
|
// silently drops these fields. One query holds the whole map; mutations
|
||||||
// patch the cache optimistically and PUT to the sidecar.
|
// patch the cache optimistically and PUT to the sidecar.
|
||||||
@@ -265,6 +246,7 @@
|
|||||||
const currentColor = $derived(photoMark.color ?? '');
|
const currentColor = $derived(photoMark.color ?? '');
|
||||||
|
|
||||||
const pf = $derived(primaryFile(photo));
|
const pf = $derived(primaryFile(photo));
|
||||||
|
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||||
const sizeStr = $derived(
|
const sizeStr = $derived(
|
||||||
pf.Size
|
pf.Size
|
||||||
@@ -307,47 +289,44 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
|
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
|
||||||
<!-- Header strip — thumb + filename + favorite + private -->
|
<!-- Header strip — thumb + path (read-only) over editable basename. The
|
||||||
<div class="flex items-center gap-2">
|
sidecar's rename endpoint only accepts a bare basename and preserves
|
||||||
|
the directory on disk, so the split UI mirrors that contract. -->
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'tile_100')}
|
src={thumbUrl(pf.Hash, 'tile_100')}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-10 w-10 shrink-0 rounded object-cover"
|
class="h-10 w-10 shrink-0 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
{#if dirPath}
|
||||||
|
<div
|
||||||
|
class="truncate text-[10px] text-muted-foreground"
|
||||||
|
title={dirPath}
|
||||||
|
>
|
||||||
|
{dirPath}/
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
class="min-w-0 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium leading-snug break-all hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||||
bind:value={filename}
|
bind:value={basename}
|
||||||
disabled={renaming}
|
disabled={renaming}
|
||||||
onblur={commitFilename}
|
onblur={commitFilename}
|
||||||
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
||||||
/>
|
/>
|
||||||
<!-- Inline spinner next to the filename so the user sees the rename
|
</div>
|
||||||
in flight without having to scan to the bottom of the sidebar. -->
|
<!-- Inline spinner so the user sees the rename in flight without
|
||||||
|
scanning to the bottom of the sidebar. -->
|
||||||
{#if renaming}
|
{#if renaming}
|
||||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
<Loader2 class="mt-1 h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
|
||||||
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
|
||||||
class:text-red-500={photo.Favorite}
|
|
||||||
class:text-muted-foreground={!photo.Favorite}
|
|
||||||
disabled={favoriteMutation.isPending}
|
|
||||||
onclick={() => favoriteMutation.mutate(!photo.Favorite)}
|
|
||||||
title={photo.Favorite ? 'Remove favorite (F)' : 'Add favorite (F)'}
|
|
||||||
>
|
|
||||||
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
|
||||||
class:text-foreground={photo.Private}
|
|
||||||
class:text-muted-foreground={!photo.Private}
|
|
||||||
disabled={patchMutation.isPending}
|
|
||||||
onclick={togglePrivate}
|
|
||||||
title={photo.Private ? 'Private' : 'Public'}
|
|
||||||
>
|
|
||||||
<Lock class="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compact info rows -->
|
<!-- Compact info rows -->
|
||||||
@@ -356,8 +335,12 @@
|
|||||||
<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" />
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="text"
|
||||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring"
|
inputmode="numeric"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
pattern="\d{4}-\d{2}-\d{2}"
|
||||||
|
aria-invalid={!takenAtValid}
|
||||||
|
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
||||||
bind:value={takenAt}
|
bind:value={takenAt}
|
||||||
onblur={commitTakenAt}
|
onblur={commitTakenAt}
|
||||||
/>
|
/>
|
||||||
@@ -606,66 +589,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- IPTC credits. Static default (closed); user's choice persists. -->
|
|
||||||
<details
|
|
||||||
class="rounded border border-border"
|
|
||||||
open={getMetadataSectionOpen('credits', false)}
|
|
||||||
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
|
|
||||||
>
|
|
||||||
<summary
|
|
||||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
|
||||||
>
|
|
||||||
Credits & notes
|
|
||||||
</summary>
|
|
||||||
<div class="space-y-1 p-2 pt-1">
|
|
||||||
<label class="flex items-center gap-1">
|
|
||||||
<span class="w-16 text-[10px] text-muted-foreground">Subject</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
bind:value={subject}
|
|
||||||
onblur={() => commitDetails('Subject', subject)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1">
|
|
||||||
<span class="w-16 text-[10px] text-muted-foreground">Artist</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
bind:value={artist}
|
|
||||||
onblur={() => commitDetails('Artist', artist)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1">
|
|
||||||
<span class="w-16 text-[10px] text-muted-foreground">Copyright</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
bind:value={copyright}
|
|
||||||
onblur={() => commitDetails('Copyright', copyright)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1">
|
|
||||||
<span class="w-16 text-[10px] text-muted-foreground">License</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
bind:value={license}
|
|
||||||
onblur={() => commitDetails('License', license)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-start gap-1">
|
|
||||||
<span class="w-16 pt-0.5 text-[10px] text-muted-foreground">Private notes</span>
|
|
||||||
<textarea
|
|
||||||
rows="2"
|
|
||||||
class="min-w-0 flex-1 resize-y rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
bind:value={notes}
|
|
||||||
onblur={() => commitDetails('Notes', notes)}
|
|
||||||
></textarea>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- File metadata. Closed by default; persists once opened. -->
|
<!-- File metadata. Closed by default; persists once opened. -->
|
||||||
<details
|
<details
|
||||||
class="rounded border border-border"
|
class="rounded border border-border"
|
||||||
|
|||||||
@@ -7,10 +7,8 @@
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
likePhoto,
|
|
||||||
listHeaps,
|
listHeaps,
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
unlikePhoto,
|
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { batchEdit } from '$lib/services/batch';
|
import { batchEdit } from '$lib/services/batch';
|
||||||
@@ -21,7 +19,7 @@
|
|||||||
setFocused
|
setFocused
|
||||||
} from '$lib/stores/selection.svelte';
|
} from '$lib/stores/selection.svelte';
|
||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -51,8 +49,8 @@
|
|||||||
const isBulk = $derived(selection.ids.size > 0);
|
const isBulk = $derived(selection.ids.size > 0);
|
||||||
// Review section uses a two-button decision flow (Keep / Archive) —
|
// Review section uses a two-button decision flow (Keep / Archive) —
|
||||||
// every other action is hidden so the choice can't be confused with
|
// every other action is hidden so the choice can't be confused with
|
||||||
// favoriting / heap-adding / restoring. The S keybinding is rerouted
|
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||||
// 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');
|
||||||
// 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
|
||||||
@@ -152,30 +150,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFavorite() {
|
|
||||||
const ids = snapshotIds();
|
|
||||||
if (ids.length === 0) return;
|
|
||||||
await withBusy(async () => {
|
|
||||||
const { updated, errors } = await batchEdit(ids, (id) => likePhoto(id));
|
|
||||||
if (errors.length) {
|
|
||||||
toast.error(`Favorited ${updated.length}; ${errors.length} failed`);
|
|
||||||
} else {
|
|
||||||
toast.success(`Favorited ${ids.length}`);
|
|
||||||
}
|
|
||||||
pushUndo(`Favorited ${ids.length}`, async () => {
|
|
||||||
await batchEdit(ids, (id) => unlikePhoto(id));
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
});
|
|
||||||
clearSelection();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onUndo() {
|
|
||||||
const entry = await popAndRun();
|
|
||||||
if (entry) toast.success(`Undone: ${entry.label}`);
|
|
||||||
else toast.message('Nothing to undo');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onAddToHeap(heap: PpAlbum) {
|
async function onAddToHeap(heap: PpAlbum) {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
@@ -244,8 +218,8 @@
|
|||||||
{#if isReview}
|
{#if isReview}
|
||||||
<!-- Review pile = binary decision. Keep approves (Quality →
|
<!-- Review pile = binary decision. Keep approves (Quality →
|
||||||
3+, lands in the main timeline); Archive batches into
|
3+, lands in the main timeline); Archive batches into
|
||||||
the archive section. Everything else (heap, favorite,
|
the archive section. Everything else (heap, restore)
|
||||||
restore) is hidden so the choice reads as decisive. -->
|
is hidden so the choice reads as decisive. -->
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -335,15 +309,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<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}
|
|
||||||
onclick={onFavorite}
|
|
||||||
title="Favorite"
|
|
||||||
>
|
|
||||||
♥ Favorite
|
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
|
|
||||||
</button>
|
|
||||||
<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}
|
||||||
@@ -353,25 +318,7 @@
|
|||||||
Archive
|
Archive
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||||
</button>
|
</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"
|
|
||||||
disabled={busy}
|
|
||||||
onclick={onRestore}
|
|
||||||
title="Restore"
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/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 || undoStack.entries.length === 0}
|
|
||||||
onclick={onUndo}
|
|
||||||
title="Undo last action"
|
|
||||||
>
|
|
||||||
Undo
|
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||||
onclick={clearAll}
|
onclick={clearAll}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Single photo tile — shared by the timeline (+page.svelte) and the flat
|
Single photo tile — shared by the timeline (+page.svelte) and the flat
|
||||||
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
|
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
|
||||||
and can't drift between the two. Owns: thumbnail, selection animation,
|
and can't drift between the two. Owns: thumbnail, selection animation,
|
||||||
favorite/video badges, and the hover-only "open preview" affordance.
|
video badge, and the hover-only "open preview" affordance.
|
||||||
|
|
||||||
Sizing is delegated to the caller — PhotoTile fills its container with
|
Sizing is delegated to the caller — PhotoTile fills its container with
|
||||||
h-full w-full, so the timeline can wrap it in its windowing shell and
|
h-full w-full, so the timeline can wrap it in its windowing shell and
|
||||||
@@ -144,12 +144,6 @@
|
|||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if photo.Favorite}
|
|
||||||
<span
|
|
||||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
|
|
||||||
>♥</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if isVideo(photo)}
|
{#if isVideo(photo)}
|
||||||
<span
|
<span
|
||||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||||
|
|||||||
@@ -175,8 +175,6 @@ export interface UpdatePhotoBody {
|
|||||||
OriginalName?: string;
|
OriginalName?: string;
|
||||||
Caption?: string;
|
Caption?: string;
|
||||||
CaptionSrc?: 'manual' | '';
|
CaptionSrc?: 'manual' | '';
|
||||||
Favorite?: boolean;
|
|
||||||
Private?: boolean;
|
|
||||||
Archived?: boolean;
|
Archived?: boolean;
|
||||||
/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
|
/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
|
||||||
* use `buildTakenAtPatch` to assemble all five fields from one ISO. */
|
* use `buildTakenAtPatch` to assemble all five fields from one ISO. */
|
||||||
@@ -195,6 +193,16 @@ export interface UpdatePhotoBody {
|
|||||||
Details?: Partial<import('$lib/types/photoprism').PpDetails>;
|
Details?: Partial<import('$lib/types/photoprism').PpDetails>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when `s` is a real calendar date in strict `YYYY-MM-DD` form. Rejects
|
||||||
|
* shape mismatches AND out-of-range parts that `Date` would silently roll
|
||||||
|
* over (e.g. `2026-02-30` → Mar 2). */
|
||||||
|
export function isValidISODate(s: string): boolean {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
||||||
|
const d = new Date(`${s}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(d.getTime())) return false;
|
||||||
|
return d.toISOString().slice(0, 10) === s;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return {};
|
if (Number.isNaN(d.getTime())) return {};
|
||||||
@@ -264,18 +272,6 @@ export async function approvePhoto(uid: string): Promise<void> {
|
|||||||
await http.post(`/photos/${uid}/approve`);
|
await http.post(`/photos/${uid}/approve`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
|
||||||
* routes that are atomic; preferred over PUT for this one field.
|
|
||||||
*/
|
|
||||||
export async function likePhoto(uid: string): Promise<void> {
|
|
||||||
await http.post(`/photos/${uid}/like`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unlikePhoto(uid: string): Promise<void> {
|
|
||||||
await http.delete(`/photos/${uid}/like`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stack file operations ───────────────────────────────────────────────────
|
// ── Stack file operations ───────────────────────────────────────────────────
|
||||||
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
|
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
|
||||||
// a single Photo entity. The duplicate-resolution flow needs two ops, both
|
// a single Photo entity. The duplicate-resolution flow needs two ops, both
|
||||||
@@ -324,8 +320,6 @@ export interface PpFolder {
|
|||||||
Root: string;
|
Root: string;
|
||||||
Title: string;
|
Title: string;
|
||||||
FileCount?: number;
|
FileCount?: number;
|
||||||
Favorite?: boolean;
|
|
||||||
Private?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,39 +348,6 @@ export async function listFolders(): Promise<PpFolder[]> {
|
|||||||
.filter((f) => f.Path !== '');
|
.filter((f) => f.Path !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
|
|
||||||
* files in a separate `/photoprism/import` root, exposed via
|
|
||||||
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
|
|
||||||
* `/folders/originals`, but the photo counts come from `X-Files` and
|
|
||||||
* `X-Folders` response headers since the body only lists subfolders.
|
|
||||||
*
|
|
||||||
* BasePath does NOT apply: `/folders/import` is a separate root from
|
|
||||||
* originals (PhotoPrism's `import.path`, not under originals/), so we
|
|
||||||
* don't filter the result by the signed-in user's BasePath. If/when
|
|
||||||
* per-user inbox isolation is needed, that's a PhotoPrism-side feature.
|
|
||||||
*/
|
|
||||||
export interface ImportInfo {
|
|
||||||
files: number;
|
|
||||||
folders: number;
|
|
||||||
subfolders: PpFolder[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getImportInfo(): Promise<ImportInfo> {
|
|
||||||
const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
|
|
||||||
params: { recursive: true, uncached: true, files: false }
|
|
||||||
});
|
|
||||||
const num = (h: unknown) => {
|
|
||||||
const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
|
|
||||||
return Number.isFinite(n) ? n : 0;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
files: num(res.headers['x-files'] ?? res.headers['X-Files']),
|
|
||||||
folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
|
|
||||||
subfolders: res.data.folders ?? []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
||||||
* endpoint reports `FileCount: 0` even when populated, so the count has
|
* endpoint reports `FileCount: 0` even when populated, so the count has
|
||||||
@@ -466,7 +427,6 @@ export interface PpLabel {
|
|||||||
Slug: string;
|
Slug: string;
|
||||||
CustomSlug?: string;
|
CustomSlug?: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
Favorite?: boolean;
|
|
||||||
Priority?: number;
|
Priority?: number;
|
||||||
Description?: string;
|
Description?: string;
|
||||||
PhotoCount?: number;
|
PhotoCount?: number;
|
||||||
@@ -551,7 +511,6 @@ export interface PpAlbum {
|
|||||||
Type: string;
|
Type: string;
|
||||||
Title: string;
|
Title: string;
|
||||||
Description?: string;
|
Description?: string;
|
||||||
Favorite?: boolean;
|
|
||||||
PhotoCount?: number;
|
PhotoCount?: number;
|
||||||
CreatedAt?: string;
|
CreatedAt?: string;
|
||||||
UpdatedAt?: string;
|
UpdatedAt?: string;
|
||||||
|
|||||||
@@ -4,14 +4,13 @@
|
|||||||
* string PhotoPrism's `q` parameter accepts.
|
* string PhotoPrism's `q` parameter accepts.
|
||||||
*
|
*
|
||||||
* Sections behave like saved searches: picking a section sets a default
|
* Sections behave like saved searches: picking a section sets a default
|
||||||
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
|
* filter shape (archive → `archived:true`, etc.), and the search box on
|
||||||
* etc.), and the search box on top stacks an additional `q` term.
|
* top stacks an additional `q` term.
|
||||||
*/
|
*/
|
||||||
import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte';
|
import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
export type Section =
|
export type Section =
|
||||||
| 'all-photos'
|
| 'all-photos'
|
||||||
| 'favorites'
|
|
||||||
| 'review'
|
| 'review'
|
||||||
| 'archive'
|
| 'archive'
|
||||||
| 'hidden'
|
| 'hidden'
|
||||||
@@ -30,7 +29,7 @@ export interface FilterState {
|
|||||||
// Default landing = root folder (`/`). The Folders group sits at the top
|
// Default landing = root folder (`/`). The Folders group sits at the top
|
||||||
// of the sidebar; landing inside it gives users a stable starting point
|
// of the sidebar; landing inside it gives users a stable starting point
|
||||||
// instead of dumping them into the full library. Picking any other view
|
// instead of dumping them into the full library. Picking any other view
|
||||||
// (favorites, a heap, "All photos") clears `folderPath` to `null`.
|
// (a heap, "All photos") clears `folderPath` to `null`.
|
||||||
export const filters = $state<FilterState>({
|
export const filters = $state<FilterState>({
|
||||||
section: 'all-photos',
|
section: 'all-photos',
|
||||||
heapUid: null,
|
heapUid: null,
|
||||||
@@ -69,9 +68,6 @@ function quoteIfNeeded(v: string): string {
|
|||||||
export function filtersToQ(f: FilterState = filters): string {
|
export function filtersToQ(f: FilterState = filters): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
switch (f.section) {
|
switch (f.section) {
|
||||||
case 'favorites':
|
|
||||||
parts.push('favorite:true');
|
|
||||||
break;
|
|
||||||
case 'review':
|
case 'review':
|
||||||
// PhotoPrism's review pile: photos the indexer flagged as
|
// PhotoPrism's review pile: photos the indexer flagged as
|
||||||
// uncertain (low quality score). Cleared per-photo via the
|
// uncertain (low quality score). Cleared per-photo via the
|
||||||
@@ -127,7 +123,7 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
||||||
const sectionRaw = params.get('section') as Section | null;
|
const sectionRaw = params.get('section') as Section | null;
|
||||||
const section: Section =
|
const section: Section =
|
||||||
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
|
sectionRaw && ['all-photos', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
|
||||||
? sectionRaw
|
? sectionRaw
|
||||||
: 'all-photos';
|
: 'all-photos';
|
||||||
// Bare URL (no section/folder/heap/q params) lands on the root folder
|
// Bare URL (no section/folder/heap/q params) lands on the root folder
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ export interface PpClientConfig {
|
|||||||
documents?: number;
|
documents?: number;
|
||||||
archived?: number;
|
archived?: number;
|
||||||
hidden?: number;
|
hidden?: number;
|
||||||
favorites?: number;
|
|
||||||
review?: number;
|
review?: number;
|
||||||
private?: number;
|
|
||||||
albums?: number;
|
albums?: number;
|
||||||
moments?: number;
|
moments?: number;
|
||||||
months?: number;
|
months?: number;
|
||||||
@@ -131,8 +129,6 @@ export interface PpPhoto {
|
|||||||
Height?: number;
|
Height?: number;
|
||||||
Rating?: number;
|
Rating?: number;
|
||||||
Color?: string | number;
|
Color?: string | number;
|
||||||
Favorite?: boolean;
|
|
||||||
Private?: boolean;
|
|
||||||
Archived?: boolean;
|
Archived?: boolean;
|
||||||
Files?: PpFile[];
|
Files?: PpFile[];
|
||||||
Lat?: number;
|
Lat?: number;
|
||||||
@@ -213,7 +209,7 @@ export interface PpPhotoLabel {
|
|||||||
Source?: string;
|
Source?: string;
|
||||||
Priority?: number;
|
Priority?: number;
|
||||||
Uncertainty?: number;
|
Uncertainty?: number;
|
||||||
Label?: { Slug: string; Name: string; Favorite?: boolean };
|
Label?: { Slug: string; Name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,8 +94,6 @@
|
|||||||
const sectionLabel = $derived(buildSectionLabel());
|
const sectionLabel = $derived(buildSectionLabel());
|
||||||
function buildSectionLabel(): string {
|
function buildSectionLabel(): string {
|
||||||
switch (filters.section) {
|
switch (filters.section) {
|
||||||
case "favorites":
|
|
||||||
return "Favorites";
|
|
||||||
case "review":
|
case "review":
|
||||||
return "Review";
|
return "Review";
|
||||||
case "archive":
|
case "archive":
|
||||||
@@ -184,7 +182,7 @@
|
|||||||
// Root-folder scope is the only client-side filter we apply, and only
|
// Root-folder scope is the only client-side filter we apply, and only
|
||||||
// when the timeline is actually showing a folder view — never when the
|
// when the timeline is actually showing a folder view — never when the
|
||||||
// user is in a heap, has a free-form search, or is on a non-default
|
// user is in a heap, has a free-form search, or is on a non-default
|
||||||
// section (favorites / archive / review / hidden). Those views are
|
// section (archive / review / hidden). Those views are
|
||||||
// scoped server-side via the q-DSL and must not be re-filtered here,
|
// scoped server-side via the q-DSL and must not be re-filtered here,
|
||||||
// or labels / search will silently drop subfolder photos when the
|
// or labels / search will silently drop subfolder photos when the
|
||||||
// store hasn't fully hydrated from the URL yet.
|
// store hasn't fully hydrated from the URL yet.
|
||||||
@@ -867,8 +865,6 @@
|
|||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{#if filters.section === "archive"}
|
{#if filters.section === "archive"}
|
||||||
Archive is empty.
|
Archive is empty.
|
||||||
{:else if filters.section === "favorites"}
|
|
||||||
No favorites yet. Heart a photo to add it here.
|
|
||||||
{:else if filters.section === "review"}
|
{:else if filters.section === "review"}
|
||||||
Nothing left to review. Photos PhotoPrism's indexer wasn't sure
|
Nothing left to review. Photos PhotoPrism's indexer wasn't sure
|
||||||
about land here — use Keep to accept them into the timeline or
|
about land here — use Keep to accept them into the timeline or
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
|
||||||
import {
|
|
||||||
listDuplicateGroups,
|
|
||||||
type DuplicateGroup
|
|
||||||
} from '$lib/services/adapters/duplicates';
|
|
||||||
import {
|
|
||||||
scanCrossFolderDuplicates,
|
|
||||||
type CrossFolderScanResult
|
|
||||||
} from '$lib/services/photoprism';
|
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
|
||||||
import {
|
|
||||||
setThumbnailSize,
|
|
||||||
THUMBNAIL_SIZE_LABELS,
|
|
||||||
THUMBNAIL_SIZE_PRESETS,
|
|
||||||
view
|
|
||||||
} from '$lib/stores/view.svelte';
|
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
|
||||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
|
||||||
|
|
||||||
// Same pill-tab pattern as /tags: tab state is URL-driven so the user
|
|
||||||
// can share / refresh / hit Back and land on the right panel.
|
|
||||||
type Tab = 'stacks' | 'cross-folder';
|
|
||||||
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
|
|
||||||
function parseTab(raw: string | null): Tab {
|
|
||||||
return raw === 'cross-folder' ? 'cross-folder' : 'stacks';
|
|
||||||
}
|
|
||||||
function setTab(tab: Tab) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (tab !== 'stacks') params.set('tab', tab);
|
|
||||||
void goto(`/duplicates${params.size ? '?' + params : ''}`, {
|
|
||||||
keepFocus: true,
|
|
||||||
noScroll: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale-time matches mule-image's DuplicatesView (30 s) so quick
|
|
||||||
// toolbar bounces don't refetch the (potentially expensive) stack
|
|
||||||
// listing. Invalidation by mutations is explicit, not time-driven.
|
|
||||||
// Enabled on both tabs so the stacks-count badge stays accurate even
|
|
||||||
// while the cross-folder tab is open.
|
|
||||||
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
|
|
||||||
queryKey: ['duplicates'],
|
|
||||||
queryFn: listDuplicateGroups,
|
|
||||||
enabled: isAuthenticated(),
|
|
||||||
staleTime: 30_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Observe-only: DuplicatesView's matching query (same key) is what
|
|
||||||
// actually triggers the scan when the cross-folder tab is active.
|
|
||||||
// Here we just read the cached count for the tab badge.
|
|
||||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
|
||||||
queryKey: ['duplicates-cross-folder'],
|
|
||||||
queryFn: scanCrossFolderDuplicates,
|
|
||||||
enabled: false,
|
|
||||||
staleTime: 5 * 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
const stacksCount = $derived(dupesQuery.data?.length);
|
|
||||||
const crossCount = $derived(crossQuery.data?.groups.length);
|
|
||||||
const TABS = $derived<{ id: Tab; label: string; count: number | undefined }[]>([
|
|
||||||
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
|
||||||
{ id: 'cross-folder', label: 'Cross-folder', count: crossCount }
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Toolbar>
|
|
||||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
||||||
Duplicates
|
|
||||||
</span>
|
|
||||||
<!-- Tabs mirror /tags' pill row visually: same height, same active
|
|
||||||
treatment, same hover affordance. Count badge appears once the
|
|
||||||
underlying query has data — cross-folder stays unbadged until
|
|
||||||
the tab has been opened at least once (lazy scan). -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{#each TABS as t (t.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
|
||||||
onclick={() => setTab(t.id)}
|
|
||||||
>
|
|
||||||
<span>{t.label}</span>
|
|
||||||
{#if t.count !== undefined}
|
|
||||||
<span
|
|
||||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
|
||||||
t.id
|
|
||||||
? 'bg-primary/15 text-primary'
|
|
||||||
: 'bg-secondary text-muted-foreground'}"
|
|
||||||
>
|
|
||||||
{t.count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#snippet trailing()}
|
|
||||||
<!-- Thumbnail-size control mirrors the timeline Toolbar. The view
|
|
||||||
store is global, so the picked size persists across routes —
|
|
||||||
when you come back to the timeline it stays where you left it. -->
|
|
||||||
<div
|
|
||||||
class="flex items-center overflow-hidden rounded border border-border"
|
|
||||||
role="group"
|
|
||||||
aria-label="Thumbnail size"
|
|
||||||
>
|
|
||||||
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
|
|
||||||
class:bg-accent={view.thumbnailSize === size}
|
|
||||||
class:text-foreground={view.thumbnailSize === size}
|
|
||||||
class:text-muted-foreground={view.thumbnailSize !== size}
|
|
||||||
onclick={() => setThumbnailSize(size)}
|
|
||||||
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
|
|
||||||
>
|
|
||||||
{THUMBNAIL_SIZE_LABELS[i]}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
<DuplicatesView
|
|
||||||
{activeTab}
|
|
||||||
groups={dupesQuery.data ?? []}
|
|
||||||
pending={dupesQuery.isPending}
|
|
||||||
error={dupesQuery.error}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
11
web/src/routes/duplicates/+page.ts
Normal file
11
web/src/routes/duplicates/+page.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// /duplicates was folded into /review as additional tabs. Preserve
|
||||||
|
// bookmarks and external links with a server-side redirect to the
|
||||||
|
// equivalent /review URL.
|
||||||
|
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = ({ url }) => {
|
||||||
|
const tab = url.searchParams.get('tab') === 'cross-folder' ? 'cross-folder' : 'stacks';
|
||||||
|
redirect(307, `/review?tab=${tab}`);
|
||||||
|
};
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import {
|
|
||||||
cancelImport,
|
|
||||||
getImportInfo,
|
|
||||||
startImport,
|
|
||||||
type ImportInfo
|
|
||||||
} from '$lib/services/photoprism';
|
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
|
||||||
|
|
||||||
// PhotoPrism's `/import` root holds uploaded-but-not-yet-indexed files
|
|
||||||
// (separate from /originals which is what the timeline reads). The
|
|
||||||
// folders endpoint returns the staging tree + counts via headers;
|
|
||||||
// kicking off the import is a single POST to /import. Mutations land
|
|
||||||
// in originals after PhotoPrism finishes processing — invalidate the
|
|
||||||
// originals/folders/config caches so the rest of the UI catches up.
|
|
||||||
const importQuery = createQuery<ImportInfo>(() => ({
|
|
||||||
queryKey: ['import'],
|
|
||||||
queryFn: getImportInfo,
|
|
||||||
enabled: isAuthenticated(),
|
|
||||||
// Refetch every 5 s while the page is open so progress is visible
|
|
||||||
// without the user having to refresh. Cheap call — just headers
|
|
||||||
// and a folder list.
|
|
||||||
refetchInterval: 5_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const importMut = createMutation(() => ({
|
|
||||||
// `move: true` is the typical workflow — once a file is indexed
|
|
||||||
// into originals it doesn't need to linger in the staging area.
|
|
||||||
mutationFn: () => startImport({ move: true }),
|
|
||||||
onSuccess: (r) => {
|
|
||||||
toast.success(r.message ?? 'Import started');
|
|
||||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['folders'] });
|
|
||||||
},
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
|
||||||
}));
|
|
||||||
|
|
||||||
const cancelMut = createMutation(() => ({
|
|
||||||
mutationFn: cancelImport,
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.message('Import cancelled');
|
|
||||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
|
||||||
},
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fileCount = $derived(importQuery.data?.files ?? 0);
|
|
||||||
const folderCount = $derived(importQuery.data?.folders ?? 0);
|
|
||||||
const empty = $derived(fileCount === 0 && folderCount === 0);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Toolbar>
|
|
||||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
||||||
Inbox
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] text-muted-foreground">
|
|
||||||
{fileCount} file{fileCount === 1 ? '' : 's'} · {folderCount} folder{folderCount === 1
|
|
||||||
? ''
|
|
||||||
: 's'}
|
|
||||||
</span>
|
|
||||||
{#snippet trailing()}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary hover:bg-primary/20 disabled:opacity-50"
|
|
||||||
disabled={empty || importMut.isPending}
|
|
||||||
onclick={() => importMut.mutate()}
|
|
||||||
title="Index files from the inbox into the main library"
|
|
||||||
>
|
|
||||||
{importMut.isPending ? 'Importing…' : 'Start import'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
|
||||||
disabled={!importMut.isPending}
|
|
||||||
onclick={() => cancelMut.mutate()}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
|
||||||
{#if importQuery.isPending}
|
|
||||||
<p class="text-sm text-muted-foreground">Loading inbox…</p>
|
|
||||||
{:else if importQuery.isError}
|
|
||||||
<p class="text-sm text-destructive">
|
|
||||||
Failed to read inbox: {importQuery.error instanceof Error
|
|
||||||
? importQuery.error.message
|
|
||||||
: 'unknown error'}
|
|
||||||
</p>
|
|
||||||
{:else if empty}
|
|
||||||
<div class="space-y-2 text-sm text-muted-foreground">
|
|
||||||
<p>The inbox is empty.</p>
|
|
||||||
<p>
|
|
||||||
Drop files into <code class="rounded bg-muted px-1">/photoprism/import</code> (the
|
|
||||||
bind mount in <code class="rounded bg-muted px-1">docker-compose.photoprism.yml</code>)
|
|
||||||
and they'll show up here. Click <strong>Start import</strong> to move them into the
|
|
||||||
main library; PhotoPrism indexes them, deduplicates against existing originals, and
|
|
||||||
files them under <code class="rounded bg-muted px-1">originals/{'{Y}/{M}'}</code>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{fileCount} file{fileCount === 1 ? '' : 's'} ready to import across {folderCount} subfolder{folderCount === 1
|
|
||||||
? ''
|
|
||||||
: 's'}.
|
|
||||||
</p>
|
|
||||||
{#if importQuery.data && importQuery.data.subfolders.length > 0}
|
|
||||||
<!-- PhotoPrism doesn't surface a per-folder file count for
|
|
||||||
/import; we just list the staging subfolders so the
|
|
||||||
user has a sense of what's in there. -->
|
|
||||||
<ul class="space-y-1 text-[12px]">
|
|
||||||
{#each importQuery.data.subfolders as f (f.Path)}
|
|
||||||
<li class="flex items-center gap-2 rounded border border-border px-2 py-1">
|
|
||||||
<span class="truncate font-mono">{f.Path || '/'}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
<!--
|
<!--
|
||||||
/review — PhotoPrism's quality-flagged photo queue, one tab per
|
/review — PhotoPrism's quality-flagged photo queue *plus* the
|
||||||
cause. Tiles, selection, keyboard nav, and bulk actions all come
|
duplicate-resolution panels (stacks & cross-folder). Cause tabs and
|
||||||
from the shared timeline machinery (PhotoGrid, gridKeyNav,
|
duplicate tabs share the same pill row so the user has a single
|
||||||
BulkActionBar, BulkMetadataSidebar) so muscle memory carries
|
"things to clean up" surface instead of two routes.
|
||||||
across routes.
|
|
||||||
|
Cause tabs use the shared timeline machinery (PhotoGrid, gridKeyNav,
|
||||||
|
BulkActionBar, BulkMetadataSidebar); duplicate tabs are a different
|
||||||
|
flow (per-group card with its own action buttons) so they render in
|
||||||
|
a stripped-down layout with no BulkActionBar / right sidebar.
|
||||||
|
|
||||||
The route flips `filters.section = 'review'` while it's mounted —
|
The route flips `filters.section = 'review'` while it's mounted —
|
||||||
that's what swings the shared action surface into review semantics
|
that's what swings the shared action surface into review semantics
|
||||||
(BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to
|
(BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to
|
||||||
approve). The previous section is restored on unmount so going
|
approve). The previous section is restored on unmount.
|
||||||
back to `/` lands on whatever the user had before.
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -20,6 +23,14 @@
|
|||||||
type CauseKey,
|
type CauseKey,
|
||||||
type ReviewGroup
|
type ReviewGroup
|
||||||
} from '$lib/services/adapters/review';
|
} from '$lib/services/adapters/review';
|
||||||
|
import {
|
||||||
|
listDuplicateGroups,
|
||||||
|
type DuplicateGroup
|
||||||
|
} from '$lib/services/adapters/duplicates';
|
||||||
|
import {
|
||||||
|
scanCrossFolderDuplicates,
|
||||||
|
type CrossFolderScanResult
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
import { clearSelection, selection } from '$lib/stores/selection.svelte';
|
import { clearSelection, selection } from '$lib/stores/selection.svelte';
|
||||||
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
||||||
@@ -39,6 +50,14 @@
|
|||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
||||||
|
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||||
|
|
||||||
|
type DupTab = 'stacks' | 'cross-folder';
|
||||||
|
type Tab = CauseKey | DupTab;
|
||||||
|
|
||||||
|
function isDupTab(t: Tab | null): t is DupTab {
|
||||||
|
return t === 'stacks' || t === 'cross-folder';
|
||||||
|
}
|
||||||
|
|
||||||
// Stash the section that was active when the user arrived; restore
|
// Stash the section that was active when the user arrived; restore
|
||||||
// on unmount so navigating away doesn't leak `section=review` to
|
// on unmount so navigating away doesn't leak `section=review` to
|
||||||
@@ -59,6 +78,24 @@
|
|||||||
staleTime: 30_000
|
staleTime: 30_000
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Stacks is a cheap PhotoPrism query so we run it eagerly — the
|
||||||
|
// "Stacks" tab badge needs the count even while the user is on a
|
||||||
|
// cause tab. Cross-folder is the expensive disk scan; the page
|
||||||
|
// observes its cache (enabled:false) and DuplicatesView is what
|
||||||
|
// triggers the actual scan when its tab is active.
|
||||||
|
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||||
|
queryKey: ['duplicates'],
|
||||||
|
queryFn: listDuplicateGroups,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 30_000
|
||||||
|
}));
|
||||||
|
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||||
|
queryKey: ['duplicates-cross-folder'],
|
||||||
|
queryFn: scanCrossFolderDuplicates,
|
||||||
|
enabled: false,
|
||||||
|
staleTime: 5 * 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||||
queryKey: ['photo', selection.focused ?? ''],
|
queryKey: ['photo', selection.focused ?? ''],
|
||||||
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
|
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
|
||||||
@@ -66,20 +103,33 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const groups = $derived(reviewQuery.data ?? []);
|
const groups = $derived(reviewQuery.data ?? []);
|
||||||
const tabs = $derived(
|
const stacksCount = $derived(stacksQuery.data?.length);
|
||||||
groups.map((g) => ({ id: g.cause, label: g.meta.title, count: g.photos.length }))
|
const crossFolderCount = $derived(crossFolderQuery.data?.groups.length);
|
||||||
);
|
|
||||||
|
type TabSpec = { id: Tab; label: string; count: number | undefined };
|
||||||
|
const tabs = $derived<TabSpec[]>([
|
||||||
|
...groups.map((g) => ({
|
||||||
|
id: g.cause as Tab,
|
||||||
|
label: g.meta.title,
|
||||||
|
count: g.photos.length as number | undefined
|
||||||
|
})),
|
||||||
|
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
||||||
|
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
|
||||||
|
]);
|
||||||
|
|
||||||
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
||||||
const activeTab: CauseKey | null = $derived.by(() => {
|
const activeTab: Tab = $derived.by(() => {
|
||||||
if (tabs.length === 0) return null;
|
|
||||||
const want = tabs.find((t) => t.id === requestedTab);
|
const want = tabs.find((t) => t.id === requestedTab);
|
||||||
return (want ?? tabs[0]).id;
|
return (want ?? tabs[0]).id;
|
||||||
});
|
});
|
||||||
|
const activeIsDup = $derived(isDupTab(activeTab));
|
||||||
|
|
||||||
// Selection is global — without this effect, a user who multi-
|
// Selection is global — without this effect, a user who multi-
|
||||||
// selected in `low_resolution` then switched to `stripped_exif`
|
// selected in `low_resolution` then switched to `stripped_exif`
|
||||||
// would carry the previous tab's UIDs into the new tab's
|
// would carry the previous tab's UIDs into the new tab's
|
||||||
// BulkActionBar verbs and accidentally act on the wrong photos.
|
// BulkActionBar verbs and accidentally act on the wrong photos.
|
||||||
|
// Also fires when switching into a duplicates tab (where selection
|
||||||
|
// is meaningless anyway).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void activeTab;
|
void activeTab;
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -87,9 +137,13 @@
|
|||||||
|
|
||||||
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
||||||
|
|
||||||
function setTab(id: CauseKey) {
|
function setTab(id: Tab) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (tabs.length > 0 && tabs[0].id !== id) params.set('tab', id);
|
// First cause tab (if any) is the default — same convention as
|
||||||
|
// the old /review behaviour, so back-from-cross-folder lands on
|
||||||
|
// the user's review queue rather than the empty Stacks panel.
|
||||||
|
const defaultId = tabs[0]?.id;
|
||||||
|
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
|
||||||
void goto(`/review${params.size ? '?' + params : ''}`, {
|
void goto(`/review${params.size ? '?' + params : ''}`, {
|
||||||
keepFocus: true,
|
keepFocus: true,
|
||||||
noScroll: true
|
noScroll: true
|
||||||
@@ -106,13 +160,22 @@
|
|||||||
{#each tabs as t (t.id)}
|
{#each tabs as t (t.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
? 'border-primary/40 bg-primary/10 text-primary'
|
||||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||||
onclick={() => setTab(t.id)}
|
onclick={() => setTab(t.id)}
|
||||||
>
|
>
|
||||||
{t.label}
|
<span>{t.label}</span>
|
||||||
<span class="ml-1 text-muted-foreground/70">({t.count})</span>
|
{#if t.count !== undefined}
|
||||||
|
<span
|
||||||
|
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
||||||
|
t.id
|
||||||
|
? 'bg-primary/15 text-primary'
|
||||||
|
: 'bg-secondary text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{t.count}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +203,26 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
<!--
|
||||||
|
Two layout branches:
|
||||||
|
• Cause tabs use the timeline-style layout (grid key nav, bulk
|
||||||
|
action bar, right metadata sidebar) since the user is acting on
|
||||||
|
individual photos.
|
||||||
|
• Duplicate tabs use a stripped layout — DuplicatesView renders
|
||||||
|
its own per-group cards with built-in actions, so the bulk bar
|
||||||
|
and right sidebar would just clutter.
|
||||||
|
-->
|
||||||
|
{#if activeIsDup}
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<DuplicatesView
|
||||||
|
activeTab={activeTab as DupTab}
|
||||||
|
groups={stacksQuery.data ?? []}
|
||||||
|
pending={stacksQuery.isPending}
|
||||||
|
error={stacksQuery.error}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||||
{#if reviewQuery.isPending}
|
{#if reviewQuery.isPending}
|
||||||
@@ -157,7 +239,8 @@
|
|||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
PhotoPrism's indexer flags photos with a low quality score for human
|
PhotoPrism's indexer flags photos with a low quality score for human
|
||||||
review. New arrivals with missing EXIF, low resolution, or unknown
|
review. New arrivals with missing EXIF, low resolution, or unknown
|
||||||
cameras will land here.
|
cameras will land here. The Stacks and Cross-folder tabs above stay
|
||||||
|
available for duplicate cleanup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeGroup}
|
{:else if activeGroup}
|
||||||
@@ -196,4 +279,5 @@
|
|||||||
></div>
|
></div>
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user