Media management: Library page and editor picker — implementation spec #188
Labels
No labels
kind
bug
kind
enhancement
wayfinder
grilling
wayfinder
map
wayfinder
prototype
wayfinder
research
wayfinder
task
workflow
needs-info
workflow
needs-triage
workflow
ready-for-agent
workflow
ready-for-human
workflow
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Depends on
Reference
rosa/vernier#188
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implementation spec for the media-manage surface: the Library page and the post editor's picker. This folds together every decision from the wayfinder map #181 — metadata (#183), library design (#184), picker prototype (#185) — into one document an implementation session can execute without reopening anything. The Micropub media endpoint is untouched (#186, ruled out of scope: clients keep plain upload).
Primary sources if anything here reads ambiguous: the resolution comments on #183/#184/#185, and branch
prototype/media-picker(commit822f329) for the picker's look and interaction. Nothing from that branch merges — the winning variant A gets rebuilt properly.1. Domain (
crates/domain)1.1 Filename label
Media captures the filename it was uploaded under, as an opaque, read-only display label (#183; CONTEXT.md already records this).
MediaFilenameinsrc/models/media.rs. Parsing, from the multipart-supplied filename: take the basename only (strip everything through the last/or\— clients may send paths), trim, and treat empty/whitespace as absence (parse_optionalshape, likePhotoAlt). Cap at 255 chars; over-long input is truncated on a char boundary, never rejected — the label is cosmetic and must not fail an upload (errors defined out of existence). No rename op anywhere.Mediagainsfilename: Option<MediaFilename>+ accessor;Media::newgains the parameter.CreateMediaRequest(src/ports.rs) gainsfilename: Option<MediaFilename>.Service::create_media(src/media/service.rs) gains the parameter and threads it through.photo/filepart's client filename) and the Micropub media endpoint (field.file_name()incrates/web/src/handlers/micropub.rs). A part without a filename yieldsNone— the column stays honestly absent.Migration:
migrations/<timestamp>_media_filename.sql—Nullable; every existing row stays
NULL(uploaded before capture existed = absent, per #183).1.2 New Media Service ops (
src/media/service.rs)Three new public ops, plus one refactor. The Media Service already owns the repository, so no new ports beyond
AppRepositorymethods (§1.3).list_media_for(&self, owner: &User) -> Result<Vec<Media>, anyhow::Error>Every Media the user owns, newest-first. Shared by the Library grid and the picker grid (the picker filters status in the view layer). Ordering is the repository's guarantee, not the caller's sort.
resolve_owned_photo(&self, author: &User, id: &MediaId, alt: Option<PhotoAlt>) -> Result<Option<PhotoRef>, anyhow::Error>The id-based Photo resolution the web editor path needs (#185): the Media must exist and
authormust own it; anything else isNone(the handler maps it to a rejection — don't leak existence of other users' ids). No status gate, mirroring Micropub'sresolve_photo: attaching a Processing Media is already legal via inline upload, and a forged Failed pick is harmless (renders as Absent). The picker excludes Failed and disables Processing in the view only.Refactor:
resolve_photo(thepub(crate)Micropub op) keeps its URL-parsing head and delegates its existence + ownership tail toresolve_owned_photo, mappingNone→MicropubError::InvalidPhoto. The ownership decision then lives in exactly one place. Behavior unchanged; existing tests must keep passing untouched.list_posts_referencing(&self, id: &MediaId) -> Result<Vec<Post>, anyhow::Error>The Posts whose Photo points at this Media, any status, newest-first — the delete-confirm page's listing.
delete_media(&self, actor: &User, id: &MediaId) -> Result<(), DeleteMediaError>Warn-and-allow deletion (the warning is the confirm page; the domain op just deletes):
actordoes not own →DeleteMediaError::NotFound(one variant for both; the Library is strictly per-user, so foreign Media must be indistinguishable from absent).DeleteMediaError::StillProcessing. The Library offers no delete affordance on Processing tiles (#184: the Reconciler owns that state's integrity); refusing at the domain keeps the staged-original and live-job invariants unentangled from deletion. Ready and Failed — both terminal — are deletable.posts.photo_media_idFK isON DELETE SET NULLandmedia_renditionisON DELETE CASCADE, so detach and rendition removal are the schema's work. Disk returns on the next maintenance tick (ADR-0023) — nothing to do here.New error enum
DeleteMediaError { NotFound, StillProcessing, Other(anyhow) }insrc/errors.rs, following the existing per-op error pattern.1.3 Repository port additions (
AppRepository,src/ports.rs)list_media_by_user(&self, user_id: &UserId) -> Vec<Media>— newest-first (ORDER BY created_at DESC, id;created_atis RFC3339 text, so lexicographic order is chronological).find_posts_referencing_media(&self, id: &MediaId) -> Vec<Post>—WHERE photo_media_id = ?, newest-first bycreated_at.delete_media(&self, id: &MediaId) -> Result<(), anyhow::Error>— one transaction:UPDATE posts SET photo_alt = NULL WHERE photo_media_id = :id(so no orphaned alt survives the FK'sSET NULL), thenDELETE FROM media WHERE id = :id. Deleting an unknown id is an idempotent no-opOk— the service already resolved existence, and a concurrent-delete race shouldn't manufacture an error.Implement in
SqliteAppRepository(crates/infra/src/repositories/sqlite.rs) andMemoryAppRepo(src/mocks.rs) — the mock must mimic the detach (photo_media_id/photo_altcleared on referencing posts). Percrates/domain/CLAUDE.md: no mock-only tests — cover the new ops with conformance tests exercised against both implementations, including the detach behavior.2. Web (
crates/web)2.1 Routes (
src/routes.rs)In
app_routes()(session auth + app governor):GET /media— the Library page.POST /media— the Library upload, with.layer(DefaultBodyLimit::max(MEDIA_UPLOAD_LIMIT))like the editor routes.GET /media/{id}/delete+POST /media/{id}/delete— confirm page and delete.The unauthenticated
GET /media/{id}capability route stays exactly where it is inmedia_routes(); the prefix overlap is acknowledged and accepted (#184) — separate registrations, separate auth policies, and axum routes the distinct paths without conflict.Nav (
src/layouts.rs): a "Media" entry beside "Dashboard". Dashboard (pages::dashboard): a/medialink in the Main-actions section.2.2 Library page (
pages::+ a newhandlers/home — extendinghandlers/media.rsis fine)Per #184:
<img loading="lazy" src="/media/{id}">(the full bounded AVIF rendition — no thumbnail pipeline), wrapped in a link to/media/{id}; label beneath: filename, falling back to upload date;titletooltip carrying dimensions, content type, and the upload date when the filename is the label. Processing and Failed tiles render a placeholder (their capability URL serves nothing) plus a status badge; there is no per-Media detail page./media/{id}/delete. Processing tiles are display-only — no actions.multipart/form-dataPOST to/mediacallingcreate_media(with the captured filename), then redirect to/mediawith a successFlashMessage("Upload received — processing") — the new Media appears as a Processing tile. A rejected upload (magic-byte/size gate) redirects back with an error-level flash rather than an error page.2.3 Delete confirm page
The app's existing warn-and-allow convention (cf.
get_delete_post/post_delete_postand the admin delete pages):GET: resolve the Media (owner-scoped; missing or foreign → 404). Render its metadata (label, status, dimensions + type when Ready, upload date) and every referencing Post fromlist_posts_referencing, each a link labelled by title falling back to slug (every Post has a slug by construction — covers the title-less Draft Note), with its Draft/Published status alongside. Unreferenced: state that no Posts reference it. A POST form ("Delete") plus a cancel link back to/media.POST:delete_media(¤t_user, &id); on success redirect to/mediawith a flash.NotFound→ 404;StillProcessing→ 409-shaped error (unreachable through the UI; no affordance links here for Processing Media).2.4 Editor picker (
pages::new_post,pages::edit_post)Variant A from #185 — inline expandable library, identical in both editors (extract a shared markup helper):
<details>— "Or pick from your library" — expands an inline tile grid fed bylist_media_for: newest-first squares with the filename-or-date label plus dimensions · upload date. Processing items are disabled tiles with a badge; Failed items are excluded from the picker entirely (the Library still shows them).photo_media_idform field. Recommended shape: each selectable tile is a<label>wrapping a radio input namedphoto_media_id(value = the id) so selection works without JS; a small static JS asset (registered instatic_routes()besidejustif.jset al.) layers on the settled interaction — inline preview of the picked tile (thumbnail, label, metadata) with a "Clear selection" control, and upload/pick mutual exclusion (the last action wins and clears the other). No CSP change: no client-side preview of a freshly chosen upload; the file input's native filename display suffices.PhotoAction(handlers/posts.rs) to remove > upload > pick > keep.NewPostForm/EditPostFormgainphoto_media_id: Option<String>(trimmed; empty → absent —ValidatedMultipartalready collects arbitrary text fields). On Pick:MediaId::parsethenresolve_owned_photo(current_user, id, alt);None→ reject as a bad request (only a forged form can produce it). Uploading and picking in one submit cannot both apply — the UI prevents it, and if a tampered form sends both, upload wins per the precedence order. The existingremove_photocheckbox beats everything, as today.2.5 Untouched
Feeds, rendering,
PhotoRef, the Micropub endpoints, andGET /media/{id}serving semantics all stay as they are.3. Docs
CONTEXT.mdalready carries the Library entry and Media's filename label (commitsb929806,3acb9e0). One addition: a sentence on the Library entry recording warn-and-allow deletion — deleting a Media detaches every referencing Post's Photo (shown on a confirm page first), never blocks on references.SET NULL) was decided with the media schema (ADR-0017 era), reclamation is ADR-0023, the single-rendition model stays per ADR-0020, and the rest of this effort is product surface, recorded on the map's tickets.4. Out of scope (decided on the map — do not add)
Micropub
q=source/delete (#186); orphan-Media GC (#134); multi-photo Posts; any Operator/cross-user media surface; swapping a Post's Photo from the Library side; pagination, search/filter, retry, rename, multi-file upload, drag-and-drop; a thumbnail rendition; CSP changes.5. Suggested slices
Each lands green through
mise run format/mise run ci/mise run clippy:MediaFilename, model/port/op threading, both upload adapters, conformance tests.list_media_by_user,find_posts_referencing_media,delete_media(port + both repos + conformance tests); service ops incl. theresolve_owned_photorefactor andDeleteMediaError.