From 0d5f38094801730b06044be9521a7b2170e6e3b9 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 20 May 2026 00:13:20 +0200 Subject: [PATCH] preview: full-screen modal replaces inline split + tags route reorg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old SplitGrid + InlinePreview pane is replaced by a full-screen PreviewModal mounted once at the layout root. Open via Space on the focused tile or double-click; close on Esc (or X / Space again). Inside, PreviewPane renders the focused photo, RightSidebar carries the metadata, BulkActionBar reuses the existing per-photo actions, and PreviewCarousel windows ±50 thumbs around the focused index. Selection contract matches the grid: plain click reduces, shift extends the range, ⌘/Ctrl toggles, plain arrow drops the multi- selection, shift-arrow extends. New clearBulkToFirst() helper makes Esc / Clear collapse a bulk back to single-focus on its first member before the next press fully dismisses (modal closes, grid clears focus). Tags route reorganised into /tags/[category]/[[value]] with its own +layout and TagsBrowserSidebar; the old monolithic /tags/+page is trimmed to a legacy redirect. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/actions/gridKeyNav.ts | 38 +- web/src/lib/actions/resizableVertical.ts | 75 -- .../lib/components/layout/FolderTree.svelte | 15 +- .../lib/components/layout/LeftSidebar.svelte | 232 +++++-- .../components/preview/PreviewCarousel.svelte | 159 +++++ .../components/preview/PreviewModal.svelte | 168 +++++ ...nlinePreview.svelte => PreviewPane.svelte} | 58 +- .../components/preview/SelectionDeck.svelte | 112 --- .../lib/components/preview/SplitGrid.svelte | 59 -- .../lib/components/preview/VideoPlayer.svelte | 8 +- .../sidebar/TagsBrowserSidebar.svelte | 409 +++++++++++ .../components/timeline/BulkActionBar.svelte | 5 + .../lib/components/timeline/PhotoGrid.svelte | 3 +- web/src/lib/stores/filters.svelte.ts | 71 +- web/src/lib/stores/selection.svelte.ts | 27 + web/src/lib/stores/view.svelte.ts | 66 +- web/src/lib/utils/tagGroups.ts | 98 +++ web/src/routes/+layout.svelte | 6 + web/src/routes/+page.svelte | 11 +- web/src/routes/photo/[uid]/+page.svelte | 6 +- web/src/routes/review/+page.svelte | 59 +- web/src/routes/tags/+layout.svelte | 61 ++ web/src/routes/tags/+page.svelte | 656 +----------------- .../tags/[category]/[[value]]/+page.svelte | 259 +++++++ 24 files changed, 1593 insertions(+), 1068 deletions(-) delete mode 100644 web/src/lib/actions/resizableVertical.ts create mode 100644 web/src/lib/components/preview/PreviewCarousel.svelte create mode 100644 web/src/lib/components/preview/PreviewModal.svelte rename web/src/lib/components/preview/{InlinePreview.svelte => PreviewPane.svelte} (52%) delete mode 100644 web/src/lib/components/preview/SelectionDeck.svelte delete mode 100644 web/src/lib/components/preview/SplitGrid.svelte create mode 100644 web/src/lib/components/sidebar/TagsBrowserSidebar.svelte create mode 100644 web/src/lib/utils/tagGroups.ts create mode 100644 web/src/routes/tags/+layout.svelte create mode 100644 web/src/routes/tags/[category]/[[value]]/+page.svelte diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index fc3ebbd..ec91aaf 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -13,6 +13,7 @@ import { import { queryClient } from '$lib/queryClient'; import { filters } from '$lib/stores/filters.svelte'; import { + clearBulkToFirst, clearSelection, focusAfter, indexOf, @@ -23,7 +24,7 @@ import { toggle } from '$lib/stores/selection.svelte'; import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte'; -import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte'; +import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; import type { PpPhoto } from '$lib/types/photoprism'; /** @@ -381,6 +382,24 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + // Modal owns arrow / Escape / Space while it's open — it handles + // its own linear nav, close-on-Esc, and close-on-Space. Action + // keys (X/S/U/A/Z) still pass through because they target the + // shared selection store and work the same in either context. + if (view.previewOpen) { + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'Escape' || + e.key === ' ' || + e.code === 'Space' + ) { + return; + } + } + // S+digit chord. A digit 1–9 within the chord window consumes the key // and fires add-to-heap-N. Any other key cancels the chord without // firing the default active-heap action — the user switched intent — @@ -398,6 +417,18 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { const meta = e.metaKey || e.ctrlKey; const shift = e.shiftKey; + // Space on a focused tile opens the full-screen preview modal. + // Matches the dblclick gesture so the user has both keyboard and + // mouse paths to the same surface. `e.code === 'Space'` covers + // layouts where `e.key` is the dead-key combining mark. + if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) { + if (selection.focused) { + e.preventDefault(); + openPreview(); + return; + } + } + // ── Grid nav keys ──────────────────────────────────────────────────── switch (e.key) { case 'ArrowLeft': @@ -424,6 +455,11 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { } return; case 'Escape': + // First Esc collapses a multi-selection back to single-focus + // on its first member — the user's "starting photo" stays + // visible instead of vanishing. Only when there's no bulk + // does Esc fully dismiss focus. + if (clearBulkToFirst()) return; clearSelection(); setFocused(null); return; diff --git a/web/src/lib/actions/resizableVertical.ts b/web/src/lib/actions/resizableVertical.ts deleted file mode 100644 index 31dfeff..0000000 --- a/web/src/lib/actions/resizableVertical.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Drag-to-resize Svelte action for vertical splits. Sibling of `resizable` - * (which handles left/right edges); kept as its own file so each action's - * surface stays small and the call sites read obviously. - * - * edge: 'bottom' — handle on the bottom edge of the pane; drag down enlarges - * edge: 'top' — handle on the top edge of the pane; drag up enlarges - * - * Usage (handle on the bottom edge of the top preview pane): - *
view.previewPaneHeight, - * setHeight: setPreviewPaneHeight }} /> - */ -export interface ResizableVerticalParams { - edge: 'top' | 'bottom'; - getHeight: () => number; - setHeight: (px: number) => void; -} - -export function resizableVertical(node: HTMLElement, initial: ResizableVerticalParams) { - let params = initial; - let pointerId = -1; - let startY = 0; - let startHeight = 0; - - function onDown(e: PointerEvent) { - if (e.button !== 0) return; - pointerId = e.pointerId; - startY = e.clientY; - startHeight = params.getHeight(); - node.setPointerCapture(pointerId); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - node.addEventListener('pointermove', onMove); - node.addEventListener('pointerup', onUp); - node.addEventListener('pointercancel', onUp); - } - - function onMove(e: PointerEvent) { - if (e.pointerId !== pointerId) return; - const dy = e.clientY - startY; - // `edge: 'bottom'` — handle on the bottom edge of the controlled pane, - // drag down grows it. `edge: 'top'` — handle on the top edge of the - // controlled pane (i.e. the pane is below the handle), drag up grows - // it, so the delta is inverted. Mirrors the horizontal action. - const delta = params.edge === 'bottom' ? dy : -dy; - params.setHeight(startHeight + delta); - } - - function onUp(e: PointerEvent) { - if (pointerId === -1) return; - try { - node.releasePointerCapture(pointerId); - } catch { - // Pointer may already be released; ignore. - } - pointerId = -1; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - node.removeEventListener('pointermove', onMove); - node.removeEventListener('pointerup', onUp); - node.removeEventListener('pointercancel', onUp); - } - - node.addEventListener('pointerdown', onDown); - - return { - update(next: ResizableVerticalParams) { - params = next; - }, - destroy() { - node.removeEventListener('pointerdown', onDown); - } - }; -} diff --git a/web/src/lib/components/layout/FolderTree.svelte b/web/src/lib/components/layout/FolderTree.svelte index e4bf381..f7de61d 100644 --- a/web/src/lib/components/layout/FolderTree.svelte +++ b/web/src/lib/components/layout/FolderTree.svelte @@ -120,11 +120,11 @@ with the px-2 of Views/Heaps rows; +12px per nested level. -->
{#if hasChildren} - {:else if depth > 0} + {:else} + peers at every depth, so labels share a common left edge + across the sidebar (folders, heaps, views, manage). --> {/if} + {:else} + + {/if}
-
-
- - Views - -
- {#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)} - {@render viewRow(v)} - {/each} -
- - -
-
- - Manage - -
- {#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)} - {@render viewRow(v)} - {/each} -
- -
-
+
Heaps @@ -781,20 +812,20 @@ open), pushing the button slightly left. -->
  • + +
    + + +
    +
    + + Manage + +
    + {#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)} + {@render viewRow(v)} + {/each} +
    + + + +
    + {#each slice as tile (tile.uid)} + {@const isFocused = tile.uid === focused} + {@const isSelected = isFocused || selection.ids.has(tile.uid)} + + + {/each} +
    diff --git a/web/src/lib/components/preview/PreviewModal.svelte b/web/src/lib/components/preview/PreviewModal.svelte new file mode 100644 index 0000000..78869a9 --- /dev/null +++ b/web/src/lib/components/preview/PreviewModal.svelte @@ -0,0 +1,168 @@ + + + + { + if (!o) closePreview(); + }} +> + + + + Photo preview + + Full-screen preview of the focused photo with metadata and a thumbnail carousel. + + + +
    +
    + +
    + +
    +
    + {#if focusedPhotoQuery.data} + + {/if} +
    + + + + + + +
    +
    +
    diff --git a/web/src/lib/components/preview/InlinePreview.svelte b/web/src/lib/components/preview/PreviewPane.svelte similarity index 52% rename from web/src/lib/components/preview/InlinePreview.svelte rename to web/src/lib/components/preview/PreviewPane.svelte index ccb7675..bb02ac0 100644 --- a/web/src/lib/components/preview/InlinePreview.svelte +++ b/web/src/lib/components/preview/PreviewPane.svelte @@ -1,34 +1,29 @@ -
    - {#if multiSelect} - - {:else if uid === null} +
    + {#if uid === null}

    Select a photo to preview.

    {:else if photoQuery.isPending}

    Loading…

    @@ -82,7 +65,7 @@

    Failed to load photo.

    {:else if photoQuery.data} {@const pf = primaryFile(photoQuery.data)} - {#if currentIndex > 0} + {#if showChevrons && currentIndex > 0} {/if} - {#if currentIndex >= 0 && currentIndex < order.length - 1} + {#if showChevrons && currentIndex >= 0 && currentIndex < order.length - 1} + {/each} + + + {#if hasMoreLabels} +

    + Loading more… ({visibleCount} / {filteredLabels.length}) +

    + {/if} +
    + {/if} + {:else if category === 'keywords'} + {#if keywordsQuery.isPending} +

    + Loading keywords… (aggregates from photo details — first load may take a few seconds) +

    + {:else if keywordsQuery.isError} +

    Failed to load keywords.

    + {:else if filteredKeywords.length === 0} +

    + {filterText + ? 'No keywords match the filter.' + : 'No user-set keywords yet. Add them from a photo’s right-sidebar metadata panel.'} +

    + {:else} +
    + {#each visibleKeywords as kw (kw.keyword)} + {@const active = kw.keyword === selectedValue} + + {/each} + + {#if hasMoreKeywords} +

    + Loading more… ({visibleCount} / {filteredKeywords.length}) +

    + {/if} +
    + {/if} + {:else if category === 'colors'} + {#if marksQuery.isPending || marksPoolQuery.isPending} +

    Loading colors…

    + {:else if marksQuery.isError || marksPoolQuery.isError} +

    Failed to load colors.

    + {:else} +
    + {#each COLOR_SWATCHES as swatch (swatch.key)} + {@const group = colorGroups.find((g) => g.key === swatch.key)} + {@const count = group?.photos.length ?? 0} + {@const active = swatch.key === selectedValue} + {@const disabled = count === 0} + + {/each} +
    + {/if} + {:else if category === 'ratings'} + {#if marksQuery.isPending || marksPoolQuery.isPending} +

    Loading ratings…

    + {:else if marksQuery.isError || marksPoolQuery.isError} +

    Failed to load ratings.

    + {:else} +
    + {#each [5, 4, 3, 2, 1] as r (r)} + {@const group = ratingGroups.find((g) => g.rating === r)} + {@const count = group?.photos.length ?? 0} + {@const active = String(r) === selectedValue} + {@const disabled = count === 0} + + {/each} +
    + {/if} + {/if} +
    diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte index faee471..821d1ed 100644 --- a/web/src/lib/components/timeline/BulkActionBar.svelte +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -13,6 +13,7 @@ } from '$lib/services/photoprism'; import { batchEdit } from '$lib/services/batch'; import { + clearBulkToFirst, clearSelection, focusAfter, selection, @@ -59,6 +60,10 @@ const isArchive = $derived(filters.section === 'archive'); function clearAll() { + // Mirror gridKeyNav's Esc: a multi-selection collapses back to its + // first member (the user keeps a single-focus reference) before + // the next Clear/Esc fully dismisses focus. + if (clearBulkToFirst()) return; clearSelection(); setFocused(null); } diff --git a/web/src/lib/components/timeline/PhotoGrid.svelte b/web/src/lib/components/timeline/PhotoGrid.svelte index dbaf47c..676f848 100644 --- a/web/src/lib/components/timeline/PhotoGrid.svelte +++ b/web/src/lib/components/timeline/PhotoGrid.svelte @@ -21,7 +21,7 @@ setFocused, setOrder } from '$lib/stores/selection.svelte'; - import { view } from '$lib/stores/view.svelte'; + import { openPreview, view } from '$lib/stores/view.svelte'; import { type PpPhoto } from '$lib/types/photoprism'; import PhotoTile from './PhotoTile.svelte'; @@ -80,6 +80,7 @@ selection.ids.add(uid); setFocused(uid); setAnchor(uid); + openPreview(); } diff --git a/web/src/lib/stores/filters.svelte.ts b/web/src/lib/stores/filters.svelte.ts index 9b08f26..6b2013f 100644 --- a/web/src/lib/stores/filters.svelte.ts +++ b/web/src/lib/stores/filters.svelte.ts @@ -7,6 +7,7 @@ * filter shape (archive → `archived:true`, etc.), and the search box on * top stacks an additional `q` term. */ +import { goto } from '$app/navigation'; import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte'; export type Section = @@ -16,6 +17,22 @@ export type Section = | 'hidden' | 'heap'; +export type TagCategory = 'labels' | 'keywords' | 'colors' | 'ratings'; + +export const TAG_CATEGORIES: readonly TagCategory[] = [ + 'labels', + 'keywords', + 'colors', + 'ratings' +] as const; + +export function isTagCategory(v: unknown): v is TagCategory { + return ( + typeof v === 'string' && + (TAG_CATEGORIES as readonly string[]).includes(v) + ); +} + export interface FilterState { section: Section; /** Heap UID, used when section === 'heap'. */ @@ -24,6 +41,14 @@ export interface FilterState { folderPath: string | null; /** Free-form search text, ANDed with section-derived terms. */ search: string; + /** + * Active tag-browser category and selected value. Set by the + * `/tags/[category]/[[value]]` route on navigation. Labels/keywords + * feed `filtersToQ()` (PhotoPrism DSL); colors/ratings are resolved + * client-side from the marks pool and don't contribute to `q`. + */ + tagCategory: TagCategory | null; + tagValue: string | null; } // Default landing = root folder (`/`). The Folders group sits at the top @@ -34,7 +59,9 @@ export const filters = $state({ section: 'all-photos', heapUid: null, folderPath: '/', - search: '' + search: '', + tagCategory: null, + tagValue: null }); export function setSection(section: Section, heapUid: string | null = null): void { @@ -50,6 +77,38 @@ export function setFolderPath(path: string | null): void { filters.folderPath = path; } +export function setTagFilter( + category: TagCategory | null, + value: string | null +): void { + filters.tagCategory = category; + filters.tagValue = value; +} + +/** + * Navigate to a tag-category browse URL. Path-segment shape + * (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's + * dynamic route plumbing typecheck `page.params`. + * + * `replace` is used by the auto-select-first-tag effect so the + * empty-category URL (`/tags/labels`) doesn't end up in history — back + * would otherwise loop straight back into the auto-select redirect. + */ +export async function navigateToTag( + category: TagCategory, + value: string | null, + options: { replace?: boolean } = {} +): Promise { + const path = value + ? `/tags/${category}/${encodeURIComponent(value)}` + : `/tags/${category}`; + await goto(path, { + keepFocus: true, + noScroll: true, + replaceState: options.replace ?? false + }); +} + /** * Quote a DSL term value when it contains characters that PhotoPrism's * parser treats as boundaries (spaces, colons). We surround in double @@ -115,6 +174,16 @@ export function filtersToQ(f: FilterState = filters): string { parts.push(`path:${quoteIfNeeded(serverPath + '*')}`); } } + // Tag drill-down clauses for server-resolvable tag categories. + // Colors/ratings live in the mule-sidecar marks store and are + // applied client-side after the photo pool is fetched. + if (f.tagCategory && f.tagValue) { + if (f.tagCategory === 'labels') { + parts.push(`label:${quoteIfNeeded(f.tagValue)}`); + } else if (f.tagCategory === 'keywords') { + parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`); + } + } if (f.search) parts.push(quoteIfNeeded(f.search)); return parts.join(' '); } diff --git a/web/src/lib/stores/selection.svelte.ts b/web/src/lib/stores/selection.svelte.ts index cf21936..b061a30 100644 --- a/web/src/lib/stores/selection.svelte.ts +++ b/web/src/lib/stores/selection.svelte.ts @@ -51,6 +51,33 @@ export function clearSelection(): void { selection.anchor = null; } +/** + * Collapse a multi-selection (ids.size >= 2) down to single-focus on the + * first selected uid in display order. Returns true when a bulk was + * actually collapsed so callers can branch on it ("did Esc consume the + * bulk, or should it dismiss the surface itself?"). Used by gridKeyNav's + * Esc handler, BulkActionBar's Clear button, and the preview modal's + * Esc handler — all want the same "first Esc drops the multi-select, + * second Esc dismisses" UX. + */ +export function clearBulkToFirst(): boolean { + if (selection.ids.size < 2) return false; + let first: string | null = null; + for (const uid of selection.order) { + if (selection.ids.has(uid)) { + first = uid; + break; + } + } + clearSelection(); + if (first) { + selection.ids.add(first); + selection.focused = first; + selection.anchor = first; + } + return true; +} + export function toggle(uid: string): void { if (selection.ids.has(uid)) { selection.ids.delete(uid); diff --git a/web/src/lib/stores/view.svelte.ts b/web/src/lib/stores/view.svelte.ts index 878f416..e402a98 100644 --- a/web/src/lib/stores/view.svelte.ts +++ b/web/src/lib/stores/view.svelte.ts @@ -24,7 +24,8 @@ interface Persisted { thumbnailSize?: ThumbnailSize; leftSidebarWidth?: number; rightSidebarWidth?: number; - previewPaneHeight?: number; + tagsBrowserWidth?: number; + tagsBrowserCollapsed?: boolean; /** * Per-section expanded state for the right-sidebar metadata panel * (GPS, Credits, File). Keyed by section id; missing entries use a @@ -40,14 +41,9 @@ export const DEFAULT_LEFT_WIDTH = 224; export const MIN_RIGHT_WIDTH = 220; export const MAX_RIGHT_WIDTH = 480; export const DEFAULT_RIGHT_WIDTH = 280; -export const MIN_PREVIEW_HEIGHT = 160; -export const DEFAULT_PREVIEW_HEIGHT = 360; -/** - * Cap the preview pane at 70 % of the viewport so the grid is always - * visible underneath. Resolved against `window.innerHeight` at set-time - * (the localStorage load happens before any viewport size is known). - */ -export const MAX_PREVIEW_HEIGHT_FRAC = 0.7; +export const MIN_TAGS_BROWSER_WIDTH = 180; +export const MAX_TAGS_BROWSER_WIDTH = 480; +export const DEFAULT_TAGS_BROWSER_WIDTH = 240; function clamp(n: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, n)); @@ -78,7 +74,13 @@ export const view = $state<{ thumbnailSize: ThumbnailSize; leftSidebarWidth: number; rightSidebarWidth: number; - previewPaneHeight: number; + tagsBrowserWidth: number; + tagsBrowserCollapsed: boolean; + /** + * Ephemeral: true while the full-screen preview modal is open. Not + * persisted — a refresh always returns to the grid. + */ + previewOpen: boolean; metadataSections: Record; }>({ rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false, @@ -96,12 +98,15 @@ export const view = $state<{ MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH ), - previewPaneHeight: Math.max( - MIN_PREVIEW_HEIGHT, - typeof initial.previewPaneHeight === 'number' - ? initial.previewPaneHeight - : DEFAULT_PREVIEW_HEIGHT + tagsBrowserWidth: clamp( + typeof initial.tagsBrowserWidth === 'number' + ? initial.tagsBrowserWidth + : DEFAULT_TAGS_BROWSER_WIDTH, + MIN_TAGS_BROWSER_WIDTH, + MAX_TAGS_BROWSER_WIDTH ), + tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false, + previewOpen: false, metadataSections: initial.metadataSections && typeof initial.metadataSections === 'object' ? { ...initial.metadataSections } @@ -116,7 +121,8 @@ function persist(): void { thumbnailSize: view.thumbnailSize, leftSidebarWidth: view.leftSidebarWidth, rightSidebarWidth: view.rightSidebarWidth, - previewPaneHeight: view.previewPaneHeight, + tagsBrowserWidth: view.tagsBrowserWidth, + tagsBrowserCollapsed: view.tagsBrowserCollapsed, metadataSections: view.metadataSections }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); @@ -132,14 +138,32 @@ export function setRightSidebarWidth(px: number): void { persist(); } -export function setPreviewPaneHeight(px: number): void { - const maxH = browser - ? Math.max(MIN_PREVIEW_HEIGHT + 1, Math.floor(window.innerHeight * MAX_PREVIEW_HEIGHT_FRAC)) - : 1024; - view.previewPaneHeight = clamp(Math.round(px), MIN_PREVIEW_HEIGHT, maxH); +export function setTagsBrowserWidth(px: number): void { + view.tagsBrowserWidth = clamp( + Math.round(px), + MIN_TAGS_BROWSER_WIDTH, + MAX_TAGS_BROWSER_WIDTH + ); persist(); } +export function toggleTagsBrowser(): void { + view.tagsBrowserCollapsed = !view.tagsBrowserCollapsed; + persist(); +} + +export function openPreview(): void { + view.previewOpen = true; +} + +export function closePreview(): void { + view.previewOpen = false; +} + +export function togglePreview(): void { + view.previewOpen = !view.previewOpen; +} + export function setThumbnailSize(size: ThumbnailSize): void { view.thumbnailSize = size; persist(); diff --git a/web/src/lib/utils/tagGroups.ts b/web/src/lib/utils/tagGroups.ts new file mode 100644 index 0000000..1e7ad87 --- /dev/null +++ b/web/src/lib/utils/tagGroups.ts @@ -0,0 +1,98 @@ +/** + * Shared helpers for tag-browser surfaces (the LeftSidebar Tags submenu, + * the TagsBrowserSidebar panel, and the /tags/[category]/[[value]] drill + * page). Group builders resolve color/rating buckets out of the + * mule-sidecar marks store joined against a photo pool — both the list + * panel and the drill page consume the same groups so highlighted counts + * and resolved photo sets never drift apart. + */ +import type { PhotoMarksMap } from '$lib/services/photoprism'; +import type { PpPhoto } from '$lib/types/photoprism'; + +/** + * Lightroom culling convention: red rejects, orange reviews, yellow + * picks, green keeps. Order here is the order the TagsBrowser renders + * rows in — fixed so the user can build muscle memory. + */ +export const COLOR_SWATCHES: readonly { key: string; bg: string; title: string }[] = [ + { key: 'red', bg: 'bg-red-500', title: 'Red — reject' }, + { key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' }, + { key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' }, + { key: 'green', bg: 'bg-green-500', title: 'Green — keep' } +] as const; + +export interface RatingGroup { + rating: number; + photos: PpPhoto[]; +} + +export function buildRatingGroups( + marks: PhotoMarksMap | undefined, + pool: PpPhoto[] | undefined +): RatingGroup[] { + if (!marks || !pool) return []; + const byUid = new Map(pool.map((p) => [p.UID, p])); + const buckets = new Map(); + for (const [uid, mark] of Object.entries(marks)) { + const r = mark.rating ?? 0; + if (r <= 0) continue; + const photo = byUid.get(uid); + if (!photo) continue; + const arr = buckets.get(r) ?? []; + arr.push(photo); + buckets.set(r, arr); + } + const out: RatingGroup[] = []; + for (let r = 5; r >= 1; r--) { + const photos = buckets.get(r); + if (photos && photos.length > 0) out.push({ rating: r, photos }); + } + return out; +} + +export interface ColorGroup { + key: string; + title: string; + bg: string; + photos: PpPhoto[]; +} + +export function buildColorGroups( + marks: PhotoMarksMap | undefined, + pool: PpPhoto[] | undefined +): ColorGroup[] { + if (!marks || !pool) return []; + const byUid = new Map(pool.map((p) => [p.UID, p])); + const buckets = new Map(); + for (const [uid, mark] of Object.entries(marks)) { + const c = mark.color; + if (!c) continue; + const photo = byUid.get(uid); + if (!photo) continue; + const arr = buckets.get(c) ?? []; + arr.push(photo); + buckets.set(c, arr); + } + const out: ColorGroup[] = []; + for (const swatch of COLOR_SWATCHES) { + const photos = buckets.get(swatch.key); + if (photos && photos.length > 0) out.push({ ...swatch, photos }); + } + return out; +} + +export function countMarked( + marks: PhotoMarksMap | undefined, + field: 'rating' | 'color' +): number | undefined { + if (!marks) return undefined; + let n = 0; + for (const m of Object.values(marks)) { + if (field === 'rating' ? (m.rating ?? 0) > 0 : Boolean(m.color)) n++; + } + return n; +} + +export function starLabel(rating: number): string { + return '★'.repeat(rating); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 5443dbc..2714d33 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -16,6 +16,7 @@ import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte'; import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte'; import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte'; + import PreviewModal from '$lib/components/preview/PreviewModal.svelte'; let { children } = $props(); @@ -108,6 +109,11 @@
    + + {:else} {@render children?.()} {/if} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index dbd1fbc..0695f21 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -35,6 +35,7 @@ setOrder, } from "$lib/stores/selection.svelte"; import { + openPreview, setRightSidebarWidth, setThumbnailSize, THUMBNAIL_SIZE_LABELS, @@ -51,11 +52,9 @@ } from "$lib/actions/visibleRange"; import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte"; import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte"; - import InlinePreview from "$lib/components/preview/InlinePreview.svelte"; import PhotoTile from "$lib/components/timeline/PhotoTile.svelte"; import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte"; import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte"; - import SplitGrid from "$lib/components/preview/SplitGrid.svelte"; import Toolbar from "$lib/components/layout/Toolbar.svelte"; import { type PpPhoto } from "$lib/types/photoprism"; @@ -668,6 +667,7 @@ selection.ids.add(uid); setFocused(uid); setAnchor(uid); + openPreview(); } // Scroll root for the infinite-scroll IntersectionObserver. Bound by @@ -815,11 +815,6 @@ sibling at row level and stays full height when the bar appears. -->
    - - {#snippet preview()} - - {/snippet} - {#snippet grid()}
    - {/snippet} -
    diff --git a/web/src/routes/photo/[uid]/+page.svelte b/web/src/routes/photo/[uid]/+page.svelte index b299dc0..5d724c6 100644 --- a/web/src/routes/photo/[uid]/+page.svelte +++ b/web/src/routes/photo/[uid]/+page.svelte @@ -1,8 +1,8 @@
    - +
    diff --git a/web/src/routes/review/+page.svelte b/web/src/routes/review/+page.svelte index e2fbf86..255cb82 100644 --- a/web/src/routes/review/+page.svelte +++ b/web/src/routes/review/+page.svelte @@ -51,8 +51,6 @@ import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte'; import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte'; - import InlinePreview from '$lib/components/preview/InlinePreview.svelte'; - import SplitGrid from '$lib/components/preview/SplitGrid.svelte'; type DupTab = 'stacks' | 'cross-folder'; type Tab = CauseKey | DupTab; @@ -226,38 +224,31 @@ {:else}
    - - {#snippet preview()} - - {/snippet} - {#snippet grid()} -
    - {#if reviewQuery.isPending} -

    Loading review queue…

    - {:else if reviewQuery.error} -

    - Could not load review queue: {reviewQuery.error instanceof Error - ? reviewQuery.error.message - : 'unknown error'} -

    - {:else if groups.length === 0} -
    -

    The review queue is empty.

    -

    - PhotoPrism's 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 duplicate cleanup. -

    -
    - {:else if activeGroup} - {#key activeGroup.cause} - - {/key} - {/if} -
    - {/snippet} -
    +
    + {#if reviewQuery.isPending} +

    Loading review queue…

    + {:else if reviewQuery.error} +

    + Could not load review queue: {reviewQuery.error instanceof Error + ? reviewQuery.error.message + : 'unknown error'} +

    + {:else if groups.length === 0} +
    +

    The review queue is empty.

    +

    + PhotoPrism's 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 duplicate cleanup. +

    +
    + {:else if activeGroup} + {#key activeGroup.cause} + + {/key} + {/if} +
    diff --git a/web/src/routes/tags/+layout.svelte b/web/src/routes/tags/+layout.svelte new file mode 100644 index 0000000..28e8fb9 --- /dev/null +++ b/web/src/routes/tags/+layout.svelte @@ -0,0 +1,61 @@ + + +
    + {#if category && !view.tagsBrowserCollapsed} + + {/if} +
    + {@render children?.()} +
    +
    diff --git a/web/src/routes/tags/+page.svelte b/web/src/routes/tags/+page.svelte index 00b6bbd..50f41ec 100644 --- a/web/src/routes/tags/+page.svelte +++ b/web/src/routes/tags/+page.svelte @@ -1,647 +1,23 @@ - - - - Tags - - {#if drillKey} - - {drillTitle} - - {drillCount} photo{drillCount === 1 ? '' : 's'} - - {:else} - -
    - {#each TABS as t (t.id)} - {@const count = tabCount(t.id)} - - {/each} -
    - {/if} - {#snippet trailing()} - {#if !drillKey && (activeTab === 'labels' || activeTab === 'keywords') && totalPages > 1} - - - - {activePage + 1} / {totalPages} - - - {/if} - {/snippet} -
    - -{#if drillKey} -
    -
    - - {#snippet preview()} - - {/snippet} - {#snippet grid()} -
    - - {#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending} - - {:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError} -

    Failed to load photos.

    - {:else if drillPhotos.length === 0} -

    No photos under this tag.

    - {:else} - - {/if} -
    - {/snippet} -
    -
    - - {#if !view.rightSidebarCollapsed} - - {/if} -
    -{:else} -
    - {#if activeTab === 'labels'} - {#if labelsQuery.isPending} - - {:else if labelsQuery.isError} -

    Failed to load labels.

    - {:else if labelsSorted.length === 0} -

    - No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; - if the indexer hasn't run on real photos yet, the list will be empty. -

    - {:else} -
    - {#each pageSlice as item (item)} - {@const label = item as PpLabel} - - {/each} -
    - {/if} - {:else if activeTab === 'keywords'} - {#if keywordsQuery.isPending} - - {:else if keywordsQuery.isError} -

    Failed to load keywords.

    - {:else if keywordsSorted.length === 0} -

    - No user-set keywords yet. Add them from a photo's right-sidebar metadata panel. -

    - {:else} -
    - {#each pageSlice as item (item)} - {@const kw = item as AggregatedKeyword} - - {/each} -
    - {/if} - {:else if activeTab === 'ratings'} - {#if marksQuery.isPending || marksPoolQuery.isPending} - - {:else if marksQuery.isError || marksPoolQuery.isError} -

    Failed to load ratings.

    - {:else if ratingGroups.length === 0} -

    - No rated photos yet. Open a photo and use the star row in the right sidebar (or - 1–5 in bulk mode) to rate it. -

    - {:else} -
    - {#each ratingGroups as group (group.rating)} - {@const rep = group.photos[0]} - {@const hash = rep.Hash ?? primaryFile(rep).Hash} - - {/each} -
    - {/if} - {:else if activeTab === 'colors'} - {#if marksQuery.isPending || marksPoolQuery.isPending} - - {:else if marksQuery.isError || marksPoolQuery.isError} -

    Failed to load colors.

    - {:else if colorGroups.length === 0} -

    - No color labels yet. Open a photo and use the four-swatch row in the right - sidebar to tag it. -

    - {:else} -
    - {#each colorGroups as group (group.key)} - {@const rep = group.photos[0]} - {@const hash = rep.Hash ?? primaryFile(rep).Hash} - - {/each} -
    - {/if} - {/if} -
    -{/if} - - diff --git a/web/src/routes/tags/[category]/[[value]]/+page.svelte b/web/src/routes/tags/[category]/[[value]]/+page.svelte new file mode 100644 index 0000000..671965c --- /dev/null +++ b/web/src/routes/tags/[category]/[[value]]/+page.svelte @@ -0,0 +1,259 @@ + + + + + Tags + + {#if category} + {category} + {/if} + {#if selectedValue} + {drillTitle} + + {drillCount} photo{drillCount === 1 ? '' : 's'} + + {/if} + + +{#if !selectedValue} +
    +
    +

    Pick a {category ?? 'tag'} from the sidebar

    +

    + Click a row in the panel on the left to filter the photo grid by that tag. +

    +
    +
    +{:else} +
    +
    +
    + {#if showSkeleton} + + {:else if showError} +

    Failed to load photos.

    + {:else if drillPhotos.length === 0} +

    No photos under this tag.

    + {:else} + + {/if} +
    +
    + + {#if !view.rightSidebarCollapsed} + + {/if} +
    +{/if} + +