web: deep-link from RightSidebar to folder/map + anchor-mode timeline + fix root count
RightSidebar:
- Folder + Location rows gain a small ArrowUpRight icon button that
deep-links into the timeline / map view focused on the photo. OSM
external link removed; the in-app map nav covers the same job.
Folder navigation:
- New navigateToFolder(path, { focusUid, focusTakenAt }) helper in the
filters store; LeftSidebar's pickFolder collapses to a one-liner that
reuses it.
- One-shot pending-focus stash carries both UID and TakenAt across the
goto. URL-watch effect on the timeline consumes the stash so even
same-folder navigations (where the filter doesn't change) get
picked up.
Anchor-mode timeline query:
- listPhotosAround(q, takenAt, after, before) issues two parallel
PhotoPrism calls (`after:<day-1>` oldest-first + `before:<day+1>`
newest-first), merges + dedupes newest-first. Uses PhotoPrism's
existing date-only DSL clauses — no server changes.
- When a deep-link stashes a TakenAt, page 0 of the photosQuery uses
the merged window so the target photo is loaded even for photos
buried past the standard newest-first cursor. Pages 1+ are disabled
in anchor mode (PhotoPrism's day-precision cursor would infinite-loop
on dense days; users see 120 around the target, refresh to drop the
anchor).
- After page 0 lands, the existing scrollToIndex(targetIdx) expands the
windowed render set + scrolls the tile into view.
Map view:
- /map honors `?lat=&lng=&zoom=&focus=` URL params, jumping to the
photo's coordinates at zoom 17 instead of fitBounds-ing the full
library. Params are stripped after first apply so a manual zoom-out
+ reload doesn't snap back.
LeftSidebar root count badge:
- Now matches what Cmd+A selects in the timeline. Old code used
/config.count.all (library aggregate, includes archived/hidden/
review). Switched to countPhotos('', { merged: true }) which counts
the actual photo entries the timeline lists.
- countPhotos gains a `merged` option; with merged=true it returns the
response body length instead of the X-Count header — PhotoPrism's
X-Count is always the file-row count regardless of merged, so a
HEIC + JPG companion pair inflated the badge to 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
} from '$lib/services/adapters/review';
|
} from '$lib/services/adapters/review';
|
||||||
import {
|
import {
|
||||||
filters,
|
filters,
|
||||||
|
navigateToFolder,
|
||||||
setFolderPath,
|
setFolderPath,
|
||||||
setSection,
|
setSection,
|
||||||
TAG_CATEGORIES,
|
TAG_CATEGORIES,
|
||||||
@@ -223,23 +224,40 @@
|
|||||||
}));
|
}));
|
||||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||||
|
|
||||||
// Root entry shows "the user's library" — for admins without a
|
// Root entry shows "the user's library" using the same filter the
|
||||||
// BasePath that's still the whole library, served cheaply from
|
// timeline applies at folderPath=='/' — empty q, which PhotoPrism
|
||||||
// /api/v1/config's `count.all`. For any user with a non-empty
|
// resolves to the visible listing (no archived / hidden / review).
|
||||||
// BasePath the precomputed total is wrong (it's library-wide), so we
|
// Earlier this used /config's `count.all`, but that aggregate
|
||||||
// ask the sidecar for a recursive count rooted at the user's
|
// includes those buckets and didn't match what the user can actually
|
||||||
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
|
// click "select all" on; the discrepancy was confusing
|
||||||
// resolves to the BasePath itself, and the sidecar fan-out recurses.
|
// (LeftSidebar said 357, the action bar said ~329).
|
||||||
|
//
|
||||||
|
// `scopedRootCountQuery` retains the sidecar fan-out for users with
|
||||||
|
// a BasePath — `listFolderCounts(['''])` resolves `''` through
|
||||||
|
// `toOriginalsPath` to the user's BasePath and recurses, so it picks
|
||||||
|
// up the same subset PhotoPrism would. Empty BasePath admins use the
|
||||||
|
// PhotoPrism count-via-X-Count path so both surfaces agree.
|
||||||
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
|
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
|
||||||
queryKey: ['photos', 'root-count', userBasePath()],
|
queryKey: ['photos', 'root-count', userBasePath()],
|
||||||
queryFn: () => listFolderCounts(['']),
|
queryFn: () => listFolderCounts(['']),
|
||||||
enabled: isAuthenticated() && userBasePath() !== '',
|
enabled: isAuthenticated() && userBasePath() !== '',
|
||||||
staleTime: 60_000
|
staleTime: 60_000
|
||||||
}));
|
}));
|
||||||
|
const visibleRootCountQuery = createQuery<number>(() => ({
|
||||||
|
queryKey: ['photos', 'visible-root-count', userBasePath()],
|
||||||
|
// `merged: true` so the count matches the timeline's photo entries
|
||||||
|
// (one per logical photo) rather than its file-row total. Without
|
||||||
|
// it, sidecar/companion files inflate the badge — e.g. a HEIC + JPG
|
||||||
|
// pair counts twice — and "select all" in the timeline never
|
||||||
|
// reaches the badge's number.
|
||||||
|
queryFn: () => countPhotos(scoped(''), { merged: true }),
|
||||||
|
enabled: isAuthenticated() && userBasePath() === '' && isAdminUser,
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
const rootCount = $derived(
|
const rootCount = $derived(
|
||||||
userBasePath() === ''
|
userBasePath() === ''
|
||||||
? isAdminUser
|
? isAdminUser
|
||||||
? (configQuery.data?.count?.all ?? 0)
|
? (visibleRootCountQuery.data ?? 0)
|
||||||
: 0
|
: 0
|
||||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||||
);
|
);
|
||||||
@@ -490,14 +508,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickFolder(folderPath: string) {
|
async function pickFolder(folderPath: string) {
|
||||||
// Folder selection works on top of the All Photos section; clearing
|
await navigateToFolder(folderPath);
|
||||||
// the heap/section context mirrors mule-image's "drill into folder"
|
|
||||||
// behaviour. The URL sync $effect on the timeline picks this up.
|
|
||||||
setSection('all-photos');
|
|
||||||
setFolderPath(folderPath);
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('folder', folderPath);
|
|
||||||
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreateHeap() {
|
function onCreateHeap() {
|
||||||
|
|||||||
@@ -6,18 +6,20 @@
|
|||||||
PUT (Details fields need the full body).
|
PUT (Details fields need the full body).
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
Aperture,
|
Aperture,
|
||||||
|
ArrowUpRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
ExternalLink,
|
|
||||||
File,
|
File,
|
||||||
Folder,
|
Folder,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Map as MapIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import { navigateToFolder } from '$lib/stores/filters.svelte';
|
||||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
|
|
||||||
@@ -282,12 +285,6 @@
|
|||||||
? photo.Country.toUpperCase()
|
? photo.Country.toUpperCase()
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
const mapsHref = $derived(
|
|
||||||
photo.Lat && photo.Lng
|
|
||||||
? `https://www.openstreetmap.org/?mlat=${photo.Lat}&mlon=${photo.Lng}&zoom=15`
|
|
||||||
: ''
|
|
||||||
);
|
|
||||||
|
|
||||||
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
|
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
|
||||||
if (!c) return '';
|
if (!c) return '';
|
||||||
const make = c.Make ?? '';
|
const make = c.Make ?? '';
|
||||||
@@ -377,16 +374,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
|
<!-- Folder (read-only label + open-in-timeline icon). The `px-1 py-0.5`
|
||||||
padding on filename / date so the read-only text starts at the
|
mirrors the input padding on filename / date so the read-only
|
||||||
same x-offset as the editable rows above — otherwise spans
|
text starts at the same x-offset as the editable rows above —
|
||||||
hug the icon while inputs sit 4px in. Root-level files render
|
otherwise spans hug the icon while inputs sit 4px in. Root-level
|
||||||
as `/` so the row never disappears and the layout stays stable. -->
|
files render as `/` so the row never disappears. The arrow-up-
|
||||||
|
right icon navigates to the timeline filtered by this folder
|
||||||
|
with the photo pre-focused. -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
|
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
|
||||||
{folderLabel}
|
{folderLabel}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={() =>
|
||||||
|
void navigateToFolder(dirPath || '/', {
|
||||||
|
focusUid: photo.UID,
|
||||||
|
focusTakenAt: photo.TakenAt ?? null
|
||||||
|
})}
|
||||||
|
title="Open folder in timeline"
|
||||||
|
aria-label="Open folder in timeline"
|
||||||
|
>
|
||||||
|
<ArrowUpRight class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dimensions -->
|
<!-- Dimensions -->
|
||||||
@@ -405,22 +417,29 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location -->
|
<!-- Location (read-only label + open-on-map icon). The arrow-up-
|
||||||
|
right icon flies the in-app map to the photo's coordinates at
|
||||||
|
zoom 17 (close enough for the photo's marker to be its own,
|
||||||
|
out of any cluster). Hidden when the photo has no
|
||||||
|
coordinates. -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||||
{placeLabel || 'No location'}
|
{placeLabel || 'No location'}
|
||||||
</span>
|
</span>
|
||||||
{#if mapsHref}
|
{#if photo.Lat && photo.Lng}
|
||||||
<a
|
<button
|
||||||
href={mapsHref}
|
type="button"
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="text-muted-foreground hover:text-foreground"
|
class="text-muted-foreground hover:text-foreground"
|
||||||
title="Open in OpenStreetMap"
|
onclick={() =>
|
||||||
|
void goto(
|
||||||
|
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
|
||||||
|
)}
|
||||||
|
title="Open on map"
|
||||||
|
aria-label="Open on map"
|
||||||
>
|
>
|
||||||
<ExternalLink class="h-3 w-3" />
|
<MapIcon class="h-3 w-3" />
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -54,6 +54,14 @@
|
|||||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||||
);
|
);
|
||||||
const isBulk = $derived(selection.ids.size > 0);
|
const isBulk = $derived(selection.ids.size > 0);
|
||||||
|
// Filename for the single-focus label. Re-derives whenever
|
||||||
|
// selection.focused flips — cachedPhoto reads from the same query
|
||||||
|
// cache that drives the visible tiles, so the name resolves on the
|
||||||
|
// same tick the tile renders.
|
||||||
|
const focusedPhoto = $derived(selection.focused ? cachedPhoto(selection.focused) : undefined);
|
||||||
|
const focusedName = $derived(
|
||||||
|
focusedPhoto ? photoNameAndDir(focusedPhoto).fileName : ''
|
||||||
|
);
|
||||||
// Review section uses a two-button decision flow (Keep / Archive) —
|
// Review section uses a two-button decision flow (Keep / Archive) —
|
||||||
// every other action is hidden so the choice can't be confused with
|
// every other action is hidden so the choice can't be confused with
|
||||||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||||
@@ -248,13 +256,19 @@
|
|||||||
<div
|
<div
|
||||||
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
||||||
>
|
>
|
||||||
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
{#if isBulk}
|
||||||
{#if isBulk}
|
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
||||||
{targetCount} selected
|
{targetCount} selected
|
||||||
{:else}
|
</span>
|
||||||
Focused photo
|
{:else}
|
||||||
{/if}
|
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
|
||||||
</span>
|
<span
|
||||||
|
class="min-w-0 truncate text-[11px] font-medium text-foreground"
|
||||||
|
title={focusedName || undefined}
|
||||||
|
>
|
||||||
|
{focusedName || 'photo'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
||||||
|
|||||||
@@ -156,6 +156,95 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
|
||||||
|
* older photos preceded by `after` newer ones, merged newest-first.
|
||||||
|
* Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's
|
||||||
|
* neighbours can be loaded without paging through the whole filter.
|
||||||
|
*
|
||||||
|
* Used by the timeline's deep-link focus mode: an in-app navigation
|
||||||
|
* stashes `{uid, takenAt}`, the timeline calls this with the anchor's
|
||||||
|
* date for page 0, and the target photo lands ~`afterCount` tiles
|
||||||
|
* down with `~beforeCount` older neighbours below it.
|
||||||
|
*
|
||||||
|
* Subsequent infinite-scroll pages use plain `listPhotos` with the
|
||||||
|
* standard offset cursor — the anchor mode only matters for page 0.
|
||||||
|
*/
|
||||||
|
export interface AroundParams {
|
||||||
|
/** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */
|
||||||
|
q?: string;
|
||||||
|
/** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */
|
||||||
|
takenAt: string;
|
||||||
|
/** How many photos newer than the anchor to fetch. */
|
||||||
|
afterCount?: number;
|
||||||
|
/** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */
|
||||||
|
beforeCount?: number;
|
||||||
|
merged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
|
||||||
|
const afterCount = p.afterCount ?? 30;
|
||||||
|
const beforeCount = p.beforeCount ?? 90;
|
||||||
|
const baseQ = p.q?.trim() ?? '';
|
||||||
|
// PhotoPrism's `before:`/`after:` operators take ISO timestamps.
|
||||||
|
// `+1s` / `-1s` makes the bounds inclusive of the anchor itself in
|
||||||
|
// the `before:` half (so the target tile is in the merged result).
|
||||||
|
const anchorDate = new Date(p.takenAt);
|
||||||
|
if (Number.isNaN(anchorDate.getTime())) {
|
||||||
|
// Date parse failed — fall back to a plain newest-first page.
|
||||||
|
return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged });
|
||||||
|
}
|
||||||
|
// PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round
|
||||||
|
// up/down by a day so the anchor's own day is included in the
|
||||||
|
// `before:` half — the bounds are inclusive day boundaries, so a
|
||||||
|
// timestamp-precision anchor lands inside the `[beforeBound,
|
||||||
|
// afterBound]` window.
|
||||||
|
function ymd(d: Date): string {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dd}`;
|
||||||
|
}
|
||||||
|
const dayMs = 86_400_000;
|
||||||
|
const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs));
|
||||||
|
const afterBound = ymd(new Date(anchorDate.getTime() - dayMs));
|
||||||
|
const newerQ = `${baseQ} after:${afterBound}`.trim();
|
||||||
|
const olderQ = `${baseQ} before:${beforeBound}`.trim();
|
||||||
|
|
||||||
|
const [newerOldestFirst, older] = await Promise.all([
|
||||||
|
listPhotos({
|
||||||
|
q: newerQ,
|
||||||
|
count: afterCount,
|
||||||
|
order: 'oldest',
|
||||||
|
merged: p.merged ?? true
|
||||||
|
}),
|
||||||
|
listPhotos({
|
||||||
|
q: olderQ,
|
||||||
|
count: beforeCount,
|
||||||
|
order: 'newest',
|
||||||
|
merged: p.merged ?? true
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
// `newerOldestFirst` is oldest→newest; reverse so it reads newest-first
|
||||||
|
// to match the standard timeline order, then concat the older window.
|
||||||
|
// Dedupe by UID in case the anchor itself shows up in both halves.
|
||||||
|
const merged: PpPhoto[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const p of newerOldestFirst.slice().reverse()) {
|
||||||
|
if (!seen.has(p.UID)) {
|
||||||
|
merged.push(p);
|
||||||
|
seen.add(p.UID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of older) {
|
||||||
|
if (!seen.has(p.UID)) {
|
||||||
|
merged.push(p);
|
||||||
|
seen.add(p.UID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count photos matching a DSL query, scoped to whatever the caller's
|
* Count photos matching a DSL query, scoped to whatever the caller's
|
||||||
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
||||||
@@ -169,10 +258,17 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
|||||||
* the signed-in user actually sees, not the global library aggregate
|
* the signed-in user actually sees, not the global library aggregate
|
||||||
* exposed by `/config.count`.
|
* exposed by `/config.count`.
|
||||||
*/
|
*/
|
||||||
export async function countPhotos(q: string): Promise<number> {
|
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
|
||||||
const resp = await http.get('/photos', {
|
const merged = opts.merged ?? false;
|
||||||
params: { count: 10000, offset: 0, merged: false, q }
|
const resp = await http.get<PpPhoto[]>('/photos', {
|
||||||
|
params: { count: 10000, offset: 0, merged, q }
|
||||||
});
|
});
|
||||||
|
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
|
||||||
|
// `Files[]` entry), regardless of `merged`. With `merged: true` the
|
||||||
|
// response body is one entry per logical photo — so the body length
|
||||||
|
// is the canonical photo count when callers need to match what the
|
||||||
|
// timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`).
|
||||||
|
if (merged) return Array.isArray(resp.data) ? resp.data.length : 0;
|
||||||
const header = resp.headers['x-count'];
|
const header = resp.headers['x-count'];
|
||||||
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
|
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
|||||||
@@ -86,6 +86,59 @@ export function setTagFilter(
|
|||||||
filters.tagValue = value;
|
filters.tagValue = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot focus hand-off between an in-app navigation source (e.g. the
|
||||||
|
* RightSidebar's Folder open icon) and the timeline. We deliberately
|
||||||
|
* avoid encoding this in the URL — the store→URL effect on the
|
||||||
|
* timeline strips any param that `filtersToUrlParams` doesn't emit, so
|
||||||
|
* a `?focus=` param wouldn't survive the round-trip. A module-level
|
||||||
|
* stash that's consumed once on the next pageCount=1 landing is the
|
||||||
|
* simplest contract: not shareable, not replayed on refresh, but
|
||||||
|
* matches the "deep-link click" UX we want.
|
||||||
|
*
|
||||||
|
* `takenAt` (when known) lets the timeline anchor its first-page
|
||||||
|
* query around the target's date via PhotoPrism's `before:`/`after:`
|
||||||
|
* DSL — so deep-link focus works even for photos that aren't in the
|
||||||
|
* newest-120 page of the destination filter. Caller passes `null` if
|
||||||
|
* the date isn't readily available; the timeline can still attempt a
|
||||||
|
* page-1 match.
|
||||||
|
*/
|
||||||
|
export interface PendingFocus {
|
||||||
|
uid: string;
|
||||||
|
takenAt: string | null;
|
||||||
|
}
|
||||||
|
let pendingFocus: PendingFocus | null = null;
|
||||||
|
export function setPendingFocus(uid: string, takenAt: string | null = null): void {
|
||||||
|
pendingFocus = { uid, takenAt };
|
||||||
|
}
|
||||||
|
export function consumePendingFocus(): PendingFocus | null {
|
||||||
|
const v = pendingFocus;
|
||||||
|
pendingFocus = null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drill into a folder on the timeline. Mirrors the LeftSidebar tree's
|
||||||
|
* click handler: clear heap/section context so the folder filter
|
||||||
|
* applies on top of "all photos", then navigate. When `focusUid` is
|
||||||
|
* provided, the timeline's focus effect consumes the pending-focus
|
||||||
|
* stash on its first-page landing and pre-selects + scrolls to that
|
||||||
|
* photo instead of snapping to `photos[0]`. `focusTakenAt` enables
|
||||||
|
* the anchor-mode query so the photo can be found even when it would
|
||||||
|
* otherwise be past page 1.
|
||||||
|
*/
|
||||||
|
export async function navigateToFolder(
|
||||||
|
folderPath: string,
|
||||||
|
opts: { focusUid?: string; focusTakenAt?: string | null } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
setSection('all-photos');
|
||||||
|
setFolderPath(folderPath);
|
||||||
|
if (opts.focusUid) setPendingFocus(opts.focusUid, opts.focusTakenAt ?? null);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('folder', folderPath);
|
||||||
|
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to a tag-category browse URL. Path-segment shape
|
* Navigate to a tag-category browse URL. Path-segment shape
|
||||||
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
|
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
|
||||||
|
|||||||
@@ -13,15 +13,18 @@
|
|||||||
getPhoto,
|
getPhoto,
|
||||||
listHeaps,
|
listHeaps,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
|
listPhotosAround,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
} from "$lib/services/photoprism";
|
} from "$lib/services/photoprism";
|
||||||
import {
|
import {
|
||||||
|
consumePendingFocus,
|
||||||
filters,
|
filters,
|
||||||
filtersToQ,
|
filtersToQ,
|
||||||
filtersToUrlParams,
|
filtersToUrlParams,
|
||||||
parseUrlParams,
|
parseUrlParams,
|
||||||
setSearch,
|
setSearch,
|
||||||
setSection,
|
setSection,
|
||||||
|
type PendingFocus,
|
||||||
} 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";
|
||||||
@@ -137,24 +140,90 @@
|
|||||||
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
|
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
|
||||||
// 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;
|
||||||
|
|
||||||
|
// Anchor mode: when an in-app deep link stashes a pending focus with a
|
||||||
|
// TakenAt, the first page is fetched as a window around that date via
|
||||||
|
// PhotoPrism's `before:`/`after:` DSL — so the target photo is in the
|
||||||
|
// loaded page even when it would otherwise be hundreds of entries past
|
||||||
|
// the newest-first cursor. Subsequent pages continue chronologically
|
||||||
|
// with a `before:<oldest-loaded-TakenAt>` cursor instead of the
|
||||||
|
// standard offset, so the listing stays in newest-first order without
|
||||||
|
// jumping around the library. Cleared when the filter changes — a new
|
||||||
|
// filter is a fresh listing, possibly with its own anchor.
|
||||||
|
let anchor = $state<PendingFocus | null>(null);
|
||||||
|
let lastFilterQ: string | null = null;
|
||||||
|
// Watch every URL change so we catch pending-focus stashes even when the
|
||||||
|
// filter didn't change (e.g. user clicks the open-folder icon for a photo
|
||||||
|
// in the folder they're already on — the goto sets the same URL but the
|
||||||
|
// user still expects to land on THAT photo). Pure filter changes with
|
||||||
|
// no pending stash clear any stale anchor so a subsequent refetch
|
||||||
|
// doesn't keep the old window.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
void page.url.search;
|
||||||
|
untrack(() => {
|
||||||
|
const pending = consumePendingFocus();
|
||||||
|
if (pending) {
|
||||||
|
anchor = pending;
|
||||||
|
lastFilterQ = filtersToQ(filters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const q = filtersToQ(filters);
|
||||||
|
if (q !== lastFilterQ) {
|
||||||
|
anchor = null;
|
||||||
|
lastFilterQ = q;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
||||||
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
|
queryKey: [
|
||||||
queryFn: ({ pageParam }) =>
|
"photos",
|
||||||
listPhotos({
|
"q",
|
||||||
q: filtersToQ(filters),
|
filtersToQ(filters),
|
||||||
|
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
||||||
|
],
|
||||||
|
queryFn: ({ pageParam }) => {
|
||||||
|
const offset = pageParam as number;
|
||||||
|
const baseQ = filtersToQ(filters);
|
||||||
|
// Page 0 + anchor → load a window around the anchor's date.
|
||||||
|
// Subsequent pages aren't reachable in anchor mode (see
|
||||||
|
// getNextPageParam).
|
||||||
|
if (offset === 0 && anchor?.takenAt) {
|
||||||
|
return listPhotosAround({
|
||||||
|
q: baseQ,
|
||||||
|
takenAt: anchor.takenAt,
|
||||||
|
afterCount: 30,
|
||||||
|
beforeCount: 90,
|
||||||
|
merged: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return listPhotos({
|
||||||
|
q: baseQ,
|
||||||
count: PHOTOS_PAGE_SIZE,
|
count: PHOTOS_PAGE_SIZE,
|
||||||
offset: pageParam as number,
|
offset,
|
||||||
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
|
||||||
// photo expands into its file rows, so a "full" page of count=120
|
// photo expands into its file rows, so a "full" page of count=120
|
||||||
// typically returns ~60 photo entries. The only reliable end-of-
|
// typically returns ~60 photo entries. The only reliable end-of-
|
||||||
// pagination signal is an empty page. Costs one extra fetch at the
|
// pagination signal is an empty page. Costs one extra fetch at the
|
||||||
// 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,
|
if (last.length === 0) return undefined;
|
||||||
|
// Anchor mode terminates after page 0 — the user sees the 120-
|
||||||
|
// photo window around the deep-linked photo. PhotoPrism's
|
||||||
|
// `before:` cursor is day-precision, so paginating further
|
||||||
|
// chronologically risks dense-day infinite loops (same-day
|
||||||
|
// photos exceeding the page size keep the cursor at the same
|
||||||
|
// value). To "see more," the user clears the anchor by
|
||||||
|
// navigating fresh.
|
||||||
|
if (anchor?.takenAt) return undefined;
|
||||||
|
return pages.length * PHOTOS_PAGE_SIZE;
|
||||||
|
},
|
||||||
enabled: isAuthenticated(),
|
enabled: isAuthenticated(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -237,7 +306,22 @@
|
|||||||
// Only re-anchor focus on the very first page; later pages
|
// Only re-anchor focus on the very first page; later pages
|
||||||
// must not pull focus back to photo[0].
|
// must not pull focus back to photo[0].
|
||||||
if (pages !== 1) return;
|
if (pages !== 1) return;
|
||||||
setFocused(photos[0].UID);
|
// Anchor (from an in-app deep link) takes precedence — its UID is
|
||||||
|
// guaranteed in `photos` because page 0 was fetched as a window
|
||||||
|
// around its TakenAt. Plain navigations leave anchor null and we
|
||||||
|
// snap to photos[0] as before. `scrollToIndex` expands the
|
||||||
|
// windowed render set + scrolls the tile into view (with sticky-
|
||||||
|
// header peek) — same helper gridKeyNav uses for arrow nav.
|
||||||
|
const targetIdx =
|
||||||
|
anchor?.uid != null
|
||||||
|
? photos.findIndex((p) => p.UID === anchor!.uid)
|
||||||
|
: -1;
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
setFocused(photos[targetIdx].UID);
|
||||||
|
void scrollToIndex(targetIdx);
|
||||||
|
} else {
|
||||||
|
setFocused(photos[0].UID);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,37 @@
|
|||||||
markersOnScreen.clear();
|
markersOnScreen.clear();
|
||||||
markers.clear();
|
markers.clear();
|
||||||
|
|
||||||
|
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
|
||||||
|
// (+ optional `zoom`, `focus`) flies the map directly to the photo
|
||||||
|
// rather than fitting to the full library extent. Strip the params
|
||||||
|
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
|
||||||
|
// through to the default fitBounds when the params aren't present.
|
||||||
|
const sp = new URL(window.location.href).searchParams;
|
||||||
|
const latParam = Number(sp.get('lat'));
|
||||||
|
const lngParam = Number(sp.get('lng'));
|
||||||
|
if (
|
||||||
|
(data.features?.length ?? 0) > 0 &&
|
||||||
|
Number.isFinite(latParam) &&
|
||||||
|
Number.isFinite(lngParam) &&
|
||||||
|
sp.has('lat') &&
|
||||||
|
sp.has('lng')
|
||||||
|
) {
|
||||||
|
const zoom = Number(sp.get('zoom')) || 17;
|
||||||
|
map.jumpTo({ center: [lngParam, latParam], zoom });
|
||||||
|
const stripped = new URL(window.location.href);
|
||||||
|
stripped.searchParams.delete('lat');
|
||||||
|
stripped.searchParams.delete('lng');
|
||||||
|
stripped.searchParams.delete('zoom');
|
||||||
|
stripped.searchParams.delete('focus');
|
||||||
|
const qs = stripped.searchParams.toString();
|
||||||
|
void goto(`/map${qs ? `?${qs}` : ''}`, {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fit to data extent on the first non-empty load — prefer the
|
// Fit to data extent on the first non-empty load — prefer the
|
||||||
// server-provided bbox (PhotoPrism returns one), else compute from
|
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||||
// the features.
|
// the features.
|
||||||
|
|||||||
Reference in New Issue
Block a user