fix(sidebar,map,duplicates): flatten sidebar hierarchy, filter by user basePath, fix map coordinates

- Flatten sidebar: remove collapsible Tags and Review sections, place all items at level-0
  Notes, tag categories (Labels/Keywords/People/Colors/Ratings) now appear directly in Views
  Review tabs (Causes/Stacks/Duplicates) and Hidden appear directly in Manage
- Filter duplicates by user base path to ensure multi-tenant isolation
  listDuplicateGroups now accepts optional basePath parameter
  update review page and sidebar to pass userBasePath() for proper per-user caching
- Filter map geo data by user base path using path: query filter
  map page now only shows geotagged photos from current user's library
- Fix map coordinate positioning: PhotoPrism /geo endpoint returns [lat,lng]
  but GeoJSON and MapLibre expect [lng,lat]. Transform coordinates and bbox
  on data receive to fix photo placement and zoom behavior

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:04:56 +02:00
parent e1e508671e
commit 400b215036
5 changed files with 80 additions and 143 deletions

View File

@@ -96,8 +96,8 @@
// sidebar only observes these — cross-folder is an O(disk) scan, so it // sidebar only observes these — cross-folder is an O(disk) scan, so it
// stays enabled:false and the duplicates page populates it on first visit. // stays enabled:false and the duplicates page populates it on first visit.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({ const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'], queryKey: ['duplicates', userBasePath()],
queryFn: listDuplicateGroups, queryFn: () => listDuplicateGroups(userBasePath()),
enabled: isAuthenticated(), enabled: isAuthenticated(),
staleTime: 60_000 staleTime: 60_000
})); }));
@@ -178,45 +178,13 @@
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0'); if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
} }
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded`
// above (keeping it out of `view.metadataSections`, which is reserved for
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar
// doesn't grow on first paint.
const TAGS_OPEN_KEY = 'mule_tags_expanded';
let tagsExpanded = $state(loadTagsExpanded());
function loadTagsExpanded(): boolean {
if (!browser) return false;
const raw = localStorage.getItem(TAGS_OPEN_KEY);
return raw === '1';
}
function toggleTags() {
tagsExpanded = !tagsExpanded;
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
}
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
// row in Manage can expose the same set of tabs the /review page shows
// (cause groups + duplicates panels). Defaults to collapsed.
const REVIEW_OPEN_KEY = 'mule_review_expanded';
let reviewExpanded = $state(loadReviewExpanded());
function loadReviewExpanded(): boolean {
if (!browser) return false;
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
}
function toggleReview() {
reviewExpanded = !reviewExpanded;
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
}
// Cause-tab list is dynamic (only buckets with hits show up on /review), // Cause-tab list is dynamic (only buckets with hits show up on /review),
// so the sidebar mirrors that by reusing the same query. Gated on // so the sidebar mirrors that by reusing the same query. The queryKey is
// `reviewExpanded` to avoid paying the /photos round-trip for users who // shared with the /review page so visiting that route warms the cache for free.
// never expand the section; the queryKey is shared with the /review page
// so visiting that route warms the cache for free.
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({ const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'], queryKey: ['review-groups'],
queryFn: listReviewGroups, queryFn: listReviewGroups,
enabled: isAuthenticated() && reviewExpanded, enabled: isAuthenticated(),
staleTime: 30_000 staleTime: 30_000
})); }));
@@ -732,65 +700,34 @@
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)} {#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)} {@render viewRow(v)}
{/each} {/each}
<!-- <!-- Notes -->
Tags expandable. Whole row is a toggle (chevron + label); there is {#if true}
no landing page at /tags — selecting a sub-category is the only way
into a real view. Counts intentionally live in the TagsBrowserSidebar
(secondary sidebar) so this row stays a pure navigator.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleTags}
title={tagsExpanded ? 'Collapse tags' : 'Expand tags'}
aria-expanded={tagsExpanded}
>
<span
class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
>
<ChevronRight
class="h-4 w-4 transition-transform duration-150 {tagsExpanded ? 'rotate-90' : ''}"
/>
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span>
</span>
</button>
{#if tagsExpanded}
<!--
Notes lives alongside the tag categories — same indent and row
chrome — but routes to /notes rather than /tags/*. Tucked at
the top of the expandable so it's the first thing the user
sees when opening Tags.
-->
{@const notesActive = isNotesActive()} {@const notesActive = isNotesActive()}
<a <a
href="/notes" href="/notes"
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent" class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={notesActive} class:bg-primary={notesActive}
class:text-primary-foreground={notesActive} class:text-primary-foreground={notesActive}
class:hover:bg-primary={notesActive} class:hover:bg-primary={notesActive}
style="padding-left: 36px;"
> >
<span class="truncate">Notes</span> <span class="truncate">Notes</span>
</a> </a>
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: 36px;"
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
</a>
{/each}
{/if} {/if}
<!-- Tag categories -->
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
</a>
{/each}
</div> </div>
<!-- Manage — curation flows that decide a photo's fate. Same <!-- Manage — curation flows that decide a photo's fate. Same
@@ -802,59 +739,28 @@
Manage Manage
</span> </span>
</div> </div>
<!-- <!-- Review tabs -->
Review expandable. Mirrors the Tags affordance — pure toggle {#each reviewTabs as t (t.id)}
with no landing page; the only way into a tab is to expand and {@const active = isReviewTabActive(t.id)}
pick a subitem. Cause buckets are dynamic (only buckets with <a
hits show up); Stacks/Cross-folder are always present. href={`/review?tab=${t.id}`}
--> class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
<button class:bg-primary={active}
type="button" class:text-primary-foreground={active}
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent" class:hover:bg-primary={active}
style="padding-left: 4px;"
onclick={toggleReview}
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
aria-expanded={reviewExpanded}
>
<span
class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
> >
<ChevronRight <span class="truncate">{t.label}</span>
class="h-4 w-4 transition-transform duration-150 {reviewExpanded ? 'rotate-90' : ''}" </a>
/> {/each}
</span> <!-- Hidden -->
<span class="flex min-w-0 flex-1 items-center pl-1"> {#if true}
<span class="truncate">Review</span>
</span>
</button>
{#if reviewExpanded}
{#each reviewTabs as t (t.id)}
{@const active = isReviewTabActive(t.id)}
<a
href={`/review?tab=${t.id}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: 36px;"
>
<span class="truncate">{t.label}</span>
</a>
{/each}
<!--
Hidden lives under Review since it's the resting place for
photos dismissed during review. Section-nav (not a ?tab=),
so it's a button that flips filters.section like the flat
Manage entries — just with the subitem indent.
-->
{@const hiddenActive = isActive('hidden')} {@const hiddenActive = isActive('hidden')}
<button <button
type="button" type="button"
class="flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent" class="flex h-[22px] w-full items-center rounded pl-6 pr-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={hiddenActive} class:bg-primary={hiddenActive}
class:text-primary-foreground={hiddenActive} class:text-primary-foreground={hiddenActive}
class:hover:bg-primary={hiddenActive} class:hover:bg-primary={hiddenActive}
style="padding-left: 36px;"
onclick={() => navigateTo('hidden')} onclick={() => navigateTo('hidden')}
> >
<span class="truncate">Hidden</span> <span class="truncate">Hidden</span>

View File

@@ -24,7 +24,7 @@ export interface DuplicateGroup {
bestFileUid: string; bestFileUid: string;
} }
export async function listDuplicateGroups(): Promise<DuplicateGroup[]> { export async function listDuplicateGroups(basePath?: string): Promise<DuplicateGroup[]> {
const photos = await listPhotos({ const photos = await listPhotos({
q: 'stack:true', q: 'stack:true',
count: 200, count: 200,
@@ -32,7 +32,16 @@ export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
order: 'newest' order: 'newest'
}); });
return photos return photos
.filter((p) => (p.Files?.length ?? 0) > 1) .filter((p) => {
// Filter out photos not in the current user's base path
if (basePath) {
const photoPath = p.Path ?? '';
if (!photoPath.startsWith(basePath)) {
return false;
}
}
return (p.Files?.length ?? 0) > 1;
})
.map((p) => { .map((p) => {
const files = p.Files ?? []; const files = p.Files ?? [];
const primary = files.find((f) => f.Primary) ?? files[0]; const primary = files.find((f) => f.Primary) ?? files[0];

View File

@@ -589,6 +589,24 @@ export async function listGeo(q = ''): Promise<PpGeoCollection> {
const { data } = await http.get<PpGeoCollection>('/geo', { const { data } = await http.get<PpGeoCollection>('/geo', {
params: { count: 50000, q: q || undefined } params: { count: 50000, q: q || undefined }
}); });
// PhotoPrism's geo endpoint returns coordinates as [lat, lng] but GeoJSON
// (and MapLibre) expect [lng, lat]. Swap the coordinates in each feature.
if (data.features) {
for (const feature of data.features) {
if (feature.geometry.type === 'Point' && Array.isArray(feature.geometry.coordinates)) {
const [lat, lng] = feature.geometry.coordinates;
feature.geometry.coordinates = [lng, lat];
}
}
}
// Also swap bbox if present: [minLat, minLng, maxLat, maxLng] → [minLng, minLat, maxLng, maxLat]
if (data.bbox && data.bbox.length === 4) {
const [minLat, minLng, maxLat, maxLng] = data.bbox;
data.bbox = [minLng, minLat, maxLng, maxLat];
}
return data; return data;
} }

View File

@@ -9,15 +9,19 @@
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism'; import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte'; import { isAuthenticated, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte'; import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import Toolbar from '$lib/components/layout/Toolbar.svelte';
const geoQuery = createQuery<PpGeoCollection>(() => ({ const geoQuery = createQuery<PpGeoCollection>(() => {
queryKey: ['geo'], const bp = userBasePath();
queryFn: () => listGeo(), const q = bp ? `path:${bp}*` : '';
enabled: isAuthenticated() return {
})); queryKey: ['geo', bp],
queryFn: () => listGeo(q),
enabled: isAuthenticated()
};
});
let mapEl: HTMLDivElement | undefined = $state(); let mapEl: HTMLDivElement | undefined = $state();
let map: maplibregl.Map | undefined; let map: maplibregl.Map | undefined;

View File

@@ -30,7 +30,7 @@
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
type CrossFolderScanResult type CrossFolderScanResult
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated, userBasePath } from '$lib/stores/session.svelte';
import { clearSelection, selection } from '$lib/stores/selection.svelte'; import { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte'; import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
import { import {
@@ -85,8 +85,8 @@
// observes its cache (enabled:false) and DuplicatesView is what // observes its cache (enabled:false) and DuplicatesView is what
// triggers the actual scan when its tab is active. // triggers the actual scan when its tab is active.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({ const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'], queryKey: ['duplicates', userBasePath()],
queryFn: listDuplicateGroups, queryFn: () => listDuplicateGroups(userBasePath()),
enabled: isAuthenticated(), enabled: isAuthenticated(),
staleTime: 30_000 staleTime: 30_000
})); }));