diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index cdfe907..2ea24ff 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -7,9 +7,7 @@ import { batchArchive, batchDelete, batchRestore, - likePhoto, removeFromHeap, - unlikePhoto, type PpAlbum } from '$lib/services/photoprism'; import { queryClient } from '$lib/queryClient'; @@ -55,7 +53,7 @@ export interface GridKeyNavParams { * - Arrow-key focus navigation (with shift-extend) inside the visible grid * - Click + shift/ctrl click selection mutations * - 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 * left sidebar, i toggles right sidebar, space/enter opens preview, * esc clears, ⌘Z undoes, ⌘A selects all visible. @@ -302,46 +300,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { 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) ──────────────────────────────────────────── // 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 @@ -548,12 +506,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { e.preventDefault(); void toggleArchive('restore'); return; - case 'f': - case 'F': - if (meta || shift) return; - e.preventDefault(); - void toggleFavoriteOnTargets(); - return; case 's': case 'S': if (meta || shift) return; diff --git a/web/src/lib/components/layout/GeneralSettingsDialog.svelte b/web/src/lib/components/layout/GeneralSettingsDialog.svelte index 62d6547..ede99fe 100644 --- a/web/src/lib/components/layout/GeneralSettingsDialog.svelte +++ b/web/src/lib/components/layout/GeneralSettingsDialog.svelte @@ -63,7 +63,6 @@ 'default', 'browse', 'albums', - 'favorites', 'calendar', 'moments', 'people', diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index d7dc2b2..7dd9137 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -13,7 +13,6 @@ duplicateHeap, getAllMarks, getConfig, - getImportInfo, heapDownloadUrl, listFolderCounts, listFolders, @@ -24,7 +23,6 @@ scanCrossFolderDuplicates, triggerDownload, type CrossFolderScanResult, - type ImportInfo, type PhotoMarksMap, type PpAlbum, type PpClientConfig, @@ -73,27 +71,17 @@ 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(() => ({ - queryKey: ['import'], - queryFn: getImportInfo, - enabled: isAuthenticated(), - staleTime: 60_000 - })); - // View counts come from PhotoPrism's `/config` response, which carries a - // precomputed counter for every common bucket (all/favorites/archived/ - // labels/places/…) updated incrementally on every mutation. Cheap to - // refetch, and gives us a stable total — `/photos` only returns - // per-page row counts via `X-Count`, never a total. + // precomputed counter for every common bucket (all/archived/labels/ + // places/…) updated incrementally on every mutation. Cheap to refetch, + // and gives us a stable total — `/photos` only returns per-page row + // counts via `X-Count`, never a total. // // The key sits under the `['photos', …]` prefix so it inherits the // existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered - // across mutations (favorite, archive, restore, delete, heap add) — the - // counter map refreshes whenever the photo list does. Marks-derived - // counts (ratings/colors) react through the shared `['marks']` cache. + // across mutations (archive, restore, delete, heap add) — the counter + // map refreshes whenever the photo list does. Marks-derived counts + // (ratings/colors) react through the shared `['marks']` cache. const configQuery = createQuery(() => ({ queryKey: ['photos', 'config'], queryFn: getConfig, @@ -267,6 +255,13 @@ const rootActive = $derived(filters.folderPath === '/'); 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() { await logout(); await goto('/login', { replaceState: true }); @@ -390,9 +385,9 @@ // "everything visible in the main timeline" tally), so it matches what // the All photos view actually renders. `places` is the count of // geocoded locations — semantically what the Map view groups by. - // Duplicates sums stacks + cross-folder groups; cross-folder only - // contributes once its tab has been visited (the scan is opt-in - // per-visit, not eager from the sidebar). + // Review rolls in the duplicates tabs hosted under /review — stacks + // always contributes; cross-folder only contributes once its tab has + // been opened (the scan is lazy, not eager from the sidebar). type ViewItem = | { kind: 'section'; id: Section; 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 // duplicate it for users whose photos live under the root. 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 }, // Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors); // the badge shows total labels + keywords + ratings + colors so the @@ -421,15 +414,14 @@ ]; const manageViews: ViewItem[] = [ - { kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review }, { kind: 'route', - href: '/duplicates', - label: 'Duplicates', + href: '/review', + label: 'Review', getCount: () => { - const stacks = stacksQuery.data?.length; - if (stacks === undefined) return undefined; - return stacks + (crossFolderQuery.data?.groups.length ?? 0); + const review = configQuery.data?.count?.review; + if (review === undefined) return undefined; + return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0); } }, { 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:px-1={hasSubfolders} onclick={() => pickFolder('/')} - title="Photos directly under originals/" + title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`} > - / + {rootLabel} {#if configQuery.data} Date taken - @@ -356,8 +335,12 @@
@@ -606,66 +589,6 @@
- -
setMetadataSection('credits', e.currentTarget.open)} - > - - Credits & notes - -
- - - - - -
-
-
0); // Review section uses a two-button decision flow (Keep / Archive) — // every other action is hidden so the choice can't be confused with - // favoriting / heap-adding / restoring. The S keybinding is rerouted - // to approve from gridKeyNav for the same reason. + // heap-adding / restoring. The S keybinding is rerouted to approve + // from gridKeyNav for the same reason. const isReview = $derived(filters.section === 'review'); // Archive section is the parallel two-button flow: Keep (restore back // 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) { const ids = snapshotIds(); if (!ids.length) return; @@ -244,8 +218,8 @@ {#if isReview} + the archive section. Everything else (heap, restore) + is hidden so the choice reads as decisive. --> - {/if} - - {/each} - - {#snippet trailing()} - -
- {#each THUMBNAIL_SIZE_PRESETS as size, i (size)} - - {/each} -
- {/snippet} - - -
- -
diff --git a/web/src/routes/duplicates/+page.ts b/web/src/routes/duplicates/+page.ts new file mode 100644 index 0000000..feacfec --- /dev/null +++ b/web/src/routes/duplicates/+page.ts @@ -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}`); +}; diff --git a/web/src/routes/inbox/+page.svelte b/web/src/routes/inbox/+page.svelte deleted file mode 100644 index 44f6c00..0000000 --- a/web/src/routes/inbox/+page.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - - - - Inbox - - - {fileCount} file{fileCount === 1 ? '' : 's'} · {folderCount} folder{folderCount === 1 - ? '' - : 's'} - - {#snippet trailing()} - - - {/snippet} - - -
- {#if importQuery.isPending} -

Loading inbox…

- {:else if importQuery.isError} -

- Failed to read inbox: {importQuery.error instanceof Error - ? importQuery.error.message - : 'unknown error'} -

- {:else if empty} -
-

The inbox is empty.

-

- Drop files into /photoprism/import (the - bind mount in docker-compose.photoprism.yml) - and they'll show up here. Click Start import to move them into the - main library; PhotoPrism indexes them, deduplicates against existing originals, and - files them under originals/{'{Y}/{M}'}. -

-
- {:else} -
-

- {fileCount} file{fileCount === 1 ? '' : 's'} ready to import across {folderCount} subfolder{folderCount === 1 - ? '' - : 's'}. -

- {#if importQuery.data && importQuery.data.subfolders.length > 0} - -
    - {#each importQuery.data.subfolders as f (f.Path)} -
  • - {f.Path || '/'} -
  • - {/each} -
- {/if} -
- {/if} -
diff --git a/web/src/routes/review/+page.svelte b/web/src/routes/review/+page.svelte index 9d7926e..255cb82 100644 --- a/web/src/routes/review/+page.svelte +++ b/web/src/routes/review/+page.svelte @@ -1,15 +1,18 @@