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';
|
||||
import {
|
||||
filters,
|
||||
navigateToFolder,
|
||||
setFolderPath,
|
||||
setSection,
|
||||
TAG_CATEGORIES,
|
||||
@@ -223,23 +224,40 @@
|
||||
}));
|
||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||
|
||||
// Root entry shows "the user's library" — for admins without a
|
||||
// BasePath that's still the whole library, served cheaply from
|
||||
// /api/v1/config's `count.all`. For any user with a non-empty
|
||||
// BasePath the precomputed total is wrong (it's library-wide), so we
|
||||
// ask the sidecar for a recursive count rooted at the user's
|
||||
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
|
||||
// resolves to the BasePath itself, and the sidecar fan-out recurses.
|
||||
// Root entry shows "the user's library" using the same filter the
|
||||
// timeline applies at folderPath=='/' — empty q, which PhotoPrism
|
||||
// resolves to the visible listing (no archived / hidden / review).
|
||||
// Earlier this used /config's `count.all`, but that aggregate
|
||||
// includes those buckets and didn't match what the user can actually
|
||||
// click "select all" on; the discrepancy was confusing
|
||||
// (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>>(() => ({
|
||||
queryKey: ['photos', 'root-count', userBasePath()],
|
||||
queryFn: () => listFolderCounts(['']),
|
||||
enabled: isAuthenticated() && userBasePath() !== '',
|
||||
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(
|
||||
userBasePath() === ''
|
||||
? isAdminUser
|
||||
? (configQuery.data?.count?.all ?? 0)
|
||||
? (visibleRootCountQuery.data ?? 0)
|
||||
: 0
|
||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||
);
|
||||
@@ -490,14 +508,7 @@
|
||||
}
|
||||
|
||||
async function pickFolder(folderPath: string) {
|
||||
// Folder selection works on top of the All Photos section; clearing
|
||||
// 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 });
|
||||
await navigateToFolder(folderPath);
|
||||
}
|
||||
|
||||
function onCreateHeap() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,6 +54,14 @@
|
||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 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) —
|
||||
// every other action is hidden so the choice can't be confused with
|
||||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||
@@ -248,13 +256,19 @@
|
||||
<div
|
||||
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
|
||||
{:else}
|
||||
Focused photo
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</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
|
||||
|
||||
Reference in New Issue
Block a user