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:
2026-05-20 15:04:23 +02:00
parent c134afe023
commit 29f7ad7073
7 changed files with 363 additions and 55 deletions

View File

@@ -6,18 +6,20 @@
PUT (Details fields need the full body).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
Aperture,
ArrowUpRight,
Calendar,
ExternalLink,
File,
Folder,
HardDrive,
ImageIcon,
Loader2,
Map as MapIcon,
MapPin,
Star,
Tag,
@@ -39,6 +41,7 @@
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { navigateToFolder } from '$lib/stores/filters.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
@@ -282,12 +285,6 @@
? 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 {
if (!c) return '';
const make = c.Make ?? '';
@@ -377,16 +374,31 @@
</div>
{/if}
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
padding on filename / date so the read-only text starts at the
same x-offset as the editable rows above — otherwise spans
hug the icon while inputs sit 4px in. Root-level files render
as `/` so the row never disappears and the layout stays stable. -->
<!-- Folder (read-only label + open-in-timeline icon). The `px-1 py-0.5`
mirrors the input padding on filename / date so the read-only
text starts at the same x-offset as the editable rows above —
otherwise spans hug the icon while inputs sit 4px in. Root-level
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">
<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}>
{folderLabel}
</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>
<!-- Dimensions -->
@@ -405,22 +417,29 @@
</span>
</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">
<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">
{placeLabel || 'No location'}
</span>
{#if mapsHref}
<a
href={mapsHref}
target="_blank"
rel="noopener"
{#if photo.Lat && photo.Lng}
<button
type="button"
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" />
</a>
<MapIcon class="h-3 w-3" />
</button>
{/if}
</div>
</dl>