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:
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user