Media management: Library page and editor picker — implementation spec #188

Closed
opened 2026-08-12 05:47:58 +00:00 by rosa · 0 comments
Owner

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 (commit 822f329) 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).

  • New value type MediaFilename in src/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_optional shape, like PhotoAlt). 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.
  • Media gains filename: Option<MediaFilename> + accessor; Media::new gains the parameter.
  • CreateMediaRequest (src/ports.rs) gains filename: Option<MediaFilename>.
  • Service::create_media (src/media/service.rs) gains the parameter and threads it through.
  • Both upload adapters capture it: the web editor/library (multipart photo/file part's client filename) and the Micropub media endpoint (field.file_name() in crates/web/src/handlers/micropub.rs). A part without a filename yields None — the column stays honestly absent.
  • The display fallback (no filename → show upload date) is a view concern; the domain stores only what was given.

Migration: migrations/<timestamp>_media_filename.sql

ALTER TABLE media ADD COLUMN filename TEXT;

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 AppRepository methods (§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 author must own it; anything else is None (the handler maps it to a rejection — don't leak existence of other users' ids). No status gate, mirroring Micropub's resolve_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 (the pub(crate) Micropub op) keeps its URL-parsing head and delegates its existence + ownership tail to resolve_owned_photo, mapping NoneMicropubError::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):

  • Missing id, or a Media actor does not own → DeleteMediaError::NotFound (one variant for both; the Library is strictly per-user, so foreign Media must be indistinguishable from absent).
  • Still Processing → 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.
  • Otherwise delete via the repository (§1.3). The posts.photo_media_id FK is ON DELETE SET NULL and media_rendition is ON 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) } in src/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_at is RFC3339 text, so lexicographic order is chronological).
  • find_posts_referencing_media(&self, id: &MediaId) -> Vec<Post>WHERE photo_media_id = ?, newest-first by created_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's SET NULL), then DELETE FROM media WHERE id = :id. Deleting an unknown id is an idempotent no-op Ok — the service already resolved existence, and a concurrent-delete race shouldn't manufacture an error.

Implement in SqliteAppRepository (crates/infra/src/repositories/sqlite.rs) and MemoryAppRepo (src/mocks.rs) — the mock must mimic the detach (photo_media_id/photo_alt cleared on referencing posts). Per crates/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 in media_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 /media link in the Main-actions section.

2.2 Library page (pages:: + a new handlers/ home — extending handlers/media.rs is fine)

Per #184:

  • Grid: responsive, uniform square-cropped tiles, newest-first, the whole set on one page — no pagination. Empty state: the line "No media yet — images you upload appear here" above the upload form.
  • Tile: for Ready Media, <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; title tooltip 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.
  • Tile actions: Ready and Failed tiles carry a "Delete" link to /media/{id}/delete. Processing tiles are display-only — no actions.
  • Upload: single-file input + submit at the top, always present; plain multipart/form-data POST to /media calling create_media (with the captured filename), then redirect to /media with a success FlashMessage ("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.
  • Failed Media are deletable, never retryable — re-uploading is the retry.

2.3 Delete confirm page

The app's existing warn-and-allow convention (cf. get_delete_post/post_delete_post and 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 from list_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(&current_user, &id); on success redirect to /media with 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):

  • The file input stays the primary affordance, untouched. Beneath it, a collapsed <details> — "Or pick from your library" — expands an inline tile grid fed by list_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).
  • The pick travels as a photo_media_id form field. Recommended shape: each selectable tile is a <label> wrapping a radio input named photo_media_id (value = the id) so selection works without JS; a small static JS asset (registered in static_routes() beside justif.js et 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.
  • Server-side precedence extends PhotoAction (handlers/posts.rs) to remove > upload > pick > keep. NewPostForm/EditPostForm gain photo_media_id: Option<String> (trimmed; empty → absent — ValidatedMultipart already collects arbitrary text fields). On Pick: MediaId::parse then resolve_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 existing remove_photo checkbox beats everything, as today.
  • Keep: unchanged (existing Media kept, alt re-adopted from the form).

2.5 Untouched

Feeds, rendering, PhotoRef, the Micropub endpoints, and GET /media/{id} serving semantics all stay as they are.

3. Docs

  • CONTEXT.md already carries the Library entry and Media's filename label (commits b929806, 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.
  • No new ADR. Detach-on-delete (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:

  1. Filename capture — migration, MediaFilename, model/port/op threading, both upload adapters, conformance tests.
  2. Domain opslist_media_by_user, find_posts_referencing_media, delete_media (port + both repos + conformance tests); service ops incl. the resolve_owned_photo refactor and DeleteMediaError.
  3. Library page — routes, nav + dashboard links, grid, upload, delete confirm flow.
  4. Editor picker — form fields, precedence extension, shared grid partial, JS asset, both editors.
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` (commit 822f329) 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). - New value type `MediaFilename` in `src/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_optional` shape, like `PhotoAlt`). 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. - `Media` gains `filename: Option<MediaFilename>` + accessor; `Media::new` gains the parameter. - `CreateMediaRequest` (`src/ports.rs`) gains `filename: Option<MediaFilename>`. - `Service::create_media` (`src/media/service.rs`) gains the parameter and threads it through. - Both upload adapters capture it: the web editor/library (multipart `photo`/`file` part's client filename) and the Micropub media endpoint (`field.file_name()` in `crates/web/src/handlers/micropub.rs`). A part without a filename yields `None` — the column stays honestly absent. - The display fallback (no filename → show upload date) is a **view** concern; the domain stores only what was given. Migration: `migrations/<timestamp>_media_filename.sql` — ```sql ALTER TABLE media ADD COLUMN filename TEXT; ``` 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 `AppRepository` methods (§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 `author` must own it; anything else is `None` (the handler maps it to a rejection — don't leak existence of other users' ids). **No status gate**, mirroring Micropub's `resolve_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` (the `pub(crate)` Micropub op) keeps its URL-parsing head and delegates its existence + ownership tail to `resolve_owned_photo`, mapping `None` → `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): - Missing id, or a Media `actor` does not own → `DeleteMediaError::NotFound` (one variant for both; the Library is strictly per-user, so foreign Media must be indistinguishable from absent). - Still Processing → `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. - Otherwise delete via the repository (§1.3). The `posts.photo_media_id` FK is `ON DELETE SET NULL` and `media_rendition` is `ON 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) }` in `src/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_at` is RFC3339 text, so lexicographic order is chronological). - `find_posts_referencing_media(&self, id: &MediaId) -> Vec<Post>` — `WHERE photo_media_id = ?`, newest-first by `created_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's `SET NULL`), then `DELETE FROM media WHERE id = :id`. Deleting an unknown id is an idempotent no-op `Ok` — the service already resolved existence, and a concurrent-delete race shouldn't manufacture an error. Implement in `SqliteAppRepository` (`crates/infra/src/repositories/sqlite.rs`) **and** `MemoryAppRepo` (`src/mocks.rs`) — the mock must mimic the detach (`photo_media_id`/`photo_alt` cleared on referencing posts). Per `crates/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 in `media_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 `/media` link in the Main-actions section. ### 2.2 Library page (`pages::` + a new `handlers/` home — extending `handlers/media.rs` is fine) Per #184: - **Grid**: responsive, uniform square-cropped tiles, newest-first, the whole set on one page — no pagination. Empty state: the line "No media yet — images you upload appear here" above the upload form. - **Tile**: for Ready Media, `<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; `title` tooltip 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. - **Tile actions**: Ready and Failed tiles carry a "Delete" link to `/media/{id}/delete`. Processing tiles are display-only — no actions. - **Upload**: single-file input + submit at the top, always present; plain `multipart/form-data` POST to `/media` calling `create_media` (with the captured filename), then redirect to `/media` with a success `FlashMessage` ("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. - **Failed** Media are deletable, never retryable — re-uploading is the retry. ### 2.3 Delete confirm page The app's existing warn-and-allow convention (cf. `get_delete_post`/`post_delete_post` and 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 from `list_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(&current_user, &id)`; on success redirect to `/media` with 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): - The file input stays the primary affordance, untouched. Beneath it, a collapsed `<details>` — "Or pick from your library" — expands an inline tile grid fed by `list_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). - The pick travels as a `photo_media_id` form field. Recommended shape: each selectable tile is a `<label>` wrapping a radio input named `photo_media_id` (value = the id) so selection works without JS; a small static JS asset (registered in `static_routes()` beside `justif.js` et 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. - Server-side precedence extends `PhotoAction` (`handlers/posts.rs`) to **remove > upload > pick > keep**. `NewPostForm`/`EditPostForm` gain `photo_media_id: Option<String>` (trimmed; empty → absent — `ValidatedMultipart` already collects arbitrary text fields). On Pick: `MediaId::parse` then `resolve_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 existing `remove_photo` checkbox beats everything, as today. - Keep: unchanged (existing Media kept, alt re-adopted from the form). ### 2.5 Untouched Feeds, rendering, `PhotoRef`, the Micropub endpoints, and `GET /media/{id}` serving semantics all stay as they are. ## 3. Docs - `CONTEXT.md` already carries the Library entry and Media's filename label (commits b929806, 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. - **No new ADR.** Detach-on-delete (`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`: 1. **Filename capture** — migration, `MediaFilename`, model/port/op threading, both upload adapters, conformance tests. 2. **Domain ops** — `list_media_by_user`, `find_posts_referencing_media`, `delete_media` (port + both repos + conformance tests); service ops incl. the `resolve_owned_photo` refactor and `DeleteMediaError`. 3. **Library page** — routes, nav + dashboard links, grid, upload, delete confirm flow. 4. **Editor picker** — form fields, precedence extension, shared grid partial, JS asset, both editors.
rosa added this to the v0.1 milestone 2026-08-12 18:03:45 +00:00
rosa closed this issue 2026-08-12 22:21:17 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
rosa/vernier#188
No description provided.