feat(web): sidebar root folder + folder counts, drill-in colors/tags/ratings, click semantics rework

Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
  when the timeline is scoped to root; the photo grid post-filters to
  `Path === ''` because PhotoPrism's `path:` operator can't express an
  exact-root match. Collapsible chevron, persisted to its own
  localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
  folder in parallel via `listFolderCounts`; root count derived from
  `config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
  visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.

Timeline interaction
- Single click on a tile selects only that tile (clears others); the
  preview now lives on dblclick. Modifier clicks still go through
  `gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
  silently drops the Archived field, so we route through
  /batch/photos/{archive,restore} the same way the BulkActionBar
  already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
  photo into view via `forcedExpand`+`scrollTileIntoView` so it
  actually mounts (selection ring would otherwise stay invisible when
  the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
  view is a folder view (no heap / search / non-default section), so
  label clicks / heap views / favorites no longer drop subfolder
  photos.

Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
  matching the Toolbar's visual language. Right sidebar stays full
  height — the bar only spans the timeline width.
- Approve action wired for the review pile.

Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
  styling, single-click-selects + dblclick-previews, and `setOrder`
  for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
  trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
  and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
  with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
  `view.thumbnailSize` (XS–XL) so the timeline's size selector now
  reaches into all four grids.

Settings
- General-settings dialog merges Appearance into UI and switches free
  text inputs to selects for the PhotoPrism theme / language / start
  page / map style (the value-from-server prepends if it's outside
  the curated list so we never silently rewrite a custom value). Time
  zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.

Sidecar
- Heap convert runs reindex synchronously per source path so the
  client's invalidate-and-refetch sees the moved files.

Inbox
- New /inbox route stub for the upcoming import workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:01:41 +02:00
parent 5153aeebec
commit d5e4f23c0f
18 changed files with 1577 additions and 332 deletions

View File

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