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:
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -84,10 +85,20 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
|
||||
// Resolve destination. resolveUnderRoot ensures the target lives
|
||||
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
||||
targetAbs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
// Empty / "/" / "." are valid here — they mean "drop these into
|
||||
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
|
||||
// rejects those for safety, so handle the root case explicitly.
|
||||
var targetAbs string
|
||||
trimmed := strings.Trim(body.TargetFolder, "/")
|
||||
if trimmed == "" || trimmed == "." {
|
||||
targetAbs = cfg.OriginalsRoot
|
||||
} else {
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
targetAbs = abs
|
||||
}
|
||||
destAbs := targetAbs
|
||||
if subfolder != "" {
|
||||
@@ -123,20 +134,32 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
moved, copied := 0, 0
|
||||
|
||||
for _, photo := range photos {
|
||||
// Pick the file to physically move. PhotoPrism's "primary" file
|
||||
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
||||
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
||||
// — moving that path would fail "file missing on disk" every
|
||||
// time. Prefer the primary that lives in originals (Root=="/")
|
||||
// and fall back to the first originals-rooted file. PhotoPrism
|
||||
// regenerates sidecars on reindex, so they don't need to follow.
|
||||
var file ppFile
|
||||
found := false
|
||||
for _, f := range photo.Files {
|
||||
if f.Primary {
|
||||
if f.Root == "/" && f.Primary {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if len(photo.Files) == 0 {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no primary file"})
|
||||
continue
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
file = photo.Files[0]
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||
continue
|
||||
}
|
||||
srcRel := file.Name
|
||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||
@@ -185,9 +208,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// Reindex the destination + every source parent so PhotoPrism's
|
||||
// DB catches up. We do this in the background — the user gets
|
||||
// their counts immediately; PhotoPrism's timeline updates as the
|
||||
// reindex lands.
|
||||
// DB catches up. We block on these so the response only goes out
|
||||
// after the index reflects the move — callers (the frontend's
|
||||
// invalidateQueries refetch in particular) need the next /photos
|
||||
// fetch to return the moved files, otherwise the folder view
|
||||
// looks unchanged. PhotoPrism's index endpoint serialises calls
|
||||
// internally; running them sequentially matches that contract
|
||||
// without surprising the server.
|
||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||
paths := map[string]struct{}{destRel: {}}
|
||||
for p := range sourceParents {
|
||||
@@ -202,7 +229,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
if p != "" && p != "." {
|
||||
reindex = "/" + p
|
||||
}
|
||||
go fireReindex(cfg, pp, token, reindex)
|
||||
fireReindex(cfg, pp, token, reindex)
|
||||
}
|
||||
|
||||
heapDeleted := false
|
||||
|
||||
@@ -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 1–9 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–9 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 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}
|
||||
|
||||
106
web/src/lib/components/timeline/PhotoGrid.svelte
Normal file
106
web/src/lib/components/timeline/PhotoGrid.svelte
Normal 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 XS–XL
|
||||
* 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>
|
||||
@@ -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 };
|
||||
|
||||
@@ -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') ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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