2 Commits

Author SHA1 Message Date
af96922e89 feat(preview): inline video player via vidstack + hover-warm prefetch
Replace PreviewOverlay's bare <video controls> with a vidstack-driven
player wrapping the same source URL. Vidstack's default video layout
provides a polished chrome (gradient bottom bar, large play overlay,
hover-revealed scrubber) and registers <media-player>/<media-provider>
custom elements that vendor the browser quirks.

To keep first-frame latency low, PhotoTile starts a hover-warm fetch
of the playback URL after a short (120 ms) delay — a single Range
request of the first 512 KB pages the backend's pre-transcoded MP4
cache file into the OS page cache and lands in the browser's HTTP
cache, so when the player mounts and issues its own bytes=0- request
the response is satisfied from disk. Each hash is warmed at most once
per session; AbortController cancels hovers that don't commit.

The vidstack modules are dynamically imported on mount so they never
run during SvelteKit's static prerender — they side-effect
customElements.define() calls which would crash under SSR.
2026-05-18 08:13:59 +02:00
ccbc1050de fix(web): drop indexer debug log, harden settings dialog, escape search-placeholder quotes
Three small cleanups bundled:

- Remove the `console.debug('[indexer]', ...)` line in the indexer
  store. The PhotoPrism WS protocol is now verified; the log was a
  development aid that no longer earns its console noise.

- GeneralSettingsDialog: normalize cloned PpSettings so `ui` / `search`
  / `maps` are always real objects (some deployments return them
  unset), and re-clone the draft on each open instead of nulling it on
  close. The previous lifecycle let Dialog's exit animation keep the
  form mounted while `draft` was already null, which threw at runtime
  via the `bind:value={draft.ui!.theme}` getters.

- Search-input placeholder string: rewrite as a JS expression so the
  embedded `"vacation"` quotes inside the example don't terminate the
  HTML attribute early. The previous form was a Svelte parse error
  that stopped the dev-server module from loading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:13:42 +02:00
9 changed files with 1183 additions and 926 deletions

55
web/package-lock.json generated
View File

@@ -18,7 +18,8 @@
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"svelte-sonner": "^1.1.1", "svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2" "tailwind-variants": "^3.2.2",
"vidstack": "^1.12.13"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@@ -1835,6 +1836,15 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -1912,6 +1922,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/media-captions": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.4.tgz",
"integrity": "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -2513,6 +2532,34 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unplugin": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
"integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vidstack": {
"version": "1.12.13",
"resolved": "https://registry.npmjs.org/vidstack/-/vidstack-1.12.13.tgz",
"integrity": "sha512-vuNeyRmWoH/7EoFVDYjp9nkgcqtCMmal518LDeb78dYKgWb+p6+vtY0AzDhrkBv5q1UiCn+xwmjMmwvSlPLuhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"lit-html": "^2.8.0",
"media-captions": "^1.0.4",
"unplugin": "^1.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.13", "version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
@@ -2611,6 +2658,12 @@
} }
} }
}, },
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

View File

@@ -34,6 +34,7 @@
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"svelte-sonner": "^1.1.1", "svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2" "tailwind-variants": "^3.2.2",
"vidstack": "^1.12.13"
} }
} }

View File

@@ -90,21 +90,43 @@
enabled: open enabled: open
})); }));
let draft = $state<PpSettings | null>(null); /**
$effect(() => { * Some PhotoPrism deployments return `/settings` without the
if (settingsQuery.data && draft === null) { * `ui` / `search` / `maps` keys (older versions, custom edits to
draft = structuredClone(settingsQuery.data); * settings.yml). The form's `bind:value={draft.ui!.theme}` etc.
* non-null-asserts those sub-objects — when they're missing the
* assertion lies and the bind getter throws on the next tick. Force
* the shape on every clone so every binding has a real object to
* write into, and so `draft.ui` is never null while `draft` is non-
* null (template gates only check `draft`).
*/
function normalize(s: PpSettings): PpSettings {
return {
...s,
ui: s.ui ?? {},
search: s.search ?? {},
maps: s.maps ?? {}
};
} }
});
let draft = $state<PpSettings | null>(null);
// Re-clone on each open so reopening the dialog shows the freshest
// server state. Eagerly nulling on close used to introduce a window
// where Dialog's exit animation kept the form mounted while draft
// was already null — and bind:value getters read null, triggering
// "$.get(...) is null" / can't access .ui at runtime. Resetting on
// open instead avoids that race entirely.
$effect(() => { $effect(() => {
if (!open) draft = null; if (open && settingsQuery.data) {
draft = normalize(structuredClone(settingsQuery.data));
}
}); });
const saveMut = createMutation(() => ({ const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch), mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => { onSuccess: (next) => {
qc.setQueryData(['settings'], next); qc.setQueryData(['settings'], next);
draft = structuredClone(next); draft = normalize(structuredClone(next));
toast.success('Settings saved'); toast.success('Settings saved');
}, },
onError: (err) => onError: (err) =>
@@ -112,7 +134,7 @@
})); }));
function resetDraft() { function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data); if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
} }
const selectClass = const selectClass =

View File

@@ -10,6 +10,7 @@
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte'; import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte'; import { setFocused } from '$lib/stores/selection.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.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'; import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
const photoQuery = createQuery<PpPhoto>(() => ({ const photoQuery = createQuery<PpPhoto>(() => ({
@@ -131,15 +132,11 @@
{@const pf = primaryFile(photoQuery.data)} {@const pf = primaryFile(photoQuery.data)}
{#if isVideo(photoQuery.data)} {#if isVideo(photoQuery.data)}
{@const vf = videoFile(photoQuery.data)} {@const vf = videoFile(photoQuery.data)}
<!-- svelte-ignore a11y_media_has_caption --> <VideoPlayer
<video
src={videoUrl(vf.Hash)} src={videoUrl(vf.Hash)}
poster={thumbUrl(pf.Hash, 'fit_1920')} poster={thumbUrl(pf.Hash, 'fit_1920')}
controls title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
autoplay />
muted
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
></video>
{:else} {:else}
<img <img
src={thumbUrl(pf.Hash, 'fit_1920')} src={thumbUrl(pf.Hash, 'fit_1920')}

View File

@@ -0,0 +1,81 @@
<!--
Vidstack-based video player. Wraps the native `<video>` element with a
polished, PhotoPrism-style chrome (gradient bottom bar, large play
overlay, hover-revealed scrubber). Element registration is dynamically
imported so it never runs during SvelteKit's static prerender.
Imports are side-effecting:
- `vidstack/player` registers <media-player> / <media-provider>
- `vidstack/player/layouts/default` registers <media-video-layout>
- the two CSS imports ship the dark theme and video-layout styles
-->
<script lang="ts">
import { onMount } from 'svelte';
import 'vidstack/player/styles/default/theme.css';
import 'vidstack/player/styles/default/layouts/video.css';
interface Props {
src: string;
poster?: string;
title?: string;
}
let { src, poster, title }: Props = $props();
let ready = $state(false);
onMount(async () => {
// Bundle these into the client chunk only — the modules side-effect
// `customElements.define()` calls, which would crash under SSR.
await import('vidstack/player');
await import('vidstack/player/ui');
await import('vidstack/player/layouts/default');
ready = true;
});
</script>
{#if ready}
<!-- svelte-ignore element_invalid_self_closing_tag -->
<media-player
class="vds-player max-h-full max-w-full rounded-md shadow-2xl"
title={title ?? ''}
{src}
{poster}
autoplay
muted
playsinline
load="eager"
posterLoad="eager"
crossorigin=""
preferNativeHLS="false"
streamType="on-demand"
viewType="video"
>
<media-provider></media-provider>
<media-video-layout></media-video-layout>
</media-player>
{:else}
<!-- Fallback while the Vidstack module is loading. Keeps the poster
visible so the transition into the full chrome isn't jarring. -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
{src}
{poster}
controls
autoplay
muted
playsinline
preload="auto"
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
></video>
{/if}
<style>
:global(.vds-player) {
width: 100%;
height: 100%;
max-height: 100%;
aspect-ratio: auto;
--media-brand: #3b82f6;
--media-focus-ring-color: #3b82f6;
}
</style>

View File

@@ -18,6 +18,7 @@
import { Maximize2 } from 'lucide-svelte'; import { Maximize2 } from 'lucide-svelte';
import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte'; import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte'; import { view } from '$lib/stores/view.svelte';
import { cancelVideoPrefetch, startVideoPrefetch } from '$lib/stores/videoPrefetch.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
interface Props { interface Props {
@@ -30,6 +31,17 @@
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));
// Warm the video transcode + browser HTTP cache on hover so opening the
// preview feels instant. The helper de-bounces internally — we just
// fire-and-cancel on the hover edges.
function onMouseEnter() {
if (video) startVideoPrefetch(hash);
}
function onMouseLeave() {
if (video) cancelVideoPrefetch(hash);
}
// 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
@@ -46,7 +58,12 @@
sits as a sibling of the main tile button (not nested) — nesting <button> sits as a sibling of the main tile button (not nested) — nesting <button>
in <button> is invalid HTML. in <button> is invalid HTML.
--> -->
<div class="group relative h-full w-full"> <div
class="group relative h-full w-full"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
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.

View File

@@ -126,11 +126,6 @@ function handleMessage(raw: string): void {
const eventName = inner.event as string | undefined; const eventName = inner.event as string | undefined;
const data = (inner.data ?? {}) as Record<string, unknown>; const data = (inner.data ?? {}) as Record<string, unknown>;
if (!eventName) return; if (!eventName) return;
// PhotoPrism's WS protocol isn't a stable contract; log the live shape
// at `debug` (hidden by default in DevTools — toggle "Verbose" to see)
// so future-us can spot new indexer event names without instrumenting
// the entire app.
console.debug('[indexer]', eventName, data);
switch (eventName) { switch (eventName) {
case 'index.indexing': { case 'index.indexing': {
// Per-file event during the scan pass. PhotoPrism emits one // Per-file event during the scan pass. PhotoPrism emits one

View File

@@ -0,0 +1,60 @@
/**
* Hover-warm cache for video playback URLs.
*
* When a user hovers a video tile, we ask the browser to fetch the first
* chunk of the playback URL. That single Range request:
* 1. Forces the backend's pre-transcoded MP4 cache file open (paging it
* into the OS page cache so the real player request hits warm bytes).
* 2. Lands in the browser's HTTP cache, so when the player mounts and
* issues its own Range: bytes=0- request the response is satisfied
* from disk and playback starts within a frame or two.
*
* We delay the fetch slightly so users who are scrubbing their cursor
* across the grid don't pay the cost; cancellation via AbortController
* keeps wasted bytes bounded.
*
* Each hash is warmed at most once per session — the prefetch is purely
* cache-warming, so a second hover would be a no-op anyway.
*/
import { videoUrl } from '$lib/stores/session.svelte';
const HOVER_DELAY_MS = 120;
const PREFETCH_BYTES = 524288; // 512 KB — enough to start playback in most cases.
const warmed = new Set<string>();
const pending = new Map<string, { timer: number; controller: AbortController }>();
export function startVideoPrefetch(hash: string): void {
if (!hash || warmed.has(hash) || pending.has(hash)) return;
const url = videoUrl(hash);
if (!url) return;
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
await fetch(url, {
method: 'GET',
headers: { Range: `bytes=0-${PREFETCH_BYTES - 1}` },
signal: controller.signal,
credentials: 'same-origin',
cache: 'default'
});
warmed.add(hash);
} catch {
// Aborted hovers or network blips are expected — silently drop.
} finally {
pending.delete(hash);
}
}, HOVER_DELAY_MS);
pending.set(hash, { timer, controller });
}
export function cancelVideoPrefetch(hash: string): void {
const entry = pending.get(hash);
if (!entry) return;
clearTimeout(entry.timer);
entry.controller.abort();
pending.delete(hash);
}

View File

@@ -1,54 +1,61 @@
<script lang="ts"> <script lang="ts">
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 { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query'; import {
import { toast } from 'svelte-sonner'; createInfiniteQuery,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { import {
batchDelete, batchDelete,
getPhoto, getPhoto,
listHeaps, listHeaps,
listPhotos, listPhotos,
type PpAlbum type PpAlbum,
} from '$lib/services/photoprism'; } from "$lib/services/photoprism";
import { import {
filters, filters,
filtersToQ, filtersToQ,
filtersToUrlParams, filtersToUrlParams,
parseUrlParams, parseUrlParams,
setSearch, setSearch,
setSection setSection,
} from '$lib/stores/filters.svelte'; } from "$lib/stores/filters.svelte";
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from 'svelte'; import { untrack } from "svelte";
import { import {
isSelected, isSelected,
selectRange, selectRange,
selection, selection,
setAnchor, setAnchor,
setFocused, setFocused,
setOrder setOrder,
} from '$lib/stores/selection.svelte'; } from "$lib/stores/selection.svelte";
import { openPreview, preview } from '$lib/stores/preview.svelte'; import { openPreview, preview } from "$lib/stores/preview.svelte";
import { import {
setRightSidebarWidth, setRightSidebarWidth,
setThumbnailSize, setThumbnailSize,
THUMBNAIL_SIZE_LABELS, THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS, THUMBNAIL_SIZE_PRESETS,
view view,
} from '$lib/stores/view.svelte'; } from "$lib/stores/view.svelte";
import { tick } from 'svelte'; import { tick } from "svelte";
import { resizable } from '$lib/actions/resizable'; import { resizable } from "$lib/actions/resizable";
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav'; import { gridKeyNav, type ArrowKey } from "$lib/actions/gridKeyNav";
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from "$lib/actions/nearBottom";
import { visibleRange, getVisibleRangeHandle } from '$lib/actions/visibleRange'; import {
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; visibleRange,
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; getVisibleRangeHandle,
import PhotoTile from '$lib/components/timeline/PhotoTile.svelte'; } from "$lib/actions/visibleRange";
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte'; import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte";
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
import { type PpPhoto } from '$lib/types/photoprism'; import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { type PpPhoto } from "$lib/types/photoprism";
// ── URL ↔ filter store sync ────────────────────────────────────────────── // ── URL ↔ filter store sync ──────────────────────────────────────────────
// On nav (back/forward, deep link), reflect the URL into the store. // On nav (back/forward, deep link), reflect the URL into the store.
@@ -63,41 +70,43 @@
// When the store changes from in-app actions (left-sidebar nav, search // When the store changes from in-app actions (left-sidebar nav, search
// box, etc.), push the matching query string back so the URL is shareable. // box, etc.), push the matching query string back so the URL is shareable.
let lastWritten = $state(''); let lastWritten = $state("");
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const qs = filtersToUrlParams().toString(); const qs = filtersToUrlParams().toString();
const here = page.url.searchParams.toString(); const here = page.url.searchParams.toString();
if (qs === here || qs === lastWritten) return; if (qs === here || qs === lastWritten) return;
lastWritten = qs; lastWritten = qs;
void goto(`/${qs ? '?' + qs : ''}`, { void goto(`/${qs ? "?" + qs : ""}`, {
replaceState: true, replaceState: true,
keepFocus: true, keepFocus: true,
noScroll: true noScroll: true,
}); });
}); });
// ── Section title ──────────────────────────────────────────────────────── // ── Section title ────────────────────────────────────────────────────────
const heapsQuery = createQuery<PpAlbum[]>(() => ({ const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'], queryKey: ["heaps"],
queryFn: listHeaps, queryFn: listHeaps,
enabled: isAuthenticated() enabled: isAuthenticated(),
})); }));
const sectionLabel = $derived(buildSectionLabel()); const sectionLabel = $derived(buildSectionLabel());
function buildSectionLabel(): string { function buildSectionLabel(): string {
switch (filters.section) { switch (filters.section) {
case 'favorites': case "favorites":
return 'Favorites'; return "Favorites";
case 'review': case "review":
return 'Review'; return "Review";
case 'archive': case "archive":
return 'Archive'; return "Archive";
case 'hidden': case "hidden":
return 'Hidden'; return "Hidden";
case 'heap': { case "heap": {
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid); const heap = (heapsQuery.data ?? []).find(
return heap ? `Heap · ${heap.Title}` : 'Heap'; (h) => h.UID === filters.heapUid,
);
return heap ? `Heap · ${heap.Title}` : "Heap";
} }
default: default:
// 'all-photos' is the internal "no section filter" state — // 'all-photos' is the internal "no section filter" state —
@@ -106,9 +115,9 @@
// reflects what's actually on screen; only the rare // reflects what's actually on screen; only the rare
// `folderPath === null` case (e.g. right after deleting a // `folderPath === null` case (e.g. right after deleting a
// heap) still reads as "All photos". // heap) still reads as "All photos".
if (filters.folderPath === '/') return 'Folder · /'; if (filters.folderPath === "/") return "Folder · /";
if (filters.folderPath) return `Folder · ${filters.folderPath}`; if (filters.folderPath) return `Folder · ${filters.folderPath}`;
return 'All photos'; return "All photos";
} }
} }
@@ -120,14 +129,14 @@
// downstream into a single `photos` array consumers iterate. // downstream into a single `photos` array consumers iterate.
const PHOTOS_PAGE_SIZE = 120; const PHOTOS_PAGE_SIZE = 120;
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({ const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'q', filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }], queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
listPhotos({ listPhotos({
q: filtersToQ(filters), q: filtersToQ(filters),
count: PHOTOS_PAGE_SIZE, count: PHOTOS_PAGE_SIZE,
offset: pageParam as number, offset: pageParam as number,
order: 'newest', order: "newest",
merged: true merged: true,
}), }),
initialPageParam: 0, initialPageParam: 0,
// PhotoPrism's `count` limits SQL rows; with `merged=true` each // PhotoPrism's `count` limits SQL rows; with `merged=true` each
@@ -137,7 +146,7 @@
// tail (cheap; the empty response is small). // tail (cheap; the empty response is small).
getNextPageParam: (last, pages) => getNextPageParam: (last, pages) =>
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE, last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
enabled: isAuthenticated() enabled: isAuthenticated(),
})); }));
/** Flattened view of every loaded page, deduplicated by UID. Adjacent /** Flattened view of every loaded page, deduplicated by UID. Adjacent
@@ -155,7 +164,9 @@
* to `Path === ''` here — PhotoPrism's `path:` operator can't * to `Path === ''` here — PhotoPrism's `path:` operator can't
* express that match, so the query fetches the whole library and * express that match, so the query fetches the whole library and
* we strip subfolder rows post-hoc. */ * we strip subfolder rows post-hoc. */
const dedupedAll = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages)); const dedupedAll = $derived<PpPhoto[]>(
dedupedPhotos(photosQuery.data?.pages),
);
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters)); const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] { function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return []; if (!pages) return [];
@@ -230,15 +241,15 @@
* one-dimensional even though the template renders headers inline). * one-dimensional even though the template renders headers inline).
*/ */
type Row = type Row =
| { kind: 'header'; key: string; label: string; count: number } | { kind: "header"; key: string; label: string; count: number }
| { kind: 'tile'; photo: PpPhoto; tileIndex: number }; | { kind: "tile"; photo: PpPhoto; tileIndex: number };
const rows = $derived<Row[]>(buildRows(photos)); const rows = $derived<Row[]>(buildRows(photos));
function buildRows(list: PpPhoto[]): Row[] { function buildRows(list: PpPhoto[]): Row[] {
const out: Row[] = []; const out: Row[] = [];
const fmt = new Intl.DateTimeFormat(undefined, { const fmt = new Intl.DateTimeFormat(undefined, {
month: 'long', month: "long",
year: 'numeric' year: "numeric",
}); });
// First pass: count per month — used by the header chip. Sparse // First pass: count per month — used by the header chip. Sparse
// Map: O(months) memory, O(photos) time, both trivial at 10k. // Map: O(months) memory, O(photos) time, both trivial at 10k.
@@ -250,25 +261,33 @@
if (!labels.has(key)) labels.set(key, label); if (!labels.has(key)) labels.set(key, label);
} }
// Second pass: emit rows in document order. // Second pass: emit rows in document order.
let prev = ''; let prev = "";
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const p = list[i]; const p = list[i];
const { key } = monthKey(p, fmt); const { key } = monthKey(p, fmt);
if (key !== prev) { if (key !== prev) {
out.push({ kind: 'header', key, label: labels.get(key) ?? '', count: counts.get(key) ?? 0 }); out.push({
kind: "header",
key,
label: labels.get(key) ?? "",
count: counts.get(key) ?? 0,
});
prev = key; prev = key;
} }
out.push({ kind: 'tile', photo: p, tileIndex: i }); out.push({ kind: "tile", photo: p, tileIndex: i });
} }
return out; return out;
} }
function monthKey(p: PpPhoto, fmt: Intl.DateTimeFormat): { key: string; label: string } { function monthKey(
const raw = p.TakenAtLocal ?? p.TakenAt ?? ''; p: PpPhoto,
if (!raw) return { key: 'no-date', label: 'No date' }; fmt: Intl.DateTimeFormat,
): { key: string; label: string } {
const raw = p.TakenAtLocal ?? p.TakenAt ?? "";
if (!raw) return { key: "no-date", label: "No date" };
const d = new Date(raw); const d = new Date(raw);
if (Number.isNaN(d.getTime())) return { key: 'no-date', label: 'No date' }; if (Number.isNaN(d.getTime())) return { key: "no-date", label: "No date" };
const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
return { key: k, label: fmt.format(d) }; return { key: k, label: fmt.format(d) };
} }
@@ -287,10 +306,10 @@
let visLast = $state(INITIAL_VIS_LAST); let visLast = $state(INITIAL_VIS_LAST);
let forcedExpand = $state<number | null>(null); let forcedExpand = $state<number | null>(null);
const renderFirst = $derived( const renderFirst = $derived(
Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER) Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER),
); );
const renderLast = $derived( const renderLast = $derived(
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER,
); );
// Hoist the selection reads out of the per-tile each-block. The same // Hoist the selection reads out of the per-tile each-block. The same
@@ -348,7 +367,7 @@
}, },
destroy() { destroy() {
handle?.unregister(node); handle?.unregister(node);
} },
}; };
} }
@@ -362,7 +381,8 @@
* uses in its `scrollRowIntoView`. */ * uses in its `scrollRowIntoView`. */
function scrollTileIntoView(el: HTMLElement) { function scrollTileIntoView(el: HTMLElement) {
if (!scrollRoot) return; if (!scrollRoot) return;
const stickyH = scrollRoot.querySelector<HTMLElement>('h2.sticky')?.offsetHeight ?? 0; const stickyH =
scrollRoot.querySelector<HTMLElement>("h2.sticky")?.offsetHeight ?? 0;
const rootRect = scrollRoot.getBoundingClientRect(); const rootRect = scrollRoot.getBoundingClientRect();
const elRect = el.getBoundingClientRect(); const elRect = el.getBoundingClientRect();
const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop; const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop;
@@ -384,7 +404,9 @@
forcedExpand = i; forcedExpand = i;
await tick(); await tick();
if (!scrollRoot) return; if (!scrollRoot) return;
const el = scrollRoot.querySelector<HTMLElement>(`[data-uid-shell="${photos[i]?.UID ?? ''}"]`); const el = scrollRoot.querySelector<HTMLElement>(
`[data-uid-shell="${photos[i]?.UID ?? ""}"]`,
);
if (el) scrollTileIntoView(el); if (el) scrollTileIntoView(el);
} }
@@ -430,7 +452,9 @@
function trackGridCols(node: HTMLElement) { function trackGridCols(node: HTMLElement) {
gridEl = node; gridEl = node;
const measure = () => { const measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(' ').filter(Boolean).length; const n = getComputedStyle(node)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
cols = Math.max(1, n); cols = Math.max(1, n);
}; };
measure(); measure();
@@ -444,7 +468,7 @@
destroy() { destroy() {
ro.disconnect(); ro.disconnect();
if (gridEl === node) gridEl = undefined; if (gridEl === node) gridEl = undefined;
} },
}; };
} }
@@ -455,7 +479,9 @@
void view.thumbnailSize; void view.thumbnailSize;
queueMicrotask(() => { queueMicrotask(() => {
if (!gridEl) return; if (!gridEl) return;
const n = getComputedStyle(gridEl).gridTemplateColumns.split(' ').filter(Boolean).length; const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
cols = Math.max(1, n); cols = Math.max(1, n);
}); });
}); });
@@ -474,15 +500,15 @@
function buildVisualGrid( function buildVisualGrid(
list: PpPhoto[], list: PpPhoto[],
colsPerRow: number colsPerRow: number,
): { rows: VisualRow[]; pos: Map<string, [number, number]> } { ): { rows: VisualRow[]; pos: Map<string, [number, number]> } {
const rows: VisualRow[] = []; const rows: VisualRow[] = [];
const pos = new Map<string, [number, number]>(); const pos = new Map<string, [number, number]>();
const fmt = new Intl.DateTimeFormat(undefined, { const fmt = new Intl.DateTimeFormat(undefined, {
month: 'long', month: "long",
year: 'numeric' year: "numeric",
}); });
let curMonth = ''; let curMonth = "";
let curRow: VisualRow | null = null; let curRow: VisualRow | null = null;
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const p = list[i]; const p = list[i];
@@ -528,8 +554,8 @@
// vertical move preserves whatever column the user is already on. // vertical move preserves whatever column the user is already on.
if (intendedCol === null) intendedCol = c < 0 ? 0 : c; if (intendedCol === null) intendedCol = c < 0 ? 0 : c;
if (key === 'ArrowLeft' || key === 'ArrowRight') { if (key === "ArrowLeft" || key === "ArrowRight") {
c += key === 'ArrowRight' ? 1 : -1; c += key === "ArrowRight" ? 1 : -1;
// Wrap across row boundaries (LeftArrow at col 0 → previous // Wrap across row boundaries (LeftArrow at col 0 → previous
// row's last col, RightArrow at last col → next row's col 0). // row's last col, RightArrow at last col → next row's col 0).
while (c < 0 && r > 0) { while (c < 0 && r > 0) {
@@ -549,7 +575,7 @@
// subsequent vertical moves. // subsequent vertical moves.
intendedCol = c; intendedCol = c;
} else { } else {
r += key === 'ArrowDown' ? 1 : -1; r += key === "ArrowDown" ? 1 : -1;
if (r < 0) r = 0; if (r < 0) r = 0;
if (r >= rows.length) r = rows.length - 1; if (r >= rows.length) r = rows.length - 1;
// Vertical: prefer the intended column; clamp to destination // Vertical: prefer the intended column; clamp to destination
@@ -571,7 +597,9 @@
// Try the cheap path first: rendered tile, our scroll helper that // Try the cheap path first: rendered tile, our scroll helper that
// respects the sticky header. Fall back to scrollToIndex (windowing // respects the sticky header. Fall back to scrollToIndex (windowing
// expand + scroll) when the destination is currently unmounted. // expand + scroll) when the destination is currently unmounted.
const tile = scrollRoot?.querySelector<HTMLElement>(`[data-uid="${destUid}"]`); const tile = scrollRoot?.querySelector<HTMLElement>(
`[data-uid="${destUid}"]`,
);
if (tile) { if (tile) {
scrollTileIntoView(tile); scrollTileIntoView(tile);
} else { } else {
@@ -581,11 +609,11 @@
} }
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({ const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''], queryKey: ["photo", selection.focused ?? ""],
queryFn: () => queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null), selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused), enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0 staleTime: 0,
})); }));
const qc = useQueryClient(); const qc = useQueryClient();
@@ -602,7 +630,7 @@
if (emptyingArchive) return; if (emptyingArchive) return;
if ( if (
!confirm( !confirm(
'Permanently delete EVERY photo in the Archive? This cannot be undone.' "Permanently delete EVERY photo in the Archive? This cannot be undone.",
) )
) { ) {
return; return;
@@ -612,23 +640,23 @@
try { try {
while (true) { while (true) {
const batch = await listPhotos({ const batch = await listPhotos({
q: 'archived:true', q: "archived:true",
count: 1000, count: 1000,
offset: 0, offset: 0,
order: 'newest', order: "newest",
merged: false merged: false,
}); });
if (batch.length === 0) break; if (batch.length === 0) break;
const uids = Array.from(new Set(batch.map((p) => p.UID))); const uids = Array.from(new Set(batch.map((p) => p.UID)));
await batchDelete(uids); await batchDelete(uids);
total += uids.length; total += uids.length;
} }
toast.success(total === 0 ? 'Archive already empty' : `Deleted ${total}`); toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Empty archive failed'); toast.error(err instanceof Error ? err.message : "Empty archive failed");
} finally { } finally {
emptyingArchive = false; emptyingArchive = false;
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ["photos"] });
} }
} }
@@ -653,14 +681,20 @@
// gridKeyNav already handled the underlying click. // 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(uid, photos.map((p) => p.UID)); openPreview(
uid,
photos.map((p) => p.UID),
);
} }
// Single-click fallback for the dblclick preview gesture. Wired to the // Single-click fallback for the dblclick preview gesture. Wired to the
// hover-only Maximize icon in PhotoTile so users who haven't discovered // hover-only Maximize icon in PhotoTile so users who haven't discovered
// dblclick can still get to the preview. // dblclick can still get to the preview.
function onTileOpenPreview(uid: string) { function onTileOpenPreview(uid: string) {
openPreview(uid, photos.map((p) => p.UID)); openPreview(
uid,
photos.map((p) => p.UID),
);
} }
// Scroll root for the infinite-scroll IntersectionObserver. Bound by // Scroll root for the infinite-scroll IntersectionObserver. Bound by
@@ -681,10 +715,10 @@
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on // PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet. // focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [ const SEARCH_EXAMPLES = [
'label:dog', "label:dog",
'keyword:vacation', "keyword:vacation",
'taken:2024', "taken:2024",
'"exact phrase"' '"exact phrase"',
]; ];
let searchFocused = $state(false); let searchFocused = $state(false);
function onSearchFocus() { function onSearchFocus() {
@@ -702,22 +736,12 @@
</script> </script>
<Toolbar showRightToggle> <Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"> <span
class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{sectionLabel} {sectionLabel}
</span> </span>
<!-- {#if filters.section === "archive" && photos.length > 0}
Persistent gesture hint. The new click-semantics (single = select,
double = open) aren't intuitive for users arriving from Google
Photos / Apple Photos, so surface them in plain text where the eye
can see them without hover. Hidden below sm: so the search bar
still gets room on narrow viewports.
-->
{#if photos.length > 0}
<span class="hidden text-[10px] text-muted-foreground lg:inline">
click select · ⇧ range · ⌘ toggle · dblclick open
</span>
{/if}
{#if filters.section === 'archive' && photos.length > 0}
<button <button
type="button" type="button"
class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50" class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
@@ -725,13 +749,13 @@
onclick={onEmptyArchive} onclick={onEmptyArchive}
title="Permanently delete every archived photo" title="Permanently delete every archived photo"
> >
{emptyingArchive ? 'Emptying…' : 'Empty Archive'} {emptyingArchive ? "Emptying…" : "Empty Archive"}
</button> </button>
{/if} {/if}
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}> <form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input <input
type="search" type="search"
placeholder='Search · label:website / "vacation"' placeholder={'Search · label:website / "vacation"'}
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring" class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
bind:value={searchDraft} bind:value={searchDraft}
onfocus={onSearchFocus} onfocus={onSearchFocus}
@@ -748,8 +772,8 @@
type="button" type="button"
class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent" class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
onclick={() => { onclick={() => {
searchDraft = ''; searchDraft = "";
setSearch(''); setSearch("");
}} }}
title="Clear search" title="Clear search"
> >
@@ -765,7 +789,9 @@
<div <div
class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md" class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
> >
<div class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground"> <div
class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Examples Examples
</div> </div>
{#each SEARCH_EXAMPLES as ex (ex)} {#each SEARCH_EXAMPLES as ex (ex)}
@@ -806,7 +832,6 @@
</button> </button>
{/each} {/each}
</div> </div>
{/snippet} {/snippet}
</Toolbar> </Toolbar>
@@ -826,7 +851,7 @@
visFirst = f; visFirst = f;
visLast = l; visLast = l;
}, },
sampleEvery: TILE_SAMPLE sampleEvery: TILE_SAMPLE,
}} }}
> >
<div class="p-6 pb-24"> <div class="p-6 pb-24">
@@ -836,24 +861,24 @@
<p class="text-sm text-destructive"> <p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error Failed to load photos: {photosQuery.error instanceof Error
? photosQuery.error.message ? photosQuery.error.message
: 'unknown error'} : "unknown error"}
</p> </p>
{:else if photos.length === 0} {:else if photos.length === 0}
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{#if filters.section === 'archive'} {#if filters.section === "archive"}
Archive is empty. Archive is empty.
{:else if filters.section === 'favorites'} {:else if filters.section === "favorites"}
No favorites yet. Heart a photo to add it here. No favorites yet. Heart a photo to add it here.
{:else if filters.section === 'review'} {:else if filters.section === "review"}
Nothing left to review. Photos PhotoPrism's indexer wasn't sure about Nothing left to review. Photos PhotoPrism's indexer wasn't sure
land here — use Keep to accept them into the timeline or Archive to about land here — use Keep to accept them into the timeline or
set them aside. Archive to set them aside.
{:else if filters.section === 'hidden'} {:else if filters.section === "hidden"}
No hidden photos. PhotoPrism auto-hides files it can't index (broken No hidden photos. PhotoPrism auto-hides files it can't index
files, very low quality); they only ever show up here. (broken files, very low quality); they only ever show up here.
{:else if filters.section === 'heap'} {:else if filters.section === "heap"}
This heap has no photos yet. Select some photos and use the bulk bar's This heap has no photos yet. Select some photos and use the bulk
" Add to heap" button. bar's " Add to heap" button.
{:else} {:else}
No photos. Index a folder via PhotoPrism's reindex command. No photos. Index a folder via PhotoPrism's reindex command.
{/if} {/if}
@@ -865,8 +890,8 @@
class="grid gap-2" class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));" style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
> >
{#each rows as row (row.kind === 'header' ? `h:${row.key}` : `t:${row.photo.UID}`)} {#each rows as row (row.kind === "header" ? `h:${row.key}` : `t:${row.photo.UID}`)}
{#if row.kind === 'header'} {#if row.kind === "header"}
<!-- col-span-full + position:sticky pins the month label to <!-- col-span-full + position:sticky pins the month label to
the top of the scrolling main as the user passes through. the top of the scrolling main as the user passes through.
-mx-6 stretches the bar past the wrapper padding so it -mx-6 stretches the bar past the wrapper padding so it
@@ -876,7 +901,9 @@
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"
> >
{row.label} {row.label}
<span class="ml-2 text-[10px] font-normal text-muted-foreground"> <span
class="ml-2 text-[10px] font-normal text-muted-foreground"
>
{row.count} {row.count}
</span> </span>
</h2> </h2>
@@ -893,7 +920,8 @@
use:tileRegister={i} use:tileRegister={i}
> >
{#if inWindow} {#if inWindow}
{@const sel = selectedIds.has(photo.UID) || focusedUid === photo.UID} {@const sel =
selectedIds.has(photo.UID) || focusedUid === photo.UID}
<PhotoTile <PhotoTile
{photo} {photo}
selected={sel} selected={sel}
@@ -915,12 +943,15 @@
class="h-4" class="h-4"
use:nearBottom={{ use:nearBottom={{
onHit: () => photosQuery.fetchNextPage(), onHit: () => photosQuery.fetchNextPage(),
enabled: photosQuery.hasNextPage && !photosQuery.isFetchingNextPage, enabled:
root: scrollRoot photosQuery.hasNextPage && !photosQuery.isFetchingNextPage,
root: scrollRoot,
}} }}
></div> ></div>
{#if photosQuery.isFetchingNextPage} {#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground">Loading more</p> <p class="py-3 text-center text-xs text-muted-foreground">
Loading more
</p>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -947,8 +978,8 @@
<div class="space-y-2 p-4 text-center"> <div class="space-y-2 p-4 text-center">
<div class="text-xl"></div> <div class="text-xl"></div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a thumbnail Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click
to view its metadata here. on a thumbnail to view its metadata here.
</p> </p>
</div> </div>
{/if} {/if}
@@ -958,9 +989,9 @@
<div <div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize" class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{ use:resizable={{
edge: 'left', edge: "left",
getWidth: () => view.rightSidebarWidth, getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth setWidth: setRightSidebarWidth,
}} }}
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"