19 Commits

Author SHA1 Message Date
6c96c22b33 feat(compose): opt-in GPU overlay for VA-API ffmpeg accel
Layer docker-compose.gpu.yml to mount /dev/dri/{card0,renderD128}
into pp-app, add it to render (992) + video (44) groups, and set
PHOTOPRISM_FFMPEG_ENCODER=vaapi. Hosts without a VA-API device just
skip the overlay (`-f docker-compose.yml -f docker-compose.gpu.yml`
becomes opt-in per deploy).

Drops video transcode + thumbnail generation from CPU to the iGPU
where present — large win for HEVC libraries. README documents the
flag; default behavior on the base compose is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:28:21 +02:00
70dc1b6bdf Merge pull request 'Mulimage 2.0' (#1) from new into main
Reviewed-on: #1
2026-05-21 22:48:54 +02:00
e3d4f6d92e web: SVG favicon (Greek lowercase mu) with light/dark adaptive fill
Use the Greek lowercase mu glyph as the app's favicon. The SVG
carries a `prefers-color-scheme` media query that flips the path
fill between near-black (light mode) and near-white (dark mode),
so it stays legible against any tab-bar background without an
extra browser hint.

Linked before the existing PNG so browsers that support SVG
favicons (Chrome 80+, Firefox 41+, Safari 9+) pick it up; the PNG
remains as a fallback. `apple-touch-icon` keeps the PNG since iOS
home-screen icons can't be SVG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:30:23 +02:00
9fc650fb12 web(sidebar): auto-expand folder tree ancestors of the active folder
When the timeline navigates to a nested folder (RightSidebar's
open-folder icon, URL hydration, back/forward), the LeftSidebar
already highlighted the matching row via filters.folderPath — but
if the parent folder was collapsed in the persisted openSet, the
highlighted row wasn't visible at all.

Each FolderTree instance now runs an effect that adds every
ancestor of the active path to its openSet on filter change. The
root instance expands the top-level ancestor first, which mounts
the next-depth FolderTree instance — and the same effect runs
there, cascading down to the leaf. Persisted to localStorage so
the expansion sticks across reloads.

Skipped in `readonly` mode (heap-convert picker has its own
selectedPath and shouldn't drive the sidebar state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:12:36 +02:00
29f7ad7073 web: deep-link from RightSidebar to folder/map + anchor-mode timeline + fix root count
RightSidebar:
- Folder + Location rows gain a small ArrowUpRight icon button that
  deep-links into the timeline / map view focused on the photo. OSM
  external link removed; the in-app map nav covers the same job.

Folder navigation:
- New navigateToFolder(path, { focusUid, focusTakenAt }) helper in the
  filters store; LeftSidebar's pickFolder collapses to a one-liner that
  reuses it.
- One-shot pending-focus stash carries both UID and TakenAt across the
  goto. URL-watch effect on the timeline consumes the stash so even
  same-folder navigations (where the filter doesn't change) get
  picked up.

Anchor-mode timeline query:
- listPhotosAround(q, takenAt, after, before) issues two parallel
  PhotoPrism calls (`after:<day-1>` oldest-first + `before:<day+1>`
  newest-first), merges + dedupes newest-first. Uses PhotoPrism's
  existing date-only DSL clauses — no server changes.
- When a deep-link stashes a TakenAt, page 0 of the photosQuery uses
  the merged window so the target photo is loaded even for photos
  buried past the standard newest-first cursor. Pages 1+ are disabled
  in anchor mode (PhotoPrism's day-precision cursor would infinite-loop
  on dense days; users see 120 around the target, refresh to drop the
  anchor).
- After page 0 lands, the existing scrollToIndex(targetIdx) expands the
  windowed render set + scrolls the tile into view.

Map view:
- /map honors `?lat=&lng=&zoom=&focus=` URL params, jumping to the
  photo's coordinates at zoom 17 instead of fitBounds-ing the full
  library. Params are stripped after first apply so a manual zoom-out
  + reload doesn't snap back.

LeftSidebar root count badge:
- Now matches what Cmd+A selects in the timeline. Old code used
  /config.count.all (library aggregate, includes archived/hidden/
  review). Switched to countPhotos('', { merged: true }) which counts
  the actual photo entries the timeline lists.
- countPhotos gains a `merged` option; with merged=true it returns the
  response body length instead of the X-Count header — PhotoPrism's
  X-Count is always the file-row count regardless of merged, so a
  HEIC + JPG companion pair inflated the badge to 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:04:23 +02:00
c134afe023 web(review): confidence-aware date guesser with combined filename + path signals
Refactor suggestDateFromPath to combine multiple signals instead of
trying patterns in priority order:

- Filename Y-M-D corroborated by path Y-M/Y-M-D → HIGH (filename-
  agrees-path). Fixes the case where a Samsung-style 20240226_xxx.jpg
  under 2024/02/ was returning the path-only 2024-02-01.
- Filename Y-M-D with no path signal → HIGH (filename-only).
- 10/13-digit Unix epoch in basename → HIGH (unix-timestamp) —
  covers WeChat (mmexport...) and FB saves.
- Path Y-M-D → HIGH (path-ymd).
- Path Y-M only → MEDIUM (path-ym-default-day, synthesised day=01).
  Sidebar row labels these "(estimated day)" so the user knows.

Filename parser now accepts `.` and space separators (covers macOS
screenshots, manual 2024.02.26 renames). Path parser accepts `.` too.
OriginalName participates as a secondary filename signal when present
and different from the on-disk basename.

Patterns we explicitly DO NOT parse, to avoid silent date flips:
DD-MM-YYYY / MM-DD-YYYY, 2-digit years, bare camera sequence numbers.

Add photoNameAndDir(p) helper next to primaryFile so RightSidebar,
BulkActionBar, photoActions, and gridKeyNav all derive {fileName,
path} the same way — fixes the bug where photo.FileName was
undefined on the single-photo detail endpoint and the basename branch
was being skipped entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:04:48 +02:00
a54d90a2d9 web(review): YYYY/MM path fallback + suggestion row below date + tighten 'a' gate
- suggestDateFromPath: when only year+month appear in the path
  (e.g. 2024/01/), synthesize day=01 so date-only foldering yields
  a usable suggestion instead of null.
- RightSidebar: move the suggestion row below the Taken-at input.
- BulkActionBar + gridKeyNav: show the "Accept date & Keep" button
  and fire the bare 'a' shortcut only when EVERY targeted photo has
  a path-derivable date — no more silent approve-without-fix for
  mixed selections.
- gridKeyNav: drop local cachedPhoto duplicate, use the shared one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:41:40 +02:00
97f51a05c4 web(review): scope date suggestion to EXIF Stripped tab + 'a' shortcut
- Gate the sidebar's "Suggested from path" row on /review?tab=stripped_exif
  instead of a per-photo TakenSrc heuristic — PhotoPrism stores a guessed
  TakenAt for stripped-EXIF photos too, so the heuristic was hiding the
  row even when a path-derived date was available.
- Same gate on the BulkActionBar's "Accept date & Keep" button.
- Extract acceptDateAndKeep() + cachedPhoto() into photoActions so the
  bar button and a new bare-'a' shortcut in gridKeyNav share one path.
- Show an 'A' kbd hint on the bar button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:31:04 +02:00
d1ddc48f81 web(review): switch cause tabs to PhotoGrid, add date-from-path suggestion
- CauseGroupCard now wraps PhotoGrid so selection, keyboard nav, and
  preview flow through the standard timeline plumbing. Per-tile hover
  Approve/Archive and the group-wide Approve all are gone — the bottom
  BulkActionBar's review-section Keep/Archive handle single + bulk.
- Low Resolution tab opts into a new PhotoTile dimensionBadge prop so
  WxH stays visible on each tile.
- New suggestDateFromPath util parses YYYY-MM-DD from filename or
  folder path. RightSidebar surfaces it as an amber Apply row above
  the Taken-at input whenever the photo lacks a trusted TakenAt.
- BulkActionBar gains a "Accept date & Keep" button (review section
  only) that patches each selected photo's TakenAt from its path
  suggestion when available, then approves.
- Drop the Same folder / Same camera / Same year strips and the
  RelatedStrip component from the metadata sidebar.

Also bundles in-progress Notes route + tile components and small
tweaks to LeftSidebar, DuplicatesView, CrossFolderGroupCard, and
photoprism.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:20:02 +02:00
55c870c155 web(sidebar): nest Hidden under Review submenu
Hidden is the resting place for photos dismissed during review, so it
groups naturally with the Review subitems. Stays a section-nav button
(keeping its scoped count badge); only Archive remains as a flat Manage
entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:44:43 +02:00
981328faff web(review): expandable Review group in sidebar, tabs become subitems
Mirrors the Tags affordance: chevron-only toggle, no /review landing
entry, navigation only via subitems (cause buckets + Stacks +
Cross-folder linked as /review?tab=<id>). Cause list reuses the
review-groups query so empty buckets stay hidden. The /review toolbar
drops the pill row and shows the active tab as a breadcrumb segment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:42:43 +02:00
e1707c314d preview: keep metadata sidebar mounted across photo changes
Previously the modal's aside was gated on `focusedPhotoQuery.data`, so
each arrow-skim unmounted the sidebar until the next photo's metadata
arrived — which reflowed the preview pane sideways. Now the aside is
always mounted while the modal is open; its contents swap between the
metadata panel and a small InlineLoader the same way the timeline's
right-aside does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:12 +02:00
64c0da794d web(sidebar): drop count badges from Map and Tags rows
Per-tag totals are already surfaced by the TagsBrowserSidebar, so the
main sidebar's Map/Tags rows stay as pure navigators. Also removes the
now-orphaned geo, marks, and keywords cache observers that only fed
those badges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:24:53 +02:00
0f4e2e0b8f preview: LQIP + ±2 prefetch + bound action bar to its column
Preview pane paints instantly: a blurred copy of the same thumbnail the
grid loaded (cache hit) rides beneath the sharp fit_1280, which now
carries fetchpriority=high and decoding=async. A $effect prefetches
fit_1280 for the ±2 neighbours so arrow-skim hits the HTTP cache.
Carousel thumbs drop to fetchpriority=low so they yield to the main
image. Skeleton grid gains an mt-2 to breathe against the toolbar.
BulkActionBar moves inside the main column in both PreviewModal and the
/tags drill-in so it no longer stretches under the right sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:24:31 +02:00
ea1803ec2f web: 8-color label palette + VIDEO badge in carousel
Expand color labels from the 4-swatch Lightroom culling palette to 8
neutral colors (red/orange/yellow/green/teal/blue/purple/pink) with no
attached semantics, rendered as outlines that fill in when picked.
Carousel tiles now show a VIDEO badge, and the folder row in the right
sidebar always renders ("/" for root) instead of disappearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:56:05 +02:00
fc5f30fad1 fix(sidebar): use count.labels for Tags badge, drop bogus label:* query
PhotoPrism's q-DSL has no "has any label" predicate: `label:*` matches
every photo regardless of label, `label:<slug>` only matches that one
slug, and `keywords:*` behaves the same way. The prior `all:true label:*`
returned a 400 (and the earlier "drop all:true" follow-up made it return
the unfiltered library size, which then fed into tagsTotal and inflated
the parent Tags badge to ~library_size on admin sessions).

Switch labelsBadge to the precomputed `configQuery.count.labels` — the
same source the Tags sub-row's `tagCategoryCount('labels')` already
uses. The parent Tags badge now sums the exact same numbers the sub-rows
display: labels, keywords, people (distinct slugs/keywords/subjects)
plus ratings/colors (photos carrying each mark). Drop the wantScoped
short-circuit since the values are all library-wide now anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:41:07 +00:00
a7b8a60473 web: admin surfaces so the PhotoPrism UI is never needed
- Account tab in General settings — self-service password change.
- UsersDialog (admin-only footer entry) — full /api/v1/users CRUD with
  admin-issued password reset.
- People as a fifth tag category alongside Labels/Keywords/Colors/Ratings,
  backed by /api/v1/subjects and the `person:` DSL clause.
- About tab in Library settings — version, library counts, feature chips,
  and a collapsible env-config help panel for the bits PP has no runtime
  API for (OIDC, TF, WebDAV).
- Library tab expanded with Indexer-advanced, extra Downloads checksums,
  and a Features grid that only renders keys PhotoPrism actually returns.
- Fix the SettingsDialog null-draft race the same way GeneralSettingsDialog
  already had: normalize on open, never null on close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
24dfa996b3 web: de-brand PhotoPrism references in user-facing copy
Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts,
log header, login screen) with neutral terms like "the indexer", "the
library", "the server" — accurate regardless of backend. The login
header becomes "Mulimage" and drops the explicit PhotoPrism mention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
e364e4128f web: unify empty + loading states behind EmptyState/InlineLoader
Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
43 changed files with 3112 additions and 1002 deletions

View File

@@ -115,6 +115,7 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
.
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
├── docker-compose.gpu.yml opt-in VA-API GPU passthrough overlay
├── .env.example required env vars (copy to .env)
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
├── pp/ PhotoPrism bind-mounted state (storage, import)
@@ -122,4 +123,18 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
└── web/ SvelteKit frontend
```
## GPU video acceleration (optional)
Hosts with a VA-API-capable GPU (Intel iGPU, AMD APU, etc.) can layer
[`docker-compose.gpu.yml`](docker-compose.gpu.yml) to hand `/dev/dri/*`
to PhotoPrism and switch ffmpeg to hardware encode/decode — a large
perf win for video thumbnails and HEVC→H.264 transcodes:
```bash
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
```
Set `PP_FFMPEG_ENCODER=vaapi` in `.env` (default for the overlay). Verify
with `docker exec pp-app photoprism show config | grep -i ffmpeg`.
[pp]: https://photoprism.app/

24
docker-compose.gpu.yml Normal file
View File

@@ -0,0 +1,24 @@
# Overlay for hosts with a VA-API-capable GPU passed through (Intel
# QSV, AMD VCN/VCE, any VA-API driver). PhotoPrism's :latest image
# ships VA-API-enabled ffmpeg; this file just wires the device + group
# membership + encoder selection. Layered in by the deploy script on
# hosts where /dev/dri/renderD128 exists.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
services:
photoprism:
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
- /dev/dri/card0:/dev/dri/card0
# Match host GIDs (render=992, video=44 on Debian). PhotoPrism's
# container user (PP_UID:PP_GID, typically 33:10000) is not in
# these groups by default; group_add grants access to the device
# nodes without changing the primary user.
group_add:
- "992"
- "44"
environment:
PHOTOPRISM_FFMPEG_ENCODER: ${PP_FFMPEG_ENCODER:-vaapi}
PHOTOPRISM_FFMPEG_BITRATE: ${PP_FFMPEG_BITRATE:-32}

View File

@@ -4,6 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<!-- SVG favicon adapts to light/dark via `prefers-color-scheme`
inside the file itself; PNG remains as a fallback for browsers
that don't support SVG icons. Apple touch icon stays PNG
since iOS home-screen icons can't be SVG. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
%sveltekit.head%

View File

@@ -10,6 +10,9 @@ import {
removeFromHeap,
type PpAlbum
} from '$lib/services/photoprism';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import {
@@ -25,7 +28,6 @@ import {
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
/**
* Optional parameters the host passes via `use:gridKeyNav={...}`.
@@ -160,35 +162,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return [];
}
/** Look up a photo's current cached state without forcing a refetch.
* Walks every `['photos', …]` cache entry first, then the per-photo
* cache. Lets `x` decide "archive vs restore" based on the actual current
* state instead of always sending Archived=true.
*
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
* scroll. Walk both — assuming a flat array on the timeline cache used
* to throw `list.find is not a function` and abort the F/X handlers. */
function cachedPhoto(uid: string): PpPhoto | undefined {
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === uid);
if (hit) return hit;
}
}
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
}
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
const ids = cullTargets();
if (ids.length === 0) {
@@ -333,7 +306,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
// truth.
if (added.length === 0) {
toast.error(`Nothing added to ${heap.Title}`, {
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
});
return;
}
@@ -498,6 +471,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
if (meta) {
e.preventDefault();
for (const id of selection.order) selection.ids.add(id);
return;
}
if (shift) return;
// Bare `a` on the EXIF Stripped review tab fires the same
// "Accept date & Keep" flow as the bar button. Mirrors the
// bar's all-targets-have-a-suggestion gate so the shortcut
// can't silently approve photos without a date fix.
if (
filters.section === 'review' &&
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
) {
const ids = cullTargets();
if (ids.length === 0) return;
for (const id of ids) {
const p = cachedPhoto(id);
if (!p) return;
const { fileName, path } = photoNameAndDir(p);
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
return;
}
}
e.preventDefault();
void acceptDateAndKeep(ids);
}
return;
case 'x':

View File

@@ -158,7 +158,7 @@
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
{
description: losingIndexed
? 'The previously-indexed copy was moved; PhotoPrism will drop it on the next index pass.'
? 'The previously-indexed copy was moved; the indexer will drop it on the next index pass.'
: 'Files moved to .duplicates/ inside originals.'
}
);
@@ -179,7 +179,7 @@
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Cross-folder duplicate · ${group.files.length} copies`}
aria-label={`Duplicate group · ${group.files.length} copies`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"
@@ -246,7 +246,7 @@
{#if isIndexed}
<span
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Currently indexed by PhotoPrism"
title="Currently in the library"
>
Indexed
</span>

View File

@@ -30,6 +30,8 @@
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
type Tab = 'stacks' | 'cross-folder';
@@ -63,7 +65,7 @@
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Cross-folder scan failed'
: 'Duplicates scan failed'
);
}
});
@@ -75,20 +77,24 @@
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p>
<InlineLoader label="Loading stacks…" />
{:else if error}
<p class="text-sm text-destructive">
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'}
</p>
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>No stacks.</p>
<p class="text-xs">
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index
time live under the Cross-folder tab.
<EmptyState icon={Copy} title="No stacks">
{#snippet descriptionSnippet()}
<p>
The library stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies dropped at index time live under
the Duplicates tab.
</p>
</div>
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
@@ -99,12 +105,12 @@
</div>
{/if}
<!-- Cross-folder tab ----------------------------------------------- -->
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files PhotoPrism dropped at index time. Found by scanning the
Byte-identical files the indexer dropped at index time. Found by scanning the
originals tree directly.
</p>
<button
@@ -122,22 +128,26 @@
</header>
{#if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">Hashing files under originals…</p>
<InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError}
<p class="text-sm text-destructive">
Scan failed: {crossQuery.error instanceof Error
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
</p>
/>
{:else if crossCount === 0}
<p class="text-sm text-muted-foreground">
No cross-folder duplicates found.
<EmptyState icon={CheckCircle2} title="No duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms)
</span>
{/if}
<p class="text-[10px] text-muted-foreground/70">
scanned in {crossQuery.data.scannedMs} ms
</p>
{/if}
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}

View File

@@ -0,0 +1,84 @@
<!--
Shared empty / no-data placeholder. Doubles as an error display when
`tone="destructive"` (swaps colors and announces with role=alert).
Use `size="compact"` inside sidebars where vertical space is tight.
-->
<script lang="ts">
import type { Component, Snippet } from 'svelte';
interface Props {
icon?: Component<any> | any;
title: string;
description?: string;
descriptionSnippet?: Snippet;
align?: 'left' | 'center';
tone?: 'muted' | 'destructive';
size?: 'compact' | 'default';
children?: Snippet;
}
let {
icon: Icon,
title,
description,
descriptionSnippet,
align,
tone = 'muted',
size = 'default',
children
}: Props = $props();
const resolvedAlign = $derived(align ?? (size === 'compact' ? 'left' : 'center'));
const isDestructive = $derived(tone === 'destructive');
</script>
{#if size === 'compact'}
<div
class="flex gap-1.5 px-3 py-2 text-[11px] {resolvedAlign === 'center'
? 'items-center justify-center text-center'
: 'items-start'} {isDestructive ? 'text-destructive' : 'text-muted-foreground'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon class="h-3 w-3 shrink-0 {resolvedAlign === 'left' ? 'mt-0.5' : ''}" aria-hidden="true" />
{/if}
<div class="min-w-0">
<span>{title}</span>
{#if descriptionSnippet}
<div class="mt-0.5 opacity-80">{@render descriptionSnippet()}</div>
{:else if description}
<div class="mt-0.5 opacity-80">{description}</div>
{/if}
{#if children}
<div class="mt-1.5">{@render children()}</div>
{/if}
</div>
</div>
{:else}
<div
class="flex flex-col gap-2 p-8 {resolvedAlign === 'center'
? 'items-center text-center'
: 'items-start text-left'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon
class="h-5 w-5 {isDestructive ? 'text-destructive' : 'text-muted-foreground/70'}"
aria-hidden="true"
/>
{/if}
<p class="text-sm font-medium {isDestructive ? 'text-destructive' : ''}">{title}</p>
{#if descriptionSnippet}
<div class="max-w-prose space-y-2 text-xs text-muted-foreground">
{@render descriptionSnippet()}
</div>
{:else if description}
<p class="max-w-prose text-xs text-muted-foreground">{description}</p>
{/if}
{#if children}
<div class="mt-2">{@render children()}</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,39 @@
<!--
Tiny "Loading…" indicator: spinner + label. Use this for in-flight queries
in sidebars, popovers, and right rails. For the initial photo-grid load,
use SkeletonGrid instead (layout-preserving).
-->
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
interface Props {
label?: string;
size?: 'sm' | 'default';
align?: 'left' | 'center';
srOnly?: boolean;
polite?: boolean;
}
let {
label = 'Loading…',
size = 'default',
align = 'left',
srOnly = false,
polite = true
}: Props = $props();
const textSize = $derived(size === 'sm' ? 'text-[11px]' : 'text-xs');
const iconSize = $derived(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5');
const padding = $derived(size === 'sm' ? 'px-3 py-2' : 'px-3 py-2');
const justify = $derived(align === 'center' ? 'justify-center' : 'justify-start');
</script>
<p
role="status"
aria-busy="true"
aria-live={polite ? 'polite' : 'off'}
class="flex items-center gap-1.5 {padding} {textSize} {justify} text-muted-foreground"
>
<Loader2 class="{iconSize} animate-spin" aria-hidden="true" />
<span class={srOnly ? 'sr-only' : ''}>{label}</span>
</p>

View File

@@ -0,0 +1,2 @@
export { default as EmptyState } from './EmptyState.svelte';
export { default as InlineLoader } from './InlineLoader.svelte';

View File

@@ -40,6 +40,7 @@
<script lang="ts">
import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -105,6 +106,37 @@
if (selectedPath !== undefined) return selectedPath === path;
return filters.folderPath === path;
}
// Auto-expand the ancestor chain of the active folder so the
// highlighted row is actually visible after a deep-link navigation
// (RightSidebar's open-folder icon, URL hydration, etc.). Each
// FolderTree instance only owns the openSet entries for the nodes
// rendered at its depth, but since the root instance expands the
// top-level ancestor first, the child instance for that subtree is
// then mounted and runs the same effect — the cascade naturally
// reaches the leaf. Skipped in `readonly` mode (the heap-convert
// picker has its own selectedPath and shouldn't drive the sidebar
// state). Skipped for top-level paths (nothing to expand).
$effect(() => {
if (readonly || !browser) return;
const fp = selectedPath ?? filters.folderPath;
if (!fp || fp === '/' || !fp.includes('/')) return;
untrack(() => {
const parts = fp.split('/');
let changed = false;
for (let i = 1; i < parts.length; i++) {
const ancestor = parts.slice(0, i).join('/');
if (ancestor && !openSet.has(ancestor)) {
openSet.add(ancestor);
changed = true;
}
}
if (changed) {
openSet = new Set(openSet);
persist();
}
});
});
</script>
<ul>

View File

@@ -16,8 +16,10 @@
import {
getSettings,
saveSettings,
setUserPassword,
type PpSettings
} from '$lib/services/photoprism';
import { session } from '$lib/stores/session.svelte';
interface Props {
open: boolean;
@@ -27,7 +29,29 @@
const qc = useQueryClient();
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
// ── Account tab — password change ─────────────────────────────────────
let pwOld = $state('');
let pwNew = $state('');
let pwConfirm = $state('');
const pwMut = createMutation(() => ({
mutationFn: async () => {
if (!session.user) throw new Error('Not signed in');
if (pwNew.length < 8) throw new Error('New password must be at least 8 characters');
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
await setUserPassword(session.user.UID, pwOld, pwNew);
},
onSuccess: () => {
pwOld = '';
pwNew = '';
pwConfirm = '';
toast.success('Password updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update password')
}));
const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun },
@@ -160,8 +184,8 @@
General settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app and your PhotoPrism account.
Library admin lives under Folders → ⚙.
Preferences for Mulimage and your account. Library admin lives
under Folders → ⚙.
</Dialog.Description>
</div>
<Dialog.Close
@@ -174,7 +198,7 @@
<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)}
{#each ['ui', 'search', 'maps', 'account'] 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"
@@ -217,13 +241,13 @@
</section>
{#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
<p class="px-1 text-muted-foreground">Loading server settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
<p class="px-1 text-destructive">Could not load server 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
Server UI
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span>
@@ -275,11 +299,11 @@
{/if}
</Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui'}
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
<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'}
{:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
@@ -332,6 +356,97 @@
</label>
</Tabs.Content>
{/if}
<!-- Account — independent of /settings; reads from the session
store and round-trips its own mutation. -->
<Tabs.Content value="account" 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">
Signed in as
</h3>
<div class="space-y-1 rounded border border-border bg-muted/30 p-2">
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Name</span>
<span class="font-medium">{session.user?.Name ?? '—'}</span>
</div>
{#if session.user?.DisplayName}
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Display name</span>
<span>{session.user.DisplayName}</span>
</div>
{/if}
{#if session.user?.Email}
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Email</span>
<span>{session.user.Email}</span>
</div>
{/if}
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">Role</span>
<span>{session.user?.Role ?? '—'}</span>
</div>
</div>
</section>
<form
class="space-y-3"
onsubmit={(e) => {
e.preventDefault();
pwMut.mutate();
}}
>
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Change password
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Current password</span>
<input
type="password"
autocomplete="current-password"
bind:value={pwOld}
required
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">New password</span>
<input
type="password"
autocomplete="new-password"
bind:value={pwNew}
required
minlength={8}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Confirm new password</span>
<input
type="password"
autocomplete="new-password"
bind:value={pwConfirm}
required
minlength={8}
class={selectClass}
/>
</label>
<div class="flex justify-end">
<button
type="submit"
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"
disabled={pwMut.isPending ||
!pwOld ||
pwNew.length < 8 ||
pwNew !== pwConfirm}
>
{#if pwMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Update password
</button>
</div>
</form>
</Tabs.Content>
</Tabs.Root>
<!-- Datalist for time-zone autocomplete. Falls back to the
@@ -344,8 +459,9 @@
<!-- 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}
only show the action row when there's something to save.
Account tab has its own Update-password button, so skip. -->
{#if draft && activeTab !== 'account'}
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
<button
type="button"

View File

@@ -14,7 +14,8 @@
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, Loader2 } from 'lucide-svelte';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
convertHeap,
listFolders,
@@ -153,11 +154,14 @@
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<p class="px-2 py-1 text-[11px] text-muted-foreground">Loading folders…</p>
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-[11px] text-muted-foreground">
No folders. Create one from the sidebar first.
</p>
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty

View File

@@ -13,32 +13,35 @@
deleteFolder,
deleteHeap,
duplicateHeap,
getAllMarks,
getConfig,
heapDownloadUrl,
listFolderCounts,
listFolders,
listGeo,
listHeaps,
listPhotosWithNotes,
logout,
renameFolder,
renameHeap,
scanCrossFolderDuplicates,
triggerDownload,
type AggregatedKeyword,
type CrossFolderScanResult,
type PhotoMarksMap,
type PhotoWithNote,
type PpAlbum,
type PpClientConfig,
type PpFolder,
type PpGeoCollection
type PpFolder
} from '$lib/services/photoprism';
import {
listDuplicateGroups,
type DuplicateGroup
} from '$lib/services/adapters/duplicates';
import {
listReviewGroups,
type CauseKey,
type ReviewGroup
} from '$lib/services/adapters/review';
import {
filters,
navigateToFolder,
setFolderPath,
setSection,
TAG_CATEGORIES,
@@ -51,18 +54,23 @@
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import {
Copy,
Download,
FolderInput,
FolderOpen,
FolderPlus,
Layers,
LogOut,
Moon,
Pencil,
Settings,
Sun,
Trash2
Trash2,
Users
} from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
const qc = useQueryClient();
@@ -128,28 +136,15 @@
}));
}
// One query per badge. Admins with no BasePath skip these
// One query per badge. Admins with no BasePath skip this
// (enabled:false via `wantScoped`) and the configQuery numbers are
// used directly — same chrome as before that fix, no extra
// round-trips.
const favoritesCountQuery = scopedCountQuery('favorites', 'favorite:true');
const reviewCountQuery = scopedCountQuery('review', 'review:true');
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
// round-trip. Review and Hidden have no aggregate badge (pure
// toggles in the sidebar now, like Tags), so they don't appear here.
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
// Labels is special: `configQuery.count.labels` is the number of distinct
// label categories (PhotoPrism's roll-up), not the number of photos that
// carry a label. The Tags surface wants picture counts everywhere, so we
// always run a `countPhotos('label:*')` query regardless of the admin/
// BasePath shape and never fall back to the category-count.
const labelsCountQuery = createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
queryFn: () => countPhotos(scoped('label:*')),
enabled: isAuthenticated(),
staleTime: 60_000
}));
function bucketCount(
key: 'favorites' | 'review' | 'hidden' | 'archived',
key: 'archived',
query: { data: number | undefined; isPending: boolean }
): number | undefined {
if (wantScoped) {
@@ -163,13 +158,6 @@
return c[key];
}
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
// Duplicates counts for the sidebar badge. Stacks is a cheap
// PhotoPrism query so we always fetch it; cross-folder is an
// O(disk) scan, so the sidebar only *observes* its cache
@@ -189,50 +177,17 @@
staleTime: 5 * 60_000
}));
// Geotagged-photo count for the Map sidebar badge. PhotoPrism's
// `count.places` is the number of distinct *locations* (cities/states),
// not the number of geotagged photos — so the sidebar would disagree
// with the "N geotagged" footer on /map. Sharing the `['geo']` cache
// keeps both numbers in lockstep and is free after /map's first visit.
const geoQuery = createQuery<PpGeoCollection>(() => ({
queryKey: ['geo'],
queryFn: () => listGeo(),
// Notes-view badge. Cheap (one list round-trip, no fan-out) so we
// fetch eagerly — sharing the queryKey with /notes means the page hits
// the warm cache, and the ['photos', …] prefix lets existing mutation
// invalidations keep both in sync.
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
queryKey: ['photos', 'with-notes'],
queryFn: listPhotosWithNotes,
enabled: isAuthenticated(),
staleTime: 5 * 60_000
staleTime: 60_000
}));
// Keywords contribution to the Tags badge. Aggregation is heavy
// (1000-photo fan-out), so the sidebar observes the cache populated
// by /tags?tab=keywords rather than triggering its own fetch — same
// lazy pattern as the cross-folder duplicates count above.
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
enabled: false,
staleTime: 5 * 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))
);
@@ -269,37 +224,48 @@
}));
const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root entry shows "the user's library" — for admins without a
// BasePath that's still the whole library, served cheaply from
// /api/v1/config's `count.all`. For any user with a non-empty
// BasePath the precomputed total is wrong (it's library-wide), so we
// ask the sidecar for a recursive count rooted at the user's
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
// resolves to the BasePath itself, and the sidecar fan-out recurses.
// Root entry shows "the user's library" using the same filter the
// timeline applies at folderPath=='/' — empty q, which PhotoPrism
// resolves to the visible listing (no archived / hidden / review).
// Earlier this used /config's `count.all`, but that aggregate
// includes those buckets and didn't match what the user can actually
// click "select all" on; the discrepancy was confusing
// (LeftSidebar said 357, the action bar said ~329).
//
// `scopedRootCountQuery` retains the sidecar fan-out for users with
// a BasePath — `listFolderCounts(['''])` resolves `''` through
// `toOriginalsPath` to the user's BasePath and recurses, so it picks
// up the same subset PhotoPrism would. Empty BasePath admins use the
// PhotoPrism count-via-X-Count path so both surfaces agree.
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'root-count', userBasePath()],
queryFn: () => listFolderCounts(['']),
enabled: isAuthenticated() && userBasePath() !== '',
staleTime: 60_000
}));
const visibleRootCountQuery = createQuery<number>(() => ({
queryKey: ['photos', 'visible-root-count', userBasePath()],
// `merged: true` so the count matches the timeline's photo entries
// (one per logical photo) rather than its file-row total. Without
// it, sidecar/companion files inflate the badge — e.g. a HEIC + JPG
// pair counts twice — and "select all" in the timeline never
// reaches the badge's number.
queryFn: () => countPhotos(scoped(''), { merged: true }),
enabled: isAuthenticated() && userBasePath() === '' && isAdminUser,
staleTime: 60_000
}));
const rootCount = $derived(
userBasePath() === ''
? isAdminUser
? (configQuery.data?.count?.all ?? 0)
? (visibleRootCountQuery.data ?? 0)
: 0
: (scopedRootCountQuery.data?.[''] ?? 0)
);
// Favorites / Review / Hidden / Archive nav entries use these
// derived values rather than peeking at configQuery directly so the
// scoped path is invisible to the views[]/manageViews[] declarations.
const favoritesBadge = $derived(bucketCount('favorites', favoritesCountQuery));
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
// Archive nav entry uses this derived value rather than peeking at
// configQuery directly so the scoped path is invisible to the
// manageViews[] declarations.
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
const labelsBadge = $derived<number | undefined>(
labelsCountQuery.isPending ? undefined : labelsCountQuery.data
);
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
@@ -348,6 +314,10 @@
// admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false);
// Admin-only user management dialog. Footer icon is gated on
// `isAdminUser` so non-admins never see the entry point.
let usersOpen = $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.
@@ -379,27 +349,61 @@
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
}
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
// row in Manage can expose the same set of tabs the /review page shows
// (cause groups + duplicates panels). Defaults to collapsed.
const REVIEW_OPEN_KEY = 'mule_review_expanded';
let reviewExpanded = $state(loadReviewExpanded());
function loadReviewExpanded(): boolean {
if (!browser) return false;
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
}
function toggleReview() {
reviewExpanded = !reviewExpanded;
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
}
// Cause-tab list is dynamic (only buckets with hits show up on /review),
// so the sidebar mirrors that by reusing the same query. Gated on
// `reviewExpanded` to avoid paying the /photos round-trip for users who
// never expand the section; the queryKey is shared with the /review page
// so visiting that route warms the cache for free.
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'],
queryFn: listReviewGroups,
enabled: isAuthenticated() && reviewExpanded,
staleTime: 30_000
}));
type ReviewTabId = CauseKey | 'stacks' | 'cross-folder';
// Stacks + Duplicates are always present on the /review tab strip
// regardless of count (the cross-folder scan is lazy from its own
// panel), so they tail every cause-tab list the sidebar renders.
// The 'cross-folder' tab id is kept internal/URL-stable; the label
// the user sees is "Duplicates".
const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([
...(reviewGroupsQuery.data ?? []).map((g) => ({
id: g.cause as ReviewTabId,
label: g.meta.title
})),
{ id: 'stacks', label: 'Stacks' },
{ id: 'cross-folder', label: 'Duplicates' }
]);
const reviewActive = $derived(page.url.pathname === '/review');
function isReviewTabActive(id: ReviewTabId): boolean {
if (!reviewActive) return false;
return page.url.searchParams.get('tab') === id;
}
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels',
keywords: 'Keywords',
people: 'People',
colors: 'Colors',
ratings: 'Ratings'
};
function tagCategoryCount(cat: TagCategory): number | undefined {
// Labels reads PhotoPrism's pre-computed distinct-label counter
// (`/api/v1/config` → count.labels), not the photo-count from
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
// whose indexer hadn't surfaced labelled photos yet, leaving the
// badge silently empty; the precomputed counter is always present
// and reads as "how many labels you can pick from", matching the
// Keywords sub-row's distinct-count semantics.
if (cat === 'labels') return configQuery.data?.count?.labels;
if (cat === 'keywords') return keywordsQuery.data?.length;
if (cat === 'ratings') return ratingsCount;
return colorsCount;
}
function isTagCategoryActive(cat: TagCategory): boolean {
return page.url.pathname.startsWith(`/tags/${cat}`);
}
@@ -504,14 +508,7 @@
}
async function pickFolder(folderPath: string) {
// Folder selection works on top of the All Photos section; clearing
// the heap/section context mirrors mule-image's "drill into folder"
// behaviour. The URL sync $effect on the timeline picks this up.
setSection('all-photos');
setFolderPath(folderPath);
const params = new URLSearchParams();
params.set('folder', folderPath);
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
await navigateToFolder(folderPath);
}
function onCreateHeap() {
@@ -545,14 +542,12 @@
//
// `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. Map uses the shared `['geo']`
// cache so its badge matches /map's "N geotagged" footer exactly —
// `count.places` would have shown distinct locations instead.
// Review rolls in the duplicates tabs hosted under /review — stacks
// always contributes; cross-folder only contributes once its tab has
// been opened (the scan is lazy, not eager from the sidebar).
// Map and Tags intentionally render without a count badge; the count
// columns inside the TagsBrowserSidebar are the canonical surface for
// per-tag totals. Review rolls in the duplicates tabs hosted under
// /review — stacks always contributes; cross-folder only contributes
// once its tab has been opened (the scan is lazy, not eager from the
// sidebar).
type ViewItem =
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
@@ -562,41 +557,24 @@
// separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root.
const views: ViewItem[] = [
// Map's `geoQuery` already returns the GeoJSON the user is
// permitted to see (PhotoPrism's /geo applies the session ACL),
// so the badge is per-user-correct without extra scoping.
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
// Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat
// section/route ViewItem shape.
// section/route ViewItem shape. Notes lives under that expandable
// alongside the tag categories.
];
// Total badge for the "Tags" header row. Rolls up labels + keywords +
// ratings + colors. Labels flows through countPhotos (scoped); keywords/
// ratings/colors are library-wide marks tables and only contribute when
// we're in admin-without-BasePath mode (their sources don't scope).
const tagsTotal = $derived.by<number | undefined>(() => {
if (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0;
return labelsBadge + keywords + ratingsCount + colorsCount;
});
const manageViews: ViewItem[] = [
{
kind: 'route',
href: '/review',
label: 'Review',
getCount: () => {
if (reviewBadge === undefined) return undefined;
// The two duplicates queries are library-wide; only admins
// without a BasePath roll them into the Review badge.
if (wantScoped) return reviewBadge;
return reviewBadge + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
function isNotesActive(): boolean {
return page.url.pathname === '/notes';
}
},
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
// Review is rendered separately below as a pure expandable toggle
// (mirroring Tags — no /review landing entry from the sidebar,
// navigation only via subitems, with Hidden tucked in alongside the
// tab subitems). This list carries the flat Manage entries that
// follow it.
const manageViews: ViewItem[] = [
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
];
@@ -757,9 +735,9 @@
</div>
</div>
{#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading folders…" />
{:else if !hasSubfolders}
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
<EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
{:else if rootExpanded}
<!--
depth=1 visually nests the top-level subfolders one indent
@@ -795,11 +773,11 @@
</div>
{#if heapsQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading heaps…" />
{:else if heapsQuery.isError}
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p>
<EmptyState size="compact" tone="destructive" title="Failed to load heaps" />
{:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p>
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else}
<ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
@@ -890,9 +868,10 @@
{@render viewRow(v)}
{/each}
<!--
Tags expandable. Whole row is a toggle (chevron + label + badge);
there is no landing page at /tags — selecting a sub-category is the
only way into a real view.
Tags expandable. Whole row is a toggle (chevron + label); there is
no landing page at /tags — selecting a sub-category is the only way
into a real view. Counts intentionally live in the TagsBrowserSidebar
(secondary sidebar) so this row stays a pure navigator.
-->
<button
type="button"
@@ -909,19 +888,39 @@
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span>
{#if tagsTotal !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
>
{tagsTotal}
</span>
{/if}
</span>
</button>
{#if tagsExpanded}
<!--
Notes lives alongside the tag categories — same indent and row
chrome — but routes to /notes rather than /tags/*. Tucked at
the top of the expandable so it's the first thing the user
sees when opening Tags. Count badge renders once the shared
['photos', 'with-notes'] query has resolved.
-->
{@const notesActive = isNotesActive()}
{@const notesCount = notesQuery.data?.length}
<a
href="/notes"
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={notesActive}
class:text-primary-foreground={notesActive}
class:hover:bg-primary={notesActive}
style="padding-left: 36px;"
>
<span class="truncate">Notes</span>
{#if notesCount !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {notesActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{notesCount}
</span>
{/if}
</a>
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
{@const count = tagCategoryCount(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
@@ -933,15 +932,6 @@
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
{/if}
</a>
{/each}
{/if}
@@ -956,6 +946,62 @@
Manage
</span>
</div>
<!--
Review expandable. Mirrors the Tags affordance — pure toggle
with no landing page; the only way into a tab is to expand and
pick a subitem. Cause buckets are dynamic (only buckets with
hits show up); Stacks/Cross-folder are always present.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleReview}
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
aria-expanded={reviewExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
>
{reviewExpanded ? '▾' : '▸'}
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Review</span>
</span>
</button>
{#if reviewExpanded}
{#each reviewTabs as t (t.id)}
{@const active = isReviewTabActive(t.id)}
<a
href={`/review?tab=${t.id}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: 36px;"
>
<span class="truncate">{t.label}</span>
</a>
{/each}
<!--
Hidden lives under Review since it's the resting place for
photos dismissed during review. Section-nav (not a ?tab=),
so it's a button that flips filters.section like the flat
Manage entries — just with the subitem indent.
-->
{@const hiddenActive = isActive('hidden')}
<button
type="button"
class="flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={hiddenActive}
class:text-primary-foreground={hiddenActive}
class:hover:bg-primary={hiddenActive}
style="padding-left: 36px;"
onclick={() => navigateTo('hidden')}
>
<span class="truncate">Hidden</span>
</button>
{/if}
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
@@ -999,6 +1045,17 @@
>
<Settings class="h-3.5 w-3.5" />
</button>
{#if isAdminUser}
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (usersOpen = true)}
title="Users"
aria-label="Manage users"
>
<Users class="h-3.5 w-3.5" />
</button>
{/if}
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -1017,3 +1074,6 @@
open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)}
/>
{#if isAdminUser}
<UsersDialog open={usersOpen} onClose={() => (usersOpen = false)} />
{/if}

View File

@@ -9,10 +9,12 @@
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
cancelImport,
cancelIndex,
getConfig,
getErrors,
getSettings,
saveSettings,
@@ -23,6 +25,7 @@
type PpLogEntry,
type PpSettings
} from '$lib/services/photoprism';
import type { PpClientConfig } from '$lib/types/photoprism';
import { userBasePath } from '$lib/stores/session.svelte';
interface Props {
@@ -33,7 +36,7 @@
const qc = useQueryClient();
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library');
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
// ── Library tab ───────────────────────────────────────────────────────
// Pull settings only while the dialog is open so we don't keep them
@@ -45,22 +48,42 @@
enabled: open
}));
let draft = $state<PpSettings | null>(null);
$effect(() => {
if (settingsQuery.data && draft === null) {
draft = structuredClone(settingsQuery.data);
/**
* Force the shape on every clone so each `bind:value={draft.index!.*}`
* etc. has a real object to write into. Older PhotoPrism versions
* return /settings without one or more of these sub-objects, and
* non-null assertions on a missing sub-object throw on the next tick
* when Svelte's bind getter reads through it.
*
* Same shape-coercion pattern used by GeneralSettingsDialog —
* keep them in sync if you add a new top-level group there.
*/
function normalize(s: PpSettings): PpSettings {
return {
...s,
index: s.index ?? {},
import: s.import ?? {},
stack: s.stack ?? {},
download: s.download ?? {}
};
}
});
// Reset the draft when the dialog closes so the next open re-reads.
let draft = $state<PpSettings | null>(null);
// Re-clone on every open so reopening shows the freshest server state.
// Resetting on open (not close) avoids the race where bits-ui's exit
// animation keeps the form mounted with `draft === null` and the
// `bind:value={draft.download!.originals}` getter throws.
$effect(() => {
if (!open) draft = null;
if (open && settingsQuery.data) {
draft = normalize(structuredClone(settingsQuery.data));
}
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = structuredClone(next);
draft = normalize(structuredClone(next));
toast.success('Settings saved');
},
onError: (err) =>
@@ -68,7 +91,7 @@
}));
function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
}
// ── Index tab ─────────────────────────────────────────────────────────
@@ -119,6 +142,64 @@
enabled: open && activeTab === 'logs',
refetchInterval: open && activeTab === 'logs' ? 5000 : false
}));
// ── About tab ─────────────────────────────────────────────────────────
// Reuses the same query key as the LeftSidebar's `['photos', 'config']`
// so the About tab never triggers an extra round-trip — config is
// already warm by the time the user opens this dialog.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
enabled: open && activeTab === 'about'
}));
// PhotoPrism's `flags` is a space-separated bag of feature toggles
// ("experimental tensorflow places webdav share download import oidc").
// Parse once so the chip grid can render in stable order.
const flagSet = $derived.by<Set<string>>(() => {
const raw = configQuery.data?.flags ?? '';
return new Set(raw.split(/\s+/).filter(Boolean));
});
// Env-driven knobs that don't have a runtime PP API. Listed here so the
// About tab can render a "you need to edit .env and restart" help
// section instead of pretending these are mutable from the UI.
interface EnvKnob {
envVar: string;
label: string;
on: boolean;
}
const envKnobs = $derived.by<EnvKnob[]>(() => {
const f = flagSet;
const oidc = configQuery.data?.ext?.oidc?.enabled === true;
return [
{ envVar: 'OIDC_*', label: 'OIDC SSO', on: oidc },
{ envVar: 'PP_AUTH_MODE=public', label: 'Public (no-auth) mode', on: f.has('public') },
{ envVar: 'PHOTOPRISM_DISABLE_TF', label: 'TensorFlow / AI classifier', on: f.has('tensorflow') },
{ envVar: 'PHOTOPRISM_DISABLE_PLACES', label: 'Places (geocoding)', on: f.has('places') },
{ envVar: 'PHOTOPRISM_DISABLE_WEBDAV', label: 'WebDAV', on: f.has('webdav') }
];
});
// Show the config block collapsed by default — most users only want the
// version + counts; the env help is for the rare admin moment.
let envHelpOpen = $state(false);
// Library counts surfaced as a compact 2-column grid. Order matches
// what users care about most often (photos, then derived buckets).
const COUNT_ROWS: { key: keyof NonNullable<PpClientConfig['count']>; label: string }[] = [
{ key: 'all', label: 'Photos' },
{ key: 'videos', label: 'Videos' },
{ key: 'live', label: 'Live photos' },
{ key: 'favorites', label: 'Favorites' },
{ key: 'review', label: 'In review' },
{ key: 'archived', label: 'Archived' },
{ key: 'hidden', label: 'Hidden' },
{ key: 'people', label: 'People' },
{ key: 'labels', label: 'Labels' },
{ key: 'folders', label: 'Folders' },
{ key: 'albums', label: 'Albums' }
];
</script>
<Dialog.Root
@@ -141,7 +222,7 @@
Library settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Drive PhotoPrism's library, indexer, importer and server log.
Drive the library, indexer, importer and server log.
</Dialog.Description>
</div>
<Dialog.Close
@@ -156,7 +237,7 @@
<Tabs.List
class="mb-3 flex gap-1 border-b border-border"
>
{#each ['library', 'index', 'import', 'logs'] as const as t (t)}
{#each ['library', 'index', 'import', 'logs', 'about'] 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"
@@ -272,8 +353,92 @@
/>
Disable downloads entirely
</label>
{#if draft.download?.crc32 !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.crc32}
/>
Include CRC32 checksum
</label>
{/if}
{#if draft.download?.sha1 !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.download!.sha1}
/>
Include SHA1 checksum
</label>
{/if}
</section>
<!-- Indexer advanced — only renders the fields PP actually
reported. Older PP versions return a smaller `index`
block and we don't want to fabricate UI for missing keys. -->
{#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Indexer advanced
</h3>
{#if draft.index?.skipMeta !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipMeta}
/>
Skip metadata-only changes
</label>
{/if}
{#if draft.index?.skipRaw !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipRaw}
/>
Skip RAW files
</label>
{/if}
{#if draft.index?.skipHidden !== undefined}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.index!.skipHidden}
/>
Skip hidden files
</label>
{/if}
</section>
{/if}
<!-- Features — PhotoPrism's gating bag. Render only the
keys actually present in the response (PP version
drift), labelled human-readably. -->
{#if draft.features && Object.keys(draft.features).length > 0}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Features
</h3>
<p class="text-muted-foreground">
Toggling a feature off hides it from PhotoPrism's own
UI and disables the underlying API surface.
</p>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.keys(draft.features).sort() as key (key)}
{#if typeof draft.features![key] === 'boolean'}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={draft.features![key]}
/>
<span class="capitalize">{key}</span>
</label>
{/if}
{/each}
</div>
</section>
{/if}
</div>
<div class="mt-4 flex items-center justify-end gap-2">
@@ -398,12 +563,136 @@
</div>
</Tabs.Content>
<!-- About — version, library counts, env-driven config help -->
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
{#if configQuery.isPending}
<InlineLoader size="sm" label="Loading server info…" />
{:else if configQuery.isError || !configQuery.data}
<EmptyState
size="compact"
tone="destructive"
icon={AlertCircle}
title="Could not load server info"
/>
{:else}
{@const cfg = configQuery.data}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Server
</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
<span class="text-muted-foreground">PhotoPrism</span>
<span class="text-right tabular-nums">{cfg.edition} {cfg.version}</span>
<span class="text-muted-foreground">Site</span>
<span class="truncate text-right" title={cfg.siteUrl}>
{cfg.siteUrl || '—'}
</span>
<span class="text-muted-foreground">Auth mode</span>
<span class="text-right">{cfg.mode}</span>
</div>
</section>
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Features
</h3>
<div class="flex flex-wrap gap-1.5">
{#each envKnobs as knob (knob.envVar)}
<span
class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] {knob.on
? 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'
: 'border-border bg-secondary text-muted-foreground'}"
title={knob.envVar}
>
<span
class="h-1.5 w-1.5 rounded-full {knob.on
? 'bg-green-500'
: 'bg-muted-foreground/40'}"
></span>
{knob.label}
</span>
{/each}
</div>
</section>
{#if cfg.count}
<section class="space-y-1.5">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
{#each COUNT_ROWS as row (row.key)}
{@const v = cfg.count?.[row.key]}
{#if v !== undefined}
<span class="text-muted-foreground">{row.label}</span>
<span class="text-right tabular-nums">{v}</span>
{/if}
{/each}
</div>
</section>
{/if}
<!-- Env-driven config: there is no PhotoPrism API for these.
The panel surfaces what's on/off and reminds the admin
where to flip the switch — .env + restart. -->
<section class="space-y-2">
<button
type="button"
class="flex w-full items-center justify-between rounded border border-border bg-muted/30 px-2 py-1.5 text-left hover:bg-accent"
onclick={() => (envHelpOpen = !envHelpOpen)}
aria-expanded={envHelpOpen}
>
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Server configuration (env-driven)
</span>
<span class="text-[10px] text-muted-foreground">
{envHelpOpen ? '▾' : '▸'}
</span>
</button>
{#if envHelpOpen}
<div class="space-y-2 rounded border border-border bg-muted/20 p-2 text-[11px]">
<p class="text-muted-foreground">
These knobs aren't exposed through the API. Edit
<code class="rounded bg-background px-1">.env</code>
on the host and restart PhotoPrism:
</p>
<pre
class="overflow-x-auto rounded bg-background p-2 font-mono text-[11px] leading-snug"
>docker compose up -d photoprism
# or, with podman-compose:
podman-compose --env-file .env -f docker-compose.yml -f docker-compose.podman.yml up -d photoprism</pre>
<ul class="space-y-0.5">
{#each envKnobs as knob (knob.envVar)}
<li>
<code class="rounded bg-background px-1">{knob.envVar}</code>
<span class:text-green-600={knob.on}
class:text-muted-foreground={!knob.on}>
{knob.on ? 'enabled' : 'disabled'}
</span>
</li>
{/each}
</ul>
{#if cfg.ext?.oidc?.enabled}
<p class="text-muted-foreground">
OIDC provider:
<span class="text-foreground">
{cfg.ext.oidc.provider ?? '—'}
</span>
</p>
{/if}
</div>
{/if}
</section>
{/if}
</Tabs.Content>
<!-- Logs — recent server errors -->
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
<div class="flex items-center justify-between">
<p class="text-muted-foreground">
Most recent PhotoPrism errors and warnings. Auto-refreshes
every 5 seconds.
Most recent server errors and warnings. Auto-refreshes every
5 seconds.
</p>
<button
type="button"
@@ -418,11 +707,16 @@
</button>
</div>
{#if errorsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading error log…" />
{:else if errorsQuery.isError}
<p class="px-1 text-destructive">Could not load error log.</p>
<EmptyState
size="compact"
tone="destructive"
icon={AlertCircle}
title="Could not load error log"
/>
{:else if (errorsQuery.data ?? []).length === 0}
<p class="px-1 text-muted-foreground">No errors logged.</p>
<EmptyState size="compact" icon={CheckCircle2} title="No errors logged" />
{:else}
<ul
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"

View File

@@ -0,0 +1,484 @@
<!--
Admin-only user management. PhotoPrism exposes /api/v1/users CRUD; this
dialog wraps it in a list/edit two-pane so the PP web UI never has to be
opened for routine user changes. Mounted from LeftSidebar's footer
(visible only when session.user.Role === 'admin').
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Loader2, Plus, Trash2, Users as UsersIcon, X } from 'lucide-svelte';
import {
createUser,
deleteUser,
listUsers,
setUserPassword,
updateUser,
type CreateUserBody
} from '$lib/services/photoprism';
import type { PpRole, PpUser } from '$lib/types/photoprism';
import { session } from '$lib/stores/session.svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
const usersQuery = createQuery<PpUser[]>(() => ({
queryKey: ['users'],
queryFn: listUsers,
enabled: open
}));
// Selection state: a UID picks an existing user from the list; `null`
// means "no selection" (right pane empty); `'new'` opens the new-user
// form. Reset whenever the dialog opens so reopening doesn't strand a
// stale form.
type Selection = string | 'new' | null;
let selection = $state<Selection>(null);
$effect(() => {
if (open) selection = null;
});
const ROLES: PpRole[] = ['admin', 'user', 'contributor', 'guest', 'visitor'];
// Editable copy of the selected user. Re-cloned on every selection
// change so the form starts from the server-side snapshot (and a
// failed save doesn't leak stale values into the next selection).
let draft = $state<EditableUser>(emptyDraft());
interface EditableUser {
UID: string;
Name: string;
DisplayName: string;
Email: string;
Role: PpRole;
BasePath: string;
UploadPath: string;
WebDAV: boolean;
Password: string;
}
function emptyDraft(): EditableUser {
return {
UID: '',
Name: '',
DisplayName: '',
Email: '',
Role: 'user',
BasePath: '',
UploadPath: '',
WebDAV: false,
Password: ''
};
}
function userToDraft(u: PpUser): EditableUser {
return {
UID: u.UID,
Name: u.Name ?? '',
DisplayName: u.DisplayName ?? '',
Email: u.Email ?? '',
Role: u.Role ?? 'user',
BasePath: u.BasePath ?? '',
UploadPath: u.UploadPath ?? '',
// Server may or may not return WebDAV depending on PP version;
// default to false rather than guessing the current value.
WebDAV: Boolean((u as PpUser & { WebDAV?: boolean }).WebDAV),
Password: ''
};
}
$effect(() => {
if (selection === 'new') {
draft = emptyDraft();
} else if (selection) {
const u = (usersQuery.data ?? []).find((x) => x.UID === selection);
if (u) draft = userToDraft(u);
} else {
draft = emptyDraft();
}
});
// Password sub-form (only relevant when editing an existing user).
// Decoupled from `draft` because the password endpoint is a separate
// PUT and never goes through createUser/updateUser.
let pwNew = $state('');
let pwConfirm = $state('');
$effect(() => {
// Reset password fields whenever the selection changes.
void selection;
pwNew = '';
pwConfirm = '';
});
function toBody(d: EditableUser): CreateUserBody {
const body: CreateUserBody = {
Name: d.Name.trim(),
Role: d.Role
};
if (d.DisplayName.trim()) body.DisplayName = d.DisplayName.trim();
if (d.Email.trim()) body.Email = d.Email.trim();
if (d.BasePath.trim()) body.BasePath = d.BasePath.trim();
if (d.UploadPath.trim()) body.UploadPath = d.UploadPath.trim();
body.WebDAV = d.WebDAV;
return body;
}
const createMut = createMutation(() => ({
mutationFn: async () => {
if (!draft.Name.trim()) throw new Error('Username is required');
if (!draft.Password || draft.Password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
const body = toBody(draft);
body.Password = draft.Password;
return createUser(body);
},
onSuccess: (u) => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success(`Created user ${u.Name}`);
selection = u.UID;
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create user')
}));
const updateMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
return updateUser(selection, toBody(draft));
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success('User updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update user')
}));
const deleteMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
return deleteUser(selection);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] });
toast.success('User deleted');
selection = null;
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not delete user')
}));
const pwMut = createMutation(() => ({
mutationFn: async () => {
if (!selection || selection === 'new') throw new Error('No user selected');
if (pwNew.length < 8) throw new Error('Password must be at least 8 characters');
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
// Admin-issued password reset: PhotoPrism accepts an empty `old`
// when the caller is an admin acting on another user.
await setUserPassword(selection, '', pwNew);
},
onSuccess: () => {
pwNew = '';
pwConfirm = '';
toast.success('Password updated');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not update password')
}));
function onDeleteClick() {
if (!draft.Name) return;
if (!confirm(`Delete user "${draft.Name}"? This cannot be undone.`)) return;
deleteMut.mutate();
}
const isSelf = $derived(
selection !== 'new' && selection !== null && selection === session.user?.UID
);
const inputClass =
'rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring';
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
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-[760px] -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">
<UsersIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">Users</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Manage accounts. Roles + per-user library paths come from the
server's ACL — changes apply immediately.
</Dialog.Description>
</div>
<Dialog.Close
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close"
>
<X class="h-3.5 w-3.5" />
</Dialog.Close>
</div>
<div class="grid grid-cols-[220px_1fr] gap-4">
<!-- Left pane: user list + new-user trigger. -->
<div class="flex max-h-[480px] flex-col overflow-hidden rounded border border-border">
<button
type="button"
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-border px-2 text-left text-[12px] hover:bg-accent"
class:bg-accent={selection === 'new'}
onclick={() => (selection = 'new')}
>
<Plus class="h-3.5 w-3.5" />
<span>New user</span>
</button>
<div class="flex-1 overflow-y-auto">
{#if usersQuery.isPending}
<p class="px-2 py-2 text-[12px] text-muted-foreground">Loading…</p>
{:else if usersQuery.isError}
<p class="px-2 py-2 text-[12px] text-destructive">
Could not load users.
</p>
{:else}
<ul>
{#each usersQuery.data ?? [] as u (u.UID)}
{@const active = selection === u.UID}
<button
type="button"
class="flex w-full flex-col gap-0.5 border-b border-border/40 px-2 py-1.5 text-left text-[12px] hover:bg-accent"
class:bg-accent={active}
onclick={() => (selection = u.UID)}
>
<span class="flex items-center justify-between gap-2">
<span class="truncate font-medium">
{u.DisplayName?.trim() || u.Name}
</span>
<span
class="shrink-0 rounded bg-secondary px-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
{u.Role}
</span>
</span>
{#if u.BasePath}
<span class="truncate text-[11px] text-muted-foreground">
{u.BasePath}
</span>
{/if}
</button>
{/each}
</ul>
{/if}
</div>
</div>
<!-- Right pane: edit form for selected user (or empty/new form). -->
<div class="min-w-0">
{#if selection === null}
<div
class="flex h-full min-h-[280px] items-center justify-center rounded border border-dashed border-border p-4 text-center text-[12px] text-muted-foreground"
>
Pick a user on the left, or click "New user" to create one.
</div>
{:else}
<form
class="space-y-3"
onsubmit={(e) => {
e.preventDefault();
if (selection === 'new') createMut.mutate();
else updateMut.mutate();
}}
>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Username
<span class="text-destructive">*</span>
</span>
<input
type="text"
bind:value={draft.Name}
required
autocomplete="off"
disabled={selection !== 'new'}
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Display name</span>
<input
type="text"
bind:value={draft.DisplayName}
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Email</span>
<input
type="email"
bind:value={draft.Email}
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Role</span>
<select bind:value={draft.Role} class={inputClass}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Base path
</span>
<input
type="text"
bind:value={draft.BasePath}
placeholder="e.g. alice"
autocomplete="off"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Upload path
</span>
<input
type="text"
bind:value={draft.UploadPath}
autocomplete="off"
class={inputClass}
/>
</label>
</div>
<label class="flex items-center gap-2 text-[12px]">
<input type="checkbox" bind:checked={draft.WebDAV} />
Allow WebDAV access
</label>
{#if selection === 'new'}
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">
Initial password <span class="text-destructive">*</span>
</span>
<input
type="password"
bind:value={draft.Password}
required
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
{/if}
<div class="flex items-center justify-between gap-2 border-t border-border pt-3">
{#if selection !== 'new'}
<button
type="button"
class="flex items-center gap-1.5 rounded border border-destructive/40 px-3 py-1 text-[12px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
onclick={onDeleteClick}
disabled={isSelf || deleteMut.isPending}
title={isSelf ? 'Cannot delete yourself' : 'Delete user'}
>
{#if deleteMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{:else}
<Trash2 class="h-3 w-3" />
{/if}
Delete
</button>
{:else}
<span></span>
{/if}
<button
type="submit"
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"
disabled={createMut.isPending || updateMut.isPending || !draft.Name.trim()}
>
{#if createMut.isPending || updateMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{selection === 'new' ? 'Create user' : 'Save changes'}
</button>
</div>
</form>
{#if selection !== 'new'}
<!-- Admin-issued password reset. Separate from the user's own
password change in GeneralSettingsDialog (which requires
their current password); admins reset without old-pw. -->
<form
class="mt-4 space-y-3 rounded border border-border bg-muted/30 p-3"
onsubmit={(e) => {
e.preventDefault();
pwMut.mutate();
}}
>
<h4 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Reset password
</h4>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">New password</span>
<input
type="password"
bind:value={pwNew}
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-muted-foreground">Confirm</span>
<input
type="password"
bind:value={pwConfirm}
minlength={8}
autocomplete="new-password"
class={inputClass}
/>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
class="flex items-center gap-1.5 rounded border border-border px-3 py-1 text-[12px] hover:bg-accent disabled:opacity-50"
disabled={pwMut.isPending || pwNew.length < 8 || pwNew !== pwConfirm}
>
{#if pwMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Set password
</button>
</div>
</form>
{/if}
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -22,7 +22,7 @@
setFocused,
toggle
} from '$lib/stores/selection.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
const qc = useQueryClient();
@@ -30,22 +30,22 @@
* any time, plenty to cover normal arrow-skim without bloating. */
const WINDOW = 50;
function lookup(uid: string): string | null {
function lookup(uid: string): PpPhoto | null {
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
if (direct) return primaryFile(direct).Hash ?? null;
if (direct) return direct;
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null;
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null;
if (hit) return hit;
}
}
return null;
@@ -58,6 +58,7 @@
interface Tile {
uid: string;
hash: string | null;
video: boolean;
idx: number;
}
const slice = $derived.by<Tile[]>(() => {
@@ -66,7 +67,13 @@
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
const out: Tile[] = [];
for (let i = lo; i < hi; i++) {
out.push({ uid: order[i], hash: lookup(order[i]), idx: i });
const photo = lookup(order[i]);
out.push({
uid: order[i],
hash: photo ? (primaryFile(photo).Hash ?? null) : null,
video: photo ? isVideo(photo) : false,
idx: i
});
}
return out;
});
@@ -148,12 +155,19 @@
alt=""
loading="lazy"
decoding="async"
fetchpriority="low"
class="h-full w-full object-cover"
/>
{/if}
{#if isSelected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if tile.video}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/each}
</div>

View File

@@ -33,6 +33,7 @@
import PreviewCarousel from './PreviewCarousel.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import { InlineLoader } from '$lib/components/feedback';
const focusedUid = $derived(selection.focused);
@@ -133,7 +134,11 @@
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
</Dialog.Description>
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
<!-- Top row: preview pane (fills) + sidebar (fixed width).
BulkActionBar lives inside the main column — same shape as the
timeline (+page.svelte) so the bar stays bounded by the
column's width and doesn't stretch under the metadata
sidebar. -->
<div class="flex min-h-0 flex-1">
<div class="relative flex min-w-0 flex-1 flex-col">
<button
@@ -148,19 +153,24 @@
<div class="flex min-h-0 flex-1">
<PreviewPane uid={focusedUid} order={selection.order} />
</div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
</div>
{#if focusedPhotoQuery.data}
<!-- Sidebar stays mounted across photo changes so the preview
pane doesn't reflow on arrow-skim; contents swap between
the metadata panel and a small loader the same way the
timeline's right-aside does. -->
<aside
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
>
{#if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
</aside>
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{/if}
</aside>
</div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
<!-- Bottom filmstrip across selection.order. -->
<PreviewCarousel />
</Dialog.Content>

View File

@@ -9,12 +9,15 @@
throw away. Until the timer fires, the poster image stands in.
-->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
interface Props {
uid: string | null;
@@ -25,6 +28,8 @@
}
let { uid, order, showChevrons = true }: Props = $props();
const qc = useQueryClient();
const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', uid ?? ''],
queryFn: () => getPhoto(uid as string),
@@ -33,6 +38,48 @@
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
/** Mirrors PreviewCarousel.lookup — pulls a PpPhoto out of TanStack's
* cache without firing a fetch, so we can resolve adjacent hashes for
* prefetching without making the prefetch itself trigger more work. */
function lookupCached(target: string): PpPhoto | null {
const direct = qc.getQueryData<PpPhoto>(['photo', target]);
if (direct) return direct;
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === target);
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === target);
if (hit) return hit;
}
}
return null;
}
// Prefetch fit_1280 for the ±2 neighbours of the focused photo so
// arrow-skim feels instant. We `new Image()` rather than `<link
// rel=preload>` because the URLs are runtime-derived and a throwaway
// Image() reuses the browser's HTTP cache the same way.
$effect(() => {
if (!uid || currentIndex < 0) return;
for (const offset of [-1, 1, -2, 2]) {
const idx = currentIndex + offset;
if (idx < 0 || idx >= order.length) continue;
const photo = lookupCached(order[idx]);
if (!photo) continue;
const hash = primaryFile(photo).Hash;
if (!hash) continue;
const img = new Image();
img.src = thumbUrl(hash, 'fit_1280');
}
});
const VIDEO_LOAD_DELAY_MS = 250;
let armedUid = $state<string | null>(null);
@@ -58,11 +105,11 @@
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
{#if uid === null}
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
<EmptyState icon={ImageIcon} title="Select a photo to preview" />
{:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p>
<InlineLoader label="Loading photo…" align="center" />
{:else if photoQuery.isError}
<p class="text-sm text-destructive">Failed to load photo.</p>
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photo" />
{:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)}
{#if showChevrons && currentIndex > 0}
@@ -84,9 +131,8 @@
</button>
{/if}
{#if isVideo(photoQuery.data)}
{#if isVideo(photoQuery.data) && armedUid === uid}
{@const vf = videoFile(photoQuery.data)}
{#if armedUid === uid}
{#key vf.Hash}
<VideoPlayer
src={videoUrl(vf.Hash)}
@@ -95,17 +141,33 @@
/>
{/key}
{:else}
{@const altText =
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img
src={thumbUrl(pf.Hash, 'fit_1280')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
src={thumbSrc(pf.Hash, view.thumbnailSize)}
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
style="aspect-ratio: {pf.Width} / {pf.Height};"
/>
{/if}
{:else}
<img
src={thumbUrl(pf.Hash, 'fit_1280')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
alt={altText}
fetchpriority="high"
decoding="async"
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
/>
{/if}
{/if}

View File

@@ -1,171 +1,29 @@
<!--
One review-queue cause group rendered as a card. Mirrors StackGroupCard's
chrome (focusable container, ResizeObserver column tracking, keyboard
nav) but the per-tile semantics differ:
One review-queue cause group, rendered as a thin wrapper around
PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
main, BulkActionBar below, RightSidebar/BulkMetadataSidebar on the
right) — this component just adds the per-group header + suggestion
row above the grid, then delegates tiles to PhotoGrid so selection,
keyboard nav, and previews work the same way they do everywhere else.
- Click a tile → emits `select` so the parent can open the metadata
sidebar. Shift-click toggles bulk-select instead of opening.
- Header has `Approve all` + `Archive all` for the whole group.
- Suggestion line above the grid spotlights the likely-correct bulk
action with an inline button (per the plan).
- Keyboard: arrows move the focused tile; `S` approves the focused
tile, `A` archives it, `Enter` opens detail, `Esc` blurs.
The Low Resolution tab opts in to PhotoTile's dimension badge so the
user can spot under-2-MP photos without opening each tile.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
approvePhoto,
batchArchive
} from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import {
deriveCauses,
type ReviewGroup
} from '$lib/services/adapters/review';
import CauseBadges from './CauseBadges.svelte';
import { batchArchive } from '$lib/services/photoprism';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import { type ReviewGroup } from '$lib/services/adapters/review';
interface Props {
group: ReviewGroup;
autoFocus?: boolean;
/** Parent emits when the user picks a tile to inspect (Enter or
* plain click). Parent owns the RightSidebar mount. */
onSelect?: (photo: PpPhoto) => void;
}
let { group, autoFocus = false, onSelect }: Props = $props();
let { group }: Props = $props();
const qc = useQueryClient();
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let focusedIdx = $state(0);
let cols = $state(1);
let busy = $state(false);
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
});
// Match StackGroupCard's column-tracking trick so arrow Up/Down jump
// by row width.
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
});
function moveFocus(delta: number) {
if (group.photos.length === 0) return;
focusedIdx = Math.min(
Math.max(0, focusedIdx + delta),
group.photos.length - 1
);
}
function dims(p: PpPhoto): string {
const f = primaryFile(p);
const w = p.Width ?? f.Width;
const h = p.Height ?? f.Height;
if (!w || !h) return '';
return `${w}×${h}`;
}
function thumb(p: PpPhoto): string {
// list endpoint puts the hash on the photo itself; primaryFile is
// the fallback for detail responses.
const h = p.Hash ?? primaryFile(p).Hash;
return h ? thumbUrl(h, 'tile_500') : '';
}
async function approveOne(p: PpPhoto) {
if (busy) return;
busy = true;
try {
await approvePhoto(p.UID);
toast.success('Approved');
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Approve failed');
} finally {
busy = false;
}
}
async function archiveOne(p: PpPhoto) {
if (busy) return;
busy = true;
try {
await batchArchive([p.UID]);
toast.success('Archived');
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
} finally {
busy = false;
}
}
async function approveAll() {
if (busy || group.photos.length === 0) return;
if (!confirm(`Approve all ${group.photos.length} photos in "${group.meta.title}"?`)) return;
busy = true;
const total = group.photos.length;
let done = 0;
const toastId = toast.loading(`Approving 0 / ${total}…`);
try {
// PhotoPrism has no batch-approve, so fan out one-at-a-time.
// A small concurrency cap keeps the server responsive without
// stalling for very large groups.
const QUEUE = 4;
const uids = group.photos.map((p) => p.UID);
let idx = 0;
async function worker() {
while (idx < uids.length) {
const my = idx++;
try {
await approvePhoto(uids[my]);
} catch {
// Carry on — partial success is better than abort.
}
done++;
toast.loading(`Approving ${done} / ${total}…`, { id: toastId });
}
}
await Promise.all(Array.from({ length: Math.min(QUEUE, uids.length) }, worker));
toast.success(`Approved ${done} / ${total}`, { id: toastId });
void qc.invalidateQueries({ queryKey: ['review-groups'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Approve all failed', {
id: toastId
});
} finally {
busy = false;
}
}
async function archiveAll() {
if (busy || group.photos.length === 0) return;
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
@@ -183,64 +41,14 @@
}
}
function onKeydown(e: KeyboardEvent) {
if (busy) return;
const p = group.photos[focusedIdx];
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
moveFocus(-1);
return;
case 'ArrowRight':
e.preventDefault();
moveFocus(1);
return;
case 'ArrowUp':
e.preventDefault();
moveFocus(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveFocus(cols);
return;
case 'Enter':
e.preventDefault();
if (p) onSelect?.(p);
return;
case 's':
case 'S':
e.preventDefault();
if (p) void approveOne(p);
return;
case 'a':
case 'A':
e.preventDefault();
if (p) void archiveOne(p);
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
}
}
function runSuggestion() {
if (group.meta.suggestedAction === 'approve') void approveAll();
else if (group.meta.suggestedAction === 'archive') void archiveAll();
// 'manual' suggestion has no button — the suggestion line is text-only.
// Only 'archive' suggestions are reachable through this button now —
// the page's BulkActionBar handles per-photo / multi-select Keep.
if (group.meta.suggestedAction === 'archive') void archiveAll();
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Cause group ${group.meta.title} with ${group.photos.length} photos`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"
>
<div class="space-y-2">
<header class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-foreground">
@@ -248,128 +56,30 @@
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.photos.length === 0}
onclick={approveAll}
title="Approve every photo in this group"
>
Approve all
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.photos.length === 0}
onclick={archiveAll}
title="Archive every photo in this group"
>
Archive all
</button>
</div>
</header>
<!-- Suggestion line — sits above the grid, surfaces the likely-correct
bulk action with an inline trigger. 'manual' causes get no
inline button; the user has to use the header bulk bar instead. -->
<!-- Suggestion line — surfaces the likely-correct bulk action. Only
'archive' renders a quick-button; 'manual' is text-only and
'approve' is unused today. Per-photo Keep / Archive comes from the
page's BulkActionBar (review section) once the user selects. -->
<div
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
>
<span>{group.meta.suggestion}</span>
{#if group.meta.suggestedAction !== 'manual'}
{#if group.meta.suggestedAction === 'archive'}
<button
type="button"
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={runSuggestion}
>
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'}
Archive all
</button>
{/if}
</div>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.photos as photo, i (photo.UID)}
{@const causes = deriveCauses(photo)}
{@const isFocused = i === focusedIdx}
<!-- Tile is a <div> with role=button so the inner per-tile
action buttons aren't nested inside another <button> (which
is invalid HTML and trips a11y linters). -->
<div
role="button"
tabindex="-1"
aria-label={`${photo.FileName ?? photo.Name ?? photo.UID} — press Enter to inspect`}
onclick={() => onSelect?.(photo)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect?.(photo);
}
}}
class:ring-2={isFocused}
class:ring-blue-500={isFocused}
class:ring-offset-2={isFocused}
class:ring-offset-background={isFocused}
class="group relative flex cursor-pointer flex-col overflow-hidden rounded-md border border-border bg-secondary text-left transition-shadow"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumb(photo)}
alt={photo.FileName ?? photo.Name ?? ''}
loading="lazy"
class="h-full w-full object-cover"
<PhotoGrid
photos={group.photos}
dimensionBadge={group.cause === 'low_resolution'}
/>
{#if dims(photo)}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
>
{dims(photo)}
</span>
{/if}
<!-- Per-tile hover actions: stop propagation so a click
here doesn't also open the sidebar. -->
<div
class="absolute bottom-1.5 right-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
type="button"
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
onclick={(e) => {
e.stopPropagation();
void approveOne(photo);
}}
title="Approve (S)"
>
Approve
</button>
<button
type="button"
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
onclick={(e) => {
e.stopPropagation();
void archiveOne(photo);
}}
title="Archive (A)"
>
Archive
</button>
</div>
</div>
<div class="space-y-1 px-2 py-1.5">
<CauseBadges {causes} />
<div
class="truncate text-[10px] leading-tight text-muted-foreground"
title={photo.FileName ?? photo.Name ?? ''}
>
{photo.FileName ?? photo.Name ?? ''}
</div>
</div>
</div>
{/each}
</div>
</div>

View File

@@ -18,6 +18,7 @@
type UpdatePhotoBody
} from '$lib/services/photoprism';
import { patchTargets } from '$lib/services/bulk';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
const qc = useQueryClient();
@@ -119,13 +120,6 @@
colorDraft = null;
}
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
async function applyKeyword() {
if (busy) return;
const kw = keywordDraft.trim().replace(/,/g, '');
@@ -270,16 +264,18 @@
</button>
</section>
<!-- Color label — same pattern as Score. -->
<!-- Colors — same pattern as Score. -->
<section class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)}
{@const picked = colorDraft === c.key}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}"
class:ring-foreground={colorDraft === c.key}
class:ring-transparent={colorDraft !== c.key}
class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
? c.bg
: 'bg-transparent'}"
aria-pressed={picked}
disabled={busy}
onclick={() => (colorDraft = c.key)}
title={`Pick ${c.title}`}

View File

@@ -1,100 +0,0 @@
<!--
One horizontal strip of related-photo thumbnails for the metadata
sidebar. Used three times on the /review sidebar (folder / camera /
year). Self-fetches via the PhotoPrism DSL so each strip stays
independent.
The header is clickable: it navigates back to the timeline with the
same DSL applied as a `?q=` param, so the user can drill into the
full result set if they want to. Strips with zero hits collapse
silently — no header, no whitespace.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { listPhotos } from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
interface Props {
title: string;
/** PhotoPrism DSL fragment, e.g. `path:"2024/lyon"` or `year:2024`. */
q: string;
/** Cap on tiles rendered in the strip. Defaults to a small set
* that fits one row in a typical sidebar width. */
limit?: number;
/** UID to filter out — usually the photo whose sidebar this strip
* is on, so the user doesn't see itself in its own "related"
* list. */
excludeUid?: string;
}
let { title, q, limit = 12, excludeUid }: Props = $props();
const stripQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['related', q, limit],
queryFn: () => listPhotos({ q, count: limit + 1, order: 'newest' }),
// Strips are cheap to refetch; the data behind them changes
// rarely, but a stale-while-revalidate window keeps the sidebar
// snappy when the user clicks through similar photos.
staleTime: 60_000,
enabled: q.length > 0
}));
const photos = $derived(
(stripQuery.data ?? []).filter((p) => p.UID !== excludeUid).slice(0, limit)
);
function openTimeline() {
// Same `?q=` param the timeline already accepts (see filters store)
// — clicking the strip header pivots the main timeline into the
// same filtered scope so the user can browse the full set.
const params = new URLSearchParams({ q });
void goto(`/?${params.toString()}`, { keepFocus: true });
}
function openOne(uid: string) {
// Focus the picked photo so the sidebar re-renders against it.
// Useful for the "decide these together" workflow without leaving
// the review page.
setFocused(uid);
}
</script>
{#if stripQuery.isPending}
<div class="text-[10px] text-muted-foreground/70">Loading {title.toLowerCase()}</div>
{:else if stripQuery.isError}
<!-- Errors shouldn't break the sidebar; just hide the strip. -->
{null}
{:else if photos.length > 0}
<div class="space-y-1">
<button
type="button"
class="flex w-full items-baseline justify-between text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground"
onclick={openTimeline}
title={`Open the timeline filtered by ${q}`}
>
<span>{title}</span>
<span class="text-muted-foreground/70">({photos.length}+)</span>
</button>
<div class="flex gap-1 overflow-x-auto">
{#each photos as p (p.UID)}
<button
type="button"
class="h-12 w-12 shrink-0 overflow-hidden rounded border border-border bg-secondary hover:border-primary"
onclick={() => openOne(p.UID)}
title={p.FileName ?? p.Name ?? p.UID}
>
{#if p.Hash}
<img
src={thumbUrl(p.Hash, 'tile_100')}
alt=""
loading="lazy"
class="h-full w-full object-cover"
/>
{/if}
</button>
{/each}
</div>
</div>
{/if}

View File

@@ -6,17 +6,20 @@
PUT (Details fields need the full body).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
Aperture,
ArrowUpRight,
Calendar,
ExternalLink,
File,
Folder,
HardDrive,
ImageIcon,
Loader2,
Map as MapIcon,
MapPin,
Star,
Tag,
@@ -37,16 +40,15 @@
import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import RelatedStrip from './RelatedStrip.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { navigateToFolder } from '$lib/stores/filters.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props {
/** When true, append related-photo strips (folder/camera/year) below
* Keywords. Used by the /review route; left off on the timeline. */
showRelated?: boolean;
photo: PpPhoto;
}
let { photo, showRelated = false }: Props = $props();
let { photo }: Props = $props();
const qc = useQueryClient();
@@ -130,6 +132,32 @@
commit({ Caption: caption, CaptionSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
// are the photos with definitionally-untrusted dates, and showing the
// row anywhere else would compete with the existing TakenAt.
// PhotoPrism stores a TakenAt for stripped-EXIF photos too (filename
// guess or file mtime), so a per-photo "needs date" heuristic would
// silently hide the suggestion — the tab is the more reliable signal.
const onExifStrippedTab = $derived(
page.url.pathname === '/review' &&
page.url.searchParams.get('tab') === 'stripped_exif'
);
const dateSuggestion = $derived.by(() => {
const { fileName, path } = photoNameAndDir(photo);
return suggestDateFromPath({
fileName,
originalName: photo.OriginalName,
path
});
});
const showDateSuggestion = $derived(
onExifStrippedTab && !!dateSuggestion && dateSuggestion.iso !== takenAt
);
function applyDateSuggestion() {
if (!dateSuggestion) return;
takenAt = dateSuggestion.iso;
commitTakenAt();
}
function commitTakenAt() {
if (!takenAt) return;
if (!isValidISODate(takenAt)) {
@@ -225,29 +253,20 @@
}
/** Click-to-toggle: clicking the current color clears it; clicking a
* different swatch swaps. Same four-swatch palette as mule-image. */
* different swatch swaps. */
function setColor(next: string) {
const value = currentColor === next ? '' : next;
if (value === currentColor) return;
void applyMark({ color: value });
}
// Tooltips follow the Lightroom culling convention so the swatches
// read as actions, not just colors. Red = reject, Yellow = pick,
// Green = keep, Orange = review-later.
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? '');
const pf = $derived(primaryFile(photo));
const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived(
pf.Size
@@ -266,12 +285,6 @@
? photo.Country.toUpperCase()
: ''
);
const mapsHref = $derived(
photo.Lat && photo.Lng
? `https://www.openstreetmap.org/?mlat=${photo.Lat}&mlon=${photo.Lng}&zoom=15`
: ''
);
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
if (!c) return '';
const make = c.Make ?? '';
@@ -333,19 +346,61 @@
/>
</div>
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
padding on filename / date so the read-only text starts at the
same x-offset as the editable rows above — otherwise spans
hug the icon while inputs sit 4px in. -->
{#if dirPath}
<div class="flex items-center gap-2">
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}>
{dirPath}/
<!-- Date suggestion derived from filename / folder signals. Only
shown on the EXIF Stripped review tab; amber styling marks
it as unconfirmed. `(estimated day)` hint appears when the
day was synthesised because only Y-M was available — same
row, just so the user knows that part is fabricated. Apply
writes the value into the date input above and commits as
a manual TakenAt edit. -->
{#if showDateSuggestion && dateSuggestion}
<div
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
>
<Folder class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">
Suggested from path: <span class="font-medium">{dateSuggestion.iso}</span>
{#if dateSuggestion.source === 'path-ym-default-day'}
<span class="text-amber-600/80 dark:text-amber-400/70">(estimated day)</span>
{/if}
</span>
<button
type="button"
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
onclick={applyDateSuggestion}
>
Apply
</button>
</div>
{/if}
<!-- Folder (read-only label + open-in-timeline icon). The `px-1 py-0.5`
mirrors the input padding on filename / date so the read-only
text starts at the same x-offset as the editable rows above —
otherwise spans hug the icon while inputs sit 4px in. Root-level
files render as `/` so the row never disappears. The arrow-up-
right icon navigates to the timeline filtered by this folder
with the photo pre-focused. -->
<div class="flex items-center gap-2">
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
{folderLabel}
</span>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
void navigateToFolder(dirPath || '/', {
focusUid: photo.UID,
focusTakenAt: photo.TakenAt ?? null
})}
title="Open folder in timeline"
aria-label="Open folder in timeline"
>
<ArrowUpRight class="h-3 w-3" />
</button>
</div>
<!-- Dimensions -->
<div class="flex items-center gap-2">
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -362,45 +417,41 @@
</span>
</div>
<!-- Location -->
<!-- Location (read-only label + open-on-map icon). The arrow-up-
right icon flies the in-app map to the photo's coordinates at
zoom 17 (close enough for the photo's marker to be its own,
out of any cluster). Hidden when the photo has no
coordinates. -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if mapsHref}
<a
href={mapsHref}
target="_blank"
rel="noopener"
{#if photo.Lat && photo.Lng}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
title="Open in OpenStreetMap"
onclick={() =>
void goto(
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
)}
title="Open on map"
aria-label="Open on map"
>
<ExternalLink class="h-3 w-3" />
</a>
<MapIcon class="h-3 w-3" />
</button>
{/if}
</div>
</dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
mule-image's nomenclature). -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<!-- Tags — score, color label, keywords, and auto-labels grouped under
one collapsible section. Score + color are stored on the mule-
sidecar (PhotoPrism's PUT can't persist them); keywords live on
Details; auto-labels come from PhotoPrism's TF classifier and are
read-only. Open by default since these are the culling marks the
user reaches for first. -->
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
under one collapsible section. Note (PhotoPrism's Caption field,
labelled here to match mule-image's nomenclature) sits at the top
of the group since it's the most-edited per-photo field. Score +
color are stored on the mule-sidecar (PhotoPrism's PUT can't
persist them); keywords live on Details; auto-labels come from
PhotoPrism's TF classifier and are read-only. Open by default
since these are the culling marks the user reaches for first. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)}
@@ -414,6 +465,17 @@
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
@@ -434,14 +496,16 @@
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)}
{@const picked = currentColor === c.key}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
class:ring-foreground={currentColor === c.key}
class:ring-transparent={currentColor !== c.key}
class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
? c.bg
: 'bg-transparent'}"
aria-pressed={picked}
onclick={() => setColor(c.key)}
title={c.title}
aria-label={`Color ${c.key}`}
@@ -511,35 +575,6 @@
</div>
</details>
<!-- Related strips (only the /review route opts in). The three
scopes match the three decisions the user usually makes here:
"did all these come from the same shoot?" (folder), "same
camera, EXIF-stripped together?" (camera), "right year?"
(year). Strips with zero hits collapse silently. -->
{#if showRelated}
<div class="space-y-2 border-t border-border pt-2">
<RelatedStrip
title="Same folder"
q={`path:"${photo.Path ?? ''}"`}
excludeUid={photo.UID}
/>
{#if photo.CameraID && photo.CameraID !== 1}
<RelatedStrip
title="Same camera"
q={`camera:${photo.CameraID}`}
excludeUid={photo.UID}
/>
{/if}
{#if photo.Year}
<RelatedStrip
title="Same year"
q={`year:${photo.Year}`}
excludeUid={photo.UID}
/>
{/if}
</div>
{/if}
<!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr

View File

@@ -5,9 +5,11 @@
getAllMarks,
listLabels,
listPhotos,
listSubjects,
type AggregatedKeyword,
type PhotoMarksMap,
type PpLabel
type PpLabel,
type PpSubject
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
@@ -19,6 +21,8 @@
starLabel
} from '$lib/utils/tagGroups';
import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag, User } from 'lucide-svelte';
interface Props {
category: TagCategory;
@@ -54,6 +58,12 @@
staleTime: 5 * 60_000
}));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
@@ -96,6 +106,20 @@
return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q));
});
const subjectsSorted = $derived(
[...(subjectsQuery.data ?? [])].sort(
(a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)
)
);
const filteredSubjects = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return subjectsSorted;
return subjectsSorted.filter(
(s) =>
s.Name.toLowerCase().includes(q) || s.Slug.toLowerCase().includes(q)
);
});
const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
@@ -122,8 +146,10 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
function loadMore() {
visibleCount += PAGE_SIZE;
@@ -138,6 +164,9 @@
function pickKeyword(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickPerson(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickColor(key: string) {
if (selectedValue !== key) onSelect(key);
}
@@ -159,6 +188,9 @@
if (category === 'keywords') {
return keywordsSorted[0]?.keyword ?? null;
}
if (category === 'people') {
return subjectsSorted[0]?.Slug ?? null;
}
if (category === 'colors') {
return colorGroups[0]?.key ?? null;
}
@@ -187,12 +219,16 @@
? 'Labels'
: category === 'keywords'
? 'Keywords'
: category === 'people'
? 'People'
: category === 'colors'
? 'Colors'
: 'Ratings'
);
const showFilterInput = $derived(category === 'labels' || category === 'keywords');
const showFilterInput = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
);
</script>
<div class="flex h-full min-h-0 flex-col">
@@ -214,13 +250,15 @@
{#if category === 'labels'}
{#if labelsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading labels…</p>
<InlineLoader size="sm" label="Loading labels…" />
{:else if labelsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load labels.</p>
<EmptyState size="compact" tone="destructive" title="Failed to load labels" />
{:else if filteredLabels.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{filterText ? 'No labels match the filter.' : 'No labels yet.'}
</p>
<EmptyState
size="compact"
icon={Tag}
title={filterText ? 'No labels match the filter' : 'No labels yet'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleLabels as label (label.UID ?? label.Slug)}
@@ -276,17 +314,21 @@
{/if}
{:else if category === 'keywords'}
{#if keywordsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
Loading keywords… (aggregates from photo details — first load may take a few seconds)
</p>
<InlineLoader
size="sm"
label="Loading keywords… (aggregates from photo details — first load may take a few seconds)"
/>
{:else if keywordsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load keywords.</p>
<EmptyState size="compact" tone="destructive" title="Failed to load keywords" />
{:else if filteredKeywords.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{filterText
? 'No keywords match the filter.'
: 'No user-set keywords yet. Add them from a photos right-sidebar metadata panel.'}
</p>
<EmptyState
size="compact"
icon={Hash}
title={filterText ? 'No keywords match the filter' : 'No user-set keywords yet'}
description={filterText
? undefined
: 'Add them from a photos right-sidebar metadata panel.'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleKeywords as kw (kw.keyword)}
@@ -333,6 +375,74 @@
{/if}
</div>
{/if}
{:else if category === 'people'}
{#if subjectsQuery.isPending}
<InlineLoader size="sm" label="Loading people…" />
{:else if subjectsQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load people" />
{:else if filteredSubjects.length === 0}
<EmptyState
size="compact"
icon={User}
title={filterText ? 'No people match the filter' : 'No people yet'}
description={filterText
? undefined
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleSubjects as subject (subject.UID ?? subject.Slug)}
{@const active = subject.Slug === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickPerson(subject.Slug)}
title={subject.Name}
>
{#if subject.Thumb}
<img
src={thumbUrl(subject.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-secondary"
>
<User class="h-3 w-3 text-muted-foreground" />
</span>
{/if}
<span class="min-w-0 flex-1 truncate">{subject.Name}</span>
<span
class="flex h-4 min-w-[20px] 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'}"
>
{subject.PhotoCount ?? 0}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreSubjects,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreSubjects}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredSubjects.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
@@ -355,7 +465,8 @@
onclick={() => pickColor(swatch.key)}
title={swatch.title}
>
<span class="h-3 w-3 shrink-0 rounded-full {swatch.bg}"></span>
<span class="h-3 w-3 shrink-0 rounded-full border-2 bg-transparent {swatch.border}"
></span>
<span class="min-w-0 flex-1 truncate">{swatch.title}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
@@ -12,6 +13,9 @@
type PpAlbum
} from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import {
clearBulkToFirst,
clearSelection,
@@ -22,6 +26,8 @@
import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
const qc = useQueryClient();
let busy = $state(false);
@@ -48,11 +54,50 @@
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
);
const isBulk = $derived(selection.ids.size > 0);
// Filename for the single-focus label. Re-derives whenever
// selection.focused flips — cachedPhoto reads from the same query
// cache that drives the visible tiles, so the name resolves on the
// same tick the tile renders.
const focusedPhoto = $derived(selection.focused ? cachedPhoto(selection.focused) : undefined);
const focusedName = $derived(
focusedPhoto ? photoNameAndDir(focusedPhoto).fileName : ''
);
// Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with
// heap-adding / restoring. The S keybinding is rerouted to approve
// from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// "Accept date & Keep" is scoped to the EXIF Stripped review tab —
// that's where path-derived dates are the most useful fix. Outside the
// tab the button stays hidden even if a selected photo would otherwise
// have a path-parseable date, to keep other tabs uncluttered.
const onExifStrippedTab = $derived(
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
);
// Surface the button only when EVERY targeted photo has a derivable
// suggestion — otherwise clicking it would silently approve some
// photos without a date fix, which contradicts the verb. A uid not in
// any cache also counts as "no suggestion" so we don't promise
// something we can't verify.
const allHaveSuggestion = $derived.by(() => {
if (!onExifStrippedTab) return false;
const ids =
selection.ids.size > 0
? Array.from(selection.ids)
: selection.focused
? [selection.focused]
: [];
if (ids.length === 0) return false;
for (const id of ids) {
const p = cachedPhoto(id);
if (!p) return false;
const { fileName, path } = photoNameAndDir(p);
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
return false;
}
}
return true;
});
// 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;
@@ -97,6 +142,12 @@
});
}
async function onAcceptDateAndKeep() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(() => acceptDateAndKeep(ids));
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
@@ -172,7 +223,7 @@
// a no-op.
if (added.length === 0) {
toast.error(`Nothing added to ${heap.Title}`, {
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
});
return;
}
@@ -205,13 +256,19 @@
<div
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
>
<span class="shrink-0 text-[11px] font-medium text-foreground">
{#if isBulk}
<span class="shrink-0 text-[11px] font-medium text-foreground">
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
{:else}
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
<span
class="min-w-0 truncate text-[11px] font-medium text-foreground"
title={focusedName || undefined}
>
{focusedName || 'photo'}
</span>
{/if}
<!--
`overflow-x-auto` would clip the heap-picker dropdown — CSS
@@ -229,16 +286,31 @@
the archive section. Everything else (heap, 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"
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 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>
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
</button>
{#if allHaveSuggestion}
<!-- Visible only when every selected photo has a path-
derivable date. Clicking applies each photo's
suggestion then approves it; mirrored by the bare
`a` shortcut in gridKeyNav. -->
<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"
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
disabled={busy}
onclick={onAcceptDateAndKeep}
title="Accept the date suggested from the file/folder path, then keep"
>
📅 Accept date & Keep
<kbd class="rounded bg-amber-200/40 px-1 text-[9px] font-medium text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">A</kbd>
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
@@ -253,13 +325,13 @@
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"
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 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>
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">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"
@@ -273,13 +345,13 @@
{:else}
<div class="relative">
<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"
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={busy}
onclick={() => (heapPickerOpen = !heapPickerOpen)}
title="Add to heap (S then 19 picks a heap)"
>
Add to heap
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90"
>S&nbsp;N</kbd
>
</button>
@@ -288,9 +360,9 @@
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
>
{#if heapsQuery.isPending}
<p class="px-2 py-1 text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading heaps…" />
{:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p>
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else}
{#each heapsQuery.data ?? [] as heap, i (heap.UID)}
<button
@@ -318,7 +390,7 @@
{/if}
</div>
<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"
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
@@ -328,7 +400,7 @@
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>

View File

@@ -0,0 +1,81 @@
<!--
Notes-view flat grid — same skeleton as PhotoGrid, but each cell pairs
a photo with its note hint via NotesPhotoTile. Kept as a sibling
component (rather than threading a `note` slot through PhotoGrid) so
the Notes-view chrome stays out of the shared timeline/tags codepath.
The grid carries `data-photo-grid` and each tile (rendered inside
PhotoTile) carries `data-tile`+`data-uid` — same contract the
gridKeyNav action and shared selection helpers expect, so arrow-key
nav, range select, and bulk action bar work for free.
-->
<script lang="ts">
import { untrack } from 'svelte';
import {
isSelected,
selection,
setAnchor,
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview, view } from '$lib/stores/view.svelte';
import type { PhotoWithNote } from '$lib/services/photoprism';
import NotesPhotoTile from './NotesPhotoTile.svelte';
interface Props {
items: PhotoWithNote[];
columns?: string;
}
let { items, columns }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
const order = $derived(items.map((it) => it.photo.UID));
$effect(() => {
setOrder(order);
untrack(() => {
if (order.length === 0) {
setFocused(null);
selection.ids.clear();
return;
}
const cur = selection.focused;
if (cur && order.includes(cur)) return;
setFocused(order[0]);
setAnchor(order[0]);
selection.ids.clear();
});
});
function onClick(e: MouseEvent, uid: string) {
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();
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
openPreview();
}
</script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each items as item (item.photo.UID)}
{@const sel = isSelected(item.photo.UID) || selection.focused === item.photo.UID}
<NotesPhotoTile
photo={item.photo}
note={item.note}
selected={sel}
onClick={(e) => onClick(e, item.photo.UID)}
onDblclick={(e) => onDblclick(e, item.photo.UID)}
/>
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<!--
PhotoTile + footer card showing a hint of the photo's note. Only the
Notes view (/notes) uses this — every other surface keeps the bare
PhotoTile, so the note-card chrome doesn't leak into the timeline or
tag drill-ins.
Composition: square PhotoTile on top, presentational note strip
underneath. Clicks/dblclicks land on PhotoTile's own <button data-tile>
so selection/keyboard/preview behave exactly like every other tile.
-->
<script lang="ts">
import type { PpPhoto } from '$lib/types/photoprism';
import PhotoTile from './PhotoTile.svelte';
interface Props {
photo: PpPhoto;
selected: boolean;
note: string;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
}
let { photo, selected, note, onClick, onDblclick }: Props = $props();
</script>
<div class="flex h-full w-full flex-col">
<div class="aspect-square">
<PhotoTile {photo} {selected} {onClick} {onDblclick} />
</div>
<div
class="line-clamp-2 rounded-b-md border border-t-0 border-border bg-card px-2 py-1.5 text-[11px] leading-snug text-muted-foreground"
title={note}
>
{note}
</div>
</div>

View File

@@ -31,8 +31,12 @@
* `view.thumbnailSize` so drill-in grids honour the same XSXL
* preset the timeline uses. */
columns?: string;
/** Forwarded to every PhotoTile. The Low Resolution review tab
* opts in so users can spot pixel dimensions without opening
* each tile. */
dimensionBadge?: boolean;
}
let { photos, columns }: Props = $props();
let { photos, columns, dimensionBadge = false }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
@@ -91,6 +95,7 @@
<PhotoTile
{photo}
selected={sel}
{dimensionBadge}
onClick={(e) => onClick(e, photo.UID)}
onDblclick={(e) => onDblclick(e, photo.UID)}
/>

View File

@@ -22,11 +22,23 @@
selected: boolean;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
/** Opt-in `WxH` overlay in the top-right corner. Used by the Low
* Resolution review tab so the user can spot-check pixel dimensions
* without opening each tile. Default off so other surfaces stay
* uncluttered. */
dimensionBadge?: boolean;
}
let { photo, selected, onClick, onDblclick }: Props = $props();
let { photo, selected, onClick, onDblclick, dimensionBadge = false }: Props = $props();
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo));
const dims = $derived.by(() => {
if (!dimensionBadge) return '';
const f = primaryFile(photo);
const w = photo.Width ?? f.Width;
const h = photo.Height ?? f.Height;
return w && h ? `${w}×${h}` : '';
});
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
@@ -144,5 +156,11 @@
>VIDEO</span
>
{/if}
{#if dims}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
>{dims}</span
>
{/if}
</button>
</div>

View File

@@ -22,7 +22,7 @@
<div
aria-hidden="true"
class="grid gap-2"
class="mt-2 grid gap-2"
style="grid-template-columns: {tracks};"
>
{#each Array.from({ length: count }) as _, i (i)}

View File

@@ -53,7 +53,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
title: 'Implausible year',
chip: 'bad year',
suggestion:
"Filenames suggest a date PhotoPrism doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
"Filenames suggest a date the indexer doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
suggestedAction: 'manual'
},
non_image_type: {
@@ -66,7 +66,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
title: 'Other quality issues',
chip: 'low quality',
suggestion:
'PhotoPrism flagged these but the metadata looks fine. Open the first one to investigate.',
'The indexer flagged these but the metadata looks fine. Open the first one to investigate.',
suggestedAction: 'manual'
}
};

View File

@@ -14,10 +14,49 @@
import { toast } from 'svelte-sonner';
import { batchEdit } from './batch';
import { invalidatePhotos } from './bulk';
import { approvePhoto, batchArchive, batchRestore } from './photoprism';
import {
approvePhoto,
batchArchive,
batchRestore,
buildTakenAtPatch,
updatePhoto
} from './photoprism';
import { queryClient } from '$lib/queryClient';
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir, type PpPhoto } from '$lib/types/photoprism';
/** Walk every cache that might hold a photo's metadata — timeline list
* (flat or infinite), review-groups bucket, per-photo detail — without
* forcing a refetch. Returns undefined when the uid hasn't been seen.
* Shared by callers that need to look up photo state by uid from
* outside a component (gridKeyNav, photoActions). */
export function cachedPhoto(uid: string): PpPhoto | undefined {
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const pg of pages) {
const hit = pg?.find?.((p) => p.UID === uid);
if (hit) return hit;
}
}
const review = queryClient.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
if (review) {
for (const group of review) {
const hit = group.photos?.find((p) => p.UID === uid);
if (hit) return hit;
}
}
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
}
/**
* Dismiss photos out of the review queue by bumping their quality
@@ -44,6 +83,43 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
toast.success(`Dismissed ${uids.length}`);
}
/**
* Walk the selected uids, applying each photo's path-derived date
* suggestion (when one exists) before approving it. UIDs without a
* suggestion fall through to a plain approve. Used by the EXIF Stripped
* review tab — the `📅 Accept date & Keep` button and the bare `a`
* keyboard shortcut both route here so wording / focus / toast
* behaviour stay in lockstep.
*/
export async function acceptDateAndKeep(uids: string[]): Promise<void> {
if (uids.length === 0) return;
const { updated, errors } = await batchEdit(uids, async (id) => {
const p = cachedPhoto(id);
if (p) {
const { fileName, path } = photoNameAndDir(p);
const guess = suggestDateFromPath({
fileName,
originalName: p.OriginalName,
path
});
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
}
await approvePhoto(id);
return id;
});
focusAfter(uids);
clearSelection();
invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
description: errors[0].message
});
return;
}
toast.success(`Kept ${uids.length}`);
}
/**
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
*/

View File

@@ -13,6 +13,7 @@ import { primaryFile } from '$lib/types/photoprism';
import type {
PpClientConfig,
PpPhoto,
PpRole,
PpSessionResponse,
PpUser
} from '$lib/types/photoprism';
@@ -155,6 +156,95 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
return data;
}
/**
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
* older photos preceded by `after` newer ones, merged newest-first.
* Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's
* neighbours can be loaded without paging through the whole filter.
*
* Used by the timeline's deep-link focus mode: an in-app navigation
* stashes `{uid, takenAt}`, the timeline calls this with the anchor's
* date for page 0, and the target photo lands ~`afterCount` tiles
* down with `~beforeCount` older neighbours below it.
*
* Subsequent infinite-scroll pages use plain `listPhotos` with the
* standard offset cursor — the anchor mode only matters for page 0.
*/
export interface AroundParams {
/** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */
q?: string;
/** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */
takenAt: string;
/** How many photos newer than the anchor to fetch. */
afterCount?: number;
/** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */
beforeCount?: number;
merged?: boolean;
}
export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
const afterCount = p.afterCount ?? 30;
const beforeCount = p.beforeCount ?? 90;
const baseQ = p.q?.trim() ?? '';
// PhotoPrism's `before:`/`after:` operators take ISO timestamps.
// `+1s` / `-1s` makes the bounds inclusive of the anchor itself in
// the `before:` half (so the target tile is in the merged result).
const anchorDate = new Date(p.takenAt);
if (Number.isNaN(anchorDate.getTime())) {
// Date parse failed — fall back to a plain newest-first page.
return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged });
}
// PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round
// up/down by a day so the anchor's own day is included in the
// `before:` half — the bounds are inclusive day boundaries, so a
// timestamp-precision anchor lands inside the `[beforeBound,
// afterBound]` window.
function ymd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
const dayMs = 86_400_000;
const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs));
const afterBound = ymd(new Date(anchorDate.getTime() - dayMs));
const newerQ = `${baseQ} after:${afterBound}`.trim();
const olderQ = `${baseQ} before:${beforeBound}`.trim();
const [newerOldestFirst, older] = await Promise.all([
listPhotos({
q: newerQ,
count: afterCount,
order: 'oldest',
merged: p.merged ?? true
}),
listPhotos({
q: olderQ,
count: beforeCount,
order: 'newest',
merged: p.merged ?? true
})
]);
// `newerOldestFirst` is oldest→newest; reverse so it reads newest-first
// to match the standard timeline order, then concat the older window.
// Dedupe by UID in case the anchor itself shows up in both halves.
const merged: PpPhoto[] = [];
const seen = new Set<string>();
for (const p of newerOldestFirst.slice().reverse()) {
if (!seen.has(p.UID)) {
merged.push(p);
seen.add(p.UID);
}
}
for (const p of older) {
if (!seen.has(p.UID)) {
merged.push(p);
seen.add(p.UID);
}
}
return merged;
}
/**
* Count photos matching a DSL query, scoped to whatever the caller's
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
@@ -168,10 +258,17 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
* the signed-in user actually sees, not the global library aggregate
* exposed by `/config.count`.
*/
export async function countPhotos(q: string): Promise<number> {
const resp = await http.get('/photos', {
params: { count: 10000, offset: 0, merged: false, q }
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
const merged = opts.merged ?? false;
const resp = await http.get<PpPhoto[]>('/photos', {
params: { count: 10000, offset: 0, merged, q }
});
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
// `Files[]` entry), regardless of `merged`. With `merged: true` the
// response body is one entry per logical photo — so the body length
// is the canonical photo count when callers need to match what the
// timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`).
if (merged) return Array.isArray(resp.data) ? resp.data.length : 0;
const header = resp.headers['x-count'];
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
return Number.isFinite(n) ? n : 0;
@@ -477,6 +574,33 @@ export interface AggregatedKeyword {
sampleHash: string;
}
/**
* Photos carrying a non-empty user note. mule-image's "Note" is
* PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which
* is a top-level scalar — present on the list response, so a single
* round-trip is enough.
*/
export interface PhotoWithNote {
photo: PpPhoto;
note: string;
}
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
const list = await listPhotos({ count: 1000, order: 'newest', merged: true });
const out: PhotoWithNote[] = [];
const seen = new Set<string>();
for (const p of list) {
// `merged: true` can repeat a photo across file-rows; dedupe by UID
// so the same tile doesn't render twice.
if (seen.has(p.UID)) continue;
seen.add(p.UID);
const note = p.Caption?.trim();
if (!note) continue;
out.push({ photo: p, note });
}
return out;
}
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
const list = await listPhotos({ count: 1000, merged: true });
const buckets = new Map<string, AggregatedKeyword>();
@@ -525,6 +649,39 @@ export async function listLabels(): Promise<PpLabel[]> {
return data;
}
// ── Subjects (people / face recognition) ────────────────────────────────────
//
// PhotoPrism's face indexer clusters detected faces into Subjects, each with a
// stable UID, a human-editable Name, and a slug. The DSL operator `person:<slug>`
// filters photos to those carrying a marker assigned to that subject.
export interface PpSubject {
UID: string;
Slug: string;
Name: string;
Favorite?: boolean;
Private?: boolean;
Excluded?: boolean;
PhotoCount?: number;
Thumb?: string;
}
export async function listSubjects(): Promise<PpSubject[]> {
const { data } = await http.get<PpSubject[]>('/subjects', {
params: { count: 1000, order: 'count' }
});
return data ?? [];
}
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
const { data } = await http.put<PpSubject>(`/subjects/${uid}`, patch);
return data;
}
export async function deleteSubject(uid: string): Promise<void> {
await http.delete(`/subjects/${uid}`);
}
// ── Albums = Heaps ───────────────────────────────────────────────────────────
export interface PpAlbum {
@@ -831,7 +988,15 @@ export interface PpSettings {
showCaptions?: boolean;
};
maps?: { animate?: number; style?: string };
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
index?: {
path?: string;
convert?: boolean;
rescan?: boolean;
skipArchived?: boolean;
skipMeta?: boolean;
skipRaw?: boolean;
skipHidden?: boolean;
};
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: {
@@ -840,6 +1005,41 @@ export interface PpSettings {
originals?: boolean;
mediaRaw?: boolean;
mediaSidecar?: boolean;
crc32?: boolean;
sha1?: boolean;
};
/**
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
* matching API endpoints) inside PP's own SPA — disabling `share` for
* example hides every share button. Optional because older PP versions
* don't return the block; the Library tab only renders toggles for
* keys it actually sees in the response.
*/
features?: {
archive?: boolean;
private?: boolean;
review?: boolean;
files?: boolean;
folders?: boolean;
moments?: boolean;
calendar?: boolean;
places?: boolean;
edit?: boolean;
share?: boolean;
library?: boolean;
import?: boolean;
logs?: boolean;
search?: boolean;
account?: boolean;
settings?: boolean;
services?: boolean;
people?: boolean;
labels?: boolean;
download?: boolean;
upload?: boolean;
delete?: boolean;
ratings?: boolean;
[k: string]: boolean | undefined;
};
[k: string]: unknown;
}
@@ -907,6 +1107,55 @@ export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEnt
return data ?? [];
}
// ── Users ────────────────────────────────────────────────────────────────────
//
// PhotoPrism's admin user endpoints. List/create/update/delete require an
// admin session; the password endpoint accepts the user's own UID with their
// current password as `old`.
export interface CreateUserBody {
Name: string;
DisplayName?: string;
Email?: string;
Role: PpRole;
BasePath?: string;
UploadPath?: string;
WebDAV?: boolean;
Password?: string;
}
export type UpdateUserBody = Partial<CreateUserBody>;
export async function listUsers(): Promise<PpUser[]> {
const { data } = await http.get<PpUser[] | { users?: PpUser[] }>('/users', {
params: { count: 1000, order: 'name' }
});
if (Array.isArray(data)) return data;
return data.users ?? [];
}
export async function createUser(body: CreateUserBody): Promise<PpUser> {
const { data } = await http.post<PpUser>('/users', body);
return data;
}
export async function updateUser(uid: string, patch: UpdateUserBody): Promise<PpUser> {
const { data } = await http.put<PpUser>(`/users/${uid}`, patch);
return data;
}
export async function deleteUser(uid: string): Promise<void> {
await http.delete(`/users/${uid}`);
}
export async function setUserPassword(
uid: string,
oldPassword: string,
newPassword: string
): Promise<void> {
await http.put(`/users/${uid}/password`, { old: oldPassword, new: newPassword });
}
// ── Re-exports ───────────────────────────────────────────────────────────────
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };

View File

@@ -17,11 +17,12 @@ export type Section =
| 'hidden'
| 'heap';
export type TagCategory = 'labels' | 'keywords' | 'colors' | 'ratings';
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels',
'keywords',
'people',
'colors',
'ratings'
] as const;
@@ -85,6 +86,59 @@ export function setTagFilter(
filters.tagValue = value;
}
/**
* One-shot focus hand-off between an in-app navigation source (e.g. the
* RightSidebar's Folder open icon) and the timeline. We deliberately
* avoid encoding this in the URL — the store→URL effect on the
* timeline strips any param that `filtersToUrlParams` doesn't emit, so
* a `?focus=` param wouldn't survive the round-trip. A module-level
* stash that's consumed once on the next pageCount=1 landing is the
* simplest contract: not shareable, not replayed on refresh, but
* matches the "deep-link click" UX we want.
*
* `takenAt` (when known) lets the timeline anchor its first-page
* query around the target's date via PhotoPrism's `before:`/`after:`
* DSL — so deep-link focus works even for photos that aren't in the
* newest-120 page of the destination filter. Caller passes `null` if
* the date isn't readily available; the timeline can still attempt a
* page-1 match.
*/
export interface PendingFocus {
uid: string;
takenAt: string | null;
}
let pendingFocus: PendingFocus | null = null;
export function setPendingFocus(uid: string, takenAt: string | null = null): void {
pendingFocus = { uid, takenAt };
}
export function consumePendingFocus(): PendingFocus | null {
const v = pendingFocus;
pendingFocus = null;
return v;
}
/**
* Drill into a folder on the timeline. Mirrors the LeftSidebar tree's
* click handler: clear heap/section context so the folder filter
* applies on top of "all photos", then navigate. When `focusUid` is
* provided, the timeline's focus effect consumes the pending-focus
* stash on its first-page landing and pre-selects + scrolls to that
* photo instead of snapping to `photos[0]`. `focusTakenAt` enables
* the anchor-mode query so the photo can be found even when it would
* otherwise be past page 1.
*/
export async function navigateToFolder(
folderPath: string,
opts: { focusUid?: string; focusTakenAt?: string | null } = {}
): Promise<void> {
setSection('all-photos');
setFolderPath(folderPath);
if (opts.focusUid) setPendingFocus(opts.focusUid, opts.focusTakenAt ?? null);
const params = new URLSearchParams();
params.set('folder', folderPath);
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
/**
* Navigate to a tag-category browse URL. Path-segment shape
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
@@ -176,12 +230,15 @@ export function filtersToQ(f: FilterState = filters): string {
}
// Tag drill-down clauses for server-resolvable tag categories.
// Colors/ratings live in the mule-sidecar marks store and are
// applied client-side after the photo pool is fetched.
// applied client-side after the photo pool is fetched. People uses
// PhotoPrism's `person:` operator, which accepts the subject's slug.
if (f.tagCategory && f.tagValue) {
if (f.tagCategory === 'labels') {
parts.push(`label:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'keywords') {
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'people') {
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
}
}
if (f.search) parts.push(quoteIfNeeded(f.search));

View File

@@ -213,6 +213,23 @@ export interface PpPhotoLabel {
Label?: { Slug: string; Name: string };
}
/**
* Split a photo into its basename + directory portion using the
* primary file's relative `Name` (`'2024/02/IMG.jpg'` → `{ fileName:
* 'IMG.jpg', path: '2024/02' }`). Falls back to `photo.Path` when the
* primary file's `Name` lacks a directory prefix — that pairs with the
* list-endpoint shape where `Path` is its own field. Shared so date-
* suggestion code paths in RightSidebar / photoActions / gridKeyNav
* derive inputs the same way regardless of which cache shape they
* have on hand (list vs detail).
*/
export function photoNameAndDir(p: PpPhoto): { fileName: string; path: string } {
const full = primaryFile(p).Name ?? '';
const i = full.lastIndexOf('/');
if (i < 0) return { fileName: full, path: p.Path ?? '' };
return { fileName: full.slice(i + 1), path: full.slice(0, i) };
}
/**
* Return the photo's primary file (the one with `Primary: true`) or the
* first file if no primary marker is set. Falls back to a synthetic entry

View File

@@ -0,0 +1,221 @@
/**
* Best-effort calendar date guessed from a photo's filename / parent
* folders, with a confidence label so the UI can warn the user when
* the day is fabricated.
*
* Conventions we support (no false-positive risk):
* - Samsung / Android stock 20240226_135421.jpg
* - Google Pixel PXL_20240226_135421.123.jpg
* - WhatsApp IMG-20240226-WA0001.jpg
* - Telegram photo_2024-02-26_13-54-21.jpg
* - macOS screenshot Screen Shot 2024-02-26 at 1.54.21 PM.png
* - Android screenshot Screenshot_20240226-135421.png
* - Manual dot-format 2024.02.26 - title.jpg
* - WeChat mmexport1645900000000.jpeg (13-digit ms)
* - Facebook saves FB_IMG_1583926812.jpg (10-digit s)
* - Path forms 2024/02/26, 2024-02-26, 2024_02_26,
* 2024.02.26, plus Y-M-only 2024/02/
*
* Conventions we deliberately do NOT parse (locale-ambiguous or no
* recoverable signal):
* - DD-MM-YYYY / MM-DD-YYYY 26-02-2024.jpg, 02-26-2024.jpg
* - 2-digit years 24-02-26.jpg
* - Bare sequence numbers IMG_1234.HEIC, DSC_0123.NEF,
* GOPR0123.JPG, DJI_0123.JPG
*
* Used by the EXIF Stripped review tab to surface a date suggestion
* row in the metadata sidebar and to power the "Accept date & Keep"
* bulk action.
*/
import { isValidISODate } from '$lib/services/photoprism';
interface Input {
fileName?: string; // basename, e.g. '20240226_000000_A6D42DF3.jpg'
originalName?: string; // optional second filename signal (PpPhoto.OriginalName)
path?: string; // directory portion, e.g. '2024/02'
}
export interface DateGuess {
iso: string;
confidence: 'high' | 'medium';
source:
| 'filename-agrees-path'
| 'filename-only'
| 'unix-timestamp'
| 'path-ymd'
| 'path-ym-default-day';
}
interface YMD {
y: number;
m: number;
d: number;
}
interface YM {
y: number;
m: number;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
function isoOf(ymd: YMD): string {
return `${ymd.y}-${pad2(ymd.m)}-${pad2(ymd.d)}`;
}
function tryYMD(y: number, m: number, d: number): YMD | null {
if (y < 1900 || y > 2100) return null;
if (m < 1 || m > 12) return null;
if (d < 1 || d > 31) return null;
if (!isValidISODate(`${y}-${pad2(m)}-${pad2(d)}`)) return null;
return { y, m, d };
}
function tryYM(y: number, m: number): YM | null {
if (y < 1900 || y > 2100) return null;
if (m < 1 || m > 12) return null;
return { y, m };
}
// `YYYY[sep]MM[sep]DD` for basenames. sep ∈ {nothing, -, _, ., space}. The
// non-digit lookbehind/ahead keeps a leading prefix like `PXL_` and a
// trailing time like `_135421` from polluting the match.
const BASENAME_YMD = /(?<!\d)(\d{4})[-_. ]?(\d{2})[-_. ]?(\d{2})(?!\d)/;
// Path Y-M-D and Y-M. Includes `/` for directory separators and `.` for
// rare dot-organised libraries (`Photos/2024.02/...`).
const PATH_YMD = /(?<!\d)(\d{4})[-_/.](\d{2})[-_/.](\d{2})(?!\d)/;
const PATH_YM = /(?<!\d)(\d{4})[-_/.](\d{2})(?!\d)/;
// 10- or 13-digit Unix epoch, anchored. Years widened to [1990, current+1]
// to dodge accidental matches on phone numbers, hex hashes containing
// digits, etc. — but 10-digit seconds still has to round-trip into a
// plausible calendar year before we trust it.
const BASENAME_EPOCH = /(?<!\d)(\d{10}|\d{13})(?!\d)/;
function parseFilenameYMD(name: string): YMD | null {
const m = name.match(BASENAME_YMD);
if (!m) return null;
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
}
function parseUnixTimestampInName(name: string): YMD | null {
const m = name.match(BASENAME_EPOCH);
if (!m) return null;
const digits = m[1];
const ms = digits.length === 13 ? Number(digits) : Number(digits) * 1000;
if (!Number.isFinite(ms)) return null;
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return null;
const y = d.getUTCFullYear();
if (y < 1990 || y > new Date().getUTCFullYear() + 1) return null;
return tryYMD(y, d.getUTCMonth() + 1, d.getUTCDate());
}
function parsePathYMD(path: string): YMD | null {
const m = path.match(PATH_YMD);
if (!m) return null;
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
}
function parsePathYM(path: string): YM | null {
const m = path.match(PATH_YM);
if (!m) return null;
return tryYM(Number(m[1]), Number(m[2]));
}
function ymdAgreesWithYM(ymd: YMD, ym: YM): boolean {
return ymd.y === ym.y && ymd.m === ym.m;
}
function ymdEqual(a: YMD, b: YMD): boolean {
return a.y === b.y && a.m === b.m && a.d === b.d;
}
/** Pick the filename-derived YMD that best aligns with the path. When
* both `fileName` and `originalName` yield candidates, prefer the one
* that matches the path's year+month; ties fall back to `fileName`. */
function pickFilenameYMD(
fileName: string,
originalName: string,
pathYM: YM | null
): YMD | null {
const candidates: YMD[] = [];
const a = parseFilenameYMD(fileName);
if (a) candidates.push(a);
if (originalName && originalName !== fileName) {
const b = parseFilenameYMD(originalName);
if (b && !candidates.some((c) => ymdEqual(c, b))) candidates.push(b);
}
if (candidates.length === 0) return null;
if (!pathYM) return candidates[0];
const aligned = candidates.find((c) => ymdAgreesWithYM(c, pathYM));
return aligned ?? candidates[0];
}
function pickUnixTimestamp(fileName: string, originalName: string): YMD | null {
return (
parseUnixTimestampInName(fileName) ??
(originalName && originalName !== fileName
? parseUnixTimestampInName(originalName)
: null)
);
}
export function suggestDateFromPath(input: Input): DateGuess | null {
const fileName = (input.fileName ?? '').trim();
const originalName = (input.originalName ?? '').trim();
const path = (input.path ?? '').trim();
const pathYMD = path ? parsePathYMD(path) : null;
const pathYM = path && !pathYMD ? parsePathYM(path) : null;
// 1. Filename Y-M-D corroborated by the path.
const fnYMD = pickFilenameYMD(fileName, originalName, pathYM);
if (fnYMD) {
if (pathYMD && ymdEqual(fnYMD, pathYMD)) {
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
}
if (pathYM && ymdAgreesWithYM(fnYMD, pathYM)) {
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
}
// 2. Filename Y-M-D with no path signal at all.
if (!pathYMD && !pathYM) {
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-only' };
}
// Filename present but disagrees with path → fall through.
}
// 3. Unix epoch in filename, optionally corroborated.
const epoch = pickUnixTimestamp(fileName, originalName);
if (epoch) {
if (!pathYM && !pathYMD) {
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
}
if (pathYM && ymdAgreesWithYM(epoch, pathYM)) {
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
}
if (pathYMD && ymdEqual(epoch, pathYMD)) {
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
}
// disagreement → fall through to path
}
// 4. Path Y-M-D standalone.
if (pathYMD) {
return { iso: isoOf(pathYMD), confidence: 'high', source: 'path-ymd' };
}
// 5. Path Y-M with synthesised day = 01.
if (pathYM) {
const ymd = tryYMD(pathYM.y, pathYM.m, 1);
if (ymd) {
return { iso: isoOf(ymd), confidence: 'medium', source: 'path-ym-default-day' };
}
}
return null;
}

View File

@@ -10,15 +10,29 @@ import type { PhotoMarksMap } from '$lib/services/photoprism';
import type { PpPhoto } from '$lib/types/photoprism';
/**
* Lightroom culling convention: red rejects, orange reviews, yellow
* picks, green keeps. Order here is the order the TagsBrowser renders
* rows in — fixed so the user can build muscle memory.
* Color labels are purely a marking dimension — no semantic meaning is
* attached. Order here is the order the TagsBrowser renders rows in,
* roughly rainbow-then-neutrals so the picker reads naturally.
*
* `bg` and `border` are paired Tailwind classes so a swatch can be
* rendered either filled (e.g. to indicate selection) or as a colored
* outline (the default display). Literal strings keep Tailwind's
* content scanner happy — do not interpolate.
*/
export const COLOR_SWATCHES: readonly { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
export const COLOR_SWATCHES: readonly {
key: string;
bg: string;
border: string;
title: string;
}[] = [
{ key: 'red', bg: 'bg-red-500', border: 'border-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', border: 'border-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', border: 'border-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', border: 'border-green-500', title: 'Green' },
{ key: 'teal', bg: 'bg-teal-500', border: 'border-teal-500', title: 'Teal' },
{ key: 'blue', bg: 'bg-blue-500', border: 'border-blue-500', title: 'Blue' },
{ key: 'purple', bg: 'bg-purple-500', border: 'border-purple-500', title: 'Purple' },
{ key: 'pink', bg: 'bg-pink-500', border: 'border-pink-500', title: 'Pink' }
] as const;
export interface RatingGroup {

View File

@@ -13,15 +13,18 @@
getPhoto,
listHeaps,
listPhotos,
listPhotosAround,
type PpAlbum,
} from "$lib/services/photoprism";
import {
consumePendingFocus,
filters,
filtersToQ,
filtersToUrlParams,
parseUrlParams,
setSearch,
setSection,
type PendingFocus,
} from "$lib/stores/filters.svelte";
import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from "svelte";
@@ -56,6 +59,16 @@
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { EmptyState, InlineLoader } from "$lib/components/feedback";
import {
AlertCircle,
Archive,
EyeOff,
ImageOff,
Layers,
MousePointerClick,
Sparkles,
} from "lucide-svelte";
import { type PpPhoto } from "$lib/types/photoprism";
// ── URL ↔ filter store sync ──────────────────────────────────────────────
@@ -127,24 +140,90 @@
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
// downstream into a single `photos` array consumers iterate.
const PHOTOS_PAGE_SIZE = 120;
// Anchor mode: when an in-app deep link stashes a pending focus with a
// TakenAt, the first page is fetched as a window around that date via
// PhotoPrism's `before:`/`after:` DSL — so the target photo is in the
// loaded page even when it would otherwise be hundreds of entries past
// the newest-first cursor. Subsequent pages continue chronologically
// with a `before:<oldest-loaded-TakenAt>` cursor instead of the
// standard offset, so the listing stays in newest-first order without
// jumping around the library. Cleared when the filter changes — a new
// filter is a fresh listing, possibly with its own anchor.
let anchor = $state<PendingFocus | null>(null);
let lastFilterQ: string | null = null;
// Watch every URL change so we catch pending-focus stashes even when the
// filter didn't change (e.g. user clicks the open-folder icon for a photo
// in the folder they're already on — the goto sets the same URL but the
// user still expects to land on THAT photo). Pure filter changes with
// no pending stash clear any stale anchor so a subsequent refetch
// doesn't keep the old window.
$effect(() => {
if (!browser) return;
void page.url.search;
untrack(() => {
const pending = consumePendingFocus();
if (pending) {
anchor = pending;
lastFilterQ = filtersToQ(filters);
return;
}
const q = filtersToQ(filters);
if (q !== lastFilterQ) {
anchor = null;
lastFilterQ = q;
}
});
});
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
queryFn: ({ pageParam }) =>
listPhotos({
q: filtersToQ(filters),
queryKey: [
"photos",
"q",
filtersToQ(filters),
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
],
queryFn: ({ pageParam }) => {
const offset = pageParam as number;
const baseQ = filtersToQ(filters);
// Page 0 + anchor → load a window around the anchor's date.
// Subsequent pages aren't reachable in anchor mode (see
// getNextPageParam).
if (offset === 0 && anchor?.takenAt) {
return listPhotosAround({
q: baseQ,
takenAt: anchor.takenAt,
afterCount: 30,
beforeCount: 90,
merged: true,
});
}
return listPhotos({
q: baseQ,
count: PHOTOS_PAGE_SIZE,
offset: pageParam as number,
offset,
order: "newest",
merged: true,
}),
});
},
initialPageParam: 0,
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
// photo expands into its file rows, so a "full" page of count=120
// typically returns ~60 photo entries. The only reliable end-of-
// pagination signal is an empty page. Costs one extra fetch at the
// tail (cheap; the empty response is small).
getNextPageParam: (last, pages) =>
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
getNextPageParam: (last, pages) => {
if (last.length === 0) return undefined;
// Anchor mode terminates after page 0 — the user sees the 120-
// photo window around the deep-linked photo. PhotoPrism's
// `before:` cursor is day-precision, so paginating further
// chronologically risks dense-day infinite loops (same-day
// photos exceeding the page size keep the cursor at the same
// value). To "see more," the user clears the anchor by
// navigating fresh.
if (anchor?.takenAt) return undefined;
return pages.length * PHOTOS_PAGE_SIZE;
},
enabled: isAuthenticated(),
}));
@@ -227,7 +306,22 @@
// Only re-anchor focus on the very first page; later pages
// must not pull focus back to photo[0].
if (pages !== 1) return;
// Anchor (from an in-app deep link) takes precedence — its UID is
// guaranteed in `photos` because page 0 was fetched as a window
// around its TakenAt. Plain navigations leave anchor null and we
// snap to photos[0] as before. `scrollToIndex` expands the
// windowed render set + scrolls the tile into view (with sticky-
// header peek) — same helper gridKeyNav uses for arrow nav.
const targetIdx =
anchor?.uid != null
? photos.findIndex((p) => p.UID === anchor!.uid)
: -1;
if (targetIdx >= 0) {
setFocused(photos[targetIdx].UID);
void scrollToIndex(targetIdx);
} else {
setFocused(photos[0].UID);
}
});
});
@@ -831,29 +925,42 @@
{#if photosQuery.isPending}
<SkeletonGrid />
{:else if photosQuery.isError}
<p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Failed to load photos"
description={photosQuery.error instanceof Error
? photosQuery.error.message
: "unknown error"}
</p>
/>
{:else if photos.length === 0}
<p class="text-sm text-muted-foreground">
{#if filters.section === "archive"}
Archive is empty.
<EmptyState icon={Archive} title="Archive is empty" />
{: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.
<EmptyState
icon={Sparkles}
title="Nothing left to review"
description="Photos the 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.
<EmptyState
icon={EyeOff}
title="No hidden photos"
description="The indexer auto-hides files it can't read (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.
<EmptyState
icon={Layers}
title="This heap has no photos yet"
description={'Select some photos and use the bulk bars “+ Add to heap” button.'}
/>
{:else}
No photos. Index a folder via PhotoPrism's reindex command.
<EmptyState
icon={ImageOff}
title="No photos"
description="Index a folder from Settings → Index, or run a reindex from the server."
/>
{/if}
</p>
{:else}
<div
data-photo-grid
@@ -920,9 +1027,12 @@
}}
></div>
{#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground">
Loading more
</p>
<InlineLoader
size="sm"
align="center"
polite={false}
label="Loading more photos…"
/>
{/if}
{/if}
</div>
@@ -944,15 +1054,16 @@
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading</p>
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<div class="space-y-2 p-4 text-center">
<div class="text-xl"></div>
<p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click
on a thumbnail to view its metadata here.
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd
>+click on a thumbnail to view its metadata here.
</p>
</div>
{/snippet}
</EmptyState>
{/if}
</div>
<!-- Resize handle on the left edge; mirrors the layout's left aside

View File

@@ -61,8 +61,8 @@
class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm"
>
<header class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mule</h1>
<p class="text-sm text-muted-foreground">Sign in with your PhotoPrism account.</p>
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mulimage</h1>
<p class="text-sm text-muted-foreground">Sign in to your account.</p>
</header>
<label class="block space-y-1.5">

View File

@@ -299,6 +299,37 @@
markersOnScreen.clear();
markers.clear();
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
// (+ optional `zoom`, `focus`) flies the map directly to the photo
// rather than fitting to the full library extent. Strip the params
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
// through to the default fitBounds when the params aren't present.
const sp = new URL(window.location.href).searchParams;
const latParam = Number(sp.get('lat'));
const lngParam = Number(sp.get('lng'));
if (
(data.features?.length ?? 0) > 0 &&
Number.isFinite(latParam) &&
Number.isFinite(lngParam) &&
sp.has('lat') &&
sp.has('lng')
) {
const zoom = Number(sp.get('zoom')) || 17;
map.jumpTo({ center: [lngParam, latParam], zoom });
const stripped = new URL(window.location.href);
stripped.searchParams.delete('lat');
stripped.searchParams.delete('lng');
stripped.searchParams.delete('zoom');
stripped.searchParams.delete('focus');
const qs = stripped.searchParams.toString();
void goto(`/map${qs ? `?${qs}` : ''}`, {
replaceState: true,
keepFocus: true,
noScroll: true
});
return;
}
// Fit to data extent on the first non-empty load — prefer the
// server-provided bbox (PhotoPrism returns one), else compute from
// the features.

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getPhoto,
listPhotosWithNotes,
type PhotoWithNote
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import { selection } from '$lib/stores/selection.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NotesPhotoGrid from '$lib/components/timeline/NotesPhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, MousePointerClick, StickyNote } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism';
// Photos with a non-empty Details.Notes. The query is shared by the
// LeftSidebar's count badge ($derived off the same key), so visiting
// /notes warms the badge and vice-versa. Keyed under the ['photos', …]
// prefix so the existing mutation invalidations cascade in.
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
queryKey: ['photos', 'with-notes'],
queryFn: listPhotosWithNotes,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const items = $derived<PhotoWithNote[]>(notesQuery.data ?? []);
const count = $derived(items.length);
// Right-sidebar metadata for the focused tile. Same wiring as the tag
// drill-in page so the metadata panel reads consistently.
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''],
queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0
}));
</script>
<Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Notes
</span>
{#if !notesQuery.isPending && !notesQuery.isError}
<span class="text-[11px] text-muted-foreground">
{count} photo{count === 1 ? '' : 's'}
</span>
{/if}
</Toolbar>
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if notesQuery.isPending}
<SkeletonGrid />
{:else if notesQuery.isError}
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load notes" />
{:else if items.length === 0}
<EmptyState
icon={StickyNote}
title="No photos with notes"
description="Add a note to a photo from its metadata sidebar and it will appear here."
/>
{:else}
<NotesPhotoGrid {items} />
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
<aside
class="relative h-full shrink-0 border-l border-border bg-card/30"
style="width: {view.rightSidebarWidth}px;"
>
<div class="h-full overflow-y-auto">
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
{/snippet}
</EmptyState>
{/if}
</div>
<div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'left',
getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize info panel"
>
<div
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
</div>

View File

@@ -15,7 +15,6 @@
approve). The previous section is restored on unmount.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
import {
@@ -51,6 +50,8 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Sparkles } from 'lucide-svelte';
type DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab;
@@ -114,7 +115,7 @@
count: g.photos.length as number | undefined
})),
{ id: 'stacks', label: 'Stacks', count: stacksCount },
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
{ id: 'cross-folder', label: 'Duplicates', count: crossFolderCount }
]);
const requestedTab = $derived(page.url.searchParams.get('tab'));
@@ -136,49 +137,18 @@
});
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
function setTab(id: Tab) {
const params = new URLSearchParams();
// First cause tab (if any) is the default — same convention as
// the old /review behaviour, so back-from-cross-folder lands on
// the user's review queue rather than the empty Stacks panel.
const defaultId = tabs[0]?.id;
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
void goto(`/review${params.size ? '?' + params : ''}`, {
keepFocus: true,
noScroll: true
});
}
const activeTabSpec = $derived(tabs.find((t) => t.id === activeTab));
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Review
</span>
{#if tabs.length > 0}
<div class="flex items-center gap-1">
{#each tabs as t (t.id)}
<button
type="button"
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => setTab(t.id)}
>
<span>{t.label}</span>
{#if t.count !== undefined}
<span
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
t.id
? 'bg-primary/15 text-primary'
: 'bg-secondary text-muted-foreground'}"
>
{t.count}
</span>
{#if activeTabSpec}
<span class="text-[11px] font-medium">{activeTabSpec.label}</span>
{#if activeTabSpec.count !== undefined}
<span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
{/if}
</button>
{/each}
</div>
{/if}
{#snippet trailing()}
<div
@@ -226,23 +196,27 @@
<div class="flex min-w-0 flex-1 flex-col">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
<InlineLoader label="Loading review queue…" />
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load review queue"
description={reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
/>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
<EmptyState icon={Sparkles} title="Nothing to review">
{#snippet descriptionSnippet()}
<p>
The indexer flags photos with a low quality score for human review. New
arrivals with missing EXIF, low resolution, or unknown cameras will land
here. The Stacks and Duplicates tabs above stay available for
duplicate cleanup.
</p>
</div>
{/snippet}
</EmptyState>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
@@ -261,9 +235,9 @@
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading metadata…" />
{/if}
</div>
<div

View File

@@ -6,8 +6,10 @@
getPhoto,
listLabels,
listPhotos,
listSubjects,
type PhotoMarksMap,
type PpLabel
type PpLabel,
type PpSubject
} from '$lib/services/photoprism';
import {
filtersToQ,
@@ -32,6 +34,8 @@
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, ImageOff, MousePointerClick, Tag } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism';
// URL-driven category + value. `isTagCategory` rejects typos so a stray
@@ -63,7 +67,9 @@
// filter on top would make drill counts disagree with the badges (a
// label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them).
const useServer = $derived(category === 'labels' || category === 'keywords');
const useServer = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
);
const drillQ = $derived(
useServer && selectedValue
? filtersToQ({
@@ -126,6 +132,11 @@
queryFn: listLabels,
enabled: isAuthenticated() && category === 'labels'
}));
const subjectsQuery = createQuery<PpSubject[]>(() => ({
queryKey: ['subjects'],
queryFn: listSubjects,
enabled: isAuthenticated() && category === 'people'
}));
const drillTitle = $derived.by(() => {
if (!selectedValue) return '';
if (category === 'labels') {
@@ -135,6 +146,10 @@
return hit?.Name ?? selectedValue;
}
if (category === 'keywords') return selectedValue;
if (category === 'people') {
const hit = (subjectsQuery.data ?? []).find((s) => s.Slug === selectedValue);
return hit?.Name ?? selectedValue;
}
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
if (category === 'colors') {
return (
@@ -188,12 +203,11 @@
{#if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<div class="max-w-sm space-y-2 text-center">
<p class="text-sm font-medium">Pick a {category ?? 'tag'} from the sidebar</p>
<p class="text-xs text-muted-foreground">
Click a row in the panel on the left to filter the photo grid by that tag.
</p>
</div>
<EmptyState
icon={Tag}
title={`Pick a ${category ?? 'tag'} from the sidebar`}
description="Click a row in the panel on the left to filter the photo grid by that tag."
/>
</main>
{:else}
<div class="flex min-h-0 flex-1">
@@ -205,13 +219,14 @@
{#if showSkeleton}
<SkeletonGrid />
{:else if showError}
<p class="text-sm text-destructive">Failed to load photos.</p>
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photos" />
{:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
<EmptyState icon={ImageOff} title="No photos under this tag" />
{:else}
<PhotoGrid photos={drillPhotos} />
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
@@ -225,15 +240,16 @@
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<div class="space-y-2 p-4 text-center">
<div class="text-xl"></div>
<p class="text-xs text-muted-foreground">
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
</div>
{/snippet}
</EmptyState>
{/if}
</div>
<div
@@ -255,5 +271,3 @@
{/if}
</div>
{/if}
<BulkActionBar />

10
web/static/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 73 100">
<style>
path { fill: #0f172a; }
@media (prefers-color-scheme: dark) {
path { fill: #f8fafc; }
}
</style>
<path d="m45.268 55.124q-13.062 14-22.732 14-6.206 0-11.546-4.7629v3.8248q0 6.6391 2.0193 13.351 2.3093 7.4328 2.3093 9.8145 0 3.3195-2.0925 5.4846-2.0924 2.165-5.1956 2.165-3.1753 0-5.0515-2.5257-1.8753-2.5257-1.8753-6.0618 0-2.598 1.8753-9.3814 2.1657-7.5053 2.1657-14.577v-65.452h11.907v42.792q0 6.495 1.0817 9.5259 1.1549 3.031 3.9692 4.9795 2.8867 1.8764 6.495 1.8764 6.4225 0 16.67-8.8042v-50.371h11.979v50.153q0 6.3504 1.299 8.8042 1.2989 2.3815 4.4023 2.3815 4.907 0 6.3504-9.5979h2.5979q-1.3721 16.381-13.856 16.381-5.4122 0-9.0207-3.6082-3.536-3.6804-3.7525-10.392z"/>
</svg>

After

Width:  |  Height:  |  Size: 804 B