Files
intotheeast-com/docs/working/plans/2026-07-05-photo-editor-media-api.md
m038andClaude Opus 4.8 084f683e19 docs(plans): mark frontend-entry-edit + photo-editor-media-api complete
Both shipped with feat/journal-post-form (merged + deployed to prod) and
passed owner UI/touch-drag QA on 2026-07-08. Corrected the stale
"not merged / not deployed" language and fixed a duplicate Status marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:08:20 +02:00

14 KiB

title, date
title date
Photo Editor for Journal Entries (media-API) — Plan 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-formmerged 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 bugstext/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<entry-route>/media (FormData file, owner session) → 201, file on disk ✓
  • DELETE /api/v1/pages<entry-route>/media/<filename>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 <img> 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<entry-route>/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<entry-route>/media/<filename> — 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/<slug>/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/<slug>/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/<slug> 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: <img> thumbnail + ✕ delete. Inline confirm: ✕ swaps the cell to "Delete? [Confirm] [Cancel]" (Confirm disabled while the DELETE is in flight) → DELETE …/media/<file> → 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) untouchedexcept 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 safetyresolved: 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 cadenceresolved: 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 rebuildresolved: deploy must clear the API route-map cache so /entry/<slug>/photos/order registers; the existing DELETE /entry/<slug> proves the nested-route pattern works. (Now a deploy note in the Server section.)
  • .meta.yaml sidecars not renamed by renumberPhotosdeferred (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.