preview: full-screen modal replaces inline split + tags route reorg
The old SplitGrid + InlinePreview pane is replaced by a full-screen PreviewModal mounted once at the layout root. Open via Space on the focused tile or double-click; close on Esc (or X / Space again). Inside, PreviewPane renders the focused photo, RightSidebar carries the metadata, BulkActionBar reuses the existing per-photo actions, and PreviewCarousel windows ±50 thumbs around the focused index. Selection contract matches the grid: plain click reduces, shift extends the range, ⌘/Ctrl toggles, plain arrow drops the multi- selection, shift-arrow extends. New clearBulkToFirst() helper makes Esc / Clear collapse a bulk back to single-focus on its first member before the next press fully dismisses (modal closes, grid clears focus). Tags route reorganised into /tags/[category]/[[value]] with its own +layout and TagsBrowserSidebar; the old monolithic /tags/+page is trimmed to a legacy redirect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,9 @@
|
||||
filters,
|
||||
setFolderPath,
|
||||
setSection,
|
||||
type Section
|
||||
TAG_CATEGORIES,
|
||||
type Section,
|
||||
type TagCategory
|
||||
} from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
@@ -361,6 +363,59 @@
|
||||
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');
|
||||
}
|
||||
|
||||
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||
labels: 'Labels',
|
||||
keywords: 'Keywords',
|
||||
colors: 'Colors',
|
||||
ratings: 'Ratings'
|
||||
};
|
||||
|
||||
function tagCategoryCount(cat: TagCategory): number | undefined {
|
||||
// Labels reads PhotoPrism's pre-computed distinct-label counter
|
||||
// (`/api/v1/config` → count.labels), not the photo-count from
|
||||
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
|
||||
// whose indexer hadn't surfaced labelled photos yet, leaving the
|
||||
// badge silently empty; the precomputed counter is always present
|
||||
// and reads as "how many labels you can pick from", matching the
|
||||
// Keywords sub-row's distinct-count semantics.
|
||||
if (cat === 'labels') return configQuery.data?.count?.labels;
|
||||
if (cat === 'keywords') return keywordsQuery.data?.length;
|
||||
if (cat === 'ratings') return ratingsCount;
|
||||
return colorsCount;
|
||||
}
|
||||
|
||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||
return page.url.pathname.startsWith(`/tags/${cat}`);
|
||||
}
|
||||
|
||||
// Hover-prefetch for the expensive keywords aggregation. Same idea as
|
||||
// the cross-folder duplicates pattern: the LeftSidebar's badge query is
|
||||
// `enabled: false`, but we eagerly populate the cache on intent so the
|
||||
// click into /tags/keywords lands on warm data.
|
||||
function prefetchKeywords(): void {
|
||||
void qc.prefetchQuery({
|
||||
queryKey: ['photos', 'keywords'],
|
||||
queryFn: aggregateKeywords,
|
||||
staleTime: 5 * 60_000
|
||||
});
|
||||
}
|
||||
|
||||
const rootActive = $derived(filters.folderPath === '/');
|
||||
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
||||
|
||||
@@ -510,24 +565,24 @@
|
||||
// Map's `geoQuery` already returns the GeoJSON the user is
|
||||
// permitted to see (PhotoPrism's /geo applies the session ACL),
|
||||
// so the badge is per-user-correct without extra scoping.
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length },
|
||||
// Tags rolls up labels + keywords + ratings + colors. Labels
|
||||
// flows through countPhotos (scoped); keywords/ratings/colors are
|
||||
// from library-wide marks tables and only contribute when we're
|
||||
// in admin-without-BasePath mode (their sources don't scope).
|
||||
{
|
||||
kind: 'route',
|
||||
href: '/tags',
|
||||
label: 'Tags',
|
||||
getCount: () => {
|
||||
if (labelsBadge === undefined) return undefined;
|
||||
if (wantScoped) return labelsBadge;
|
||||
const keywords = keywordsQuery.data?.length ?? 0;
|
||||
return labelsBadge + keywords + ratingsCount + colorsCount;
|
||||
}
|
||||
}
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
|
||||
// Tags is rendered as a bespoke expandable block below the
|
||||
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
||||
// Ratings) and a chevron, neither of which fits the flat
|
||||
// section/route ViewItem shape.
|
||||
];
|
||||
|
||||
// Total badge for the "Tags" header row. Rolls up labels + keywords +
|
||||
// ratings + colors. Labels flows through countPhotos (scoped); keywords/
|
||||
// ratings/colors are library-wide marks tables and only contribute when
|
||||
// we're in admin-without-BasePath mode (their sources don't scope).
|
||||
const tagsTotal = $derived.by<number | undefined>(() => {
|
||||
if (labelsBadge === undefined) return undefined;
|
||||
if (wantScoped) return labelsBadge;
|
||||
const keywords = keywordsQuery.data?.length ?? 0;
|
||||
return labelsBadge + keywords + ratingsCount + colorsCount;
|
||||
});
|
||||
|
||||
const manageViews: ViewItem[] = [
|
||||
{
|
||||
kind: 'route',
|
||||
@@ -555,7 +610,7 @@
|
||||
{@const count = v.getCount()}
|
||||
{#if v.kind === 'section'}
|
||||
<button
|
||||
class="flex h-[24px] w-full items-center rounded px-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={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
@@ -564,7 +619,7 @@
|
||||
<span class="truncate">{v.label}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -575,7 +630,7 @@
|
||||
{:else}
|
||||
<a
|
||||
href={v.href}
|
||||
class="flex h-[24px] items-center rounded px-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={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
@@ -583,7 +638,7 @@
|
||||
<span class="truncate">{v.label}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -603,7 +658,7 @@
|
||||
slightly faded even at scroll-bottom.
|
||||
-->
|
||||
<nav
|
||||
class="flex-1 space-y-3 overflow-y-auto p-3"
|
||||
class="flex-1 space-y-2 overflow-y-auto p-2"
|
||||
style="mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);"
|
||||
>
|
||||
<!-- Folders — top of the sidebar because the root folder is the
|
||||
@@ -612,7 +667,7 @@
|
||||
hover-revealed actions on the header for library settings and
|
||||
new-top-level-folder. -->
|
||||
<div>
|
||||
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
|
||||
<div class="group/header flex items-center gap-0.5 px-2 pb-0.5">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Library
|
||||
</span>
|
||||
@@ -641,11 +696,11 @@
|
||||
below — same affordance as nested folder rows.
|
||||
-->
|
||||
<div
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={rootActive}
|
||||
class:text-primary-foreground={rootActive}
|
||||
class:hover:bg-primary={rootActive}
|
||||
style="padding-left: 8px;"
|
||||
style="padding-left: 4px;"
|
||||
>
|
||||
{#if hasSubfolders}
|
||||
<button
|
||||
@@ -658,6 +713,10 @@
|
||||
>
|
||||
{rootExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer keeps chevronless rows aligned with their chevroned
|
||||
peers, so labels share a common left edge across the sidebar. -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<!--
|
||||
Count badge lives INSIDE the button so the entire row (label
|
||||
@@ -666,15 +725,14 @@
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center text-left"
|
||||
class:px-1={hasSubfolders}
|
||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||
onclick={() => pickFolder('/')}
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
>
|
||||
<span class="truncate">{rootLabel}</span>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -721,35 +779,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Views — everyday browse entries (section + route mixed) under
|
||||
a single uppercase eyebrow. Compact rows, no icons. -->
|
||||
<div>
|
||||
<div class="px-3 pb-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Views
|
||||
</span>
|
||||
</div>
|
||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||
row shape as Views; grouped separately so the binary-decision
|
||||
destinations (Review/Archive) don't crowd the browse list. -->
|
||||
<div>
|
||||
<div class="px-3 pb-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="group/header flex items-center px-3 pb-1">
|
||||
<div class="group/header flex items-center px-2 pb-0.5">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Heaps
|
||||
</span>
|
||||
@@ -781,20 +812,20 @@
|
||||
open), pushing the button slightly left.
|
||||
-->
|
||||
<li
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class="group 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}
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center gap-2 px-2 text-left"
|
||||
class="flex min-w-0 flex-1 items-center pl-6 text-left"
|
||||
onclick={() => navigateTo('heap', heap.UID)}
|
||||
ondblclick={() => onRenameHeap(heap)}
|
||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||
>
|
||||
<span class="truncate">{heap.Title}</span>
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
@@ -847,6 +878,89 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Views — everyday browse entries (section + route mixed) under
|
||||
a single uppercase eyebrow. Compact rows, no icons. -->
|
||||
<div>
|
||||
<div class="px-2 pb-0.5">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Views
|
||||
</span>
|
||||
</div>
|
||||
{#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 + badge);
|
||||
there is no landing page at /tags — selecting a sub-category is the
|
||||
only way into a real view.
|
||||
-->
|
||||
<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-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||
>
|
||||
{tagsExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Tags</span>
|
||||
{#if tagsTotal !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{tagsTotal}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if tagsExpanded}
|
||||
{#each TAG_CATEGORIES as cat (cat)}
|
||||
{@const active = isTagCategoryActive(cat)}
|
||||
{@const count = tagCategoryCount(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>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||
row shape as Views; grouped separately so the binary-decision
|
||||
destinations (Review/Archive) don't crowd the browse list. -->
|
||||
<div>
|
||||
<div class="px-2 pb-0.5">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user