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

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