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