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
|
// 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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user