--- title: Photo Editor for Journal Entries (media-API) — Plan date: 2026-07-05 --- # Photo Editor for Journal Entries (media-API) — Plan **Status:** ✅ Complete (2026-07-08). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean. Shipped with `feat/journal-post-form` — **merged to `main` in both repos and deployed** (outer pin `f4ab730` == `user/` `main` == `origin/main`; content pushed to Gitea → prod). Owner-session UI QA (add incl. HEIC, inline-confirm delete, mouse reorder, combined, feed cover=first, regressions a/b/c) and on-device touch-drag both passed 2026-07-08. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred). ## Why this exists (the honest reason) M2 tried to edit an entry's photos by reusing the `/post` **create** form + FilePond + the abandoned `add-page-by-form` plugin. Two distinct failure classes came out of that, and it matters not to blur them into one root cause: - **FilePond-widget bugs** — `text/html` previews and broken touch-drag. FilePond is built to upload new files to a fresh entry, not to load/preview/reorder existing server files; these are the widget used against its grain. - **PHP-side bugs** — the header-cast fatal and the rename-reconcile gymnastics live in `add-page-by-form` / `cache-on-save`, **not** in FilePond. This plan **reuses that same rename-reconcile logic** (see the reorder route below), so it must be validated on its own merits — a "different foundation" does not make the carried-forward reconcile code automatically safe. This plan replaces the **photo UI** with the **proven `gpx-manager` pattern**: our own UI talking straight to the Grav media API. ## Foundation status (what's proven vs still assumed) **Proven on 2026-07-05 (not assumed):** - `POST /api/v1/pages/media` (FormData `file`, owner session) → **201**, file on disk ✓ - `DELETE /api/v1/pages/media/` → **204**, removed ✓ - Owner session auth works on entry routes ✓ **Still assumed (novel, load-bearing, NOT yet proven — this is where the 4-day risk lives):** - The custom reorder route (rename to `photo-01..NN`) — no stock endpoint exists. - Live reorder-rename behaviour under real add/delete ops. - Three independent live mutations interacting cleanly with the edit session's text-field Save. - HEIC→JPEG conversion at real photo sizes/counts on a phone. ## Design decisions 1. **Live, not on-submit.** Add / delete / reorder each persist **immediately** via the API — decoupled from the `/post` form's text-field Save. No flash, no submit-time reconcile. This sidesteps `add-page-by-form` for the photo path entirely (the text-field save still uses it + our committed patch). *(Edit-then-leave / no-undo behaviour for the destructive delete path is unresolved — see Open Questions.)* 2. **Inline on `/post?edit`.** In edit mode, hide the FilePond section and render the photo-editor component from the media list. **Create mode keeps FilePond, untouched** (out of scope). Hiding the section alone is **not** enough — see the FilePond decommission step in the Client section. 3. **Own thumbnail grid, SortableJS for drag.** Square `` thumbnails in a grid. Reorder via **SortableJS** — exactly what FilePond couldn't do reliably here. SortableJS is **not yet a theme dependency**: install `sortablejs` and import it into `js/src/post-form.js` so esbuild bundles it into `js/post`. This is a task, not existing foundation. 4. **Cover = first.** After any add/delete/reorder, files are renumbered **`photo-01..NN`** (zero-padded, wide enough for the expected max) in display order; the client sorts thumbnails **numerically**, and the feed renders `media.images|first` as cover. Zero-padding is required so lexicographic media order equals numeric order past 10 photos (otherwise photo-1, photo-10, photo-2…). The shared renumber helper must also normalise any pre-existing un-padded `photo-N` files on first reorder. **This helper also runs on create-mode reconcile**, so create-mode entries will now emit `photo-01..NN` too — an intentional, accepted change (see Scope boundaries). Existing published entries keep their un-padded names harmlessly (they have <10 photos and the client sorts numerically). 5. **Add/delete via stock media API; reorder via one custom scope-guarded route.** Stock `POST`/`DELETE …/media` are already proven on entry routes, so add + delete use the **stock media API** (client-side, session-auth). Only the missing **reorder** (rename to `photo-01..NN`) is a custom route in the **`entry-actions`** plugin using `EntryScopeGuard` (owner-username + direct-child-of-active-dailies, the R6 guard). **Accepted tradeoff:** server-side scope enforcement on photo **add/delete** is a **known R6 gap** — any account with `api.media.write` can reach the un-scoped stock endpoint directly, and the client UI gate is **not** an access-control boundary. For a solo-owner blog this is accepted for launch and tracked as a **documented fast-follow** (promote add/delete onto scope-guarded custom routes later). HEIC→JPEG happens client-side before upload (reuse the existing converter). ## Server — `entry-actions` plugin, 1 custom route (+ stock media API for add/delete) **Add / delete — stock media API (client-side, session-auth):** - `POST /api/v1/pages/media` — upload (stock endpoint). **No SVG support for now:** add `svg` to `security.uploads_dangerous_extensions` (or reject `.svg` in the upload path) so SVGs are **blocked, not sanitized** — this removes the stored-XSS-via-SVG vector without depending on `security.sanitize_svg` staying enabled. Other executable types (html/js/php) are already blocked by Grav's default dangerous-extension denylist, which is the **actual** control on this stock path — there is no positive MIME allowlist or on-disk extension rewrite here. Allowed image types: **jpg/jpeg/png/webp**; the client file input accepts those **plus HEIC** (converted client-side to JPEG before upload) and excludes SVG. If stronger positive-MIME validation is ever wanted, it moves add onto the scope-guarded custom route (the same place the R6 add/delete fast-follow lands). - `DELETE /api/v1/pages/media/` — remove. - **After every stock add and every stock delete, immediately call the reorder route (below) to re-establish `photo-01..NN`.** Stock upload keeps the file's original (slugified) name — not the next `photo-N` — and stock delete leaves a numbering gap without renumbering; without a follow-up renumber, `cover = first` breaks until the next manual drag. The reorder route is the single owner of the `photo-N` invariant. **Reorder — one custom route (owner + scope guarded):** - `POST /api/v1/entry//photos/order` — body: ordered filenames → two-phase rename to `photo-01..NN` (reuse the proven cache-on-save rename logic; factor it into a shared helper — and validate that helper on its own, per "Why this exists"). Handler: `EntryScopeGuard::isOwnerUser` + `resolveActiveDailyChild` (reject 403/400 otherwise), then filesystem op, then `cache->deleteAll()`. Reject filenames containing `/` or `..`. **Operate only on filenames that already exist as image media** in the entry folder — any name in the ordered list that isn't a current image file is ignored, so the entry `.md`, a `.gpx`, or a `.meta.yaml` can never be renamed or clobbered by a crafted order body. **Deploy note:** the new `/entry//photos/order` route only registers after the API route-map cache is rebuilt, so a cache clear must run on deploy. The existing `DELETE /entry/` route confirms the nested-static-after-param pattern registers fine. ## Client — new `photo-editor.js` (bundled into the post-form entry) In edit mode only: - **Decommission the FilePond photo path (hiding it is not enough).** Skip `editLoadPhotos()` and the FilePond `photo_order` submit-handler wiring entirely — do not initialise/populate FilePond. Otherwise the stale `photo_order` manifest posted on text Save drives `cache-on-save.reconcilePhotos()` → `deleteUnlistedImages()`, which **silently deletes any photo added live after page-open**. With an empty manifest the reconcile leaves the live-managed folder untouched. - Hide the FilePond `.photos-collapse`; render `.photo-editor` from `GET …/media` (image files, numeric-sorted). Show a **loading placeholder** during the fetch and an **empty state** for zero-photo entries that keeps the "Add photos" button visible ("No photos yet — add some"). - Each cell: `` thumbnail + ✕ delete. **Inline confirm:** ✕ swaps the cell to "Delete? [Confirm] [Cancel]" (Confirm disabled while the DELETE is in flight) → `DELETE …/media/` → renumber → re-render; Cancel reverts. - "Add photos" button → hidden file input → HEIC→JPEG → `POST …/media` (one per file) → renumber → re-render. **Upload progress:** disable the button while a batch is in flight and show "Uploading N of M…", clearing per file. - **Add is a two-write op (stock upload, then reorder).** On a multi-file add, upload each file (stock `POST …/media`) and call the reorder route **once after the whole batch** — not per file — so there is one renumber pass and only the final numbering matters. The client passes the stock-uploaded basenames into that reorder manifest. If an upload succeeds (201) but the follow-up reorder fails, auto-retry the reorder — it is idempotent, since `renumberPhotos` skips files not on disk — or roll back by `DELETE`-ing the just-uploaded file(s), and surface a single inline error. Never leave an orphan stock-named file in the folder: it is a real image, so it would break `cover = first` and the numeric sort until the next successful drag. - `Sortable` on the grid → on drop, `POST …/photos/order` with the new filename order → re-render. First cell = cover. - **Failure path (every op).** On non-2xx / network error: show an inline error near the affected control (reuse gpx-manager's `.gpx-status.error`), keep the item in place — for reorder, **revert the SortableJS move to the last-known-good order** — re-enable the control for retry, and do **not** silently re-render. Displayed order/cover must never disagree with disk without an error shown. - All live; independent of the form's Save button (which continues to handle title/date/content/etc.). ## Scope boundaries (non-goals) - **Create flow (new-entry FilePond) untouched** — *except* that the shared renumber helper is now zero-padded, so create-mode entries also emit `photo-01..NN`. That is the only create-path side effect; the FilePond UI itself is unchanged. Two photo UIs for now (FilePond on create, this on edit); unifying them is a follow-up. - **Text-field editing unchanged** (`/post` form + `add-page-by-form` + our patch). - No captions, no crop/rotate, no bulk ops. ## Verification - **I verify in-harness:** add (incl. HEIC), delete, reorder-by-**mouse**, and combined — each persists to disk + shows in the feed immediately; cover = first after reorder; the reorder route's owner/scope-guard rejects non-owner + out-of-scope. - **Regression checks:** (a) a text-field Save *after* a live photo add does **not** delete the added photo (FilePond decommission); (b) an entry with **10+ photos** keeps arranged order and the correct cover (zero-padding); (c) each op's failure path shows an inline error and leaves UI and disk consistent. - **You verify on-device (the one thing I can't simulate):** touch-drag reorder on a phone. ## Estimate One focused implementation push — 1 custom reorder handler + stock add/delete reuse + one JS component + CSS + the SortableJS dependency (install + import). Not another multi-day cycle. Residual risk concentrated in the "still assumed" list above. ## Deferred / Open Questions ### From 2026-07-05 review - **No undo / cancel model for destructive live edits (P1).** Add/delete/reorder persist immediately and delete is a destructive `unlink`; the Save button trains the user that leaving without saving discards changes, but live deletes are already gone with no undo and no "permanent" signal. Decide between: (a) accept live-is-permanent + add a "saves immediately" affordance and a real delete confirm (cheapest for the deadline); (b) soft-delete to a trash subfolder purged on Save/leave; (c) stage deletes client-side and commit on Save. Resolve before implementing the delete path. ### Deferred during implementation (2026-07-05) - **Server-side SVG block deferred to the R6 add/delete fast-follow.** The plan called for adding `svg` to `security.uploads_dangerous_extensions`, but `user/config/security.yaml` is **gitignored** (a Grav 1.7-era rule from when the HMAC `salt` lived there; obsolete in 2.0.7 where the secret moved to the still-ignored `security-private.php`). Tracking it would mean un-ignoring a security-namespace file from another work session's era — out of scope for this push. Instead: **SVG is excluded client-side** in the photo-editor file input `accept` (jpg/jpeg/png/webp + HEIC only). The **server-side** block is a documented fast-follow that lands together with promoting photo add/delete onto the scope-guarded custom route (the same R6 gap already accepted above) — both concern the un-scoped stock media endpoint, which only the solo owner can reach. ### Resolved at review close (2026-07-05) — recorded for the implementer - **Reorder-route filename safety** — *resolved:* the handler operates only on filenames already present as image media in the folder, so a crafted order body can't rename/clobber the entry `.md`, a `.gpx`, or a `.meta.yaml`. (Now in the Server reorder-route spec.) - **Multi-photo add — reorder cadence** — *resolved:* call the reorder route **once after the whole batch** of uploads, not once per file. (Now in the Client "Add is a two-write op" spec.) - **New route 404 until cache rebuild** — *resolved:* deploy must clear the API route-map cache so `/entry//photos/order` registers; the existing `DELETE /entry/` proves the nested-route pattern works. (Now a deploy note in the Server section.) - **`.meta.yaml` sidecars not renamed by `renumberPhotos`** — *deferred (genuine future work):* no effect today because per-image captions are deferred. When captions ship, the shared renumber helper must rename each image's `.meta.yaml` sidecar alongside it (and clean up orphans), or per-image metadata will drift on reorder/delete.