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:
@@ -96,8 +96,8 @@
|
||||
// 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.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
queryKey: ['duplicates', userBasePath()],
|
||||
queryFn: () => listDuplicateGroups(userBasePath()),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
@@ -178,45 +178,13 @@
|
||||
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),
|
||||
// so the sidebar mirrors that by reusing the same query. Gated on
|
||||
// `reviewExpanded` to avoid paying the /photos round-trip for users who
|
||||
// never expand the section; the queryKey is shared with the /review page
|
||||
// so visiting that route warms the cache for free.
|
||||
// so the sidebar mirrors that by reusing the same query. The queryKey is
|
||||
// shared with the /review page so visiting that route warms the cache for free.
|
||||
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
|
||||
queryKey: ['review-groups'],
|
||||
queryFn: listReviewGroups,
|
||||
enabled: isAuthenticated() && reviewExpanded,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
@@ -732,65 +700,34 @@
|
||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
<!--
|
||||
Tags expandable. Whole row is a toggle (chevron + label); there is
|
||||
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.
|
||||
-->
|
||||
<!-- Notes -->
|
||||
{#if true}
|
||||
{@const notesActive = isNotesActive()}
|
||||
<a
|
||||
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:text-primary-foreground={notesActive}
|
||||
class:hover:bg-primary={notesActive}
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<span class="truncate">Notes</span>
|
||||
</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}
|
||||
<!-- 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>
|
||||
|
||||
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||
@@ -802,59 +739,28 @@
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
Review expandable. Mirrors the Tags affordance — pure toggle
|
||||
with no landing page; the only way into a tab is to expand and
|
||||
pick a subitem. Cause buckets are dynamic (only buckets with
|
||||
hits show up); Stacks/Cross-folder are always present.
|
||||
-->
|
||||
<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={toggleReview}
|
||||
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
|
||||
aria-expanded={reviewExpanded}
|
||||
>
|
||||
<span
|
||||
class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
|
||||
<!-- Review tabs -->
|
||||
{#each reviewTabs as t (t.id)}
|
||||
{@const active = isReviewTabActive(t.id)}
|
||||
<a
|
||||
href={`/review?tab=${t.id}`}
|
||||
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}
|
||||
>
|
||||
<ChevronRight
|
||||
class="h-4 w-4 transition-transform duration-150 {reviewExpanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<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.
|
||||
-->
|
||||
<span class="truncate">{t.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<!-- Hidden -->
|
||||
{#if true}
|
||||
{@const hiddenActive = isActive('hidden')}
|
||||
<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:text-primary-foreground={hiddenActive}
|
||||
class:hover:bg-primary={hiddenActive}
|
||||
style="padding-left: 36px;"
|
||||
onclick={() => navigateTo('hidden')}
|
||||
>
|
||||
<span class="truncate">Hidden</span>
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface DuplicateGroup {
|
||||
bestFileUid: string;
|
||||
}
|
||||
|
||||
export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
|
||||
export async function listDuplicateGroups(basePath?: string): Promise<DuplicateGroup[]> {
|
||||
const photos = await listPhotos({
|
||||
q: 'stack:true',
|
||||
count: 200,
|
||||
@@ -32,7 +32,16 @@ export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
|
||||
order: 'newest'
|
||||
});
|
||||
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) => {
|
||||
const files = p.Files ?? [];
|
||||
const primary = files.find((f) => f.Primary) ?? files[0];
|
||||
|
||||
@@ -589,6 +589,24 @@ export async function listGeo(q = ''): Promise<PpGeoCollection> {
|
||||
const { data } = await http.get<PpGeoCollection>('/geo', {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { goto } from '$app/navigation';
|
||||
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 Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||||
queryKey: ['geo'],
|
||||
queryFn: () => listGeo(),
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
const geoQuery = createQuery<PpGeoCollection>(() => {
|
||||
const bp = userBasePath();
|
||||
const q = bp ? `path:${bp}*` : '';
|
||||
return {
|
||||
queryKey: ['geo', bp],
|
||||
queryFn: () => listGeo(q),
|
||||
enabled: isAuthenticated()
|
||||
};
|
||||
});
|
||||
|
||||
let mapEl: HTMLDivElement | undefined = $state();
|
||||
let map: maplibregl.Map | undefined;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
scanCrossFolderDuplicates,
|
||||
type CrossFolderScanResult
|
||||
} 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 { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
||||
import {
|
||||
@@ -85,8 +85,8 @@
|
||||
// observes its cache (enabled:false) and DuplicatesView is what
|
||||
// triggers the actual scan when its tab is active.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
queryKey: ['duplicates', userBasePath()],
|
||||
queryFn: () => listDuplicateGroups(userBasePath()),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user