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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user