preview: full-screen modal replaces inline split + tags route reorg
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
* <div use:resizableVertical={{ edge: 'bottom',
|
||||
* getHeight: () => 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -120,11 +120,11 @@
|
||||
with the px-2 of Views/Heaps rows; +12px per nested level.
|
||||
-->
|
||||
<div
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
style="padding-left: {8 + depth * 12}px;"
|
||||
style="padding-left: {4 + depth * 12}px;"
|
||||
>
|
||||
{#if hasChildren}
|
||||
<button
|
||||
@@ -136,10 +136,10 @@
|
||||
>
|
||||
{open ? '▾' : '▸'}
|
||||
</button>
|
||||
{:else if depth > 0}
|
||||
{:else}
|
||||
<!-- Spacer keeps childless siblings aligned with their chevroned
|
||||
peers at nested depths. Skipped at depth 0 so root folders
|
||||
left-align with the Views/Heaps rows. -->
|
||||
peers at every depth, so labels share a common left edge
|
||||
across the sidebar (folders, heaps, views, manage). -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<!--
|
||||
@@ -148,8 +148,7 @@
|
||||
zone right where the user's eye lands.
|
||||
-->
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center text-left"
|
||||
class:px-1={hasChildren || depth > 0}
|
||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||
onclick={() => onPick(node.path)}
|
||||
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||
title={node.path}
|
||||
@@ -158,7 +157,7 @@
|
||||
{#if counts && counts[node.path] !== undefined}
|
||||
{@const n = counts[node.path]}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
filters,
|
||||
setFolderPath,
|
||||
setSection,
|
||||
type Section
|
||||
TAG_CATEGORIES,
|
||||
type Section,
|
||||
type TagCategory
|
||||
} from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
@@ -361,6 +363,59 @@
|
||||
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded`
|
||||
// above (keeping it out of `view.metadataSections`, which is reserved for
|
||||
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar
|
||||
// doesn't grow on first paint.
|
||||
const TAGS_OPEN_KEY = 'mule_tags_expanded';
|
||||
let tagsExpanded = $state(loadTagsExpanded());
|
||||
function loadTagsExpanded(): boolean {
|
||||
if (!browser) return false;
|
||||
const raw = localStorage.getItem(TAGS_OPEN_KEY);
|
||||
return raw === '1';
|
||||
}
|
||||
function toggleTags() {
|
||||
tagsExpanded = !tagsExpanded;
|
||||
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||
labels: 'Labels',
|
||||
keywords: 'Keywords',
|
||||
colors: 'Colors',
|
||||
ratings: 'Ratings'
|
||||
};
|
||||
|
||||
function tagCategoryCount(cat: TagCategory): number | undefined {
|
||||
// Labels reads PhotoPrism's pre-computed distinct-label counter
|
||||
// (`/api/v1/config` → count.labels), not the photo-count from
|
||||
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
|
||||
// whose indexer hadn't surfaced labelled photos yet, leaving the
|
||||
// badge silently empty; the precomputed counter is always present
|
||||
// and reads as "how many labels you can pick from", matching the
|
||||
// Keywords sub-row's distinct-count semantics.
|
||||
if (cat === 'labels') return configQuery.data?.count?.labels;
|
||||
if (cat === 'keywords') return keywordsQuery.data?.length;
|
||||
if (cat === 'ratings') return ratingsCount;
|
||||
return colorsCount;
|
||||
}
|
||||
|
||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||
return page.url.pathname.startsWith(`/tags/${cat}`);
|
||||
}
|
||||
|
||||
// Hover-prefetch for the expensive keywords aggregation. Same idea as
|
||||
// the cross-folder duplicates pattern: the LeftSidebar's badge query is
|
||||
// `enabled: false`, but we eagerly populate the cache on intent so the
|
||||
// click into /tags/keywords lands on warm data.
|
||||
function prefetchKeywords(): void {
|
||||
void qc.prefetchQuery({
|
||||
queryKey: ['photos', 'keywords'],
|
||||
queryFn: aggregateKeywords,
|
||||
staleTime: 5 * 60_000
|
||||
});
|
||||
}
|
||||
|
||||
const rootActive = $derived(filters.folderPath === '/');
|
||||
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
||||
|
||||
@@ -510,24 +565,24 @@
|
||||
// Map's `geoQuery` already returns the GeoJSON the user is
|
||||
// permitted to see (PhotoPrism's /geo applies the session ACL),
|
||||
// so the badge is per-user-correct without extra scoping.
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length },
|
||||
// Tags rolls up labels + keywords + ratings + colors. Labels
|
||||
// flows through countPhotos (scoped); keywords/ratings/colors are
|
||||
// from library-wide marks tables and only contribute when we're
|
||||
// in admin-without-BasePath mode (their sources don't scope).
|
||||
{
|
||||
kind: 'route',
|
||||
href: '/tags',
|
||||
label: 'Tags',
|
||||
getCount: () => {
|
||||
if (labelsBadge === undefined) return undefined;
|
||||
if (wantScoped) return labelsBadge;
|
||||
const keywords = keywordsQuery.data?.length ?? 0;
|
||||
return labelsBadge + keywords + ratingsCount + colorsCount;
|
||||
}
|
||||
}
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
|
||||
// Tags is rendered as a bespoke expandable block below the
|
||||
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
||||
// Ratings) and a chevron, neither of which fits the flat
|
||||
// section/route ViewItem shape.
|
||||
];
|
||||
|
||||
// Total badge for the "Tags" header row. Rolls up labels + keywords +
|
||||
// ratings + colors. Labels flows through countPhotos (scoped); keywords/
|
||||
// ratings/colors are library-wide marks tables and only contribute when
|
||||
// we're in admin-without-BasePath mode (their sources don't scope).
|
||||
const tagsTotal = $derived.by<number | undefined>(() => {
|
||||
if (labelsBadge === undefined) return undefined;
|
||||
if (wantScoped) return labelsBadge;
|
||||
const keywords = keywordsQuery.data?.length ?? 0;
|
||||
return labelsBadge + keywords + ratingsCount + colorsCount;
|
||||
});
|
||||
|
||||
const manageViews: ViewItem[] = [
|
||||
{
|
||||
kind: 'route',
|
||||
@@ -555,7 +610,7 @@
|
||||
{@const count = v.getCount()}
|
||||
{#if v.kind === 'section'}
|
||||
<button
|
||||
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class="flex h-[22px] w-full items-center rounded pl-6 pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
@@ -564,7 +619,7 @@
|
||||
<span class="truncate">{v.label}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -575,7 +630,7 @@
|
||||
{:else}
|
||||
<a
|
||||
href={v.href}
|
||||
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
@@ -583,7 +638,7 @@
|
||||
<span class="truncate">{v.label}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -603,7 +658,7 @@
|
||||
slightly faded even at scroll-bottom.
|
||||
-->
|
||||
<nav
|
||||
class="flex-1 space-y-3 overflow-y-auto p-3"
|
||||
class="flex-1 space-y-2 overflow-y-auto p-2"
|
||||
style="mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);"
|
||||
>
|
||||
<!-- Folders — top of the sidebar because the root folder is the
|
||||
@@ -612,7 +667,7 @@
|
||||
hover-revealed actions on the header for library settings and
|
||||
new-top-level-folder. -->
|
||||
<div>
|
||||
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
|
||||
<div class="group/header flex items-center gap-0.5 px-2 pb-0.5">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Library
|
||||
</span>
|
||||
@@ -641,11 +696,11 @@
|
||||
below — same affordance as nested folder rows.
|
||||
-->
|
||||
<div
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={rootActive}
|
||||
class:text-primary-foreground={rootActive}
|
||||
class:hover:bg-primary={rootActive}
|
||||
style="padding-left: 8px;"
|
||||
style="padding-left: 4px;"
|
||||
>
|
||||
{#if hasSubfolders}
|
||||
<button
|
||||
@@ -658,6 +713,10 @@
|
||||
>
|
||||
{rootExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer keeps chevronless rows aligned with their chevroned
|
||||
peers, so labels share a common left edge across the sidebar. -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<!--
|
||||
Count badge lives INSIDE the button so the entire row (label
|
||||
@@ -666,15 +725,14 @@
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center text-left"
|
||||
class:px-1={hasSubfolders}
|
||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||
onclick={() => pickFolder('/')}
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
>
|
||||
<span class="truncate">{rootLabel}</span>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -721,35 +779,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Views — everyday browse entries (section + route mixed) under
|
||||
a single uppercase eyebrow. Compact rows, no icons. -->
|
||||
<div>
|
||||
<div class="px-3 pb-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Views
|
||||
</span>
|
||||
</div>
|
||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||
row shape as Views; grouped separately so the binary-decision
|
||||
destinations (Review/Archive) don't crowd the browse list. -->
|
||||
<div>
|
||||
<div class="px-3 pb-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="group/header flex items-center px-3 pb-1">
|
||||
<div class="group/header flex items-center px-2 pb-0.5">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Heaps
|
||||
</span>
|
||||
@@ -781,20 +812,20 @@
|
||||
open), pushing the button slightly left.
|
||||
-->
|
||||
<li
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center gap-2 px-2 text-left"
|
||||
class="flex min-w-0 flex-1 items-center pl-6 text-left"
|
||||
onclick={() => navigateTo('heap', heap.UID)}
|
||||
ondblclick={() => onRenameHeap(heap)}
|
||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||
>
|
||||
<span class="truncate">{heap.Title}</span>
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -847,6 +878,89 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Views — everyday browse entries (section + route mixed) under
|
||||
a single uppercase eyebrow. Compact rows, no icons. -->
|
||||
<div>
|
||||
<div class="px-2 pb-0.5">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Views
|
||||
</span>
|
||||
</div>
|
||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
<!--
|
||||
Tags expandable. Whole row is a toggle (chevron + label + badge);
|
||||
there is no landing page at /tags — selecting a sub-category is the
|
||||
only way into a real view.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
style="padding-left: 4px;"
|
||||
onclick={toggleTags}
|
||||
title={tagsExpanded ? 'Collapse tags' : 'Expand tags'}
|
||||
aria-expanded={tagsExpanded}
|
||||
>
|
||||
<span
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||
>
|
||||
{tagsExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Tags</span>
|
||||
{#if tagsTotal !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{tagsTotal}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if tagsExpanded}
|
||||
{#each TAG_CATEGORIES as cat (cat)}
|
||||
{@const active = isTagCategoryActive(cat)}
|
||||
{@const count = tagCategoryCount(cat)}
|
||||
<a
|
||||
href={`/tags/${cat}`}
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
style="padding-left: 36px;"
|
||||
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||
>
|
||||
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||
row shape as Views; grouped separately so the binary-decision
|
||||
destinations (Review/Archive) don't crowd the browse list. -->
|
||||
<div>
|
||||
<div class="px-2 pb-0.5">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!--
|
||||
|
||||
159
web/src/lib/components/preview/PreviewCarousel.svelte
Normal file
159
web/src/lib/components/preview/PreviewCarousel.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
Bottom filmstrip for the full-screen PreviewModal. Walks the same
|
||||
`selection.order` list the host view uses, so the user steps through
|
||||
their current context (timeline, drill-in, etc.) without surprises.
|
||||
|
||||
Windowed: only renders a slice of ±WINDOW around the focused index, so
|
||||
a 10k-photo timeline doesn't paint 10k thumbs. The slice shifts as
|
||||
focus moves; the strip re-scrolls the focused tile into view on every
|
||||
change.
|
||||
|
||||
Thumbnails are resolved from TanStack's cache so this surface stays
|
||||
side-effect-free (no extra fetches just to draw a strip).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
selectOnly,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused,
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
/** Tiles either side of focus to render. ±50 = ~100 thumbs in DOM at
|
||||
* any time, plenty to cover normal arrow-skim without bloating. */
|
||||
const WINDOW = 50;
|
||||
|
||||
function lookup(uid: string): string | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (direct) return primaryFile(direct).Hash ?? null;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const order = $derived(selection.order);
|
||||
const focused = $derived(selection.focused);
|
||||
const focusedIdx = $derived(focused ? order.indexOf(focused) : -1);
|
||||
|
||||
interface Tile {
|
||||
uid: string;
|
||||
hash: string | null;
|
||||
idx: number;
|
||||
}
|
||||
const slice = $derived.by<Tile[]>(() => {
|
||||
if (focusedIdx < 0) return [];
|
||||
const lo = Math.max(0, focusedIdx - WINDOW);
|
||||
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
|
||||
const out: Tile[] = [];
|
||||
for (let i = lo; i < hi; i++) {
|
||||
out.push({ uid: order[i], hash: lookup(order[i]), idx: i });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Scroll the focused tile into the centre of the strip whenever
|
||||
// `focused` changes. We key the scroll on `data-uid` so the lookup
|
||||
// survives slice re-renders.
|
||||
let stripEl: HTMLDivElement | null = $state(null);
|
||||
$effect(() => {
|
||||
const uid = focused;
|
||||
if (!uid || !stripEl) return;
|
||||
// Defer until after the slice re-renders — without this, the
|
||||
// querySelector on the freshly added tile element returns null.
|
||||
queueMicrotask(() => {
|
||||
const el = stripEl?.querySelector<HTMLElement>(`[data-strip-uid="${uid}"]`);
|
||||
el?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifier-aware click: shift extends the range from the anchor, ⌘/Ctrl
|
||||
* toggles in/out of the multi-selection, plain click reduces to this
|
||||
* tile. Same semantics as the grid's PhotoTile + gridKeyNav click
|
||||
* handler, so the carousel reads as a continuation of the grid rather
|
||||
* than a separate surface.
|
||||
*/
|
||||
function onTileClick(e: MouseEvent, uid: string) {
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRange(uid);
|
||||
setFocused(uid);
|
||||
return;
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
toggle(uid);
|
||||
setFocused(uid);
|
||||
return;
|
||||
}
|
||||
selectOnly(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={stripEl}
|
||||
class="flex h-20 shrink-0 items-center gap-1 overflow-x-auto overflow-y-hidden border-t border-border bg-background/80 px-2 py-1.5 backdrop-blur"
|
||||
>
|
||||
{#each slice as tile (tile.uid)}
|
||||
{@const isFocused = tile.uid === focused}
|
||||
{@const isSelected = isFocused || selection.ids.has(tile.uid)}
|
||||
<!--
|
||||
Mirror PhotoTile's selected styling so the focused tile in the
|
||||
strip reads identically to a selected tile in the grid: the
|
||||
springy scale-90, the blue ring with background offset, and
|
||||
the blue tint overlay. Keeps the visual language consistent
|
||||
as the user moves between grid and modal.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
data-strip-uid={tile.uid}
|
||||
onclick={(e) => onTileClick(e, tile.uid)}
|
||||
aria-label={`Photo ${tile.idx + 1} of ${order.length}`}
|
||||
aria-current={isFocused ? 'true' : undefined}
|
||||
class:scale-90={isSelected}
|
||||
class:ring-2={isSelected}
|
||||
class:ring-blue-500={isSelected}
|
||||
class:ring-offset-2={isSelected}
|
||||
class:ring-offset-background={isSelected}
|
||||
class:transition-[transform,box-shadow]={isSelected}
|
||||
class:duration-300={isSelected}
|
||||
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={isSelected}
|
||||
class="relative h-full aspect-square shrink-0 overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
{#if tile.hash}
|
||||
<img
|
||||
src={thumbUrl(tile.hash, 'tile_100')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{#if isSelected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
168
web/src/lib/components/preview/PreviewModal.svelte
Normal file
168
web/src/lib/components/preview/PreviewModal.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<!--
|
||||
Full-screen preview modal. Mounted once at the layout root so every
|
||||
route (timeline, /tags, future grids) can open it via Space or a
|
||||
double-click on a tile. Reads from the global selection store:
|
||||
|
||||
- selection.focused → which photo to display
|
||||
- selection.order → walked left/right + drives PreviewCarousel
|
||||
|
||||
Layout: preview pane (left, fills) + RightSidebar (right) + action
|
||||
toolbar + thumbnail carousel along the bottom. Keyboard nav (←/→/Space/
|
||||
Esc) is owned here; gridKeyNav bails out of those keys while the modal
|
||||
is open so we don't double-handle. Action keys (X/S/U/⌘Z/etc.) continue
|
||||
to flow through gridKeyNav since they target selection state, which
|
||||
the modal shares.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { closePreview, view } from '$lib/stores/view.svelte';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import PreviewPane from './PreviewPane.svelte';
|
||||
import PreviewCarousel from './PreviewCarousel.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
|
||||
const focusedUid = $derived(selection.focused);
|
||||
|
||||
// Fetch the focused photo so the RightSidebar always has full
|
||||
// metadata even when the user jumped here from a list that only
|
||||
// hydrated thumbnails.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', focusedUid ?? ''],
|
||||
queryFn: () => getPhoto(focusedUid as string),
|
||||
enabled: Boolean(focusedUid) && isAuthenticated() && view.previewOpen
|
||||
}));
|
||||
|
||||
function step(delta: number, extending: boolean) {
|
||||
const order = selection.order;
|
||||
const cur = focusedUid ? order.indexOf(focusedUid) : -1;
|
||||
if (cur < 0) return;
|
||||
const next = order[cur + delta];
|
||||
if (!next) return;
|
||||
// Plain arrow drops any prior multi-selection down to the cursor —
|
||||
// otherwise the previously selected tiles keep their blue ring and
|
||||
// the carousel reads as two simultaneously selected images (the old
|
||||
// ones still in ids, plus the freshly focused one). Mirrors
|
||||
// gridKeyNav.moveFocus's contract.
|
||||
if (!extending) clearSelection();
|
||||
setFocused(next);
|
||||
if (extending) selectRange(next);
|
||||
else setAnchor(next);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Don't steal keys from inputs inside the sidebar (editable
|
||||
// metadata fields, keyword chips, etc.).
|
||||
const t = e.target as HTMLElement | null;
|
||||
const tag = t?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
// stopImmediatePropagation prevents gridKeyNav's window-level
|
||||
// handler from firing afterwards — without it, closing on Space
|
||||
// would set previewOpen=false and then gridKeyNav's Space branch
|
||||
// would re-open the modal because selection.focused is still set.
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
// First Esc collapses a bulk back to single-focus on its first
|
||||
// member, keeping the modal open so the user can keep working.
|
||||
// Second Esc (no bulk left) closes the modal.
|
||||
if (clearBulkToFirst()) return;
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(-1, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(1, e.shiftKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind a window-level handler only while the modal is open. The
|
||||
// `capture` phase lets us pre-empt gridKeyNav's own listener for the
|
||||
// keys we own (arrows, Esc, Space) without having to coordinate
|
||||
// listener order.
|
||||
$effect(() => {
|
||||
if (!view.previewOpen) return;
|
||||
const handler = (e: KeyboardEvent) => onKeydown(e);
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handler, { capture: true } as EventListenerOptions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={view.previewOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closePreview();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/95 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
>
|
||||
<Dialog.Title class="sr-only">Photo preview</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">
|
||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||
</Dialog.Description>
|
||||
|
||||
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="relative flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/80 text-foreground shadow hover:bg-background"
|
||||
onclick={closePreview}
|
||||
aria-label="Close preview"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||
</div>
|
||||
</div>
|
||||
{#if focusedPhotoQuery.data}
|
||||
<aside
|
||||
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
||||
>
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
|
||||
<!-- Bottom filmstrip across selection.order. -->
|
||||
<PreviewCarousel />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -1,34 +1,29 @@
|
||||
<!--
|
||||
Inline preview pane: replaces the old fullscreen `PreviewOverlay`.
|
||||
Renders the focused photo or video inside its host pane so the grid
|
||||
stays visible below. Prev/next move `selection.focused` directly so
|
||||
the right metadata sidebar tracks in lockstep.
|
||||
Inner preview surface used by the full-screen PreviewModal and the
|
||||
shareable /photo/[uid] deep-link route. Renders the focused photo or
|
||||
video, with optional prev/next chevrons that walk `order` via
|
||||
setFocused.
|
||||
|
||||
Video mounting is debounced 250 ms so arrow-skim across a stretch of
|
||||
video tiles doesn't open (and immediately cancel) range requests we'd
|
||||
throw away. Until the timer fires, the poster image stands in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { selection, setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||||
import SelectionDeck from '$lib/components/preview/SelectionDeck.svelte';
|
||||
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
uid: string | null;
|
||||
/** Ordered uid list for prev/next chevrons. The host page mirrors
|
||||
* whatever list the user is currently looking at into this prop so
|
||||
* navigation stays in context (timeline order, drill-in order, etc). */
|
||||
order: string[];
|
||||
/** Hide the floating prev/next chevrons (callers that wrap their
|
||||
* own nav can disable to avoid duplication). */
|
||||
showChevrons?: boolean;
|
||||
}
|
||||
let { uid, order }: Props = $props();
|
||||
|
||||
// Multi-select swaps the single-photo pane for a fanned deck of the
|
||||
// selected thumbnails. Reads `selection.ids` directly (already in
|
||||
// scope via the import) so the host doesn't need to thread it
|
||||
// through as a prop. Spreading SvelteSet preserves insertion order,
|
||||
// so the most recently picked card sits on top of the fan.
|
||||
const selectedUids = $derived([...selection.ids]);
|
||||
const multiSelect = $derived(selectedUids.length >= 2);
|
||||
let { uid, order, showChevrons = true }: Props = $props();
|
||||
|
||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', uid ?? ''],
|
||||
@@ -38,20 +33,10 @@
|
||||
|
||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||
|
||||
// Debounce window before a focused video actually mounts <VideoPlayer>
|
||||
// and opens an HTTP range request. Short enough that a deliberate
|
||||
// click feels instant; long enough that arrow-skim across video tiles
|
||||
// never opens (and immediately cancels) a stream we'd have thrown
|
||||
// away anyway. The grid's thumbnail traffic is the thing this
|
||||
// protects.
|
||||
const VIDEO_LOAD_DELAY_MS = 250;
|
||||
let armedUid = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
// Re-arm on every uid change. Until the timer fires, the template
|
||||
// renders the poster image instead of <VideoPlayer>, so no video
|
||||
// fetch is issued. If uid changes again before the 250 ms is up,
|
||||
// the cleanup clears the pending timer and the new one takes over.
|
||||
const target = uid;
|
||||
if (!target) {
|
||||
armedUid = null;
|
||||
@@ -71,10 +56,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/30 p-4">
|
||||
{#if multiSelect}
|
||||
<SelectionDeck uids={selectedUids} />
|
||||
{:else if uid === null}
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||
{#if uid === null}
|
||||
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
|
||||
{:else if photoQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading…</p>
|
||||
@@ -82,7 +65,7 @@
|
||||
<p class="text-sm text-destructive">Failed to load photo.</p>
|
||||
{:else if photoQuery.data}
|
||||
{@const pf = primaryFile(photoQuery.data)}
|
||||
{#if currentIndex > 0}
|
||||
{#if showChevrons && currentIndex > 0}
|
||||
<button
|
||||
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||
onclick={() => focusAt(currentIndex - 1)}
|
||||
@@ -91,7 +74,7 @@
|
||||
‹
|
||||
</button>
|
||||
{/if}
|
||||
{#if currentIndex >= 0 && currentIndex < order.length - 1}
|
||||
{#if showChevrons && currentIndex >= 0 && currentIndex < order.length - 1}
|
||||
<button
|
||||
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||
onclick={() => focusAt(currentIndex + 1)}
|
||||
@@ -104,10 +87,6 @@
|
||||
{#if isVideo(photoQuery.data)}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
{#if armedUid === uid}
|
||||
<!-- Key on the video hash so navigating to a new video remounts the
|
||||
player. Without this the <media-player> element keeps the
|
||||
previous src bound and `autoplay` doesn't re-fire — clicking
|
||||
a video tile would leave the pane idle on its poster. -->
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
@@ -116,9 +95,6 @@
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<!-- Debounce window: render the poster only. Matches the
|
||||
still-photo branch's styling so the pane reads identically
|
||||
until <VideoPlayer> arms in. -->
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
|
||||
@@ -1,112 +0,0 @@
|
||||
<!--
|
||||
Multi-select deck. Renders the selected thumbnails as a fanned spread
|
||||
of cards in the inline preview pane. Each new selection flies onto
|
||||
the deck; each removal shrinks out. Layout reflow on add/remove is
|
||||
driven by CSS transforms transitioning between the recomputed fan
|
||||
positions.
|
||||
|
||||
Hashes come from the per-photo TanStack cache (populated by the
|
||||
timeline + the right-sidebar's getPhoto query); we walk the photos-
|
||||
infinite-query cache as a fallback for photos the user selected
|
||||
before the right sidebar had a chance to fetch them. Anything not
|
||||
found is skipped silently — the deck just renders the resolvable
|
||||
subset, which keeps this surface side-effect-free (no fetches just
|
||||
to draw a thumbnail spread).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
uids: string[];
|
||||
}
|
||||
let { uids }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
/** Resolve a uid to its primary-file hash by walking TanStack's caches.
|
||||
* Order: per-photo cache first (cheapest), then any infinite-query
|
||||
* entry under `['photos', …]` — both shapes (flat list and Infinite-
|
||||
* Data envelope) are handled because the timeline uses the envelope
|
||||
* while pools/sidebars use the flat list. Mirrors the cachedPhoto
|
||||
* helper in gridKeyNav.ts. */
|
||||
function lookup(uid: string): string | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (direct) return primaryFile(direct).Hash ?? null;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Card {
|
||||
uid: string;
|
||||
hash: string;
|
||||
}
|
||||
const cards = $derived<Card[]>(
|
||||
uids
|
||||
.map((uid) => {
|
||||
const hash = lookup(uid);
|
||||
return hash ? { uid, hash } : null;
|
||||
})
|
||||
.filter((c): c is Card => c !== null)
|
||||
);
|
||||
|
||||
/** Fan geometry. The spread tightens as the deck grows so 20 cards
|
||||
* still fit visually; small selections (2–4) get wide-stance spreads
|
||||
* so each card reads as its own surface. The `mid`-relative offset
|
||||
* keeps the deck centred regardless of count. */
|
||||
function transform(i: number, n: number): string {
|
||||
if (n <= 1) return 'translateX(0) rotate(0deg)';
|
||||
const mid = (n - 1) / 2;
|
||||
const off = i - mid;
|
||||
// Tighter angle + offset as N grows; cap so very large selections
|
||||
// don't degenerate to a single overlapping pile or, conversely,
|
||||
// spread past the pane edges.
|
||||
const angleStep = Math.min(9, 30 / n);
|
||||
const transStep = Math.min(60, 220 / n);
|
||||
const angle = off * angleStep;
|
||||
const tx = off * transStep;
|
||||
return `translateX(${tx}px) rotate(${angle}deg)`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center overflow-hidden">
|
||||
{#if cards.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Selection has no resolvable thumbnails yet.</p>
|
||||
{/if}
|
||||
{#each cards as card, i (card.uid)}
|
||||
<img
|
||||
src={thumbUrl(card.hash, 'fit_720')}
|
||||
alt=""
|
||||
class="absolute max-h-[80%] max-w-[60%] rounded-lg object-contain shadow-2xl ring-1 ring-black/40 transition-transform duration-300 ease-out"
|
||||
style="transform: {transform(i, cards.length)}; z-index: {i};"
|
||||
in:fly={{ y: -180, duration: 320, easing: cubicOut }}
|
||||
out:scale={{ start: 0.5, duration: 220, easing: cubicOut }}
|
||||
/>
|
||||
{/each}
|
||||
<!-- Count chip so the user can see at a glance how many photos are
|
||||
in the bulk selection without counting cards. -->
|
||||
{#if cards.length > 0}
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-3 right-3 rounded-full bg-background/85 px-2.5 py-1 text-xs font-medium shadow"
|
||||
>
|
||||
{cards.length} selected
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,59 +0,0 @@
|
||||
<!--
|
||||
Vertical split layout: a top pane (preview) sized by
|
||||
`view.previewPaneHeight`, a draggable divider, and a flex-1 bottom
|
||||
pane (the grid). Mirrors the horizontal sidebar resize pattern.
|
||||
|
||||
Usage:
|
||||
<SplitGrid>
|
||||
{#snippet preview()}
|
||||
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||
{/snippet}
|
||||
{#snippet grid()}
|
||||
<main>…</main>
|
||||
{/snippet}
|
||||
</SplitGrid>
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { resizableVertical } from '$lib/actions/resizableVertical';
|
||||
import { setPreviewPaneHeight, view } from '$lib/stores/view.svelte';
|
||||
|
||||
interface Props {
|
||||
preview: Snippet;
|
||||
grid: Snippet;
|
||||
}
|
||||
let { preview, grid }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Top: preview pane. Fixed pixel height controlled by the divider;
|
||||
overflow-hidden so videos / images can't push the divider off-screen. -->
|
||||
<div
|
||||
class="relative shrink-0 overflow-hidden border-b border-border"
|
||||
style="height: {view.previewPaneHeight}px;"
|
||||
>
|
||||
{@render preview()}
|
||||
<!-- Divider sits on the bottom edge of the preview pane.
|
||||
`edge: 'bottom'` matches the convention: positive dy = pane grows. -->
|
||||
<div
|
||||
class="group absolute -bottom-1.5 left-0 z-20 h-3 w-full cursor-row-resize"
|
||||
use:resizableVertical={{
|
||||
edge: 'bottom',
|
||||
getHeight: () => view.previewPaneHeight,
|
||||
setHeight: setPreviewPaneHeight
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize preview pane"
|
||||
>
|
||||
<div
|
||||
class="mt-1 h-0.5 w-full bg-transparent transition-colors group-hover:bg-primary/40"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: grid container; the caller's snippet handles its own scroll. -->
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{@render grid()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,10 +106,10 @@
|
||||
:global(.vds-player) {
|
||||
/* Let the player size to the video's intrinsic aspect ratio,
|
||||
capped by the host pane. Don't set width/height to 100% — that
|
||||
was stretching widescreen video into the square inline-preview
|
||||
pane. The default vidstack layout reads the loaded media's
|
||||
aspect and sizes the box accordingly; we just clamp the upper
|
||||
bound so it can't escape the SplitGrid top pane. */
|
||||
was stretching widescreen video into the square preview pane.
|
||||
The default vidstack layout reads the loaded media's aspect
|
||||
and sizes the box accordingly; we just clamp the upper bound
|
||||
so it can't escape its host pane. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
--media-brand: #3b82f6;
|
||||
|
||||
409
web/src/lib/components/sidebar/TagsBrowserSidebar.svelte
Normal file
409
web/src/lib/components/sidebar/TagsBrowserSidebar.svelte
Normal file
@@ -0,0 +1,409 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
getAllMarks,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
type PpLabel
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { nearBottom } from '$lib/actions/nearBottom';
|
||||
import type { TagCategory } from '$lib/stores/filters.svelte';
|
||||
import {
|
||||
buildColorGroups,
|
||||
buildRatingGroups,
|
||||
COLOR_SWATCHES,
|
||||
starLabel
|
||||
} from '$lib/utils/tagGroups';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
category: TagCategory;
|
||||
selectedValue: string | null;
|
||||
onSelect: (value: string | null, options?: { replace?: boolean }) => void;
|
||||
}
|
||||
const { category, selectedValue, onSelect }: Props = $props();
|
||||
|
||||
let filterText = $state('');
|
||||
|
||||
// Reset the inline filter input whenever the user switches categories so
|
||||
// a leftover "sun" query from Labels doesn't silently hide every Keyword
|
||||
// when they jump tabs.
|
||||
$effect(() => {
|
||||
category;
|
||||
filterText = '';
|
||||
});
|
||||
|
||||
// Queries are gated on the active category so opening the panel on
|
||||
// Labels doesn't also pull the expensive keywords aggregation in the
|
||||
// background. Cache keys match the LeftSidebar / legacy tags page so
|
||||
// the fetches dedupe through TanStack.
|
||||
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||
queryKey: ['labels'],
|
||||
queryFn: listLabels,
|
||||
enabled: isAuthenticated() && category === 'labels'
|
||||
}));
|
||||
|
||||
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
|
||||
queryKey: ['photos', 'keywords'],
|
||||
queryFn: aggregateKeywords,
|
||||
enabled: isAuthenticated() && category === 'keywords',
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors'),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
// Same marks-pool query the drill page uses — colors/ratings need a
|
||||
// representative photo per bucket for the count rollup. Cheap once
|
||||
// cached; the drill page kicks the same key.
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors')
|
||||
}));
|
||||
|
||||
// PhotoPrism returns labels in arbitrary order; sort by photo count
|
||||
// descending so the most-used labels are surfaced first. Keywords from
|
||||
// `aggregateKeywords()` already arrive count-sorted.
|
||||
const labelsSorted = $derived(
|
||||
[...(labelsQuery.data ?? [])].sort(
|
||||
(a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)
|
||||
)
|
||||
);
|
||||
|
||||
const filteredLabels = $derived.by(() => {
|
||||
const q = filterText.trim().toLowerCase();
|
||||
if (!q) return labelsSorted;
|
||||
return labelsSorted.filter(
|
||||
(l) =>
|
||||
l.Name.toLowerCase().includes(q) ||
|
||||
(l.CustomSlug ?? l.Slug).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const keywordsSorted = $derived<AggregatedKeyword[]>(keywordsQuery.data ?? []);
|
||||
const filteredKeywords = $derived.by(() => {
|
||||
const q = filterText.trim().toLowerCase();
|
||||
if (!q) return keywordsSorted;
|
||||
return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const ratingGroups = $derived(
|
||||
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
const colorGroups = $derived(
|
||||
buildColorGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
|
||||
// ── Infinite scroll (labels + keywords) ────────────────────────────────
|
||||
// Chunked rendering instead of virtualization: simpler, robust against
|
||||
// the panel mounting/unmounting on category change, and totally fine for
|
||||
// the cardinalities we see (a few hundred to a few thousand). Each
|
||||
// nearBottom hit grows the window by PAGE_SIZE; reset the window when
|
||||
// the underlying list changes (new category, filter input change) so a
|
||||
// 10k-item list doesn't render its full DOM after a long scroll session.
|
||||
const PAGE_SIZE = 100;
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
$effect(() => {
|
||||
category;
|
||||
filterText;
|
||||
visibleCount = PAGE_SIZE;
|
||||
});
|
||||
|
||||
let scrollEl: HTMLElement | undefined = $state();
|
||||
|
||||
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
|
||||
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
|
||||
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
|
||||
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
|
||||
|
||||
function loadMore() {
|
||||
visibleCount += PAGE_SIZE;
|
||||
}
|
||||
|
||||
// Click handlers — always set the clicked value. Re-clicking the same
|
||||
// row is a no-op (the auto-select-first effect below makes "clear"
|
||||
// untenable: it would just re-pick the first row immediately).
|
||||
function pickLabel(value: string) {
|
||||
if (selectedValue !== value) onSelect(value);
|
||||
}
|
||||
function pickKeyword(value: string) {
|
||||
if (selectedValue !== value) onSelect(value);
|
||||
}
|
||||
function pickColor(key: string) {
|
||||
if (selectedValue !== key) onSelect(key);
|
||||
}
|
||||
function pickRating(r: number) {
|
||||
const s = String(r);
|
||||
if (selectedValue !== s) onSelect(s);
|
||||
}
|
||||
|
||||
// First non-empty entry for the active category. Labels/keywords are
|
||||
// already sorted by count desc, so [0] is the most-used tag; colors
|
||||
// and ratings walk their fixed display order and pick the first
|
||||
// bucket with photos in it. Returns null when nothing's loaded yet or
|
||||
// the category genuinely has no tagged photos.
|
||||
const firstValue = $derived.by<string | null>(() => {
|
||||
if (category === 'labels') {
|
||||
const first = labelsSorted[0];
|
||||
return first ? (first.CustomSlug ?? first.Slug) : null;
|
||||
}
|
||||
if (category === 'keywords') {
|
||||
return keywordsSorted[0]?.keyword ?? null;
|
||||
}
|
||||
if (category === 'colors') {
|
||||
return colorGroups[0]?.key ?? null;
|
||||
}
|
||||
if (category === 'ratings') {
|
||||
const g = ratingGroups[0];
|
||||
return g ? String(g.rating) : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Auto-select the first tag when the user lands on a category URL
|
||||
// without an explicit value. `replace: true` keeps `/tags/labels` out
|
||||
// of the back-button stack so the redirect doesn't trap the user in a
|
||||
// loop. The guard `selectedValue == null` means this effect only
|
||||
// fires when there's genuinely no selection — once a value is picked
|
||||
// (by the user or by this effect), the URL drives selectedValue and
|
||||
// the effect no-ops.
|
||||
$effect(() => {
|
||||
if (selectedValue != null) return;
|
||||
if (firstValue == null) return;
|
||||
onSelect(firstValue, { replace: true });
|
||||
});
|
||||
|
||||
const categoryTitle = $derived(
|
||||
category === 'labels'
|
||||
? 'Labels'
|
||||
: category === 'keywords'
|
||||
? 'Keywords'
|
||||
: category === 'colors'
|
||||
? 'Colors'
|
||||
: 'Ratings'
|
||||
);
|
||||
|
||||
const showFilterInput = $derived(category === 'labels' || category === 'keywords');
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="shrink-0 border-b border-border px-3 py-2">
|
||||
<span
|
||||
class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"
|
||||
>
|
||||
{categoryTitle}
|
||||
</span>
|
||||
{#if showFilterInput}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Filter ${categoryTitle.toLowerCase()}…`}
|
||||
class="mt-2 w-full rounded border border-border bg-background px-2 py-1 text-[12px] outline-none placeholder:text-muted-foreground/60 focus:border-primary/40"
|
||||
bind:value={filterText}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if category === 'labels'}
|
||||
{#if labelsQuery.isPending}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading labels…</p>
|
||||
{:else if labelsQuery.isError}
|
||||
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load labels.</p>
|
||||
{:else if filteredLabels.length === 0}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{filterText ? 'No labels match the filter.' : 'No labels yet.'}
|
||||
</p>
|
||||
{:else}
|
||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each visibleLabels as label (label.UID ?? label.Slug)}
|
||||
{@const slug = label.CustomSlug ?? label.Slug}
|
||||
{@const active = slug === selectedValue}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => pickLabel(slug)}
|
||||
title={label.Name}
|
||||
>
|
||||
{#if label.Thumb}
|
||||
<img
|
||||
src={thumbUrl(label.Thumb, 'tile_50')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="h-5 w-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="h-5 w-5 shrink-0 rounded bg-secondary"></span>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">{label.Name}</span>
|
||||
<span
|
||||
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{label.PhotoCount ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Sentinel: trips well before the user reaches the bottom so
|
||||
the next page is mounted invisibly. -->
|
||||
<div
|
||||
use:nearBottom={{
|
||||
onHit: loadMore,
|
||||
enabled: hasMoreLabels,
|
||||
root: scrollEl ?? null,
|
||||
preloadPx: 400
|
||||
}}
|
||||
class="h-px"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{#if hasMoreLabels}
|
||||
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
|
||||
Loading more… ({visibleCount} / {filteredLabels.length})
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if category === 'keywords'}
|
||||
{#if keywordsQuery.isPending}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Loading keywords… (aggregates from photo details — first load may take a few seconds)
|
||||
</p>
|
||||
{:else if keywordsQuery.isError}
|
||||
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load keywords.</p>
|
||||
{:else if filteredKeywords.length === 0}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{filterText
|
||||
? 'No keywords match the filter.'
|
||||
: 'No user-set keywords yet. Add them from a photo’s right-sidebar metadata panel.'}
|
||||
</p>
|
||||
{:else}
|
||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each visibleKeywords as kw (kw.keyword)}
|
||||
{@const active = kw.keyword === selectedValue}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => pickKeyword(kw.keyword)}
|
||||
title={kw.keyword}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(kw.sampleHash, 'tile_50')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="h-5 w-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{kw.keyword}</span>
|
||||
<span
|
||||
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{kw.count}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
<div
|
||||
use:nearBottom={{
|
||||
onHit: loadMore,
|
||||
enabled: hasMoreKeywords,
|
||||
root: scrollEl ?? null,
|
||||
preloadPx: 400
|
||||
}}
|
||||
class="h-px"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{#if hasMoreKeywords}
|
||||
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
|
||||
Loading more… ({visibleCount} / {filteredKeywords.length})
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if category === 'colors'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
|
||||
{:else if marksQuery.isError || marksPoolQuery.isError}
|
||||
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load colors.</p>
|
||||
{:else}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#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}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] leading-tight hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
{disabled}
|
||||
onclick={() => pickColor(swatch.key)}
|
||||
title={swatch.title}
|
||||
>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full {swatch.bg}"></span>
|
||||
<span class="min-w-0 flex-1 truncate">{swatch.title}</span>
|
||||
<span
|
||||
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if category === 'ratings'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading ratings…</p>
|
||||
{:else if marksQuery.isError || marksPoolQuery.isError}
|
||||
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load ratings.</p>
|
||||
{:else}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#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}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] leading-tight hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
{disabled}
|
||||
onclick={() => pickRating(r)}
|
||||
title={`${r} star${r === 1 ? '' : 's'}`}
|
||||
>
|
||||
<span class="text-yellow-500">{starLabel(r)}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{r} star{r === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span
|
||||
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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<FilterState>({
|
||||
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<void> {
|
||||
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(' ');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
}>({
|
||||
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();
|
||||
|
||||
98
web/src/lib/utils/tagGroups.ts
Normal file
98
web/src/lib/utils/tagGroups.ts
Normal file
@@ -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<number, PpPhoto[]>();
|
||||
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<string, PpPhoto[]>();
|
||||
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);
|
||||
}
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Single shared preview modal. Reads view.previewOpen +
|
||||
selection.focused; any route can pop it via the openPreview
|
||||
helper (called by the timeline / PhotoGrid dblclick paths and
|
||||
by gridKeyNav's Space handler). -->
|
||||
<PreviewModal />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<SplitGrid>
|
||||
{#snippet preview()}
|
||||
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||
{/snippet}
|
||||
{#snippet grid()}
|
||||
<main
|
||||
bind:this={scrollRoot}
|
||||
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||||
@@ -932,8 +927,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
{/snippet}
|
||||
</SplitGrid>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
|
||||
import PreviewPane from '$lib/components/preview/PreviewPane.svelte';
|
||||
|
||||
// Deep-link entry. The route renders a full-page InlinePreview keyed
|
||||
// Deep-link entry. The route renders a full-page PreviewPane keyed
|
||||
// on the URL `uid` so the link stays shareable and reload-safe. No
|
||||
// surrounding grid here — this surface is a single-photo viewer.
|
||||
const uid = $derived((page.params.uid ?? null) as string | null);
|
||||
@@ -10,5 +10,5 @@
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<InlinePreview {uid} {order} />
|
||||
<PreviewPane {uid} {order} showChevrons={false} />
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<SplitGrid>
|
||||
{#snippet preview()}
|
||||
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||
{/snippet}
|
||||
{#snippet grid()}
|
||||
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||
{#if reviewQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||
{:else if reviewQuery.error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load review queue: {reviewQuery.error instanceof Error
|
||||
? reviewQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||
<p>The review queue is empty.</p>
|
||||
<p class="text-xs">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{:else if activeGroup}
|
||||
{#key activeGroup.cause}
|
||||
<CauseGroupCard group={activeGroup} />
|
||||
{/key}
|
||||
{/if}
|
||||
</main>
|
||||
{/snippet}
|
||||
</SplitGrid>
|
||||
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||
{#if reviewQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||
{:else if reviewQuery.error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load review queue: {reviewQuery.error instanceof Error
|
||||
? reviewQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||
<p>The review queue is empty.</p>
|
||||
<p class="text-xs">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{:else if activeGroup}
|
||||
{#key activeGroup.cause}
|
||||
<CauseGroupCard group={activeGroup} />
|
||||
{/key}
|
||||
{/if}
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
|
||||
61
web/src/routes/tags/+layout.svelte
Normal file
61
web/src/routes/tags/+layout.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import {
|
||||
isTagCategory,
|
||||
navigateToTag,
|
||||
type TagCategory
|
||||
} from '$lib/stores/filters.svelte';
|
||||
import { setTagsBrowserWidth, view } from '$lib/stores/view.svelte';
|
||||
import TagsBrowserSidebar from '$lib/components/sidebar/TagsBrowserSidebar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Active category comes straight from the URL path so deep links
|
||||
// hydrate the panel without a separate route-load hop. `isTagCategory`
|
||||
// rejects typos in URLs (e.g. /tags/labl) by treating them as "no
|
||||
// category active" — the dynamic page itself will 404 or fall through
|
||||
// to the landing prompt.
|
||||
const category = $derived<TagCategory | null>(
|
||||
isTagCategory(page.params.category) ? page.params.category : null
|
||||
);
|
||||
const selectedValue = $derived<string | null>(
|
||||
typeof page.params.value === 'string' && page.params.value.length > 0
|
||||
? decodeURIComponent(page.params.value)
|
||||
: null
|
||||
);
|
||||
|
||||
function onSelect(value: string | null, options: { replace?: boolean } = {}) {
|
||||
if (!category) return;
|
||||
void navigateToTag(category, value, options);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if category && !view.tagsBrowserCollapsed}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-r border-border bg-card/30"
|
||||
style="width: {view.tagsBrowserWidth}px;"
|
||||
>
|
||||
<TagsBrowserSidebar {category} {selectedValue} {onSelect} />
|
||||
<div
|
||||
class="group absolute -right-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||
use:resizable={{
|
||||
edge: 'right',
|
||||
getWidth: () => view.tagsBrowserWidth,
|
||||
setWidth: setTagsBrowserWidth
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize tags panel"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||
></div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,647 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
countPhotos,
|
||||
getAllMarks,
|
||||
getPhoto,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
type PpLabel
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, session, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
|
||||
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.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 { isTagCategory } from '$lib/stores/filters.svelte';
|
||||
|
||||
// Tag-flavoured surfaces, all under one route so the user can swap
|
||||
// between them without losing place. Four tabs:
|
||||
// - labels classifier-derived (PhotoPrism's /labels)
|
||||
// - keywords user-typed (Details.Keywords aggregated)
|
||||
// - ratings 1-5 star marks from mule-sidecar
|
||||
// - colors 4-swatch marks from mule-sidecar
|
||||
// Tab + drill state live in the URL so refresh / share / back work.
|
||||
|
||||
type Tab = 'labels' | 'keywords' | 'ratings' | 'colors';
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'labels', label: 'Labels (auto)' },
|
||||
{ id: 'keywords', label: 'Keywords' },
|
||||
{ id: 'ratings', label: 'Ratings' },
|
||||
{ id: 'colors', label: 'Colors' }
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
|
||||
const activePage = $derived(parsePage(page.url.searchParams.get('page')));
|
||||
const drillKey = $derived(page.url.searchParams.get('drill'));
|
||||
|
||||
function parseTab(raw: string | null): Tab {
|
||||
if (raw === 'keywords' || raw === 'ratings' || raw === 'colors') return raw;
|
||||
return 'labels';
|
||||
}
|
||||
function parsePage(raw: string | null): number {
|
||||
const n = raw ? parseInt(raw, 10) : 0;
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
function setTab(tab: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
if (tab !== 'labels') params.set('tab', tab);
|
||||
void goto(`/tags${params.size ? '?' + params : ''}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
function setPage(p: number) {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
if (p === 0) params.delete('page');
|
||||
else params.set('page', String(p));
|
||||
params.delete('drill');
|
||||
void goto(`/tags${params.size ? '?' + params : ''}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
function drillInto(key: string) {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
params.set('drill', key);
|
||||
void goto(`/tags?${params}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
function clearDrill() {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
params.delete('drill');
|
||||
void goto(`/tags${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
// ── Data sources ─────────────────────────────────────────────────────────
|
||||
// Labels and marks are always enabled while /tags is mounted so every
|
||||
// tab pill can render its count badge, not just the active tab. Both
|
||||
// share queryKeys with the sidebar so the fetch is deduped. Keywords
|
||||
// stays lazy (it's an O(1000-photo getPhoto fan-out) — heavy).
|
||||
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||
queryKey: ['labels'],
|
||||
queryFn: listLabels,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
// Photo-count for the Labels tab pill — counts pictures that carry any
|
||||
// label, not distinct label categories. Mirrors (and shares cache with)
|
||||
// the LeftSidebar's labels badge by reusing its scoping rules + queryKey
|
||||
// so the two reads dedupe through svelte-query.
|
||||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||||
function scopedLabelsFilter(): string {
|
||||
const bp = userBasePath();
|
||||
const base = 'all:true label:*';
|
||||
if (isAdminUser && bp === '') return base;
|
||||
if (!isAdminUser && bp === '') return 'uid:none';
|
||||
return `${base} path:"${bp}*"`;
|
||||
}
|
||||
const labelsPhotoCountQuery = createQuery<number>(() => ({
|
||||
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
|
||||
queryFn: () => countPhotos(scopedLabelsFilter()),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
|
||||
queryKey: ['photos', 'keywords'],
|
||||
queryFn: aggregateKeywords,
|
||||
enabled: isAuthenticated() && activeTab === 'keywords',
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
// Marks → photo resolution. The sidecar's marks map is keyed by UID;
|
||||
// we need a thumbnail per UID, so pool the most-recent photo list.
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && (activeTab === 'ratings' || activeTab === 'colors')
|
||||
}));
|
||||
|
||||
// ── Group builders ───────────────────────────────────────────────────────
|
||||
interface RatingGroup {
|
||||
rating: number;
|
||||
photos: PpPhoto[];
|
||||
}
|
||||
const ratingGroups = $derived<RatingGroup[]>(
|
||||
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
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<number, PpPhoto[]>();
|
||||
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);
|
||||
// `/tags` is not a reachable destination from the UI — the sidebar's
|
||||
// "Tags" row only toggles the submenu. This page exists purely as a
|
||||
// redirect target so direct navigation to /tags (a stale bookmark, a
|
||||
// legacy `?tab=`/`?drill=` URL, or someone typing it) bounces somewhere
|
||||
// sensible instead of rendering a dead-end prompt.
|
||||
$effect(() => {
|
||||
const tab = page.url.searchParams.get('tab');
|
||||
const drill = page.url.searchParams.get('drill');
|
||||
if (tab && isTagCategory(tab)) {
|
||||
const path = drill
|
||||
? `/tags/${tab}/${encodeURIComponent(drill)}`
|
||||
: `/tags/${tab}`;
|
||||
void goto(path, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Titles follow the Lightroom culling convention so users see the
|
||||
// swatch's *intent* (reject/review/pick/keep), not just its color.
|
||||
// Used both as tooltip on swatches and as the visible card label in
|
||||
// the colors-tab picker grid below.
|
||||
const COLOR_SWATCHES: { 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' }
|
||||
];
|
||||
interface ColorGroup {
|
||||
key: string;
|
||||
title: string;
|
||||
bg: string;
|
||||
photos: PpPhoto[];
|
||||
}
|
||||
const colorGroups = $derived<ColorGroup[]>(
|
||||
buildColorGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
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<string, PpPhoto[]>();
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Per-tab badge counts ─────────────────────────────────────────────────
|
||||
// Each pill shows the number of *photos* a tab covers (not categories),
|
||||
// so all four tabs read on the same scale and match the sidebar's Tags
|
||||
// badge. Keywords stays as a distinct-keyword count because the keywords
|
||||
// pool is the only one without a cheap photo-rollup query.
|
||||
// `undefined` means the underlying query hasn't resolved yet — the badge
|
||||
// is skipped rather than showing a misleading 0.
|
||||
const labelsCount = $derived<number | undefined>(labelsPhotoCountQuery.data);
|
||||
const keywordsCount = $derived<number | undefined>(keywordsQuery.data?.length);
|
||||
const ratedPhotosCount = $derived<number | undefined>(
|
||||
marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined
|
||||
);
|
||||
const coloredPhotosCount = $derived<number | undefined>(
|
||||
marksQuery.data ? countMarked(marksQuery.data, 'color') : undefined
|
||||
);
|
||||
function countMarked(marks: PhotoMarksMap, field: 'rating' | 'color'): number {
|
||||
let n = 0;
|
||||
for (const m of Object.values(marks)) {
|
||||
if (field === 'rating' ? (m.rating ?? 0) > 0 : Boolean(m.color)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function tabCount(tab: Tab): number | undefined {
|
||||
if (tab === 'labels') return labelsCount;
|
||||
if (tab === 'keywords') return keywordsCount;
|
||||
if (tab === 'ratings') return ratedPhotosCount;
|
||||
return coloredPhotosCount;
|
||||
}
|
||||
|
||||
// ── Sorted full lists per tab (most-common first), then page slice ───────
|
||||
const labelsSorted = $derived(
|
||||
[...(labelsQuery.data ?? [])].sort((a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0))
|
||||
);
|
||||
const keywordsSorted = $derived(keywordsQuery.data ?? []); // already sorted
|
||||
// Ratings/Colors are small fixed-cardinality buckets — no pagination.
|
||||
const totalPages = $derived.by(() => {
|
||||
if (activeTab === 'labels') return Math.ceil(labelsSorted.length / PAGE_SIZE);
|
||||
if (activeTab === 'keywords') return Math.ceil(keywordsSorted.length / PAGE_SIZE);
|
||||
return 0;
|
||||
void goto('/', { replaceState: true, keepFocus: true, noScroll: true });
|
||||
});
|
||||
const pageSlice = $derived.by(() => {
|
||||
const start = activePage * PAGE_SIZE;
|
||||
const end = start + PAGE_SIZE;
|
||||
if (activeTab === 'labels') return labelsSorted.slice(start, end);
|
||||
if (activeTab === 'keywords') return keywordsSorted.slice(start, end);
|
||||
return [];
|
||||
});
|
||||
|
||||
// ── Drill-down: server-side q-DSL query for the picked group/tile ───────
|
||||
const drillQ = $derived.by(() => {
|
||||
if (!drillKey) return '';
|
||||
if (activeTab === 'labels') return `label:${drillKey}`;
|
||||
if (activeTab === 'keywords') return `keywords:${drillKey}`;
|
||||
return ''; // ratings/colors resolve locally from the marks pool
|
||||
});
|
||||
const drillPhotosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'tag-drill', activeTab, drillQ],
|
||||
queryFn: () =>
|
||||
listPhotos({
|
||||
q: drillQ,
|
||||
count: 1000,
|
||||
order: 'newest',
|
||||
merged: true
|
||||
}),
|
||||
enabled: isAuthenticated() && Boolean(drillQ)
|
||||
}));
|
||||
|
||||
// Local drill resolution (ratings / colors): the marks pool already
|
||||
// carries the photos; just pick the bucket the user clicked.
|
||||
const localDrillPhotos = $derived.by<PpPhoto[]>(() => {
|
||||
if (!drillKey) return [];
|
||||
if (activeTab === 'ratings') {
|
||||
const r = parseInt(drillKey, 10);
|
||||
return ratingGroups.find((g) => g.rating === r)?.photos ?? [];
|
||||
}
|
||||
if (activeTab === 'colors') {
|
||||
return colorGroups.find((g) => g.key === drillKey)?.photos ?? [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const drillPhotos = $derived<PpPhoto[]>(
|
||||
activeTab === 'ratings' || activeTab === 'colors'
|
||||
? localDrillPhotos
|
||||
: drillPhotosQuery.data ?? []
|
||||
);
|
||||
|
||||
// Metadata for the focused tile in the drill-in PhotoGrid. Mirrors the
|
||||
// timeline (+page.svelte) and /review wiring so the right sidebar
|
||||
// reads consistently across views.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||
queryKey: ['photo', selection.focused ?? ''],
|
||||
queryFn: () =>
|
||||
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||||
enabled: isAuthenticated() && Boolean(selection.focused),
|
||||
staleTime: 0
|
||||
}));
|
||||
|
||||
function starLabel(rating: number): string {
|
||||
return '★'.repeat(rating);
|
||||
}
|
||||
|
||||
// Friendly label for the current drill — used in the toolbar pill.
|
||||
const drillTitle = $derived.by(() => {
|
||||
if (!drillKey) return '';
|
||||
if (activeTab === 'labels') {
|
||||
const hit = (labelsQuery.data ?? []).find(
|
||||
(l) => (l.CustomSlug ?? l.Slug) === drillKey
|
||||
);
|
||||
return hit?.Name ?? drillKey;
|
||||
}
|
||||
if (activeTab === 'keywords') return drillKey;
|
||||
if (activeTab === 'ratings') return starLabel(parseInt(drillKey, 10));
|
||||
if (activeTab === 'colors') {
|
||||
return COLOR_SWATCHES.find((c) => c.key === drillKey)?.title ?? drillKey;
|
||||
}
|
||||
return drillKey;
|
||||
});
|
||||
|
||||
const drillCount = $derived<number>(drillPhotos.length);
|
||||
</script>
|
||||
|
||||
<Toolbar showRightToggle={Boolean(drillKey)}>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Tags
|
||||
</span>
|
||||
{#if drillKey}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||
onclick={clearDrill}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span class="text-[11px] font-medium">{drillTitle}</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{drillCount} photo{drillCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Pill row of tabs. The active tab uses the same primary-tinted
|
||||
treatment as the sidebar's active rows so the active state
|
||||
reads consistently across the app. -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#each TABS as t (t.id)}
|
||||
{@const count = tabCount(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)}
|
||||
>
|
||||
{t.label}
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="tabular-nums text-[10px] {activeTab === t.id
|
||||
? 'text-primary/70'
|
||||
: 'text-muted-foreground/70'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet trailing()}
|
||||
{#if !drillKey && (activeTab === 'labels' || activeTab === 'keywords') && totalPages > 1}
|
||||
<!-- Page navigator only renders when there's actually a page to
|
||||
turn — the ratings/colors tabs cap at 5 / 4 buckets which
|
||||
always fits a single screen. -->
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={activePage === 0}
|
||||
onclick={() => setPage(activePage - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||
{activePage + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={activePage >= totalPages - 1}
|
||||
onclick={() => setPage(activePage + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
{#if drillKey}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<SplitGrid>
|
||||
{#snippet preview()}
|
||||
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||
{/snippet}
|
||||
{#snippet grid()}
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
<!-- Drill-down photo grid. Reused for all four tabs; the q-DSL
|
||||
drives labels/keywords while ratings/colors resolve locally
|
||||
from the marks pool already in cache. -->
|
||||
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
|
||||
<SkeletonGrid />
|
||||
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photos.</p>
|
||||
{:else if drillPhotos.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
|
||||
{:else}
|
||||
<PhotoGrid photos={drillPhotos} />
|
||||
{/if}
|
||||
</main>
|
||||
{/snippet}
|
||||
</SplitGrid>
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<div class="h-full overflow-y-auto">
|
||||
{#if selection.ids.size >= 2}
|
||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||
{:else if focusedPhotoQuery.data}
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||
{:else}
|
||||
<div class="space-y-2 p-4 text-center">
|
||||
<div class="text-xl">ⓘ</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click
|
||||
on a thumbnail to view its metadata here.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||
use:resizable={{
|
||||
edge: 'left',
|
||||
getWidth: () => view.rightSidebarWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize info panel"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||
></div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if activeTab === 'labels'}
|
||||
{#if labelsQuery.isPending}
|
||||
<SkeletonGrid />
|
||||
{:else if labelsQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load labels.</p>
|
||||
{:else if labelsSorted.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each pageSlice as item (item)}
|
||||
{@const label = item as PpLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(label.CustomSlug ?? label.Slug)}
|
||||
>
|
||||
{#if label.Thumb}
|
||||
<img
|
||||
src={thumbUrl(label.Thumb, 'tile_500')}
|
||||
alt={label.Name}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="truncate font-medium">{label.Name}</span>
|
||||
<span class="text-muted-foreground">{label.PhotoCount ?? 0}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'keywords'}
|
||||
{#if keywordsQuery.isPending}
|
||||
<SkeletonGrid />
|
||||
{:else if keywordsQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load keywords.</p>
|
||||
{:else if keywordsSorted.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No user-set keywords yet. Add them from a photo's right-sidebar metadata panel.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each pageSlice as item (item)}
|
||||
{@const kw = item as AggregatedKeyword}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(kw.keyword)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(kw.sampleHash, 'tile_500')}
|
||||
alt={kw.keyword}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="truncate font-medium">{kw.keyword}</span>
|
||||
<span class="text-muted-foreground">{kw.count}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'ratings'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<SkeletonGrid count={5} />
|
||||
{:else if marksQuery.isError || marksPoolQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load ratings.</p>
|
||||
{:else if ratingGroups.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each ratingGroups as group (group.rating)}
|
||||
{@const rep = group.photos[0]}
|
||||
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(String(group.rating))}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={starLabel(group.rating)}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="truncate font-medium text-yellow-500">
|
||||
{starLabel(group.rating)}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'colors'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<SkeletonGrid count={4} />
|
||||
{:else if marksQuery.isError || marksPoolQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load colors.</p>
|
||||
{:else if colorGroups.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No color labels yet. Open a photo and use the four-swatch row in the right
|
||||
sidebar to tag it.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each colorGroups as group (group.key)}
|
||||
{@const rep = group.photos[0]}
|
||||
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(group.key)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={group.title}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 truncate font-medium">
|
||||
<span class="h-2.5 w-2.5 rounded-full {group.bg}"></span>
|
||||
{group.title}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
259
web/src/routes/tags/[category]/[[value]]/+page.svelte
Normal file
259
web/src/routes/tags/[category]/[[value]]/+page.svelte
Normal file
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
getAllMarks,
|
||||
getPhoto,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
type PhotoMarksMap,
|
||||
type PpLabel
|
||||
} from '$lib/services/photoprism';
|
||||
import {
|
||||
filtersToQ,
|
||||
isTagCategory,
|
||||
setTagFilter,
|
||||
type TagCategory
|
||||
} from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import {
|
||||
buildColorGroups,
|
||||
buildRatingGroups,
|
||||
COLOR_SWATCHES,
|
||||
starLabel
|
||||
} from '$lib/utils/tagGroups';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
// URL-driven category + value. `isTagCategory` rejects typos so a stray
|
||||
// /tags/foo URL falls back to the no-category prompt rather than firing
|
||||
// PhotoPrism queries with an invalid DSL clause.
|
||||
const category = $derived<TagCategory | null>(
|
||||
isTagCategory(page.params.category) ? page.params.category : null
|
||||
);
|
||||
const selectedValue = $derived<string | null>(
|
||||
typeof page.params.value === 'string' && page.params.value.length > 0
|
||||
? decodeURIComponent(page.params.value)
|
||||
: null
|
||||
);
|
||||
|
||||
// Mirror URL into the shared filter store so any other consumer of
|
||||
// `filters` (e.g. cross-route navigation back to `/`) sees the active
|
||||
// tag filter, and so `filtersToQ()` produces the correct DSL clause
|
||||
// for the photo grid. Reset on unmount so leaving /tags doesn't leak
|
||||
// tag filters back to the timeline.
|
||||
$effect(() => {
|
||||
setTagFilter(category, selectedValue);
|
||||
return () => setTagFilter(null, null);
|
||||
});
|
||||
|
||||
// ── Labels / Keywords resolve via PhotoPrism's q DSL ─────────────────────
|
||||
// Tag browse is library-wide (within the user's ACL): explicitly NOT
|
||||
// inheriting the timeline's folder/section/search filters, because the
|
||||
// counts in the panel rows are themselves library-wide and a folder
|
||||
// filter on top would make drill counts disagree with the badges (a
|
||||
// label badge of 157 could otherwise drill into 0 photos because the
|
||||
// session is scoped to a folder that has none of them).
|
||||
const useServer = $derived(category === 'labels' || category === 'keywords');
|
||||
const drillQ = $derived(
|
||||
useServer && selectedValue
|
||||
? filtersToQ({
|
||||
section: 'all-photos',
|
||||
heapUid: null,
|
||||
folderPath: null,
|
||||
search: '',
|
||||
tagCategory: category,
|
||||
tagValue: selectedValue
|
||||
})
|
||||
: ''
|
||||
);
|
||||
const serverPhotosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'tag-drill', category, drillQ],
|
||||
queryFn: () =>
|
||||
listPhotos({ q: drillQ, count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && useServer && Boolean(selectedValue)
|
||||
}));
|
||||
|
||||
// ── Colors / Ratings resolve locally from the marks pool ─────────────────
|
||||
const useLocal = $derived(category === 'colors' || category === 'ratings');
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated() && useLocal,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && useLocal
|
||||
}));
|
||||
|
||||
const ratingGroups = $derived(
|
||||
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
const colorGroups = $derived(
|
||||
buildColorGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
const localPhotos = $derived.by<PpPhoto[]>(() => {
|
||||
if (!selectedValue) return [];
|
||||
if (category === 'ratings') {
|
||||
const r = parseInt(selectedValue, 10);
|
||||
return ratingGroups.find((g) => g.rating === r)?.photos ?? [];
|
||||
}
|
||||
if (category === 'colors') {
|
||||
return colorGroups.find((g) => g.key === selectedValue)?.photos ?? [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const drillPhotos = $derived<PpPhoto[]>(
|
||||
useLocal ? localPhotos : (serverPhotosQuery.data ?? [])
|
||||
);
|
||||
const drillCount = $derived(drillPhotos.length);
|
||||
|
||||
// ── Title pill — what's currently filtered ───────────────────────────────
|
||||
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||
queryKey: ['labels'],
|
||||
queryFn: listLabels,
|
||||
enabled: isAuthenticated() && category === 'labels'
|
||||
}));
|
||||
const drillTitle = $derived.by(() => {
|
||||
if (!selectedValue) return '';
|
||||
if (category === 'labels') {
|
||||
const hit = (labelsQuery.data ?? []).find(
|
||||
(l) => (l.CustomSlug ?? l.Slug) === selectedValue
|
||||
);
|
||||
return hit?.Name ?? selectedValue;
|
||||
}
|
||||
if (category === 'keywords') return selectedValue;
|
||||
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
|
||||
if (category === 'colors') {
|
||||
return (
|
||||
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
|
||||
);
|
||||
}
|
||||
return selectedValue;
|
||||
});
|
||||
|
||||
// Right-sidebar metadata for the focused tile. Mirrors the timeline
|
||||
// + review wiring so the metadata panel reads consistently here.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||
queryKey: ['photo', selection.focused ?? ''],
|
||||
queryFn: () =>
|
||||
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||||
enabled: isAuthenticated() && Boolean(selection.focused),
|
||||
staleTime: 0
|
||||
}));
|
||||
|
||||
const showSkeleton = $derived(
|
||||
useServer
|
||||
? serverPhotosQuery.isPending && Boolean(selectedValue)
|
||||
: useLocal
|
||||
? (marksQuery.isPending || marksPoolQuery.isPending) &&
|
||||
Boolean(selectedValue)
|
||||
: false
|
||||
);
|
||||
const showError = $derived(
|
||||
useServer
|
||||
? serverPhotosQuery.isError
|
||||
: useLocal
|
||||
? marksQuery.isError || marksPoolQuery.isError
|
||||
: false
|
||||
);
|
||||
</script>
|
||||
|
||||
<Toolbar showRightToggle={Boolean(selectedValue)}>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Tags
|
||||
</span>
|
||||
{#if category}
|
||||
<span class="text-[11px] capitalize text-muted-foreground">{category}</span>
|
||||
{/if}
|
||||
{#if selectedValue}
|
||||
<span class="text-[11px] font-medium">{drillTitle}</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{drillCount} photo{drillCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
</Toolbar>
|
||||
|
||||
{#if !selectedValue}
|
||||
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
||||
<div class="max-w-sm space-y-2 text-center">
|
||||
<p class="text-sm font-medium">Pick a {category ?? 'tag'} from the sidebar</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Click a row in the panel on the left to filter the photo grid by that tag.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{:else}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if showSkeleton}
|
||||
<SkeletonGrid />
|
||||
{:else if showError}
|
||||
<p class="text-sm text-destructive">Failed to load photos.</p>
|
||||
{:else if drillPhotos.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
|
||||
{:else}
|
||||
<PhotoGrid photos={drillPhotos} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<div class="h-full overflow-y-auto">
|
||||
{#if selection.ids.size >= 2}
|
||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||
{:else if focusedPhotoQuery.data}
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||
{:else}
|
||||
<div class="space-y-2 p-4 text-center">
|
||||
<div class="text-xl">ⓘ</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click on a
|
||||
thumbnail to view its metadata here.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||
use:resizable={{
|
||||
edge: 'left',
|
||||
getWidth: () => view.rightSidebarWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize info panel"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||
></div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<BulkActionBar />
|
||||
Reference in New Issue
Block a user