feat(web): sidebar root folder + folder counts, drill-in colors/tags/ratings, click semantics rework
Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -10,14 +11,21 @@
|
||||
deleteFolder,
|
||||
deleteHeap,
|
||||
duplicateHeap,
|
||||
getAllMarks,
|
||||
getConfig,
|
||||
getImportInfo,
|
||||
heapDownloadUrl,
|
||||
listFolderCounts,
|
||||
listFolders,
|
||||
listHeaps,
|
||||
logout,
|
||||
renameFolder,
|
||||
renameHeap,
|
||||
triggerDownload,
|
||||
type ImportInfo,
|
||||
type PhotoMarksMap,
|
||||
type PpAlbum,
|
||||
type PpClientConfig,
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import {
|
||||
@@ -36,6 +44,7 @@
|
||||
Copy,
|
||||
Download,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
LogOut,
|
||||
Moon,
|
||||
Pencil,
|
||||
@@ -58,10 +67,90 @@
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
|
||||
// 60s — the inbox only changes when files are uploaded or the indexer
|
||||
// runs, neither of which happens often enough to justify a tighter cadence.
|
||||
const importQuery = createQuery<ImportInfo>(() => ({
|
||||
queryKey: ['import'],
|
||||
queryFn: getImportInfo,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||
// precomputed counter for every common bucket (all/favorites/archived/
|
||||
// labels/places/…) updated incrementally on every mutation. Cheap to
|
||||
// refetch, and gives us a stable total — `/photos` only returns
|
||||
// per-page row counts via `X-Count`, never a total.
|
||||
//
|
||||
// The key sits under the `['photos', …]` prefix so it inherits the
|
||||
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
|
||||
// across mutations (favorite, archive, restore, delete, heap add) — the
|
||||
// counter map refreshes whenever the photo list does. Marks-derived
|
||||
// counts (ratings/colors) react through the shared `['marks']` cache.
|
||||
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||
queryKey: ['photos', 'config'],
|
||||
queryFn: getConfig,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
const ratingsCount = $derived(countRatings(marksQuery.data));
|
||||
const colorsCount = $derived(countColors(marksQuery.data));
|
||||
|
||||
function countRatings(marks: PhotoMarksMap | undefined): number {
|
||||
if (!marks) return 0;
|
||||
let n = 0;
|
||||
for (const m of Object.values(marks)) {
|
||||
if ((m.rating ?? 0) > 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function countColors(marks: PhotoMarksMap | undefined): number {
|
||||
if (!marks) return 0;
|
||||
let n = 0;
|
||||
for (const m of Object.values(marks)) {
|
||||
if (m.color) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const folderTree = $derived(
|
||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||
);
|
||||
|
||||
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
|
||||
// in parallel. Key the query off the folder-path list so it refetches
|
||||
// when folders are added/renamed/deleted, and share the ['photos', …]
|
||||
// prefix so it invalidates alongside the other photo caches whenever a
|
||||
// mutation lands.
|
||||
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||
queryFn: () => listFolderCounts(folderPaths),
|
||||
enabled: isAuthenticated() && folderPaths.length > 0,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||
|
||||
// Root count = total photos minus the sum of every subfolder count.
|
||||
// `config.count.all` is PhotoPrism's authoritative library total
|
||||
// (kept in sync server-side); subtracting non-root photos gives an
|
||||
// exact root-only count without a separate API trip.
|
||||
const rootCount = $derived.by(() => {
|
||||
const total = configQuery.data?.count?.all ?? 0;
|
||||
const sub = Object.values(folderCounts).reduce((a, b) => a + b, 0);
|
||||
return Math.max(0, total - sub);
|
||||
});
|
||||
|
||||
const createMut = createMutation(() => ({
|
||||
mutationFn: (title: string) => createHeap(title),
|
||||
onSuccess: (h) => {
|
||||
@@ -109,6 +198,24 @@
|
||||
// admin dialog above — opened from the bottom-of-sidebar footer.
|
||||
let generalSettingsOpen = $state(false);
|
||||
|
||||
// Root-folder collapse state. Persisted to its own localStorage key so
|
||||
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
|
||||
// to open so first-time users see the full tree.
|
||||
const ROOT_OPEN_KEY = 'mule_root_expanded';
|
||||
let rootExpanded = $state(loadRootExpanded());
|
||||
function loadRootExpanded(): boolean {
|
||||
if (!browser) return true;
|
||||
const raw = localStorage.getItem(ROOT_OPEN_KEY);
|
||||
return raw === null ? true : raw === '1';
|
||||
}
|
||||
function toggleRoot() {
|
||||
rootExpanded = !rootExpanded;
|
||||
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
const rootActive = $derived(filters.folderPath === '/');
|
||||
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
||||
|
||||
async function onSignOut() {
|
||||
await logout();
|
||||
await goto('/login', { replaceState: true });
|
||||
@@ -221,24 +328,40 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// Single Views group — section-driven entries and route-driven entries
|
||||
// mixed in display order. `kind` discriminates which click handler runs
|
||||
// (sections go through `navigateTo` to seed filter state; routes are
|
||||
// plain links). Archive intentionally sits at the bottom to keep it out
|
||||
// of the way of the everyday-browse rows.
|
||||
// Two groups: Views (everyday browse) and Manage (curation flows that
|
||||
// decide a photo's fate — review, dedup, unhide, delete). `kind`
|
||||
// discriminates which click handler runs (sections go through
|
||||
// `navigateTo` to seed filter state; routes are plain links).
|
||||
//
|
||||
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||
// derived value on every render — the arrays themselves are constant.
|
||||
// `count.all` already excludes archived/review/hidden (PhotoPrism's
|
||||
// "everything visible in the main timeline" tally), so it matches what
|
||||
// the All photos view actually renders. `places` is the count of
|
||||
// geocoded locations — semantically what the Map view groups by.
|
||||
// Duplicates has no precomputed counter; we omit its badge.
|
||||
type ViewItem =
|
||||
| { kind: 'section'; id: Section; label: string }
|
||||
| { kind: 'route'; href: string; label: string };
|
||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||
|
||||
// "All photos" is not in this list: the root-folder row at the top
|
||||
// of the sidebar is the canonical entry into the library, so a
|
||||
// separate "everything regardless of folder" destination would just
|
||||
// duplicate it for users whose photos live under the root.
|
||||
const views: ViewItem[] = [
|
||||
{ kind: 'section', id: 'all-photos', label: 'All photos' },
|
||||
{ kind: 'section', id: 'favorites', label: 'Favorites' },
|
||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
|
||||
{ kind: 'route', href: '/map', label: 'Map' },
|
||||
{ kind: 'route', href: '/ratings', label: 'Ratings' },
|
||||
{ kind: 'route', href: '/colors', label: 'Colors' },
|
||||
{ kind: 'route', href: '/tags', label: 'Tags' },
|
||||
{ kind: 'section', id: 'archive', label: 'Archive' }
|
||||
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
|
||||
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
|
||||
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
|
||||
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
|
||||
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
|
||||
];
|
||||
|
||||
const manageViews: ViewItem[] = [
|
||||
{ kind: 'section', id: 'review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
|
||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
|
||||
];
|
||||
|
||||
function isRouteActive(href: string): boolean {
|
||||
@@ -246,10 +369,176 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet viewRow(v: ViewItem)}
|
||||
{@const active = v.kind === 'section' ? isActive(v.id) : isRouteActive(v.href)}
|
||||
{@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:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => navigateTo(v.id)}
|
||||
>
|
||||
<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
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href={v.href}
|
||||
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
>
|
||||
<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
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
<!-- Views — section-driven entries + route-driven entries under a
|
||||
single uppercase eyebrow. Compact rows, no icons. -->
|
||||
<!-- Folders — top of the sidebar because the root folder is the
|
||||
default landing view (see filters store init), making it the
|
||||
primary navigation surface. Root-folder row + subfolder tree;
|
||||
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">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Library
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => (settingsOpen = true)}
|
||||
title="Library settings"
|
||||
aria-label="Library settings"
|
||||
>
|
||||
<Settings class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => onCreateFolder(null)}
|
||||
title="New top-level folder"
|
||||
aria-label="New top-level folder"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<!--
|
||||
Root-folder entry. Mirrors a subfolder row's hover/active state
|
||||
via the `/` sentinel; clicking the label filters the timeline to
|
||||
photos whose Path is empty (handled by applyFolderScope in
|
||||
+page.svelte). The chevron collapses/expands the subfolder tree
|
||||
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:bg-primary={rootActive}
|
||||
class:text-primary-foreground={rootActive}
|
||||
class:hover:bg-primary={rootActive}
|
||||
style="padding-left: 8px;"
|
||||
>
|
||||
{#if hasSubfolders}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
||||
class:text-muted-foreground={!rootActive}
|
||||
onclick={toggleRoot}
|
||||
title={rootExpanded ? 'Collapse' : 'Expand'}
|
||||
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
|
||||
>
|
||||
{rootExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center truncate text-left"
|
||||
class:px-1={hasSubfolders}
|
||||
onclick={() => pickFolder('/')}
|
||||
title="Photos directly under originals/"
|
||||
>
|
||||
<span class="truncate">/</span>
|
||||
</button>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{rootCount >= 1000 ? '1000+' : rootCount}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||
can't be renamed or deleted, so those entries are omitted
|
||||
entirely rather than greyed out. Hidden until row hover (or
|
||||
menu open) so the count holds the right edge by default. -->
|
||||
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Root folder actions">
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||
onSelect={() => onCreateFolder(null)}
|
||||
>
|
||||
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
New subfolder
|
||||
</Item>
|
||||
</KebabMenu>
|
||||
</div>
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||||
{:else if !hasSubfolders}
|
||||
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
||||
{:else if rootExpanded}
|
||||
<!--
|
||||
depth=1 visually nests the top-level subfolders one indent
|
||||
step under the root row above. Labels at depth=1 line up
|
||||
12px right of the root label, matching the same per-level
|
||||
step used for deeper folders.
|
||||
-->
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
depth={1}
|
||||
onPick={pickFolder}
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
counts={folderCounts}
|
||||
/>
|
||||
{/if}
|
||||
{#if filters.folderPath && filters.folderPath !== '/'}
|
||||
<button
|
||||
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={() => {
|
||||
setFolderPath(null);
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}}
|
||||
title="Clear folder filter"
|
||||
>
|
||||
<span class="truncate">✕ {filters.folderPath}</span>
|
||||
</button>
|
||||
{/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">
|
||||
@@ -257,27 +546,21 @@
|
||||
</span>
|
||||
</div>
|
||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{#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:bg-primary={isActive(v.id)}
|
||||
class:text-primary-foreground={isActive(v.id)}
|
||||
class:hover:bg-primary={isActive(v.id)}
|
||||
onclick={() => navigateTo(v.id)}
|
||||
>
|
||||
<span class="truncate">{v.label}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href={v.href}
|
||||
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={isRouteActive(v.href)}
|
||||
class:text-primary-foreground={isRouteActive(v.href)}
|
||||
class:hover:bg-primary={isRouteActive(v.href)}
|
||||
>
|
||||
<span class="truncate">{v.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{@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>
|
||||
|
||||
@@ -306,28 +589,36 @@
|
||||
<ul>
|
||||
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||
{@const active = isActive('heap', heap.UID)}
|
||||
<!--
|
||||
Count + kebab share the right edge: count is the
|
||||
resting state, kebab swaps in on hover (or while the
|
||||
menu is open). Moving the count out of the inner
|
||||
button is what lets it reach the row's right edge
|
||||
the way Views rows do — and the inner button still
|
||||
owns the navigate-on-click area.
|
||||
-->
|
||||
<li
|
||||
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
||||
class="group flex h-[24px] 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 flex-1 items-center gap-2 px-2 text-left"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 truncate px-2 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
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{heap.PhotoCount ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
<div class="mr-1">
|
||||
<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
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{heap.PhotoCount ?? 0}
|
||||
</span>
|
||||
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Heap actions">
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||
@@ -373,54 +664,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Folders
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => (settingsOpen = true)}
|
||||
title="Library settings"
|
||||
aria-label="Library settings"
|
||||
>
|
||||
<Settings class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => onCreateFolder(null)}
|
||||
title="New top-level folder"
|
||||
aria-label="New top-level folder"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||||
{:else if (foldersQuery.data ?? []).length === 0}
|
||||
<p class="px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
||||
{:else}
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
onPick={pickFolder}
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
/>
|
||||
{/if}
|
||||
{#if filters.folderPath}
|
||||
<button
|
||||
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={() => {
|
||||
setFolderPath(null);
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}}
|
||||
title="Clear folder filter"
|
||||
>
|
||||
<span class="truncate">✕ {filters.folderPath}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!--
|
||||
@@ -429,7 +672,7 @@
|
||||
sign-out) that used to live in the top toolbar.
|
||||
-->
|
||||
<footer
|
||||
class="flex shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3 py-2"
|
||||
class="flex h-9 shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
|
||||
|
||||
Reference in New Issue
Block a user