feat(preview): inline split-pane preview + sidebar metadata pass
Replace the fullscreen PreviewOverlay with an inline top pane above
each grid surface. SplitGrid + InlinePreview render the focused
photo/video inside the host page; a resizableVertical action drives
the divider and the height persists via the view store. Applied to
the timeline, /tags drill-in, /review cause tabs, /photo/[uid], and
/map. selection.focused is now the single source of truth for both
the inline pane and the right sidebar — preview.svelte store and
PreviewOverlay are removed.
Sidebar: drop the thumb; lead with icon-led filename and folder
rows that match the date/place rhythm. Move dims+size to the top
(below date) and camera/lens/exposure into the collapsible File
section. Read-only spans share the input padding so the text column
aligns across rows. Folder row sits between date and dims+size.
VideoPlayer: stop forcing width/height: 100% so videos honour their
intrinsic aspect ratio inside the pane. Key the player on file hash
in InlinePreview so navigating between videos remounts the element
and autoplay fires again.
Sidebar (LeftSidebar): switch the labels badge to a dedicated
countPhotos('label:*') query so it reports photos with a label
rather than PhotoPrism's category roll-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import type { Component } from 'svelte';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
@@ -13,7 +12,6 @@
|
||||
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { preview } from '$lib/stores/preview.svelte';
|
||||
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
||||
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||
@@ -56,22 +54,6 @@
|
||||
else stopIndexerWatch();
|
||||
});
|
||||
|
||||
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
|
||||
// pane, exif sidebar. Users who never click into a photo never need
|
||||
// it, so we lazy-import the first time `preview.uid` flips non-null
|
||||
// and keep the loaded module around for the rest of the session
|
||||
// (re-opens skip the network round-trip). Closing the overlay leaves
|
||||
// the component mounted but renders nothing — its internal
|
||||
// `{#if preview.uid !== null}` guard collapses the DOM tree.
|
||||
let PreviewOverlay = $state<Component | null>(null);
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (preview.uid !== null && PreviewOverlay === null) {
|
||||
void import('$lib/components/preview/PreviewOverlay.svelte').then((m) => {
|
||||
PreviewOverlay = m.default as Component;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -129,7 +111,4 @@
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
{#if PreviewOverlay}
|
||||
<PreviewOverlay />
|
||||
{/if}
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
setFocused,
|
||||
setOrder,
|
||||
} from "$lib/stores/selection.svelte";
|
||||
import { openPreview, preview } from "$lib/stores/preview.svelte";
|
||||
import {
|
||||
setRightSidebarWidth,
|
||||
setThumbnailSize,
|
||||
@@ -51,9 +50,11 @@
|
||||
} 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";
|
||||
|
||||
@@ -408,25 +409,6 @@
|
||||
if (el) scrollTileIntoView(el);
|
||||
}
|
||||
|
||||
// When the preview overlay closes, scroll the just-shown photo back
|
||||
// into the timeline window. PreviewOverlay already keeps
|
||||
// selection.focused in lockstep with preview.uid, so we just need to
|
||||
// make sure that tile is mounted (forcedExpand) and visible — the
|
||||
// blue ring renders itself once the inner button is in the DOM.
|
||||
let wasPreviewOpen = $state(false);
|
||||
$effect(() => {
|
||||
const open = preview.uid !== null;
|
||||
const closing = wasPreviewOpen && !open;
|
||||
wasPreviewOpen = open;
|
||||
if (!closing) return;
|
||||
const uid = selection.focused;
|
||||
if (!uid) return;
|
||||
untrack(() => {
|
||||
const i = photos.findIndex((p) => p.UID === uid);
|
||||
if (i >= 0) void scrollToIndex(i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||||
// The CSS Grid lays each photo into a cell with column count derived from
|
||||
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||||
@@ -675,24 +657,19 @@
|
||||
}
|
||||
|
||||
function onTileDblclick(e: MouseEvent, uid: string) {
|
||||
// Modifier-modified dblclicks shouldn't open the preview either —
|
||||
// gridKeyNav already handled the underlying click.
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
openPreview(
|
||||
uid,
|
||||
photos.map((p) => p.UID),
|
||||
);
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
|
||||
// Single-click fallback for the dblclick preview gesture. Wired to the
|
||||
// hover-only Maximize icon in PhotoTile so users who haven't discovered
|
||||
// dblclick can still get to the preview.
|
||||
function onTileOpenPreview(uid: string) {
|
||||
openPreview(
|
||||
uid,
|
||||
photos.map((p) => p.UID),
|
||||
);
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
|
||||
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
|
||||
@@ -840,6 +817,11 @@
|
||||
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"
|
||||
@@ -952,6 +934,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
{/snippet}
|
||||
</SplitGrid>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
type MapSourceDataEvent
|
||||
} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||||
@@ -154,7 +155,10 @@
|
||||
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
openPreview(uid, allUids);
|
||||
setOrder(allUids);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
void goto('/');
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
|
||||
|
||||
// Deep-link entry: opening /photo/<uid> directly pops the overlay on
|
||||
// the timeline. The route itself does not render anything; it hands
|
||||
// off to the global PreviewOverlay and redirects to `/` so the URL
|
||||
// stays clean and the timeline shows behind the modal.
|
||||
$effect(() => {
|
||||
const uid = page.params.uid as string | undefined;
|
||||
if (!uid) return;
|
||||
openPreview(uid);
|
||||
void goto('/', { replaceState: true });
|
||||
});
|
||||
// Deep-link entry. The route renders a full-page InlinePreview 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);
|
||||
const order = $derived(uid ? [uid] : []);
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<InlinePreview {uid} {order} />
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
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;
|
||||
@@ -224,31 +226,38 @@
|
||||
{: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 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>
|
||||
<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>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
countPhotos,
|
||||
getAllMarks,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
@@ -11,13 +12,16 @@
|
||||
type PhotoMarksMap,
|
||||
type PpLabel
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { isAuthenticated, session, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.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';
|
||||
|
||||
// Tag-flavoured surfaces, all under one route so the user can swap
|
||||
@@ -88,6 +92,25 @@
|
||||
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,
|
||||
@@ -186,12 +209,13 @@
|
||||
}
|
||||
|
||||
// ── Per-tab badge counts ─────────────────────────────────────────────────
|
||||
// Each tab pill shows what its grid covers: distinct labels/keywords for
|
||||
// the bucket-style tabs, photo-count for the fixed-cardinality ones
|
||||
// (ratings/colors), matching how the sidebar's Tags badge aggregates.
|
||||
// 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>(labelsQuery.data?.length);
|
||||
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
|
||||
@@ -367,24 +391,37 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
{#if drillKey}
|
||||
<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>
|
||||
{:else}
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if drillKey}
|
||||
<!-- 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}
|
||||
{:else if activeTab === 'labels'}
|
||||
{#if activeTab === 'labels'}
|
||||
{#if labelsQuery.isPending}
|
||||
<SkeletonGrid />
|
||||
{:else if labelsQuery.isError}
|
||||
@@ -546,5 +583,6 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
Reference in New Issue
Block a user