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:
@@ -12,7 +12,6 @@ import {
|
||||
} from '$lib/services/photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
|
||||
import {
|
||||
clearSelection,
|
||||
focusAfter,
|
||||
@@ -55,16 +54,14 @@ export interface GridKeyNavParams {
|
||||
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
||||
* x archive-toggle, u restore, s + (1–9) add to
|
||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
||||
* esc clears, ⌘Z undoes, ⌘A selects all visible.
|
||||
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
||||
* ⌘A selects all visible.
|
||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
||||
* keyboard shortcuts.
|
||||
*
|
||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||
* 1. preview overlay uid (when open) — applies to the visible preview
|
||||
* photo even if the grid still shows a stale selection
|
||||
* 2. multi-selection set
|
||||
* 3. focused tile
|
||||
* 1. multi-selection set
|
||||
* 2. focused tile
|
||||
*/
|
||||
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
let scrollToIndex = params.scrollToIndex;
|
||||
@@ -151,9 +148,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthesize a target list. Preview wins, then multi, then focused. */
|
||||
/** Synthesize a target list. Multi-selection wins, then focused. */
|
||||
function cullTargets(): string[] {
|
||||
if (preview.uid) return [preview.uid];
|
||||
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||
if (selection.focused) return [selection.focused];
|
||||
return [];
|
||||
@@ -376,17 +372,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
await addCullTargetsToHeap(heap);
|
||||
}
|
||||
|
||||
function openPreviewFromGrid() {
|
||||
const id = selection.focused ?? selection.order[0];
|
||||
if (!id) return;
|
||||
openPreview(id, selection.order);
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
if (preview.uid) closePreview();
|
||||
else openPreviewFromGrid();
|
||||
}
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
// Don't hijack typing inside form fields.
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
@@ -408,47 +393,35 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
const shift = e.shiftKey;
|
||||
const inPreview = preview.uid !== null;
|
||||
|
||||
// ── Grid-only nav keys (preview owns its own Arrow/Esc) ──────────────
|
||||
if (!inPreview) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (onArrow) {
|
||||
// Host owns the visual-row map (needed for grids with
|
||||
// interleaved headers). The host calls setFocused +
|
||||
// scrollToIndex + selectRange-on-shift itself.
|
||||
onArrow(e.key, shift);
|
||||
} else {
|
||||
const delta =
|
||||
e.key === 'ArrowLeft'
|
||||
? -1
|
||||
: e.key === 'ArrowRight'
|
||||
? 1
|
||||
: e.key === 'ArrowUp'
|
||||
? -tilesPerRow()
|
||||
: tilesPerRow();
|
||||
moveFocus(delta, shift);
|
||||
if (shift && selection.focused) selectRange(selection.focused);
|
||||
}
|
||||
return;
|
||||
case 'Escape':
|
||||
clearSelection();
|
||||
setFocused(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode-aware shortcuts (work in grid AND preview) ──────────────────
|
||||
// ── Grid nav keys ────────────────────────────────────────────────────
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
togglePreview();
|
||||
if (onArrow) {
|
||||
// Host owns the visual-row map (needed for grids with
|
||||
// interleaved headers). The host calls setFocused +
|
||||
// scrollToIndex + selectRange-on-shift itself.
|
||||
onArrow(e.key, shift);
|
||||
} else {
|
||||
const delta =
|
||||
e.key === 'ArrowLeft'
|
||||
? -1
|
||||
: e.key === 'ArrowRight'
|
||||
? 1
|
||||
: e.key === 'ArrowUp'
|
||||
? -tilesPerRow()
|
||||
: tilesPerRow();
|
||||
moveFocus(delta, shift);
|
||||
if (shift && selection.focused) selectRange(selection.focused);
|
||||
}
|
||||
return;
|
||||
case 'Escape':
|
||||
clearSelection();
|
||||
setFocused(null);
|
||||
return;
|
||||
case 'Tab':
|
||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||
@@ -459,7 +432,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
return;
|
||||
case 'i':
|
||||
case 'I':
|
||||
if (!meta && !shift && !inPreview) {
|
||||
if (!meta && !shift) {
|
||||
e.preventDefault();
|
||||
toggleRightSidebar();
|
||||
}
|
||||
@@ -482,7 +455,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
return;
|
||||
case 'a':
|
||||
case 'A':
|
||||
if (meta && !inPreview) {
|
||||
if (meta) {
|
||||
e.preventDefault();
|
||||
for (const id of selection.order) selection.ids.add(id);
|
||||
}
|
||||
@@ -560,8 +533,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
node.addEventListener('click', onClick);
|
||||
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
|
||||
// immediately on page load regardless of which element holds focus.
|
||||
// The filters inside `onKey` keep form-field typing and preview mode
|
||||
// safe (preview owns its own Arrow/Esc).
|
||||
// The filter inside `onKey` keeps form-field typing safe.
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
return {
|
||||
|
||||
75
web/src/lib/actions/resizableVertical.ts
Normal file
75
web/src/lib/actions/resizableVertical.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -126,7 +126,7 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// One query per badge. Admins with no BasePath skip all of these
|
||||
// One query per badge. Admins with no BasePath skip these
|
||||
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||
// used directly — same chrome as before that fix, no extra
|
||||
// round-trips.
|
||||
@@ -134,10 +134,20 @@
|
||||
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
||||
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
||||
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
||||
const labelsCountQuery = scopedCountQuery('labels', 'all:true label:*');
|
||||
// Labels is special: `configQuery.count.labels` is the number of distinct
|
||||
// label categories (PhotoPrism's roll-up), not the number of photos that
|
||||
// carry a label. The Tags surface wants picture counts everywhere, so we
|
||||
// always run a `countPhotos('label:*')` query regardless of the admin/
|
||||
// BasePath shape and never fall back to the category-count.
|
||||
const labelsCountQuery = createQuery<number>(() => ({
|
||||
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
|
||||
queryFn: () => countPhotos(scoped('all:true label:*')),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
function bucketCount(
|
||||
key: 'favorites' | 'review' | 'hidden' | 'archived' | 'labels',
|
||||
key: 'favorites' | 'review' | 'hidden' | 'archived',
|
||||
query: { data: number | undefined; isPending: boolean }
|
||||
): number | undefined {
|
||||
if (wantScoped) {
|
||||
@@ -148,7 +158,6 @@
|
||||
// (no extra round-trip).
|
||||
const c = configQuery.data?.count;
|
||||
if (!c) return undefined;
|
||||
if (key === 'labels') return c.labels;
|
||||
return c[key];
|
||||
}
|
||||
|
||||
@@ -286,7 +295,9 @@
|
||||
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
||||
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
||||
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
||||
const labelsBadge = $derived(bucketCount('labels', labelsCountQuery));
|
||||
const labelsBadge = $derived<number | undefined>(
|
||||
labelsCountQuery.isPending ? undefined : labelsCountQuery.data
|
||||
);
|
||||
|
||||
const createMut = createMutation(() => ({
|
||||
mutationFn: (title: string) => createHeap(title),
|
||||
|
||||
@@ -9,91 +9,99 @@
|
||||
necessary here.
|
||||
-->
|
||||
<script lang="ts">
|
||||
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
|
||||
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
|
||||
▛▖▞▌▌▌▐ ▌▛▛▌▀▌▛▌█▌
|
||||
▌▝ ▌▙▌▐▖▌▌▌▌█▌▙▌▙▖`;
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="mule-header relative flex h-16 items-center justify-between overflow-hidden border-b border-border px-4">
|
||||
<div class="relative flex items-center gap-3">
|
||||
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
|
||||
<pre
|
||||
aria-label="Mulimago"
|
||||
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
|
||||
style="letter-spacing: 0;"
|
||||
>{MULIMAGO_ASCII}</pre>
|
||||
</div>
|
||||
<header
|
||||
class="mule-header relative flex h-14 items-center justify-between overflow-hidden border-b border-border px-4"
|
||||
>
|
||||
<div class="relative flex items-center gap-3">
|
||||
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
|
||||
<pre
|
||||
aria-label="Mulimago"
|
||||
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
|
||||
style="letter-spacing: 0;">{MULIMAGO_ASCII}</pre>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center gap-3">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<div class="relative flex items-center gap-3">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/*
|
||||
/*
|
||||
* Two layers in the background: tiled desert.png on top scrolling
|
||||
* right→left, dusk-sky gradient underneath. The 200px tile width is
|
||||
* fixed so the `desert-scroll` keyframe moves by exactly one tile and
|
||||
* loops seamlessly.
|
||||
*/
|
||||
.mule-header {
|
||||
background-image:
|
||||
url('/mule/desert.png'),
|
||||
linear-gradient(to bottom, #2b3a5c 0%, #6b6b8a 35%, #d68a5c 75%, #f0c188 100%);
|
||||
background-repeat: repeat-x, no-repeat;
|
||||
background-size:
|
||||
200px 100%,
|
||||
100% 100%;
|
||||
background-position: 0 bottom, 0 0;
|
||||
image-rendering: pixelated;
|
||||
animation: desert-scroll 24s linear infinite;
|
||||
}
|
||||
.mule-header {
|
||||
background-image: url("/mule/desert.png"),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
#2b3a5c 0%,
|
||||
#6b6b8a 35%,
|
||||
#d68a5c 75%,
|
||||
#f0c188 100%
|
||||
);
|
||||
background-repeat: repeat-x, no-repeat;
|
||||
background-size:
|
||||
200px 100%,
|
||||
100% 100%;
|
||||
background-position:
|
||||
0 bottom,
|
||||
0 0;
|
||||
image-rendering: pixelated;
|
||||
animation: desert-scroll 24s linear infinite;
|
||||
}
|
||||
|
||||
/* 3×2 sprite-sheet, 6-frame walk cycle. `steps(1)` makes each keyframe
|
||||
/* 3×2 sprite-sheet, 6-frame walk cycle. `steps(1)` makes each keyframe
|
||||
* snap (no interpolation between frames). */
|
||||
.mule-sprite {
|
||||
background-image: url('/mule/mule-sprites.png');
|
||||
background-size: 300% 200%;
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: pixelated;
|
||||
animation: mule-walk 0.6s steps(1) infinite;
|
||||
}
|
||||
.mule-sprite {
|
||||
background-image: url("/mule/mule-sprites.png");
|
||||
background-size: 300% 200%;
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: pixelated;
|
||||
animation: mule-walk 0.6s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes mule-walk {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
16.66% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
33.33% {
|
||||
background-position: 100% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
66.66% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
83.33% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
@keyframes mule-walk {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
16.66% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
33.33% {
|
||||
background-position: 100% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
66.66% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
83.33% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes desert-scroll {
|
||||
from {
|
||||
background-position-x: 0px, 0px;
|
||||
}
|
||||
to {
|
||||
background-position-x: -200px, 0px;
|
||||
}
|
||||
}
|
||||
@keyframes desert-scroll {
|
||||
from {
|
||||
background-position-x: 0px, 0px;
|
||||
}
|
||||
to {
|
||||
background-position-x: -200px, 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
89
web/src/lib/components/preview/InlinePreview.svelte
Normal file
89
web/src/lib/components/preview/InlinePreview.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { thumbUrl, videoUrl } from '$lib/stores/session.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[];
|
||||
}
|
||||
let { uid, order }: Props = $props();
|
||||
|
||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', uid ?? ''],
|
||||
queryFn: () => getPhoto(uid as string),
|
||||
enabled: Boolean(uid)
|
||||
}));
|
||||
|
||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||
|
||||
function focusAt(i: number) {
|
||||
const next = order[i];
|
||||
if (!next) return;
|
||||
setFocused(next);
|
||||
setAnchor(next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/30 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>
|
||||
{:else if photoQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photo.</p>
|
||||
{:else if photoQuery.data}
|
||||
{@const pf = primaryFile(photoQuery.data)}
|
||||
{#if 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)}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{/if}
|
||||
{#if 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)}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isVideo(photoQuery.data)}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
<!-- 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)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1920')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1920')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import {
|
||||
closePreview,
|
||||
preview,
|
||||
previewNext,
|
||||
previewPrev
|
||||
} from '$lib/stores/preview.svelte';
|
||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { setFocused } from '$lib/stores/selection.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', preview.uid ?? ''],
|
||||
queryFn: () => getPhoto(preview.uid as string),
|
||||
enabled: Boolean(preview.uid)
|
||||
}));
|
||||
|
||||
// Mirror the currently-shown photo into selection.focused. The
|
||||
// timeline's tile uses `selection.focused === photo.UID` to draw the
|
||||
// blue ring, so this keeps the selection in lockstep with whatever
|
||||
// the user is paging through in preview. The host page (+page.svelte)
|
||||
// owns the matching scroll-into-view on close so the tile actually
|
||||
// mounts (it can be windowed out if the user navigated far).
|
||||
$effect(() => {
|
||||
if (preview.uid !== null) setFocused(preview.uid);
|
||||
});
|
||||
|
||||
// Keyboard handling lives at the document level so it works regardless
|
||||
// of focus location. Form fields inside the sidebar still keep their
|
||||
// own arrow-key behaviour because we ignore events whose target is an
|
||||
// input/textarea.
|
||||
$effect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (preview.uid === null) return;
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
const inField = tag === 'input' || tag === 'textarea' || tag === 'select';
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
closePreview();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (inField) return;
|
||||
e.preventDefault();
|
||||
previewPrev();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (inField) return;
|
||||
e.preventDefault();
|
||||
previewNext();
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
// Prevent body scroll while the overlay is up.
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const prev = document.body.style.overflow;
|
||||
if (preview.uid !== null) document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
});
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
// Clicking the dimmed area (but not the image or sidebar) closes.
|
||||
if (e.target === e.currentTarget) closePreview();
|
||||
}
|
||||
|
||||
const currentIndex = $derived(
|
||||
preview.uid ? preview.order.indexOf(preview.uid) : -1
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if preview.uid !== null}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex bg-black/80 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo preview"
|
||||
onclick={onBackdrop}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') closePreview();
|
||||
}}
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Left: image area -->
|
||||
<div
|
||||
class="relative flex flex-1 items-center justify-center p-6"
|
||||
onclick={onBackdrop}
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
class="absolute left-4 top-4 z-10 rounded-md bg-background/80 px-2.5 py-1.5 text-xs hover:bg-background"
|
||||
onclick={closePreview}
|
||||
aria-label="Close preview"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
|
||||
{#if currentIndex > 0}
|
||||
<button
|
||||
class="absolute left-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||
onclick={previewPrev}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{/if}
|
||||
{#if currentIndex >= 0 && currentIndex < preview.order.length - 1}
|
||||
<button
|
||||
class="absolute right-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
|
||||
onclick={previewNext}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if photoQuery.isPending}
|
||||
<p class="text-sm text-white/80">Loading…</p>
|
||||
{:else if photoQuery.isError}
|
||||
<p class="text-sm text-red-300">Failed to load photo.</p>
|
||||
{:else if photoQuery.data}
|
||||
{@const pf = primaryFile(photoQuery.data)}
|
||||
{#if isVideo(photoQuery.data)}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1920')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1920')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: metadata sidebar -->
|
||||
<aside
|
||||
class="w-[360px] shrink-0 overflow-y-auto border-l border-border bg-background p-4"
|
||||
>
|
||||
{#if photoQuery.data}
|
||||
<RightSidebar photo={photoQuery.data} />
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">Loading metadata…</p>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
59
web/src/lib/components/preview/SplitGrid.svelte
Normal file
59
web/src/lib/components/preview/SplitGrid.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -77,11 +77,21 @@
|
||||
|
||||
<style>
|
||||
:global(.vds-player) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 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. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: auto;
|
||||
--media-brand: #3b82f6;
|
||||
--media-focus-ring-color: #3b82f6;
|
||||
}
|
||||
:global(.vds-player video),
|
||||
:global(.vds-player media-provider) {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
import {
|
||||
Aperture,
|
||||
Calendar,
|
||||
Camera,
|
||||
ExternalLink,
|
||||
File,
|
||||
Folder,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
MapPin,
|
||||
@@ -34,7 +35,6 @@
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import RelatedStrip from './RelatedStrip.svelte';
|
||||
@@ -289,27 +289,18 @@
|
||||
</script>
|
||||
|
||||
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
|
||||
<!-- Header strip — thumb + path (read-only) over editable basename. The
|
||||
sidecar's rename endpoint only accepts a bare basename and preserves
|
||||
the directory on disk, so the split UI mirrors that contract. -->
|
||||
<div class="flex items-start gap-2">
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'tile_100')}
|
||||
alt=""
|
||||
class="h-10 w-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{#if dirPath}
|
||||
<div
|
||||
class="truncate text-[10px] text-muted-foreground"
|
||||
title={dirPath}
|
||||
>
|
||||
{dirPath}/
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Compact info rows. Filename / folder lead the stack as icon-led
|
||||
rows so they read in the same rhythm as the date / place / camera
|
||||
rows below. The sidecar's rename endpoint only accepts a bare
|
||||
basename and preserves the directory on disk, so the basename row
|
||||
is editable while the folder row stays read-only. -->
|
||||
<dl class="space-y-1">
|
||||
<!-- Filename (editable) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<File class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium leading-snug break-all hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
bind:value={basename}
|
||||
disabled={renaming}
|
||||
onblur={commitFilename}
|
||||
@@ -321,16 +312,11 @@
|
||||
}}
|
||||
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
||||
/>
|
||||
{#if renaming}
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Inline spinner so the user sees the rename in flight without
|
||||
scanning to the bottom of the sidebar. -->
|
||||
{#if renaming}
|
||||
<Loader2 class="mt-1 h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Compact info rows -->
|
||||
<dl class="space-y-1">
|
||||
<!-- Taken at -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
@@ -346,10 +332,31 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
|
||||
padding on filename / date so the read-only text starts at the
|
||||
same x-offset as the editable rows above — otherwise spans
|
||||
hug the icon while inputs sit 4px in. -->
|
||||
{#if dirPath}
|
||||
<div class="flex items-center gap-2">
|
||||
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}>
|
||||
{dirPath}/
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dimensions + file size -->
|
||||
<div class="flex items-center gap-2">
|
||||
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{dims} · {sizeStr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{placeLabel || 'No location'}
|
||||
</span>
|
||||
{#if mapsHref}
|
||||
@@ -364,33 +371,6 @@
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Camera / lens — only render if something to show -->
|
||||
{#if cameraStr || lensStr || exposureParts.iso || exposureParts.fnum}
|
||||
<div class="flex items-start gap-2">
|
||||
<Camera class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1 space-y-0.5 text-muted-foreground">
|
||||
{#if cameraStr}<div class="truncate">{cameraStr}</div>{/if}
|
||||
{#if lensStr && lensStr !== cameraStr}<div class="truncate">{lensStr}</div>{/if}
|
||||
{#if exposureParts.iso || exposureParts.fnum || exposureParts.focal || exposureParts.exp}
|
||||
<div class="flex flex-wrap gap-x-2 text-[10px]">
|
||||
{#if exposureParts.fnum}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Aperture class="h-2.5 w-2.5" /> {exposureParts.fnum}
|
||||
</span>
|
||||
{/if}
|
||||
{#if exposureParts.exp}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Timer class="h-2.5 w-2.5" /> {exposureParts.exp}
|
||||
</span>
|
||||
{/if}
|
||||
{#if exposureParts.iso}<span>{exposureParts.iso}</span>{/if}
|
||||
{#if exposureParts.focal}<span>{exposureParts.focal}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
|
||||
@@ -603,8 +583,31 @@
|
||||
</span>
|
||||
</summary>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
||||
<dt class="text-muted-foreground">Size</dt>
|
||||
<dd class="text-foreground/80">{dims} · {sizeStr}</dd>
|
||||
{#if cameraStr}
|
||||
<dt class="text-muted-foreground">Camera</dt>
|
||||
<dd class="text-foreground/80">{cameraStr}</dd>
|
||||
{/if}
|
||||
{#if lensStr && lensStr !== cameraStr}
|
||||
<dt class="text-muted-foreground">Lens</dt>
|
||||
<dd class="text-foreground/80">{lensStr}</dd>
|
||||
{/if}
|
||||
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
||||
<dt class="text-muted-foreground">Exposure</dt>
|
||||
<dd class="flex flex-wrap gap-x-2 text-foreground/80">
|
||||
{#if exposureParts.fnum}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Aperture class="h-2.5 w-2.5" /> {exposureParts.fnum}
|
||||
</span>
|
||||
{/if}
|
||||
{#if exposureParts.exp}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Timer class="h-2.5 w-2.5" /> {exposureParts.exp}
|
||||
</span>
|
||||
{/if}
|
||||
{#if exposureParts.iso}<span>{exposureParts.iso}</span>{/if}
|
||||
{#if exposureParts.focal}<span>{exposureParts.focal}</span>{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
<dt class="text-muted-foreground">Type</dt>
|
||||
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
||||
<dt class="text-muted-foreground">Hash</dt>
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
setFocused,
|
||||
setOrder
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { type PpPhoto } from '$lib/types/photoprism';
|
||||
import PhotoTile from './PhotoTile.svelte';
|
||||
@@ -77,11 +76,17 @@
|
||||
function onDblclick(e: MouseEvent, uid: string) {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
openPreview(uid, order);
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
|
||||
function onOpenPreview(uid: string) {
|
||||
openPreview(uid, order);
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,56 +15,56 @@
|
||||
not-yet-discovered dblclick gesture)
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Maximize2 } from 'lucide-svelte';
|
||||
import { thumbSrc, thumbSrcSet, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { Maximize2 } from "lucide-svelte";
|
||||
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
||||
import { view } from "$lib/stores/view.svelte";
|
||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||
|
||||
interface Props {
|
||||
photo: PpPhoto;
|
||||
selected: boolean;
|
||||
onClick: (e: MouseEvent) => void;
|
||||
onDblclick: (e: MouseEvent) => void;
|
||||
onOpenPreview: () => void;
|
||||
}
|
||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
||||
interface Props {
|
||||
photo: PpPhoto;
|
||||
selected: boolean;
|
||||
onClick: (e: MouseEvent) => void;
|
||||
onDblclick: (e: MouseEvent) => void;
|
||||
onOpenPreview: () => void;
|
||||
}
|
||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
||||
|
||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||
const video = $derived(isVideo(photo));
|
||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||
const video = $derived(isVideo(photo));
|
||||
|
||||
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
|
||||
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
|
||||
// before mounting the <video> so a cursor skimming across the grid
|
||||
// doesn't kick off N HTTP requests, then we cross-fade in once the
|
||||
// first frame is decoded (videoReady).
|
||||
const HOVER_DELAY = 250;
|
||||
let hoverPlaying = $state(false);
|
||||
let videoReady = $state(false);
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
|
||||
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
|
||||
// before mounting the <video> so a cursor skimming across the grid
|
||||
// doesn't kick off N HTTP requests, then we cross-fade in once the
|
||||
// first frame is decoded (videoReady).
|
||||
const HOVER_DELAY = 250;
|
||||
let hoverPlaying = $state(false);
|
||||
let videoReady = $state(false);
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onMouseEnter() {
|
||||
if (!video || selected) return;
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverPlaying = true;
|
||||
}, HOVER_DELAY);
|
||||
}
|
||||
function onMouseLeave() {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
hoverPlaying = false;
|
||||
videoReady = false;
|
||||
}
|
||||
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
|
||||
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
|
||||
// wider when the grid stretches to fill the column, but `tile_*` is
|
||||
// discrete (100/224/500) so the rounding-up at the next variant
|
||||
// boundary swallows the difference.
|
||||
const tilePx = $derived(view.thumbnailSize);
|
||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||
function onMouseEnter() {
|
||||
if (!video || selected) return;
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverPlaying = true;
|
||||
}, HOVER_DELAY);
|
||||
}
|
||||
function onMouseLeave() {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
hoverPlaying = false;
|
||||
videoReady = false;
|
||||
}
|
||||
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
|
||||
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
|
||||
// wider when the grid stretches to fill the column, but `tile_*` is
|
||||
// discrete (100/224/500) so the rounding-up at the next variant
|
||||
// boundary swallows the difference.
|
||||
const tilePx = $derived(view.thumbnailSize);
|
||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -74,12 +74,12 @@
|
||||
in <button> is invalid HTML.
|
||||
-->
|
||||
<div
|
||||
class="group relative h-full w-full"
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
role="presentation"
|
||||
class="group relative h-full w-full"
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
role="presentation"
|
||||
>
|
||||
<!--
|
||||
<!--
|
||||
Selection animation: scale to 90% + blue ring + blue tint overlay,
|
||||
driven by a springy `cubic-bezier(0.34, 1.3, 0.64, 1)` over 300ms.
|
||||
The overshoot is intentionally modest — on a *shrink* a larger
|
||||
@@ -91,85 +91,84 @@
|
||||
The keyboard-focused tile gets the same treatment as a selected one,
|
||||
so the arrow-key cursor reads as a "selection of one".
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
data-tile
|
||||
data-uid={photo.UID}
|
||||
onclick={onClick}
|
||||
ondblclick={onDblclick}
|
||||
title="Click to select · Double-click to open"
|
||||
class:scale-90={selected}
|
||||
class:ring-2={selected}
|
||||
class:ring-blue-500={selected}
|
||||
class:ring-offset-2={selected}
|
||||
class:ring-offset-background={selected}
|
||||
class:transition-[transform,box-shadow]={selected}
|
||||
class:duration-300={selected}
|
||||
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={selected}
|
||||
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={src1x}
|
||||
srcset={srcset}
|
||||
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
class:transition={!selected}
|
||||
class:group-hover:scale-105={!selected}
|
||||
/>
|
||||
{#if hoverPlaying}
|
||||
<!--
|
||||
<button
|
||||
type="button"
|
||||
data-tile
|
||||
data-uid={photo.UID}
|
||||
onclick={onClick}
|
||||
ondblclick={onDblclick}
|
||||
class:scale-90={selected}
|
||||
class:ring-2={selected}
|
||||
class:ring-blue-500={selected}
|
||||
class:ring-offset-2={selected}
|
||||
class:ring-offset-background={selected}
|
||||
class:transition-[transform,box-shadow]={selected}
|
||||
class:duration-300={selected}
|
||||
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={selected}
|
||||
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={src1x}
|
||||
{srcset}
|
||||
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? "Photo"}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
class:transition={!selected}
|
||||
class:group-hover:scale-105={!selected}
|
||||
/>
|
||||
{#if hoverPlaying}
|
||||
<!--
|
||||
Muted hover-preview video stacked over the thumbnail. We don't
|
||||
unmount the <img> underneath — the video fades in once its
|
||||
first frame is decoded, so the tile never goes blank during
|
||||
the network round-trip. `pointer-events-none` keeps clicks
|
||||
flowing through to the parent <button>.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
src={videoUrl(hash)}
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
tabindex={-1}
|
||||
oncanplay={() => (videoReady = true)}
|
||||
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
|
||||
class:opacity-0={!videoReady}
|
||||
class:opacity-100={videoReady}
|
||||
></video>
|
||||
{/if}
|
||||
{#if selected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
{#if isVideo(photo)}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||
>VIDEO</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<!--
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
src={videoUrl(hash)}
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
tabindex={-1}
|
||||
oncanplay={() => (videoReady = true)}
|
||||
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
|
||||
class:opacity-0={!videoReady}
|
||||
class:opacity-100={videoReady}
|
||||
></video>
|
||||
{/if}
|
||||
{#if selected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
{#if isVideo(photo)}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||
>VIDEO</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<!--
|
||||
Hover-only "open preview" affordance. Single-click on this icon
|
||||
opens the preview directly, giving users a one-click fallback for
|
||||
the dblclick gesture (and a visual cue that previewing is a thing
|
||||
at all). Hidden when the tile is selected — there'd be no preview
|
||||
intent on a tile the user is in the middle of bulk-acting on.
|
||||
-->
|
||||
{#if !selected}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute bottom-1.5 right-1.5 hidden rounded bg-background/80 p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenPreview();
|
||||
}}
|
||||
title="Open preview"
|
||||
aria-label="Open preview"
|
||||
>
|
||||
<Maximize2 class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !selected}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute bottom-1.5 right-1.5 hidden rounded bg-background/80 p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenPreview();
|
||||
}}
|
||||
title="Open preview"
|
||||
aria-label="Open preview"
|
||||
>
|
||||
<Maximize2 class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Single-photo preview overlay state. The lightbox sits in +layout.svelte
|
||||
* and listens to this store; any view (timeline, duplicates view, heaps)
|
||||
* can `open(uid)` to pop it. The order array is mirrored from whatever
|
||||
* list the user is currently looking at so prev/next stay in context.
|
||||
*/
|
||||
export const preview = $state<{
|
||||
uid: string | null;
|
||||
order: string[];
|
||||
}>({
|
||||
uid: null,
|
||||
order: []
|
||||
});
|
||||
|
||||
export function openPreview(uid: string, order?: string[]): void {
|
||||
if (order) preview.order = order;
|
||||
preview.uid = uid;
|
||||
}
|
||||
|
||||
export function closePreview(): void {
|
||||
preview.uid = null;
|
||||
}
|
||||
|
||||
export function previewNext(): void {
|
||||
if (!preview.uid) return;
|
||||
const i = preview.order.indexOf(preview.uid);
|
||||
if (i < 0 || i >= preview.order.length - 1) return;
|
||||
preview.uid = preview.order[i + 1];
|
||||
}
|
||||
|
||||
export function previewPrev(): void {
|
||||
if (!preview.uid) return;
|
||||
const i = preview.order.indexOf(preview.uid);
|
||||
if (i <= 0) return;
|
||||
preview.uid = preview.order[i - 1];
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface Persisted {
|
||||
thumbnailSize?: ThumbnailSize;
|
||||
leftSidebarWidth?: number;
|
||||
rightSidebarWidth?: number;
|
||||
previewPaneHeight?: number;
|
||||
/**
|
||||
* Per-section expanded state for the right-sidebar metadata panel
|
||||
* (GPS, Credits, File). Keyed by section id; missing entries use a
|
||||
@@ -39,6 +40,14 @@ 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;
|
||||
|
||||
function clamp(n: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, n));
|
||||
@@ -69,6 +78,7 @@ export const view = $state<{
|
||||
thumbnailSize: ThumbnailSize;
|
||||
leftSidebarWidth: number;
|
||||
rightSidebarWidth: number;
|
||||
previewPaneHeight: number;
|
||||
metadataSections: Record<string, boolean>;
|
||||
}>({
|
||||
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||
@@ -86,6 +96,12 @@ 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
|
||||
),
|
||||
metadataSections:
|
||||
initial.metadataSections && typeof initial.metadataSections === 'object'
|
||||
? { ...initial.metadataSections }
|
||||
@@ -100,6 +116,7 @@ function persist(): void {
|
||||
thumbnailSize: view.thumbnailSize,
|
||||
leftSidebarWidth: view.leftSidebarWidth,
|
||||
rightSidebarWidth: view.rightSidebarWidth,
|
||||
previewPaneHeight: view.previewPaneHeight,
|
||||
metadataSections: view.metadataSections
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
@@ -115,6 +132,14 @@ 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);
|
||||
persist();
|
||||
}
|
||||
|
||||
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||
view.thumbnailSize = size;
|
||||
persist();
|
||||
|
||||
@@ -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