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';
|
} from '$lib/services/photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
|
|
||||||
import {
|
import {
|
||||||
clearSelection,
|
clearSelection,
|
||||||
focusAfter,
|
focusAfter,
|
||||||
@@ -55,16 +54,14 @@ export interface GridKeyNavParams {
|
|||||||
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
||||||
* x archive-toggle, u restore, s + (1–9) add to
|
* x archive-toggle, u restore, s + (1–9) add to
|
||||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||||
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
||||||
* esc clears, ⌘Z undoes, ⌘A selects all visible.
|
* ⌘A selects all visible.
|
||||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
||||||
* keyboard shortcuts.
|
* keyboard shortcuts.
|
||||||
*
|
*
|
||||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||||
* 1. preview overlay uid (when open) — applies to the visible preview
|
* 1. multi-selection set
|
||||||
* photo even if the grid still shows a stale selection
|
* 2. focused tile
|
||||||
* 2. multi-selection set
|
|
||||||
* 3. focused tile
|
|
||||||
*/
|
*/
|
||||||
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||||
let scrollToIndex = params.scrollToIndex;
|
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[] {
|
function cullTargets(): string[] {
|
||||||
if (preview.uid) return [preview.uid];
|
|
||||||
if (selection.ids.size > 0) return Array.from(selection.ids);
|
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||||
if (selection.focused) return [selection.focused];
|
if (selection.focused) return [selection.focused];
|
||||||
return [];
|
return [];
|
||||||
@@ -376,17 +372,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
await addCullTargetsToHeap(heap);
|
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) {
|
async function onKey(e: KeyboardEvent) {
|
||||||
// Don't hijack typing inside form fields.
|
// Don't hijack typing inside form fields.
|
||||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
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 meta = e.metaKey || e.ctrlKey;
|
||||||
const shift = e.shiftKey;
|
const shift = e.shiftKey;
|
||||||
const inPreview = preview.uid !== null;
|
|
||||||
|
|
||||||
// ── Grid-only nav keys (preview owns its own Arrow/Esc) ──────────────
|
// ── Grid nav keys ────────────────────────────────────────────────────
|
||||||
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) ──────────────────
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case ' ':
|
case 'ArrowLeft':
|
||||||
case 'Enter':
|
case 'ArrowRight':
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
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;
|
return;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||||
@@ -459,7 +432,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
return;
|
return;
|
||||||
case 'i':
|
case 'i':
|
||||||
case 'I':
|
case 'I':
|
||||||
if (!meta && !shift && !inPreview) {
|
if (!meta && !shift) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleRightSidebar();
|
toggleRightSidebar();
|
||||||
}
|
}
|
||||||
@@ -482,7 +455,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
return;
|
return;
|
||||||
case 'a':
|
case 'a':
|
||||||
case 'A':
|
case 'A':
|
||||||
if (meta && !inPreview) {
|
if (meta) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
for (const id of selection.order) selection.ids.add(id);
|
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);
|
node.addEventListener('click', onClick);
|
||||||
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
|
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
|
||||||
// immediately on page load regardless of which element holds focus.
|
// immediately on page load regardless of which element holds focus.
|
||||||
// The filters inside `onKey` keep form-field typing and preview mode
|
// The filter inside `onKey` keeps form-field typing safe.
|
||||||
// safe (preview owns its own Arrow/Esc).
|
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
return {
|
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
|
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||||
// used directly — same chrome as before that fix, no extra
|
// used directly — same chrome as before that fix, no extra
|
||||||
// round-trips.
|
// round-trips.
|
||||||
@@ -134,10 +134,20 @@
|
|||||||
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
||||||
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
||||||
const archivedCountQuery = scopedCountQuery('archived', 'archived: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(
|
function bucketCount(
|
||||||
key: 'favorites' | 'review' | 'hidden' | 'archived' | 'labels',
|
key: 'favorites' | 'review' | 'hidden' | 'archived',
|
||||||
query: { data: number | undefined; isPending: boolean }
|
query: { data: number | undefined; isPending: boolean }
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
if (wantScoped) {
|
if (wantScoped) {
|
||||||
@@ -148,7 +158,6 @@
|
|||||||
// (no extra round-trip).
|
// (no extra round-trip).
|
||||||
const c = configQuery.data?.count;
|
const c = configQuery.data?.count;
|
||||||
if (!c) return undefined;
|
if (!c) return undefined;
|
||||||
if (key === 'labels') return c.labels;
|
|
||||||
return c[key];
|
return c[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +295,9 @@
|
|||||||
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
||||||
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
||||||
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
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(() => ({
|
const createMut = createMutation(() => ({
|
||||||
mutationFn: (title: string) => createHeap(title),
|
mutationFn: (title: string) => createHeap(title),
|
||||||
|
|||||||
@@ -9,91 +9,99 @@
|
|||||||
necessary here.
|
necessary here.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
|
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
|
||||||
▛▖▞▌▌▌▐ ▌▛▛▌▀▌▛▌█▌
|
▛▖▞▌▌▌▐ ▌▛▛▌▀▌▛▌█▌
|
||||||
▌▝ ▌▙▌▐▖▌▌▌▌█▌▙▌▙▖`;
|
▌▝ ▌▙▌▐▖▌▌▌▌█▌▙▌▙▖`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: import('svelte').Snippet;
|
children?: import("svelte").Snippet;
|
||||||
}
|
}
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="mule-header relative flex h-16 items-center justify-between overflow-hidden border-b border-border px-4">
|
<header
|
||||||
<div class="relative flex items-center gap-3">
|
class="mule-header relative flex h-14 items-center justify-between overflow-hidden border-b border-border px-4"
|
||||||
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
|
>
|
||||||
<pre
|
<div class="relative flex items-center gap-3">
|
||||||
aria-label="Mulimago"
|
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
|
||||||
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
|
<pre
|
||||||
style="letter-spacing: 0;"
|
aria-label="Mulimago"
|
||||||
>{MULIMAGO_ASCII}</pre>
|
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
|
||||||
</div>
|
style="letter-spacing: 0;">{MULIMAGO_ASCII}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative flex items-center gap-3">
|
<div class="relative flex items-center gap-3">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/*
|
/*
|
||||||
* Two layers in the background: tiled desert.png on top scrolling
|
* Two layers in the background: tiled desert.png on top scrolling
|
||||||
* right→left, dusk-sky gradient underneath. The 200px tile width is
|
* right→left, dusk-sky gradient underneath. The 200px tile width is
|
||||||
* fixed so the `desert-scroll` keyframe moves by exactly one tile and
|
* fixed so the `desert-scroll` keyframe moves by exactly one tile and
|
||||||
* loops seamlessly.
|
* loops seamlessly.
|
||||||
*/
|
*/
|
||||||
.mule-header {
|
.mule-header {
|
||||||
background-image:
|
background-image: url("/mule/desert.png"),
|
||||||
url('/mule/desert.png'),
|
linear-gradient(
|
||||||
linear-gradient(to bottom, #2b3a5c 0%, #6b6b8a 35%, #d68a5c 75%, #f0c188 100%);
|
to bottom,
|
||||||
background-repeat: repeat-x, no-repeat;
|
#2b3a5c 0%,
|
||||||
background-size:
|
#6b6b8a 35%,
|
||||||
200px 100%,
|
#d68a5c 75%,
|
||||||
100% 100%;
|
#f0c188 100%
|
||||||
background-position: 0 bottom, 0 0;
|
);
|
||||||
image-rendering: pixelated;
|
background-repeat: repeat-x, no-repeat;
|
||||||
animation: desert-scroll 24s linear infinite;
|
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). */
|
* snap (no interpolation between frames). */
|
||||||
.mule-sprite {
|
.mule-sprite {
|
||||||
background-image: url('/mule/mule-sprites.png');
|
background-image: url("/mule/mule-sprites.png");
|
||||||
background-size: 300% 200%;
|
background-size: 300% 200%;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
animation: mule-walk 0.6s steps(1) infinite;
|
animation: mule-walk 0.6s steps(1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes mule-walk {
|
@keyframes mule-walk {
|
||||||
0% {
|
0% {
|
||||||
background-position: 0% 0%;
|
background-position: 0% 0%;
|
||||||
}
|
}
|
||||||
16.66% {
|
16.66% {
|
||||||
background-position: 50% 0%;
|
background-position: 50% 0%;
|
||||||
}
|
}
|
||||||
33.33% {
|
33.33% {
|
||||||
background-position: 100% 0%;
|
background-position: 100% 0%;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
background-position: 0% 100%;
|
background-position: 0% 100%;
|
||||||
}
|
}
|
||||||
66.66% {
|
66.66% {
|
||||||
background-position: 50% 100%;
|
background-position: 50% 100%;
|
||||||
}
|
}
|
||||||
83.33% {
|
83.33% {
|
||||||
background-position: 100% 100%;
|
background-position: 100% 100%;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-position: 0% 0%;
|
background-position: 0% 0%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes desert-scroll {
|
@keyframes desert-scroll {
|
||||||
from {
|
from {
|
||||||
background-position-x: 0px, 0px;
|
background-position-x: 0px, 0px;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
background-position-x: -200px, 0px;
|
background-position-x: -200px, 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<style>
|
||||||
:global(.vds-player) {
|
:global(.vds-player) {
|
||||||
width: 100%;
|
/* Let the player size to the video's intrinsic aspect ratio,
|
||||||
height: 100%;
|
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%;
|
max-height: 100%;
|
||||||
aspect-ratio: auto;
|
|
||||||
--media-brand: #3b82f6;
|
--media-brand: #3b82f6;
|
||||||
--media-focus-ring-color: #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>
|
</style>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
import {
|
import {
|
||||||
Aperture,
|
Aperture,
|
||||||
Calendar,
|
Calendar,
|
||||||
Camera,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -34,7 +35,6 @@
|
|||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.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 { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
import RelatedStrip from './RelatedStrip.svelte';
|
import RelatedStrip from './RelatedStrip.svelte';
|
||||||
@@ -289,27 +289,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
|
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
|
||||||
<!-- Header strip — thumb + path (read-only) over editable basename. The
|
<!-- Compact info rows. Filename / folder lead the stack as icon-led
|
||||||
sidecar's rename endpoint only accepts a bare basename and preserves
|
rows so they read in the same rhythm as the date / place / camera
|
||||||
the directory on disk, so the split UI mirrors that contract. -->
|
rows below. The sidecar's rename endpoint only accepts a bare
|
||||||
<div class="flex items-start gap-2">
|
basename and preserves the directory on disk, so the basename row
|
||||||
<img
|
is editable while the folder row stays read-only. -->
|
||||||
src={thumbUrl(pf.Hash, 'tile_100')}
|
<dl class="space-y-1">
|
||||||
alt=""
|
<!-- Filename (editable) -->
|
||||||
class="h-10 w-10 shrink-0 rounded object-cover"
|
<div class="flex items-center gap-2">
|
||||||
/>
|
<File class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<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}
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
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}
|
bind:value={basename}
|
||||||
disabled={renaming}
|
disabled={renaming}
|
||||||
onblur={commitFilename}
|
onblur={commitFilename}
|
||||||
@@ -321,16 +312,11 @@
|
|||||||
}}
|
}}
|
||||||
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
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>
|
</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 -->
|
<!-- Taken at -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
@@ -346,10 +332,31 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Location -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<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'}
|
{placeLabel || 'No location'}
|
||||||
</span>
|
</span>
|
||||||
{#if mapsHref}
|
{#if mapsHref}
|
||||||
@@ -364,33 +371,6 @@
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
|
|
||||||
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
|
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
|
||||||
@@ -603,8 +583,31 @@
|
|||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
<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>
|
{#if cameraStr}
|
||||||
<dd class="text-foreground/80">{dims} · {sizeStr}</dd>
|
<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>
|
<dt class="text-muted-foreground">Type</dt>
|
||||||
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
||||||
<dt class="text-muted-foreground">Hash</dt>
|
<dt class="text-muted-foreground">Hash</dt>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
setFocused,
|
setFocused,
|
||||||
setOrder
|
setOrder
|
||||||
} from '$lib/stores/selection.svelte';
|
} from '$lib/stores/selection.svelte';
|
||||||
import { openPreview } from '$lib/stores/preview.svelte';
|
|
||||||
import { view } from '$lib/stores/view.svelte';
|
import { view } from '$lib/stores/view.svelte';
|
||||||
import { type PpPhoto } from '$lib/types/photoprism';
|
import { type PpPhoto } from '$lib/types/photoprism';
|
||||||
import PhotoTile from './PhotoTile.svelte';
|
import PhotoTile from './PhotoTile.svelte';
|
||||||
@@ -77,11 +76,17 @@
|
|||||||
function onDblclick(e: MouseEvent, uid: string) {
|
function onDblclick(e: MouseEvent, uid: string) {
|
||||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openPreview(uid, order);
|
selection.ids.clear();
|
||||||
|
selection.ids.add(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
setAnchor(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOpenPreview(uid: string) {
|
function onOpenPreview(uid: string) {
|
||||||
openPreview(uid, order);
|
selection.ids.clear();
|
||||||
|
selection.ids.add(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
setAnchor(uid);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -15,56 +15,56 @@
|
|||||||
not-yet-discovered dblclick gesture)
|
not-yet-discovered dblclick gesture)
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Maximize2 } from 'lucide-svelte';
|
import { Maximize2 } from "lucide-svelte";
|
||||||
import { thumbSrc, thumbSrcSet, videoUrl } from '$lib/stores/session.svelte';
|
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
||||||
import { view } from '$lib/stores/view.svelte';
|
import { view } from "$lib/stores/view.svelte";
|
||||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photo: PpPhoto;
|
photo: PpPhoto;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: (e: MouseEvent) => void;
|
onClick: (e: MouseEvent) => void;
|
||||||
onDblclick: (e: MouseEvent) => void;
|
onDblclick: (e: MouseEvent) => void;
|
||||||
onOpenPreview: () => void;
|
onOpenPreview: () => void;
|
||||||
}
|
}
|
||||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
||||||
|
|
||||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||||
const video = $derived(isVideo(photo));
|
const video = $derived(isVideo(photo));
|
||||||
|
|
||||||
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
|
// 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
|
// 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
|
// 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
|
// doesn't kick off N HTTP requests, then we cross-fade in once the
|
||||||
// first frame is decoded (videoReady).
|
// first frame is decoded (videoReady).
|
||||||
const HOVER_DELAY = 250;
|
const HOVER_DELAY = 250;
|
||||||
let hoverPlaying = $state(false);
|
let hoverPlaying = $state(false);
|
||||||
let videoReady = $state(false);
|
let videoReady = $state(false);
|
||||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function onMouseEnter() {
|
function onMouseEnter() {
|
||||||
if (!video || selected) return;
|
if (!video || selected) return;
|
||||||
if (hoverTimer) clearTimeout(hoverTimer);
|
if (hoverTimer) clearTimeout(hoverTimer);
|
||||||
hoverTimer = setTimeout(() => {
|
hoverTimer = setTimeout(() => {
|
||||||
hoverPlaying = true;
|
hoverPlaying = true;
|
||||||
}, HOVER_DELAY);
|
}, HOVER_DELAY);
|
||||||
}
|
}
|
||||||
function onMouseLeave() {
|
function onMouseLeave() {
|
||||||
if (hoverTimer) {
|
if (hoverTimer) {
|
||||||
clearTimeout(hoverTimer);
|
clearTimeout(hoverTimer);
|
||||||
hoverTimer = null;
|
hoverTimer = null;
|
||||||
}
|
}
|
||||||
hoverPlaying = false;
|
hoverPlaying = false;
|
||||||
videoReady = false;
|
videoReady = false;
|
||||||
}
|
}
|
||||||
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
|
// 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
|
// 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
|
// wider when the grid stretches to fill the column, but `tile_*` is
|
||||||
// discrete (100/224/500) so the rounding-up at the next variant
|
// discrete (100/224/500) so the rounding-up at the next variant
|
||||||
// boundary swallows the difference.
|
// boundary swallows the difference.
|
||||||
const tilePx = $derived(view.thumbnailSize);
|
const tilePx = $derived(view.thumbnailSize);
|
||||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -74,12 +74,12 @@
|
|||||||
in <button> is invalid HTML.
|
in <button> is invalid HTML.
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="group relative h-full w-full"
|
class="group relative h-full w-full"
|
||||||
onmouseenter={onMouseEnter}
|
onmouseenter={onMouseEnter}
|
||||||
onmouseleave={onMouseLeave}
|
onmouseleave={onMouseLeave}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<!--
|
<!--
|
||||||
Selection animation: scale to 90% + blue ring + blue tint overlay,
|
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.
|
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
|
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,
|
The keyboard-focused tile gets the same treatment as a selected one,
|
||||||
so the arrow-key cursor reads as a "selection of one".
|
so the arrow-key cursor reads as a "selection of one".
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tile
|
data-tile
|
||||||
data-uid={photo.UID}
|
data-uid={photo.UID}
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
ondblclick={onDblclick}
|
ondblclick={onDblclick}
|
||||||
title="Click to select · Double-click to open"
|
class:scale-90={selected}
|
||||||
class:scale-90={selected}
|
class:ring-2={selected}
|
||||||
class:ring-2={selected}
|
class:ring-blue-500={selected}
|
||||||
class:ring-blue-500={selected}
|
class:ring-offset-2={selected}
|
||||||
class:ring-offset-2={selected}
|
class:ring-offset-background={selected}
|
||||||
class:ring-offset-background={selected}
|
class:transition-[transform,box-shadow]={selected}
|
||||||
class:transition-[transform,box-shadow]={selected}
|
class:duration-300={selected}
|
||||||
class:duration-300={selected}
|
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={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"
|
||||||
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
>
|
||||||
>
|
<img
|
||||||
<img
|
src={src1x}
|
||||||
src={src1x}
|
{srcset}
|
||||||
srcset={srcset}
|
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? "Photo"}
|
||||||
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
|
loading="lazy"
|
||||||
loading="lazy"
|
decoding="async"
|
||||||
decoding="async"
|
class="h-full w-full object-cover"
|
||||||
class="h-full w-full object-cover"
|
class:transition={!selected}
|
||||||
class:transition={!selected}
|
class:group-hover:scale-105={!selected}
|
||||||
class:group-hover:scale-105={!selected}
|
/>
|
||||||
/>
|
{#if hoverPlaying}
|
||||||
{#if hoverPlaying}
|
<!--
|
||||||
<!--
|
|
||||||
Muted hover-preview video stacked over the thumbnail. We don't
|
Muted hover-preview video stacked over the thumbnail. We don't
|
||||||
unmount the <img> underneath — the video fades in once its
|
unmount the <img> underneath — the video fades in once its
|
||||||
first frame is decoded, so the tile never goes blank during
|
first frame is decoded, so the tile never goes blank during
|
||||||
the network round-trip. `pointer-events-none` keeps clicks
|
the network round-trip. `pointer-events-none` keeps clicks
|
||||||
flowing through to the parent <button>.
|
flowing through to the parent <button>.
|
||||||
-->
|
-->
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video
|
<video
|
||||||
src={videoUrl(hash)}
|
src={videoUrl(hash)}
|
||||||
autoplay
|
autoplay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsinline
|
playsinline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
tabindex={-1}
|
tabindex={-1}
|
||||||
oncanplay={() => (videoReady = true)}
|
oncanplay={() => (videoReady = true)}
|
||||||
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
|
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
|
||||||
class:opacity-0={!videoReady}
|
class:opacity-0={!videoReady}
|
||||||
class:opacity-100={videoReady}
|
class:opacity-100={videoReady}
|
||||||
></video>
|
></video>
|
||||||
{/if}
|
{/if}
|
||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isVideo(photo)}
|
{#if isVideo(photo)}
|
||||||
<span
|
<span
|
||||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||||
>VIDEO</span
|
>VIDEO</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<!--
|
<!--
|
||||||
Hover-only "open preview" affordance. Single-click on this icon
|
Hover-only "open preview" affordance. Single-click on this icon
|
||||||
opens the preview directly, giving users a one-click fallback for
|
opens the preview directly, giving users a one-click fallback for
|
||||||
the dblclick gesture (and a visual cue that previewing is a thing
|
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
|
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.
|
intent on a tile the user is in the middle of bulk-acting on.
|
||||||
-->
|
-->
|
||||||
{#if !selected}
|
{#if !selected}
|
||||||
<button
|
<button
|
||||||
type="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"
|
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) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenPreview();
|
onOpenPreview();
|
||||||
}}
|
}}
|
||||||
title="Open preview"
|
title="Open preview"
|
||||||
aria-label="Open preview"
|
aria-label="Open preview"
|
||||||
>
|
>
|
||||||
<Maximize2 class="h-3 w-3" />
|
<Maximize2 class="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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;
|
thumbnailSize?: ThumbnailSize;
|
||||||
leftSidebarWidth?: number;
|
leftSidebarWidth?: number;
|
||||||
rightSidebarWidth?: number;
|
rightSidebarWidth?: number;
|
||||||
|
previewPaneHeight?: number;
|
||||||
/**
|
/**
|
||||||
* Per-section expanded state for the right-sidebar metadata panel
|
* Per-section expanded state for the right-sidebar metadata panel
|
||||||
* (GPS, Credits, File). Keyed by section id; missing entries use a
|
* (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 MIN_RIGHT_WIDTH = 220;
|
||||||
export const MAX_RIGHT_WIDTH = 480;
|
export const MAX_RIGHT_WIDTH = 480;
|
||||||
export const DEFAULT_RIGHT_WIDTH = 280;
|
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 {
|
function clamp(n: number, lo: number, hi: number): number {
|
||||||
return Math.min(hi, Math.max(lo, n));
|
return Math.min(hi, Math.max(lo, n));
|
||||||
@@ -69,6 +78,7 @@ export const view = $state<{
|
|||||||
thumbnailSize: ThumbnailSize;
|
thumbnailSize: ThumbnailSize;
|
||||||
leftSidebarWidth: number;
|
leftSidebarWidth: number;
|
||||||
rightSidebarWidth: number;
|
rightSidebarWidth: number;
|
||||||
|
previewPaneHeight: number;
|
||||||
metadataSections: Record<string, boolean>;
|
metadataSections: Record<string, boolean>;
|
||||||
}>({
|
}>({
|
||||||
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||||
@@ -86,6 +96,12 @@ export const view = $state<{
|
|||||||
MIN_RIGHT_WIDTH,
|
MIN_RIGHT_WIDTH,
|
||||||
MAX_RIGHT_WIDTH
|
MAX_RIGHT_WIDTH
|
||||||
),
|
),
|
||||||
|
previewPaneHeight: Math.max(
|
||||||
|
MIN_PREVIEW_HEIGHT,
|
||||||
|
typeof initial.previewPaneHeight === 'number'
|
||||||
|
? initial.previewPaneHeight
|
||||||
|
: DEFAULT_PREVIEW_HEIGHT
|
||||||
|
),
|
||||||
metadataSections:
|
metadataSections:
|
||||||
initial.metadataSections && typeof initial.metadataSections === 'object'
|
initial.metadataSections && typeof initial.metadataSections === 'object'
|
||||||
? { ...initial.metadataSections }
|
? { ...initial.metadataSections }
|
||||||
@@ -100,6 +116,7 @@ function persist(): void {
|
|||||||
thumbnailSize: view.thumbnailSize,
|
thumbnailSize: view.thumbnailSize,
|
||||||
leftSidebarWidth: view.leftSidebarWidth,
|
leftSidebarWidth: view.leftSidebarWidth,
|
||||||
rightSidebarWidth: view.rightSidebarWidth,
|
rightSidebarWidth: view.rightSidebarWidth,
|
||||||
|
previewPaneHeight: view.previewPaneHeight,
|
||||||
metadataSections: view.metadataSections
|
metadataSections: view.metadataSections
|
||||||
};
|
};
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||||
@@ -115,6 +132,14 @@ export function setRightSidebarWidth(px: number): void {
|
|||||||
persist();
|
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 {
|
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||||
view.thumbnailSize = size;
|
view.thumbnailSize = size;
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import type { Component } from 'svelte';
|
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
@@ -13,7 +12,6 @@
|
|||||||
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||||
import { resizable } from '$lib/actions/resizable';
|
import { resizable } from '$lib/actions/resizable';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { preview } from '$lib/stores/preview.svelte';
|
|
||||||
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
||||||
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
||||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||||
@@ -56,22 +54,6 @@
|
|||||||
else stopIndexerWatch();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -129,7 +111,4 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
{#if PreviewOverlay}
|
|
||||||
<PreviewOverlay />
|
|
||||||
{/if}
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
setFocused,
|
setFocused,
|
||||||
setOrder,
|
setOrder,
|
||||||
} from "$lib/stores/selection.svelte";
|
} from "$lib/stores/selection.svelte";
|
||||||
import { openPreview, preview } from "$lib/stores/preview.svelte";
|
|
||||||
import {
|
import {
|
||||||
setRightSidebarWidth,
|
setRightSidebarWidth,
|
||||||
setThumbnailSize,
|
setThumbnailSize,
|
||||||
@@ -51,9 +50,11 @@
|
|||||||
} from "$lib/actions/visibleRange";
|
} from "$lib/actions/visibleRange";
|
||||||
import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
|
import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
|
||||||
import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.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 PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
|
||||||
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
|
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
|
||||||
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.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 Toolbar from "$lib/components/layout/Toolbar.svelte";
|
||||||
import { type PpPhoto } from "$lib/types/photoprism";
|
import { type PpPhoto } from "$lib/types/photoprism";
|
||||||
|
|
||||||
@@ -408,25 +409,6 @@
|
|||||||
if (el) scrollTileIntoView(el);
|
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 ──────────────────────────────────
|
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||||||
// The CSS Grid lays each photo into a cell with column count derived from
|
// The CSS Grid lays each photo into a cell with column count derived from
|
||||||
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||||||
@@ -675,24 +657,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTileDblclick(e: MouseEvent, uid: string) {
|
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;
|
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openPreview(
|
selection.ids.clear();
|
||||||
uid,
|
selection.ids.add(uid);
|
||||||
photos.map((p) => p.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) {
|
function onTileOpenPreview(uid: string) {
|
||||||
openPreview(
|
selection.ids.clear();
|
||||||
uid,
|
selection.ids.add(uid);
|
||||||
photos.map((p) => p.UID),
|
setFocused(uid);
|
||||||
);
|
setAnchor(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
|
// 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.
|
sibling at row level and stays full height when the bar appears.
|
||||||
-->
|
-->
|
||||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<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
|
<main
|
||||||
bind:this={scrollRoot}
|
bind:this={scrollRoot}
|
||||||
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||||||
@@ -952,6 +934,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{/snippet}
|
||||||
|
</SplitGrid>
|
||||||
<BulkActionBar />
|
<BulkActionBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
type MapSourceDataEvent
|
type MapSourceDataEvent
|
||||||
} from 'maplibre-gl';
|
} from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
||||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
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';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||||||
@@ -154,7 +155,10 @@
|
|||||||
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
|
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
|
||||||
el.addEventListener('click', (ev) => {
|
el.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
openPreview(uid, allUids);
|
setOrder(allUids);
|
||||||
|
setFocused(uid);
|
||||||
|
setAnchor(uid);
|
||||||
|
void goto('/');
|
||||||
});
|
});
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
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
|
// Deep-link entry. The route renders a full-page InlinePreview keyed
|
||||||
// the timeline. The route itself does not render anything; it hands
|
// on the URL `uid` so the link stays shareable and reload-safe. No
|
||||||
// off to the global PreviewOverlay and redirects to `/` so the URL
|
// surrounding grid here — this surface is a single-photo viewer.
|
||||||
// stays clean and the timeline shows behind the modal.
|
const uid = $derived((page.params.uid ?? null) as string | null);
|
||||||
$effect(() => {
|
const order = $derived(uid ? [uid] : []);
|
||||||
const uid = page.params.uid as string | undefined;
|
|
||||||
if (!uid) return;
|
|
||||||
openPreview(uid);
|
|
||||||
void goto('/', { replaceState: true });
|
|
||||||
});
|
|
||||||
</script>
|
</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 BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
||||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.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 DupTab = 'stacks' | 'cross-folder';
|
||||||
type Tab = CauseKey | DupTab;
|
type Tab = CauseKey | DupTab;
|
||||||
@@ -224,31 +226,38 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<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={{}}>
|
<SplitGrid>
|
||||||
{#if reviewQuery.isPending}
|
{#snippet preview()}
|
||||||
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||||
{:else if reviewQuery.error}
|
{/snippet}
|
||||||
<p class="text-sm text-destructive">
|
{#snippet grid()}
|
||||||
Could not load review queue: {reviewQuery.error instanceof Error
|
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||||
? reviewQuery.error.message
|
{#if reviewQuery.isPending}
|
||||||
: 'unknown error'}
|
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||||
</p>
|
{:else if reviewQuery.error}
|
||||||
{:else if groups.length === 0}
|
<p class="text-sm text-destructive">
|
||||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
Could not load review queue: {reviewQuery.error instanceof Error
|
||||||
<p>The review queue is empty.</p>
|
? reviewQuery.error.message
|
||||||
<p class="text-xs">
|
: 'unknown error'}
|
||||||
PhotoPrism's indexer flags photos with a low quality score for human
|
</p>
|
||||||
review. New arrivals with missing EXIF, low resolution, or unknown
|
{:else if groups.length === 0}
|
||||||
cameras will land here. The Stacks and Cross-folder tabs above stay
|
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||||
available for duplicate cleanup.
|
<p>The review queue is empty.</p>
|
||||||
</p>
|
<p class="text-xs">
|
||||||
</div>
|
PhotoPrism's indexer flags photos with a low quality score for human
|
||||||
{:else if activeGroup}
|
review. New arrivals with missing EXIF, low resolution, or unknown
|
||||||
{#key activeGroup.cause}
|
cameras will land here. The Stacks and Cross-folder tabs above stay
|
||||||
<CauseGroupCard group={activeGroup} />
|
available for duplicate cleanup.
|
||||||
{/key}
|
</p>
|
||||||
{/if}
|
</div>
|
||||||
</main>
|
{:else if activeGroup}
|
||||||
|
{#key activeGroup.cause}
|
||||||
|
<CauseGroupCard group={activeGroup} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
{/snippet}
|
||||||
|
</SplitGrid>
|
||||||
<BulkActionBar />
|
<BulkActionBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
aggregateKeywords,
|
aggregateKeywords,
|
||||||
|
countPhotos,
|
||||||
getAllMarks,
|
getAllMarks,
|
||||||
listLabels,
|
listLabels,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
@@ -11,13 +12,16 @@
|
|||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type PpLabel
|
type PpLabel
|
||||||
} from '$lib/services/photoprism';
|
} 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 { view } from '$lib/stores/view.svelte';
|
||||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||||
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.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 PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.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 Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
// Tag-flavoured surfaces, all under one route so the user can swap
|
// Tag-flavoured surfaces, all under one route so the user can swap
|
||||||
@@ -88,6 +92,25 @@
|
|||||||
enabled: isAuthenticated()
|
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[]>(() => ({
|
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
|
||||||
queryKey: ['photos', 'keywords'],
|
queryKey: ['photos', 'keywords'],
|
||||||
queryFn: aggregateKeywords,
|
queryFn: aggregateKeywords,
|
||||||
@@ -186,12 +209,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-tab badge counts ─────────────────────────────────────────────────
|
// ── Per-tab badge counts ─────────────────────────────────────────────────
|
||||||
// Each tab pill shows what its grid covers: distinct labels/keywords for
|
// Each pill shows the number of *photos* a tab covers (not categories),
|
||||||
// the bucket-style tabs, photo-count for the fixed-cardinality ones
|
// so all four tabs read on the same scale and match the sidebar's Tags
|
||||||
// (ratings/colors), matching how the sidebar's Tags badge aggregates.
|
// 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
|
// `undefined` means the underlying query hasn't resolved yet — the badge
|
||||||
// is skipped rather than showing a misleading 0.
|
// 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 keywordsCount = $derived<number | undefined>(keywordsQuery.data?.length);
|
||||||
const ratedPhotosCount = $derived<number | undefined>(
|
const ratedPhotosCount = $derived<number | undefined>(
|
||||||
marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined
|
marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined
|
||||||
@@ -367,24 +391,37 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</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
|
<main
|
||||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||||
use:gridKeyNav={{}}
|
use:gridKeyNav={{}}
|
||||||
>
|
>
|
||||||
{#if drillKey}
|
{#if activeTab === 'labels'}
|
||||||
<!-- 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 labelsQuery.isPending}
|
{#if labelsQuery.isPending}
|
||||||
<SkeletonGrid />
|
<SkeletonGrid />
|
||||||
{:else if labelsQuery.isError}
|
{:else if labelsQuery.isError}
|
||||||
@@ -546,5 +583,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<BulkActionBar />
|
<BulkActionBar />
|
||||||
|
|||||||
Reference in New Issue
Block a user