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:
2026-05-17 21:01:41 +02:00
parent 5153aeebec
commit d5e4f23c0f
18 changed files with 1577 additions and 332 deletions

View File

@@ -1,8 +1,12 @@
import { toast } from 'svelte-sonner';
import { batchEdit } from '$lib/services/batch';
import { patchTargets } from '$lib/services/bulk';
import { invalidatePhotos } from '$lib/services/bulk';
import {
addToHeap,
approvePhoto,
batchArchive,
batchDelete,
batchRestore,
likePhoto,
removeFromHeap,
unlikePhoto,
@@ -203,12 +207,77 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
target = !(first?.Archived ?? false);
}
await patchTargets(
ids,
{ Archived: target },
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
(p) => ({ Archived: p.Archived ?? false })
);
// PhotoPrism's photo PUT silently drops the Archived field — the
// only working path is /api/v1/batch/photos/{archive,restore}. The
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
// back, so the toast fired but nothing moved.
try {
if (target) await batchArchive(ids);
else await batchRestore(ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
return;
}
invalidatePhotos(ids);
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
toast.success(label);
pushUndo(label, async () => {
if (target) await batchRestore(ids);
else await batchArchive(ids);
invalidatePhotos(ids);
});
}
/** Permanently delete cull targets — only callable from the archive
* section (X is rerouted away from archive-toggle there). PhotoPrism
* rejects deletion of un-archived photos with a 4xx, so the section
* gate doubles as a safety guard against accidental deletes from the
* main timeline. Confirm dialog is mandatory — no undo path exists. */
async function deleteCullTargets() {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to delete', {
description: 'Click a photo or select some first'
});
return;
}
const msg =
ids.length === 1
? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return;
try {
await batchDelete(ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
return;
}
invalidatePhotos(ids);
toast.success(`Deleted ${ids.length}`);
}
/** Approve cull targets — clears them out of the review pile by
* bumping each photo's quality score above PhotoPrism's review
* threshold. The op is one-way (no /unapprove route), so we don't
* push an undo entry: a re-keyed S would just be a no-op on
* already-approved photos. */
async function approveCullTargets() {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to keep', {
description: 'Click a photo or select some first'
});
return;
}
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
invalidatePhotos(ids);
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
description: errors[0].message
});
return;
}
toast.success(`Kept ${ids.length}`);
}
/** Flip the Favorite (heart) flag on cull targets. Reads the first
@@ -427,6 +496,13 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
case 'X':
if (meta || shift) return;
e.preventDefault();
// Archive section: X becomes permanent delete (Keep/Delete
// is the binary flow there, mirroring Review's Keep/Archive).
// Everywhere else X toggles archive on the cull targets.
if (filters.section === 'archive') {
void deleteCullTargets();
return;
}
void toggleArchive('toggle');
return;
case 'u':
@@ -444,9 +520,26 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
case 's':
case 'S':
if (meta || shift) return;
e.preventDefault();
// Review section repurposes S as the Keep affordance —
// matches the BulkActionBar button and keeps the binary
// Keep/Archive flow on home-row keys (S/X). The heap chord
// is meaningless here anyway (review photos can't sensibly
// be filed before they're approved).
if (filters.section === 'review') {
void approveCullTargets();
return;
}
// Archive section: S = Keep = restore back to the timeline
// (inverse of Delete on X). Same rationale as review —
// heap-filing an archived photo isn't a flow that fits the
// section's intent.
if (filters.section === 'archive') {
void toggleArchive('restore');
return;
}
// Arm the chord. A digit 19 within S_CHORD_MS picks heap N;
// otherwise we fall back to the currently-viewed heap.
e.preventDefault();
clearSChord();
sChordTimer = window.setTimeout(() => {
sChordTimer = null;
@@ -461,6 +554,9 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
if (!tile) return;
const uid = tile.dataset.uid;
if (!uid) return;
// Modifier clicks are the only paths this document-level handler
// owns. Plain clicks bubble to the tile button's onclick, which
// reduces selection to just that tile.
if (e.shiftKey) {
e.preventDefault();
selectRange(uid);
@@ -469,13 +565,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault();
toggle(uid);
setFocused(uid);
} else if (selection.ids.size > 0) {
// When a multi-selection is active, a plain click reduces it to
// just this tile (matches mule-image's "selection mode" behaviour).
e.preventDefault();
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
}
}

View File

@@ -27,19 +27,24 @@ export interface NearBottomParams {
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
let current: NearBottomParams = params;
let io: IntersectionObserver | null = null;
// IntersectionObserver only emits on state changes. With a 4-viewport
// preload zone, the sentinel typically stays continuously intersecting
// across a whole fetchNextPage cycle: enabled flips false (fetching),
// the IO callback runs but no-ops, enabled flips back true — and no new
// event is emitted because the intersection state never changed. We'd
// stall mid-pagination. Remember the last reported intersection so the
// next `enabled` rising edge can re-fire manually.
let lastIntersecting = false;
function buildObserver(p: NearBottomParams) {
io?.disconnect();
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
io = new IntersectionObserver(
(entries) => {
if (!current.enabled) return;
for (const e of entries) {
if (e.isIntersecting) {
current.onHit();
return;
}
lastIntersecting = e.isIntersecting;
}
if (lastIntersecting && current.enabled) current.onHit();
},
{
root: p.root ?? null,
@@ -57,11 +62,15 @@ export function nearBottom(node: HTMLElement, params: NearBottomParams) {
update(next: NearBottomParams) {
const rootChanged = next.root !== current.root;
const preloadChanged = next.preloadPx !== current.preloadPx;
const enabledRose = !current.enabled && !!next.enabled;
current = next;
// `enabled` and `onHit` are read live inside the callback,
// so they don't require rebuilding the observer. Root and
// preloadPx are baked in at construction.
if (rootChanged || preloadChanged) buildObserver(current);
if (rootChanged || preloadChanged) {
buildObserver(current);
return;
}
// `enabled` rising while the sentinel is still in the preload
// zone — no IO event coming, so fire manually.
if (enabledRose && lastIntersecting) current.onHit();
},
destroy() {
io?.disconnect();

View File

@@ -60,6 +60,11 @@
* `filters.folderPath` matches (the sidebar nav case); the picker
* passes its own selection so the dialog has independent state. */
selectedPath?: string | null;
/** Optional per-path photo count. When provided, each row renders a
* compact badge with the count — matching the heaps section's
* "{n} photos" affordance. Undefined keeps the badge off entirely
* (the picker dialog doesn't need it). */
counts?: Record<string, number>;
}
let {
nodes,
@@ -69,7 +74,8 @@
onDelete,
onCreateChild,
readonly = false,
selectedPath
selectedPath,
counts
}: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state
@@ -114,7 +120,7 @@
with the px-2 of Views/Heaps rows; +12px per nested level.
-->
<div
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}
@@ -137,7 +143,7 @@
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<button
class="flex flex-1 items-center truncate text-left"
class="flex min-w-0 flex-1 items-center truncate text-left"
class:px-1={hasChildren || depth > 0}
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
@@ -145,11 +151,23 @@
>
<span class="truncate">{node.name}</span>
</button>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
{#if !readonly}
<!-- Hover-revealed kebab. Reserves zero width when idle so the
row stays compact; expands on hover and stays visible while
the menu is open. Suppressed in readonly mode (picker). -->
<div class="mr-1">
<!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]])
so the count holds the row's right edge by default
and the kebab pushes it left when it appears.
Suppressed in readonly mode (picker). -->
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="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"
@@ -187,6 +205,7 @@
{onCreateChild}
{readonly}
{selectedPath}
{counts}
/>
{/if}
</li>

View File

@@ -1,12 +1,23 @@
<!--
General app preferences. Distinct from the PhotoPrism-library admin dialog:
this one owns settings that affect *this* SvelteKit shell (theme), not the
server. Opened from the bottom of the left sidebar.
General app preferences. The UI tab owns the SvelteKit shell's
light/dark/system theme (mode-watcher) plus the per-user UI knobs
PhotoPrism's /settings exposes. Search and Maps follow the same
pattern — server prefs round-trip via /api/v1/settings.
The Library admin dialog and this one share the ['settings'] cache,
so saves from either invalidate the other.
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { mode, setMode } from 'mode-watcher';
import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import {
getSettings,
saveSettings,
type PpSettings
} from '$lib/services/photoprism';
interface Props {
open: boolean;
@@ -14,11 +25,98 @@
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
{ value: 'system', label: 'System', Icon: Monitor }
] as const;
// PhotoPrism palette names from its built-in themes. Any value
// outside this list is preserved verbatim (see `withCurrent`).
const ppThemes = [
'default',
'abyss',
'gemstone',
'grayscale',
'lavender',
'legacy',
'neon',
'onyx',
'raspberry',
'shadow',
'yellowstone'
];
// IETF subtags PhotoPrism ships translations for. Extend without
// fear — `withCurrent` keeps unknown values visible.
const ppLanguages = [
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
];
const ppStartPages = [
'default',
'browse',
'albums',
'favorites',
'calendar',
'moments',
'people',
'places',
'labels',
'states',
'library'
];
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
// Returns `opts` with `current` prepended if it's set and not
// already in the list — so e.g. an experimental theme name in the
// server response shows up selected and editable instead of
// silently being overwritten by the dropdown's default.
function withCurrent(opts: string[], current?: string): string[] {
if (!current) return opts;
return opts.includes(current) ? opts : [current, ...opts];
}
const settingsQuery = createQuery<PpSettings>(() => ({
queryKey: ['settings'],
queryFn: getSettings,
enabled: open
}));
let draft = $state<PpSettings | null>(null);
$effect(() => {
if (settingsQuery.data && draft === null) {
draft = structuredClone(settingsQuery.data);
}
});
$effect(() => {
if (!open) draft = null;
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = structuredClone(next);
toast.success('Settings saved');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save settings')
}));
function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
}
const selectClass =
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
</script>
<Dialog.Root
@@ -32,7 +130,7 @@
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[440px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[560px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
@@ -41,8 +139,8 @@
General settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app. Library-side settings live under
Folders → ⚙.
Preferences for this app and your PhotoPrism account.
Library admin lives under Folders → ⚙.
</Dialog.Description>
</div>
<Dialog.Close
@@ -53,31 +151,219 @@
</Dialog.Close>
</div>
<section class="space-y-2 text-[12px]">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Appearance
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
<Tabs.Root bind:value={activeTab}>
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
{#each ['ui', 'search', 'maps'] as const as t (t)}
<Tabs.Trigger
value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{t}
</Tabs.Trigger>
{/each}
</Tabs.List>
<!-- UI — local app theme (mode-watcher) on top, then the
PhotoPrism per-user UI knobs that go to /settings. -->
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
App theme
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{/each}
</div>
<p class="text-[11px] text-muted-foreground">
Light/dark for this app. Persists locally; no Save needed.
</p>
</section>
{#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
{:else if draft}
<section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
PhotoPrism UI
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span>
<select bind:value={draft.ui!.theme} class={selectClass}>
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Language</span>
<select bind:value={draft.ui!.language} class={selectClass}>
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Time zone</span>
<!-- IANA tz list is ~400 entries, browser support varies; use
a datalist so we get autocomplete without spamming a
gigantic <select>. "Local" is PhotoPrism's special
"follow system" sentinel. -->
<input
type="text"
list="general-tz-list"
placeholder="Local"
bind:value={draft.ui!.timeZone}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Start page</span>
<select bind:value={draft.ui!.startPage} class={selectClass}>
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
Always show scrollbars
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.zoom} />
Allow image zoom
</label>
</section>
{/if}
</Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
</Tabs.Content>
{:else if settingsQuery.isError && activeTab !== 'ui'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
</p>
</Tabs.Content>
{:else if draft}
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.listView} />
Default to list view
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showTitles} />
Show titles
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
Show captions
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Batch size (-1 = server default)
</span>
<input
type="number"
bind:value={draft.search!.batchSize}
class={selectClass}
/>
</label>
</Tabs.Content>
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Style</span>
<select bind:value={draft.maps!.style} class={selectClass}>
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Animation duration (ms, 0 = off)
</span>
<input
type="number"
bind:value={draft.maps!.animate}
class={selectClass}
/>
</label>
</Tabs.Content>
{/if}
</Tabs.Root>
<!-- Datalist for time-zone autocomplete. Falls back to the
"Local" sentinel when the browser can't enumerate the
IANA list (older Safari, etc.). -->
<datalist id="general-tz-list">
<option value="Local"></option>
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
</datalist>
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
trip). The App theme group above persists itself, so we
only show the action row when there's something to save. -->
{#if draft}
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={resetDraft}
disabled={saveMut.isPending}
>
Revert
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => draft && saveMut.mutate(draft)}
disabled={saveMut.isPending}
>
{#if saveMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Save
</button>
</div>
</section>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<script lang="ts" module>
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
// 15.3 and below) return undefined here. The component handles that
// by simply showing only the "Local" sentinel in the datalist.
export function tzOptions(): string[] {
const fn = (Intl as unknown as {
supportedValuesOf?: (k: string) => string[];
}).supportedValuesOf;
if (typeof fn !== 'function') return [];
try {
return fn('timeZone');
} catch {
return [];
}
}
</script>

View File

@@ -92,7 +92,9 @@
}));
function submit() {
if (!heap || !pickedPath) return;
// pickedPath === '' is the root selection; falsy check would
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
if (!heap || pickedPath === null) return;
convertMut.mutate({
uid: heap.UID,
body: {
@@ -154,6 +156,20 @@
No folders. Create one from the sidebar first.
</p>
{:else}
<!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty
string is the sidecar's "root" sentinel — matches
resolveUnderRoot's special case in handlers_heap. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
@@ -213,7 +229,7 @@
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={!pickedPath || convertMut.isPending}
disabled={pickedPath === null || convertMut.isPending}
>
{#if convertMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />

View File

@@ -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"

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { tick } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import {
@@ -19,25 +18,14 @@
enabled: Boolean(preview.uid)
}));
// Track the last visible uid so we can return focus to the matching
// timeline tile when the overlay closes — lets the user keep moving
// with arrow keys without re-clicking.
let lastShown: string | null = null;
// Mirror the currently-shown photo into selection.focused. The
// timeline's tile uses `selection.focused === photo.UID` to draw the
// blue ring, so this keeps the selection in lockstep with whatever
// the user is paging through in preview. The host page (+page.svelte)
// owns the matching scroll-into-view on close so the tile actually
// mounts (it can be windowed out if the user navigated far).
$effect(() => {
if (preview.uid !== null) {
lastShown = preview.uid;
setFocused(preview.uid);
} else if (lastShown) {
const target = lastShown;
lastShown = null;
// Wait for the overlay to unmount before grabbing focus, otherwise
// the browser swallows it as the modal element is removed.
void tick().then(() => {
const tile = document.querySelector<HTMLElement>(`[data-uid="${target}"]`);
tile?.focus({ preventScroll: false });
tile?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
}
if (preview.uid !== null) setFocused(preview.uid);
});
// Keyboard handling lives at the document level so it works regardless

View File

@@ -3,6 +3,7 @@
import { toast } from 'svelte-sonner';
import {
addToHeap,
approvePhoto,
batchArchive,
batchDelete,
batchRestore,
@@ -43,6 +44,16 @@
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
);
const isBulk = $derived(selection.ids.size > 0);
// Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with
// favoriting / heap-adding / restoring. The S keybinding is rerouted
// to approve from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived;
// gridKeyNav mirrors the rerouting.
const isArchive = $derived(filters.section === 'archive');
function clearAll() {
clearSelection();
@@ -59,6 +70,24 @@
}
}
async function onApprove() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
// no /unapprove route. We fan out per-photo because there's no
// batch endpoint either. Errors are tallied rather than aborting
// the loop so a single bad UID doesn't block the rest.
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
} else {
toast.success(`Kept ${ids.length}`);
}
clearSelection();
});
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
@@ -160,28 +189,90 @@
</script>
{#if targetCount > 0}
<!--
Inline row at the bottom of the main content column (NOT fixed) so
the sidebars stay reachable. Matches the Toolbar's h-9 / px-3 /
bg-background/80 backdrop-blur visual so it reads as the timeline's
own footer.
-->
<div
class="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background/95 px-6 py-3 shadow-lg backdrop-blur"
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
>
<div class="mx-auto flex max-w-7xl items-center gap-3">
<span class="text-sm font-medium text-foreground">
{#if isBulk}
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
<span class="shrink-0 text-[11px] font-medium text-foreground">
{#if isBulk}
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
<div class="ml-auto flex flex-wrap items-center gap-2">
<!--
`overflow-x-auto` would clip the heap-picker dropdown — CSS
forces `overflow-y: auto` whenever `overflow-x` is non-visible,
so the dropdown's `bottom-full` placement is clipped to zero
pixels above the 36px bar (it renders but is invisible). We
use `flex-wrap` instead so very narrow viewports get a second
row rather than a horizontal scroll, and the dropdown stays
free to escape upward.
-->
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
{#if isReview}
<!-- Review pile = binary decision. Keep approves (Quality →
3+, lands in the main timeline); Archive batches into
the archive section. Everything else (heap, favorite,
restore) is hidden so the choice reads as decisive. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onApprove}
title="Keep — accept into timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else if isArchive}
<!-- Archive section = mirror of review: Keep restores back
to the timeline; Delete is permanent and can't be
undone. X is repurposed from archive→delete since the
photo is already archived; the destructive styling
reinforces the irreversibility. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Keep — restore to timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={busy}
onclick={onDelete}
title="Permanently delete (no undo)"
>
Delete
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else}
<div class="relative">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={() => (heapPickerOpen = !heapPickerOpen)}
title="Add to heap (S then 19 picks a heap)"
>
Add to heap
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
>S&nbsp;N</kbd
>
</button>
@@ -220,70 +311,50 @@
{/if}
</div>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onFavorite}
title="Favorite"
>
♥ Favorite
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>F</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>X</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Restore"
>
Restore
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>U</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
</button>
{#if filters.section === 'archive'}
<button
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={busy}
onclick={onDelete}
title="Permanently delete (no undo)"
>
Delete
</button>
{/if}
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || undoStack.entries.length === 0}
onclick={onUndo}
title="Undo last action"
>
Undo
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>⌘Z</kbd
>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>
{isBulk ? 'Clear' : 'Dismiss'}
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Esc</kbd
>
</button>
</div>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy || undoStack.entries.length === 0}
onclick={onUndo}
title="Undo last action"
>
Undo
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>
{isBulk ? 'Clear' : 'Dismiss'}
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">Esc</kbd>
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,106 @@
<!--
Flat photo grid for views that don't need infinite-scroll windowing or
month headers — the drill-in screens in /colors, /tags, /ratings. Wears
the same tile look + click semantics as the timeline so the user gets
selection rings, single-click select, dblclick preview, and arrow-key
nav (via `gridKeyNav` on the scroll-root) without per-route plumbing.
The grid carries `data-photo-grid` so gridKeyNav can measure its
column count, and each tile carries `data-tile`+`data-uid` so the
action's document-level click handler can pick up shift/cmd/ctrl
modifiers and route them through the shared selection helpers.
-->
<script lang="ts">
import {
isSelected,
selection,
setAnchor,
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
photos: PpPhoto[];
/** Override the column template. Defaults to the global
* `view.thumbnailSize` so drill-in grids honour the same XSXL
* preset the timeline uses. */
columns?: string;
}
let { photos, columns }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
const order = $derived(photos.map((p) => p.UID));
$effect(() => {
setOrder(order);
});
function onClick(e: MouseEvent, uid: string) {
// Modifier clicks bubble to gridKeyNav's window handler (range +
// toggle paths). Plain clicks reduce the selection to this tile,
// matching the timeline's selectOnly semantics.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
function onDblclick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
openPreview(uid, order);
}
</script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={(e) => onClick(e, photo.UID)}
ondblclick={(e) => onDblclick(e, photo.UID)}
class:scale-90={sel}
class:ring-2={sel}
class:ring-blue-500={sel}
class:ring-offset-2={sel}
class:ring-offset-background={sel}
class:transition-[transform,box-shadow]={sel}
class:duration-300={sel}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
class="group relative 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"
class:transition={!sel}
class:group-hover:scale-105={!sel}
/>
{#if sel}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/each}
</div>

View File

@@ -192,6 +192,17 @@ export async function batchDelete(uids: string[]): Promise<void> {
await http.post('/batch/photos/delete', toBatchBody(uids));
}
/**
* Approve a photo in the review pile. PhotoPrism's indexer leaves photos
* with low quality scores in `review:true` purgatory; approving bumps the
* score above the review threshold (Quality goes to 3+) so the photo
* lands in the main timeline. No corresponding "unapprove" endpoint — the
* review pile is one-way out.
*/
export async function approvePhoto(uid: string): Promise<void> {
await http.post(`/photos/${uid}/approve`);
}
/**
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
* routes that are atomic; preferred over PUT for this one field.
@@ -269,6 +280,65 @@ export async function listFolders(): Promise<PpFolder[]> {
return data.folders ?? [];
}
/**
* Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
* files in a separate `/photoprism/import` root, exposed via
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
* `/folders/originals`, but the photo counts come from `X-Files` and
* `X-Folders` response headers since the body only lists subfolders.
*/
export interface ImportInfo {
files: number;
folders: number;
subfolders: PpFolder[];
}
export async function getImportInfo(): Promise<ImportInfo> {
const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
params: { recursive: true, uncached: true, files: false }
});
const num = (h: unknown) => {
const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
return Number.isFinite(n) ? n : 0;
};
return {
files: num(res.headers['x-files'] ?? res.headers['X-Files']),
folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
subfolders: res.data.folders ?? []
};
}
/**
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, and the `/photos`
* response has no total-rows header — X-Count is the per-page row count.
* So we fire one `/photos?q=path:X&count=1000` per folder and dedupe by
* UID — `merged=false` returns one row per FILE, so a HEIC+JPG companion
* pair counts twice if we trusted `data.length`. Capped at the server's
* 1000-row ceiling; folders that overflow render as "1000+" in the UI.
*
* `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct
* children only, so summing the per-path counts (no double-counting from
* nested folders) is the right way to derive the root-folder photo
* count.
*
* Returns a plain object keyed by the input paths to keep it JSON-friendly
* for TanStack's structural sharing.
*/
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
const entries = await Promise.all(
paths.map(async (path) => {
const { data } = await http.get<PpPhoto[]>('/photos', {
params: { count: 1000, offset: 0, merged: false, q: `path:${path}` }
});
const uids = new Set<string>();
for (const p of data) uids.add(p.UID);
return [path, uids.size] as const;
})
);
return Object.fromEntries(entries);
}
// ── Geo ──────────────────────────────────────────────────────────────────────
export interface PpGeoFeature {
@@ -610,8 +680,21 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
// merges server-side, so it's safe to round-trip an incomplete object.
export interface PpSettings {
ui?: { theme?: string; language?: string; scrollbar?: boolean; zoom?: boolean };
search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean };
ui?: {
theme?: string;
language?: string;
timeZone?: string;
startPage?: string;
scrollbar?: boolean;
zoom?: boolean;
};
search?: {
batchSize?: number;
listView?: boolean;
showTitles?: boolean;
showCaptions?: boolean;
};
maps?: { animate?: number; style?: string };
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };

View File

@@ -11,7 +11,9 @@
export type Section =
| 'all-photos'
| 'favorites'
| 'review'
| 'archive'
| 'hidden'
| 'heap';
export interface FilterState {
@@ -24,10 +26,14 @@ export interface FilterState {
search: string;
}
// Default landing = root folder (`/`). The Folders group sits at the top
// of the sidebar; landing inside it gives users a stable starting point
// instead of dumping them into the full library. Picking any other view
// (favorites, a heap, "All photos") clears `folderPath` to `null`.
export const filters = $state<FilterState>({
section: 'all-photos',
heapUid: null,
folderPath: null,
folderPath: '/',
search: ''
});
@@ -65,9 +71,21 @@ export function filtersToQ(f: FilterState = filters): string {
case 'favorites':
parts.push('favorite:true');
break;
case 'review':
// PhotoPrism's review pile: photos the indexer flagged as
// uncertain (low quality score). Cleared per-photo via the
// `/approve` endpoint or by archiving.
parts.push('review:true');
break;
case 'archive':
parts.push('archived:true');
break;
case 'hidden':
// Auto-hidden by the indexer (broken files, very low quality
// score). Excluded from every other view — this section is the
// only way to see them without a manual `q=hidden:true`.
parts.push('hidden:true');
break;
case 'heap':
if (f.heapUid) parts.push(`album:${f.heapUid}`);
break;
@@ -75,7 +93,13 @@ export function filtersToQ(f: FilterState = filters): string {
default:
break;
}
if (f.folderPath) parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
// `/` is the root-folder sentinel. PhotoPrism's `path:` operator can't
// express "exact root match" (path:"" / path:/ both fall back to "no
// filter"), so we leave the server query unfiltered and let the
// timeline post-filter to `Path === ''` client-side.
if (f.folderPath && f.folderPath !== '/') {
parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
}
if (f.search) parts.push(quoteIfNeeded(f.search));
return parts.join(' ');
}
@@ -84,13 +108,24 @@ export function filtersToQ(f: FilterState = filters): string {
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
const sectionRaw = params.get('section') as Section | null;
const section: Section =
sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw)
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
? sectionRaw
: 'all-photos';
// Bare URL (no section/folder/heap/q params) lands on the root folder
// — same default the store carries. Any explicit param means the user
// asked for a specific view, so the folder filter clears unless
// `folder=` is supplied on top.
const bare =
!params.has('section') &&
!params.has('folder') &&
!params.has('heap') &&
!params.has('q');
const folderRaw = params.get('folder');
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
return {
section,
heapUid: params.get('heap'),
folderPath: params.get('folder'),
folderPath,
search: params.get('q') ?? ''
};
}

View File

@@ -23,12 +23,37 @@ export interface PpClientConfig {
previewToken: string;
downloadToken: string;
flags?: string;
/**
* Precomputed library counters. PhotoPrism updates these incrementally
* on every mutation, so they're cheap to read and accurate without a
* separate aggregate query. `all` already nets out review/hidden, which
* is what the timeline shows — prefer it over `photos + videos`.
*/
count?: {
all?: number;
photos?: number;
videos?: number;
live?: number;
animated?: number;
audio?: number;
documents?: number;
archived?: number;
hidden?: number;
favorites?: number;
review?: number;
private?: number;
albums?: number;
labels?: number;
moments?: number;
months?: number;
states?: number;
folders?: number;
files?: number;
people?: number;
places?: number;
labels?: number;
cameras?: number;
lenses?: number;
countries?: number;
};
}
@@ -50,6 +75,10 @@ export interface PpPhoto {
Hash?: string;
/** Auto-derived display name (no extension). */
Name?: string;
/** Originals-relative folder this photo lives in. Empty string for
* photos directly under the originals root; otherwise the folder
* path (e.g. `2024/lyon`). Populated on list responses. */
Path?: string;
/** PhotoPrism filename + extension, populated on list responses. */
FileName?: string;
/** User-editable original/preferred name. Persisted in DB + sidecar. */

View File

@@ -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 />

View File

@@ -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 />

View 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>

View File

@@ -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 15 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 />

View File

@@ -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 />