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:
@@ -29,7 +29,7 @@
|
||||
setFocused,
|
||||
setOrder
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { openPreview, preview } from '$lib/stores/preview.svelte';
|
||||
import {
|
||||
setRightSidebarWidth,
|
||||
setThumbnailSize,
|
||||
@@ -59,6 +59,7 @@
|
||||
const next = parseUrlParams(page.url.searchParams);
|
||||
if (next.section !== undefined) filters.section = next.section;
|
||||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||
if (next.search !== undefined) filters.search = next.search;
|
||||
});
|
||||
|
||||
@@ -90,13 +91,25 @@
|
||||
switch (filters.section) {
|
||||
case 'favorites':
|
||||
return 'Favorites';
|
||||
case 'review':
|
||||
return 'Review';
|
||||
case 'archive':
|
||||
return 'Archive';
|
||||
case 'hidden':
|
||||
return 'Hidden';
|
||||
case 'heap': {
|
||||
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
|
||||
return heap ? `Heap · ${heap.Title}` : 'Heap';
|
||||
}
|
||||
default:
|
||||
// 'all-photos' is the internal "no section filter" state —
|
||||
// the visible context now comes from the folder filter
|
||||
// (root by default). Show the folder path so the title
|
||||
// reflects what's actually on screen; only the rare
|
||||
// `folderPath === null` case (e.g. right after deleting a
|
||||
// heap) still reads as "All photos".
|
||||
if (filters.folderPath === '/') return 'Folder · /';
|
||||
if (filters.folderPath) return `Folder · ${filters.folderPath}`;
|
||||
return 'All photos';
|
||||
}
|
||||
}
|
||||
@@ -133,8 +146,15 @@
|
||||
* pages can repeat a photo when its file-row span straddles the offset
|
||||
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||||
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
||||
* handlers) treat this as the single source of truth. */
|
||||
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
|
||||
* handlers) treat this as the single source of truth.
|
||||
*
|
||||
* When the user picks the root entry in the folder tree we filter
|
||||
* to `Path === ''` here — PhotoPrism's `path:` operator can't
|
||||
* express that match, so the query fetches the whole library and
|
||||
* we strip subfolder rows post-hoc. */
|
||||
const photos = $derived<PpPhoto[]>(
|
||||
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
|
||||
);
|
||||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||
if (!pages) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -148,6 +168,20 @@
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Root-folder scope is the only client-side filter we apply, and only
|
||||
// when the timeline is actually showing a folder view — never when the
|
||||
// user is in a heap, has a free-form search, or is on a non-default
|
||||
// section (favorites / archive / review / hidden). Those views are
|
||||
// scoped server-side via the q-DSL and must not be re-filtered here,
|
||||
// or labels / search will silently drop subfolder photos when the
|
||||
// store hasn't fully hydrated from the URL yet.
|
||||
function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
|
||||
if (f.folderPath !== '/') return list;
|
||||
if (f.section !== 'all-photos') return list;
|
||||
if (f.heapUid) return list;
|
||||
if (f.search) return list;
|
||||
return list.filter((p) => !p.Path);
|
||||
}
|
||||
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||||
|
||||
$effect(() => {
|
||||
@@ -331,6 +365,25 @@
|
||||
if (el) scrollTileIntoView(el);
|
||||
}
|
||||
|
||||
// When the preview overlay closes, scroll the just-shown photo back
|
||||
// into the timeline window. PreviewOverlay already keeps
|
||||
// selection.focused in lockstep with preview.uid, so we just need to
|
||||
// make sure that tile is mounted (forcedExpand) and visible — the
|
||||
// blue ring renders itself once the inner button is in the DOM.
|
||||
let wasPreviewOpen = $state(false);
|
||||
$effect(() => {
|
||||
const open = preview.uid !== null;
|
||||
const closing = wasPreviewOpen && !open;
|
||||
wasPreviewOpen = open;
|
||||
if (!closing) return;
|
||||
const uid = selection.focused;
|
||||
if (!uid) return;
|
||||
untrack(() => {
|
||||
const i = photos.findIndex((p) => p.UID === uid);
|
||||
if (i >= 0) void scrollToIndex(i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||||
// The CSS Grid lays each photo into a cell with column count derived from
|
||||
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||||
@@ -557,17 +610,26 @@
|
||||
}
|
||||
|
||||
function onTileClick(e: MouseEvent, uid: string) {
|
||||
// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
|
||||
// document-level click handler — let them bubble.
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
if (selection.ids.size > 0) return;
|
||||
// Establish the "starting photo" so a subsequent shift-click extends
|
||||
// the range from this tile. Reset both focus and anchor — anchor on
|
||||
// its own would stick to an older toggle/selectOnly tile and the
|
||||
// shift-range would silently use the wrong starting point. Also
|
||||
// clear the sticky-column intent so the next arrow press anchors
|
||||
// off the clicked tile's actual column.
|
||||
// Plain click: select this tile only. Replaces the previous
|
||||
// "click opens preview" semantics — preview now lives on dblclick.
|
||||
// Reset both focus and anchor so a subsequent shift-click extends
|
||||
// the range from this tile, and clear sticky-column intent so
|
||||
// arrow nav re-anchors off this tile's actual column.
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
intendedCol = null;
|
||||
}
|
||||
|
||||
function onTileDblclick(e: MouseEvent, uid: string) {
|
||||
// Modifier-modified dblclicks shouldn't open the preview either —
|
||||
// gridKeyNav already handled the underlying click.
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
openPreview(uid, photos.map((p) => p.UID));
|
||||
}
|
||||
|
||||
@@ -657,6 +719,12 @@
|
||||
</Toolbar>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!--
|
||||
Main column wraps the scrollable timeline and the action bar so
|
||||
the bar's width matches the timeline only — the right aside is a
|
||||
sibling at row level and stays full height when the bar appears.
|
||||
-->
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<main
|
||||
bind:this={scrollRoot}
|
||||
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||||
@@ -684,6 +752,13 @@
|
||||
Archive is empty.
|
||||
{:else if filters.section === 'favorites'}
|
||||
No favorites yet. Heart a photo to add it here.
|
||||
{:else if filters.section === 'review'}
|
||||
Nothing left to review. Photos PhotoPrism's indexer wasn't sure about
|
||||
land here — use Keep to accept them into the timeline or Archive to
|
||||
set them aside.
|
||||
{:else if filters.section === 'hidden'}
|
||||
No hidden photos. PhotoPrism auto-hides files it can't index (broken
|
||||
files, very low quality); they only ever show up here.
|
||||
{:else if filters.section === 'heap'}
|
||||
This heap has no photos yet. Select some photos and use the bulk bar's
|
||||
"+ Add to heap" button.
|
||||
@@ -742,6 +817,7 @@
|
||||
data-tile
|
||||
data-uid={photo.UID}
|
||||
onclick={(e) => onTileClick(e, photo.UID)}
|
||||
ondblclick={(e) => onTileDblclick(e, photo.UID)}
|
||||
class:scale-90={sel}
|
||||
class:ring-2={sel}
|
||||
class:ring-blue-500={sel}
|
||||
@@ -804,6 +880,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
<aside
|
||||
@@ -850,5 +928,3 @@
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
type PhotoMarksMap
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// PhotoPrism's Color is auto-derived from image content — the user-set
|
||||
@@ -110,7 +113,10 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if marksQuery.isPending || photosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
||||
{:else if marksQuery.isError || photosQuery.isError}
|
||||
@@ -121,34 +127,11 @@
|
||||
sidebar to tag it.
|
||||
</p>
|
||||
{:else if selectedGroup}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
||||
>
|
||||
{#each selectedGroup.photos as photo (photo.UID)}
|
||||
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openPreview(
|
||||
photo.UID,
|
||||
selectedGroup.photos.map((p) => p.UID)
|
||||
)}
|
||||
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<PhotoGrid photos={selectedGroup.photos} />
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each groups as group (group.key)}
|
||||
{@const rep = group.photos[0]}
|
||||
@@ -178,3 +161,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
130
web/src/routes/inbox/+page.svelte
Normal file
130
web/src/routes/inbox/+page.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
cancelImport,
|
||||
getImportInfo,
|
||||
startImport,
|
||||
type ImportInfo
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// PhotoPrism's `/import` root holds uploaded-but-not-yet-indexed files
|
||||
// (separate from /originals which is what the timeline reads). The
|
||||
// folders endpoint returns the staging tree + counts via headers;
|
||||
// kicking off the import is a single POST to /import. Mutations land
|
||||
// in originals after PhotoPrism finishes processing — invalidate the
|
||||
// originals/folders/config caches so the rest of the UI catches up.
|
||||
const importQuery = createQuery<ImportInfo>(() => ({
|
||||
queryKey: ['import'],
|
||||
queryFn: getImportInfo,
|
||||
enabled: isAuthenticated(),
|
||||
// Refetch every 5 s while the page is open so progress is visible
|
||||
// without the user having to refresh. Cheap call — just headers
|
||||
// and a folder list.
|
||||
refetchInterval: 5_000
|
||||
}));
|
||||
|
||||
const qc = useQueryClient();
|
||||
const importMut = createMutation(() => ({
|
||||
// `move: true` is the typical workflow — once a file is indexed
|
||||
// into originals it doesn't need to linger in the staging area.
|
||||
mutationFn: () => startImport({ move: true }),
|
||||
onSuccess: (r) => {
|
||||
toast.success(r.message ?? 'Import started');
|
||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
void qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||
}));
|
||||
|
||||
const cancelMut = createMutation(() => ({
|
||||
mutationFn: cancelImport,
|
||||
onSuccess: () => {
|
||||
toast.message('Import cancelled');
|
||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||
}));
|
||||
|
||||
const fileCount = $derived(importQuery.data?.files ?? 0);
|
||||
const folderCount = $derived(importQuery.data?.folders ?? 0);
|
||||
const empty = $derived(fileCount === 0 && folderCount === 0);
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Inbox
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{fileCount} file{fileCount === 1 ? '' : 's'} · {folderCount} folder{folderCount === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</span>
|
||||
{#snippet trailing()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
disabled={empty || importMut.isPending}
|
||||
onclick={() => importMut.mutate()}
|
||||
title="Index files from the inbox into the main library"
|
||||
>
|
||||
{importMut.isPending ? 'Importing…' : 'Start import'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={!importMut.isPending}
|
||||
onclick={() => cancelMut.mutate()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
{#if importQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading inbox…</p>
|
||||
{:else if importQuery.isError}
|
||||
<p class="text-sm text-destructive">
|
||||
Failed to read inbox: {importQuery.error instanceof Error
|
||||
? importQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if empty}
|
||||
<div class="space-y-2 text-sm text-muted-foreground">
|
||||
<p>The inbox is empty.</p>
|
||||
<p>
|
||||
Drop files into <code class="rounded bg-muted px-1">/photoprism/import</code> (the
|
||||
bind mount in <code class="rounded bg-muted px-1">docker-compose.photoprism.yml</code>)
|
||||
and they'll show up here. Click <strong>Start import</strong> to move them into the
|
||||
main library; PhotoPrism indexes them, deduplicates against existing originals, and
|
||||
files them under <code class="rounded bg-muted px-1">originals/{'{Y}/{M}'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{fileCount} file{fileCount === 1 ? '' : 's'} ready to import across {folderCount} subfolder{folderCount === 1
|
||||
? ''
|
||||
: 's'}.
|
||||
</p>
|
||||
{#if importQuery.data && importQuery.data.subfolders.length > 0}
|
||||
<!-- PhotoPrism doesn't surface a per-folder file count for
|
||||
/import; we just list the staging subfolders so the
|
||||
user has a sense of what's in there. -->
|
||||
<ul class="space-y-1 text-[12px]">
|
||||
{#each importQuery.data.subfolders as f (f.Path)}
|
||||
<li class="flex items-center gap-2 rounded border border-border px-2 py-1">
|
||||
<span class="truncate font-mono">{f.Path || '/'}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -6,8 +6,11 @@
|
||||
type PhotoMarksMap
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
||||
@@ -102,7 +105,10 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if marksQuery.isPending || photosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
||||
{:else if marksQuery.isError || photosQuery.isError}
|
||||
@@ -113,34 +119,11 @@
|
||||
(or 1–5 in bulk mode) to rate it.
|
||||
</p>
|
||||
{:else if selectedGroup}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
||||
>
|
||||
{#each selectedGroup.photos as photo (photo.UID)}
|
||||
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openPreview(
|
||||
photo.UID,
|
||||
selectedGroup.photos.map((p) => p.UID)
|
||||
)}
|
||||
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<PhotoGrid photos={selectedGroup.photos} />
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each groups as group (group.rating)}
|
||||
{@const rep = group.photos[0]}
|
||||
@@ -169,3 +152,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { listLabels, type PpLabel } from '$lib/services/photoprism';
|
||||
import { listLabels, listPhotos, type PpLabel } from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// Mirror /colors: a label grid that drills into a photo grid in place,
|
||||
// instead of navigating away to the timeline. Stays inside /tags so
|
||||
// the user keeps their place when they back out.
|
||||
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||
queryKey: ['labels'],
|
||||
queryFn: listLabels,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
async function openLabel(slug: string) {
|
||||
// Tags drive search — clicking jumps to the timeline with the label
|
||||
// term applied. Bookmarkable URL via the existing filter sync.
|
||||
await goto(`/?q=${encodeURIComponent(`label:${slug}`)}`);
|
||||
let selectedSlug = $state<string | null>(null);
|
||||
const selectedLabel = $derived(
|
||||
selectedSlug !== null
|
||||
? (labelsQuery.data ?? []).find(
|
||||
(l) => (l.CustomSlug ?? l.Slug) === selectedSlug
|
||||
) ?? null
|
||||
: null
|
||||
);
|
||||
|
||||
// Photo pool for the selected label. PhotoPrism's q-DSL filters
|
||||
// server-side; we cap at 1000 (the server's hard ceiling) to keep the
|
||||
// page reactive without paginating in place.
|
||||
const labelPhotosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'label', selectedSlug ?? ''],
|
||||
queryFn: () =>
|
||||
listPhotos({
|
||||
q: `label:${selectedSlug}`,
|
||||
count: 1000,
|
||||
order: 'newest',
|
||||
merged: true
|
||||
}),
|
||||
enabled: isAuthenticated() && Boolean(selectedSlug)
|
||||
}));
|
||||
|
||||
function pickLabel(slug: string) {
|
||||
selectedSlug = slug;
|
||||
}
|
||||
function clearSelection() {
|
||||
selectedSlug = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,6 +54,24 @@
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Tags
|
||||
</span>
|
||||
{#if selectedLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span class="flex items-center gap-1.5 text-[11px] font-medium">
|
||||
{selectedLabel.Name}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{labelPhotosQuery.data?.length ?? selectedLabel.PhotoCount ?? 0} photo{(labelPhotosQuery
|
||||
.data?.length ?? 0) === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</span>
|
||||
{/if}
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
||||
@@ -29,7 +79,10 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if labelsQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
||||
{:else if labelsQuery.isError}
|
||||
@@ -39,13 +92,26 @@
|
||||
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
|
||||
indexer hasn't run on real photos yet, the list will be empty.
|
||||
</p>
|
||||
{:else if selectedLabel}
|
||||
{#if labelPhotosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading photos…</p>
|
||||
{:else if labelPhotosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photos for this label.</p>
|
||||
{:else if (labelPhotosQuery.data ?? []).length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos tagged with this label.</p>
|
||||
{:else}
|
||||
<PhotoGrid photos={labelPhotosQuery.data ?? []} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));">
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each labelsQuery.data ?? [] as label (label.UID)}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => openLabel(label.CustomSlug ?? label.Slug)}
|
||||
onclick={() => pickLabel(label.CustomSlug ?? label.Slug)}
|
||||
>
|
||||
{#if label.Thumb}
|
||||
<img
|
||||
@@ -66,3 +132,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
Reference in New Issue
Block a user