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>
This commit is contained in:
2026-05-18 08:13:42 +02:00
parent cfd85a1fe8
commit ccbc1050de
3 changed files with 964 additions and 916 deletions

View File

@@ -90,21 +90,43 @@
enabled: open
}));
let draft = $state<PpSettings | null>(null);
$effect(() => {
if (settingsQuery.data && draft === null) {
draft = structuredClone(settingsQuery.data);
/**
* Some PhotoPrism deployments return `/settings` without the
* `ui` / `search` / `maps` keys (older versions, custom edits to
* 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(() => {
if (!open) draft = null;
if (open && settingsQuery.data) {
draft = normalize(structuredClone(settingsQuery.data));
}
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = structuredClone(next);
draft = normalize(structuredClone(next));
toast.success('Settings saved');
},
onError: (err) =>
@@ -112,7 +134,7 @@
}));
function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
}
const selectClass =

View File

@@ -126,11 +126,6 @@ function handleMessage(raw: string): void {
const eventName = inner.event as string | undefined;
const data = (inner.data ?? {}) as Record<string, unknown>;
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) {
case 'index.indexing': {
// Per-file event during the scan pass. PhotoPrism emits one

View File

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