Skip to main content

max / makenotwork

24.5 KB · 602 lines History Blame Raw
1 //! The Askama entry point for the described media picker.
2 //!
3 //! Shape 4 of the conversion plan (wiki `mnw-shape-conversion-plans`), step 1,
4 //! which had to stop at the landing: "`insertAtCursor` and `_mediaPickerSelect`
5 //! have no expression. See blockers, this is the finding." That finding became
6 //! quasicoherent `f35aafee`, Max ruled (a) on 2026-08-21, and
7 //! [`Act::fills`](quasi_router::Act::fills) shipped in quasi 0.54.0. So the
8 //! step finishes here and `static/media-picker.js` goes.
9 //!
10 //! # What a card says now
11 //!
12 //! `Act::new(filename, Action::local()).filling("body", reference)`. The
13 //! destination is a field name and never a caret: a webview inserts at the
14 //! selection, and where inside a box a value lands was never the description's
15 //! to say. The action is [`Destination::Local`](quasi_router::Destination), so
16 //! a pick makes no request at all — the old picker made none either, and the
17 //! two round trips this shape does cost are the open and the dismiss.
18 //!
19 //! # Three defects the shipped picker has, which this does not
20 //!
21 //! Found reading `media-picker.js:83-84` against `MediaFileResponse`
22 //! (`routes/storage/media.rs:66`), and none of them is a conversion decision —
23 //! the described version cannot have any of the three, because the value is
24 //! held once on the server instead of being spelled again in a browser.
25 //!
26 //! 1. **Every card is unlabelled.** The JSON field is `filename`; the script
27 //! reads `f.file_name`, which is `undefined`. So `label.textContent` and
28 //! `img.alt` are both the empty string on every tile.
29 //! 2. **The name filter matches nothing.** `card.dataset.name` comes from the
30 //! same `undefined`, so typing one character hides the entire library.
31 //! 3. **The inserted reference is wrapped twice.** `markdown_ref` is already
32 //! `![](folder/file.png)` (`media.rs:109-113`) and `_mediaPickerSelect`
33 //! inserts `'![](' + ref + ')'`, so what lands in the box is
34 //! `![](![](folder/file.png))`.
35 //!
36 //! Filed as its own record rather than only fixed here, because (3) means the
37 //! documents people have already written with this button carry broken image
38 //! references that no redeploy repairs.
39 //!
40 //! # Why only the grid is replaced when a filter moves
41 //!
42 //! The plan said the filter controls carry `changes` and the server re-renders
43 //! the grid as a fragment. It said the fragment is aimed at the modal; it is
44 //! aimed at [`GRID`] instead, one region further in. Replacing the modal would
45 //! replace the box being typed into, which takes the caret with it on every
46 //! keystroke.
47 //!
48 //! The name box also asks through a [`Consult`] rather than through `changes`,
49 //! which is the plan's one other deviation and is the same call
50 //! [`crate::quasi::discover_typeahead`] made: `changes` fires per keystroke and
51 //! a media library is a Postgres round trip, where a consult carries the wait
52 //! that makes it one question per pause. The folder select keeps `changes`,
53 //! because a select settles rather than being typed into.
54 //!
55 //! # The destination has to be unique in the document, and that is new
56 //!
57 //! [`Act::fills`] names a [`Field::name`], and a name identifies a field within
58 //! one description. `Filling::id_prefix` exists because that stops being true
59 //! when a document holds two copies of one form, and
60 //! [`crate::quasi::rich_field`] leans on it: five editors, all named `body`,
61 //! told apart by their ids.
62 //!
63 //! So an editor a picker can write into has to be the only `body` in its
64 //! document. Both surfaces that had a working button already are:
65 //! `dashboard-blog-editor.html` holds `post-body` alone, and
66 //! `item_details.html` holds `text-body` alone under that name. The two section
67 //! editors on that same page were raw `<textarea>` elements with an id and no
68 //! name at all, which no description can address, so they are described here
69 //! too and carry names of their own. See `rich_field::named`.
70
71 use makeover_layout::{FieldKind, Fit};
72 use quasi_router::screen::Act;
73 use quasi_router::{Action, Choice, Consult, Field, Node, Picture, RegionKind, Slot};
74
75 /// The stem both region ids are built from.
76 const REGION: &str = "media-picker";
77
78 /// The region one editor's picker is drawn into, and the empty box its trigger
79 /// ships beside it.
80 ///
81 /// One id for both, so the answer replaces the placeholder rather than nesting
82 /// inside it, and a dismissal puts the placeholder back.
83 ///
84 /// **Per destination, not per page.** `item_details.html` carries three of
85 /// these buttons, and one shared id would be three elements answering to it —
86 /// which is a duplicate id whichever of them htmx then found first. A picker
87 /// belongs to the box it was opened for, so its region is named after that box.
88 #[must_use]
89 pub fn region_id(into: &str) -> String {
90 format!("{REGION}-{into}")
91 }
92
93 /// The panel inside the scrim.
94 ///
95 /// The modal region is the scrim — fixed, covering the viewport, and what stops
96 /// the page behind it being pressed — and this is the box that sits in the
97 /// middle of it. Two regions because a scrim and a panel are two boxes, and one
98 /// element cannot be both without the panel's contents being laid out across
99 /// the whole viewport.
100 #[must_use]
101 pub fn panel_id(into: &str) -> String {
102 format!("{REGION}-{into}-panel")
103 }
104
105 /// The region inside one picker that holds the tiles.
106 ///
107 /// Addressed separately from [`region_id`] so a filter answer leaves the filter
108 /// controls, and the caret in them, alone.
109 #[must_use]
110 pub fn grid_id(into: &str) -> String {
111 format!("{REGION}-{into}-grid")
112 }
113
114 /// Whether a destination name is one this module will build ids and selectors
115 /// out of.
116 ///
117 /// `into` arrives on a query string, so it is a reader's string by the time a
118 /// route hands it back here, and it lands in an `id` and inside a `#`-selector
119 /// in `hx-target`. Both are escaped by the emitter, so this is not what stops
120 /// an injection; what it stops is a selector that is escaped, well-formed and
121 /// means something other than the element it names. The same gate quasi's own
122 /// emitter puts in front of a handle it builds a program from.
123 ///
124 /// Every field name in the tree is already a plain handle.
125 #[must_use]
126 pub fn addressable(into: &str) -> bool {
127 !into.is_empty()
128 && into.len() <= 64
129 && into
130 .bytes()
131 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
132 }
133
134 /// Where the picker comes from, and where a filtered grid comes from.
135 pub const PATH: &str = "/media/picker";
136
137 /// Where the grid alone comes from.
138 pub const GRID_PATH: &str = "/media/picker/grid";
139
140 /// What dismissing it calls.
141 pub const CLOSE_PATH: &str = "/media/picker/close";
142
143 /// The name the destination field travels under.
144 ///
145 /// Carried on every action the picker emits, because the picker is opened *for*
146 /// a box and the route rebuilds the cards against it. The shipped script held
147 /// the same fact in a module-level `targetTextareaId`.
148 pub const INTO: &str = "into";
149
150 /// The name the typed filter travels under.
151 ///
152 /// Prefixed, and both of these are, because a fragment's ids come from its
153 /// field names and this fragment lands inside a document somebody else built.
154 /// A picker carrying `id="name"` into a page with a name field on it is two
155 /// elements answering to one id, which is what `Filling::id_prefix` exists to
156 /// stop inside a form and has no equivalent for a whole document.
157 pub const NAME: &str = "media-name";
158
159 /// The name the chosen folder travels under.
160 pub const FOLDER: &str = "media-folder";
161
162 /// What the panel is called, for a stylesheet.
163 const PANEL: &str = "media-picker";
164
165 /// What the grid of tiles is called.
166 const GRID_WIDGET: &str = "media-picker-grid";
167
168 /// A named assembly, which is the hook a stylesheet attaches to.
169 ///
170 /// `RegionKind::Widget` rather than `Group` on all three, because that is what
171 /// the member is for: "the hook a stylesheet or a script attaches to in order to
172 /// draw the assembly the way a browser does it", and a name that is app
173 /// vocabulary rather than a class this renderer owns. The ids stay unique per
174 /// destination and are what an answer is aimed at; the widget name is what the
175 /// grid and the tiles are drawn by, and it is the same string on every picker in
176 /// the document.
177 fn widget(name: &str) -> RegionKind {
178 RegionKind::Widget {
179 name: name.to_owned(),
180 }
181 }
182
183 /// How long the typed filter stands still before the grid is asked for.
184 ///
185 /// `discover_typeahead`'s number, and for its reason: it is the wait that makes
186 /// a typed filter one question per pause rather than one per keystroke.
187 const WAIT: std::time::Duration = std::time::Duration::from_millis(150);
188
189 /// One media file, as the picker needs it.
190 ///
191 /// A view of `MediaFileResponse` rather than that type, so this module names
192 /// what it draws and nothing else: the picker has no use for an id, a size or a
193 /// created-at, and taking the whole response would tie the description to the
194 /// JSON shape of an API route it does not call.
195 #[derive(Debug, Clone)]
196 pub struct Entry {
197 /// The media file's own id, which is the tile's region id.
198 ///
199 /// Only here because a tile is two members and the vocabulary has no way to
200 /// hold two things together without a region, and a region has an id. See
201 /// the header on the gap that costs.
202 pub id: String,
203 /// What is read on the tile.
204 pub filename: String,
205 /// Which folder it is in, empty for the root.
206 pub folder: String,
207 /// What lands in the editor. Already a markdown image reference; this
208 /// module never spells one.
209 pub reference: String,
210 /// Where the thumbnail comes from.
211 pub url: String,
212 /// Whether there is a thumbnail to show at all.
213 pub image: bool,
214 }
215
216 /// The button that opens the picker, and the empty region it lands in.
217 ///
218 /// Both together because they are one arrangement: a trigger whose answer has
219 /// nowhere to go is a button that swaps a modal into itself. `into` is the
220 /// [`Field::name`] of the editor the picked reference goes to.
221 #[must_use]
222 pub fn trigger(into: &str) -> String {
223 use quasi_axum::Serves as _;
224
225 let act = Act::new(
226 "Insert Image",
227 Action::get(PATH)
228 .carrying(INTO, into)
229 .replacing(region_id(into)),
230 );
231
232 format!(
233 "{}{}",
234 quasi_webview::Webview::new().fragment(&Node::Act(act)),
235 dismissed(into)
236 )
237 }
238
239 /// The picker itself: the filters, and the grid under them.
240 ///
241 /// `folders` is every folder the reader has, `name` and `folder` are the
242 /// filters as they currently stand, and `entries` is what those filters
243 /// already selected — the narrowing is the route's, not this module's, because
244 /// a filter the server applied is one query rather than a library sent to a
245 /// browser to hide most of.
246 #[must_use]
247 pub fn picker(
248 entries: &[Entry],
249 folders: &[String],
250 into: &str,
251 name: &str,
252 folder: &str,
253 ) -> String {
254 use quasi_axum::Serves as _;
255
256 let mut panel = Slot::new(panel_id(into), widget(PANEL))
257 .with(Node::section("Insert from Media Library"))
258 .with(Node::Field(Box::new(name_filter(into, name, folder))))
259 .with(Node::Field(Box::new(folder_filter(
260 folders, into, name, folder,
261 ))))
262 .with(Node::Region(grid_slot(entries, into)));
263
264 // Last, so the way out is the last thing a reader tabs to rather than the
265 // first. It is an ordinary act calling an ordinary route: the shipped
266 // dismissal was `display:none` plus a scrim click, and neither is
267 // describable — a region that is there and not shown is not a state the
268 // vocabulary has, and a scrim is not a control.
269 panel = panel.with(Node::Act(Act::new(
270 "Close",
271 Action::get(CLOSE_PATH)
272 .carrying(INTO, into)
273 .replacing(region_id(into)),
274 )));
275
276 let region = Slot::new(region_id(into), RegionKind::Modal).with(Node::Region(panel));
277
278 quasi_webview::Webview::new().fragment(&Node::Region(region))
279 }
280
281 /// The grid alone, for a filter that moved.
282 #[must_use]
283 pub fn grid(entries: &[Entry], into: &str) -> String {
284 use quasi_axum::Serves as _;
285
286 quasi_webview::Webview::new().fragment(&Node::Region(grid_slot(entries, into)))
287 }
288
289 /// The empty region, for a dismissal and for the page that has not opened one.
290 ///
291 /// The same markup in both cases on purpose: "closed" and "never opened" are
292 /// one state, so there is no third thing for a reader to be in.
293 #[must_use]
294 pub fn dismissed(into: &str) -> String {
295 use quasi_axum::Serves as _;
296
297 quasi_webview::Webview::new()
298 .fragment(&Node::Region(Slot::new(region_id(into), RegionKind::Group)))
299 }
300
301 /// The tiles, or the sentence that says why there are none.
302 fn grid_slot(entries: &[Entry], into: &str) -> Slot {
303 let mut slot = Slot::new(grid_id(into), widget(GRID_WIDGET));
304 if entries.is_empty() {
305 // Two different emptinesses read the same here, and deliberately: a
306 // library with nothing in it and a filter that matched nothing both
307 // leave the reader with the filters they can see and change.
308 return slot.with(Node::text(
309 "No media files match. Upload images in your Media Library tab first.",
310 ));
311 }
312 for entry in entries {
313 slot = slot.with(card(entry, into));
314 }
315 slot
316 }
317
318 /// One tile: one control, showing the thumbnail and saying the file name.
319 ///
320 /// The label is the file name, which is the fact the shipped script lost to a
321 /// misspelled JSON key. The deposit is `markdown_ref` verbatim, which is the
322 /// fact that script wrapped twice.
323 ///
324 /// `Action::local()`, so the press makes no request. The old picker made none
325 /// either; what it did instead was 155 lines of DOM building.
326 ///
327 /// # This was two members and half a tile until quasi 0.69.0
328 ///
329 /// The conversion found that an [`Act`] was a label and nothing said a control
330 /// reads as a picture, so the tile was a region holding the picture and the
331 /// control as siblings and only the file name answered a press -- a reader
332 /// aiming at the thumbnail, which is the whole affordance of a picture picker,
333 /// hit nothing. `Act::shows` (quasicoherent `db998898`) is that member, and the
334 /// tile is one control again.
335 ///
336 /// The region went with it, and so did the id it needed. [`Entry::id`] is kept
337 /// for the caller's convenience and no longer addresses anything.
338 fn card(entry: &Entry, into: &str) -> Node {
339 let mut act = Act::new(&entry.filename, Action::local()).filling(into, &entry.reference);
340 if entry.image {
341 // The alt is the file name, because that is what the tile is for: a
342 // reader who cannot see the thumbnail is choosing between file names,
343 // which is exactly what the control says.
344 let mut picture = Picture::new(&entry.url, &entry.filename).lazy();
345 // The letterbox, which is what the shipped tile did with
346 // `object-fit: contain`: a thumbnail grid whose files are not all one
347 // shape, and the whole picture matters more than filling the square.
348 picture.fit = Fit::Contain;
349 act = act.showing(picture);
350 }
351 Node::Act(act)
352 }
353
354 /// The typed filter.
355 ///
356 /// It asks rather than writes: see this module's header on why a consult and
357 /// not `changes`. The folder rides along under [`Consult::sending`], so
358 /// narrowing by name inside a folder stays inside it.
359 ///
360 /// The folder is also carried on the action, which looks redundant and is the
361 /// fallback: htmx drops an address parameter whose name the payload repeats, so
362 /// the live select wins whenever it is found, and the carried value is what a
363 /// document with no select in reach sends instead.
364 ///
365 /// `sending` names a field document-wide, which is the vocabulary's own
366 /// reading. A document holding two open pickers would therefore match two
367 /// selects and send both values. Not guarded, because a picker is a modal
368 /// covering the viewport and a reader cannot reach the second trigger while the
369 /// first is open; recorded so the next reader knows it was seen rather than
370 /// missed.
371 fn name_filter(into: &str, name: &str, folder: &str) -> Field {
372 let mut field = Field::new(FieldKind::Text, NAME, "Filter by name").consulting(
373 Consult::new(
374 Action::get(GRID_PATH)
375 .carrying(INTO, into)
376 .carrying(FOLDER, folder)
377 .replacing(grid_id(into)),
378 )
379 .after(WAIT)
380 .sending([FOLDER]),
381 );
382 field.placeholder = Some("Filter by name...".to_owned());
383 field.value = Some(name.to_owned());
384 field
385 }
386
387 /// The folder chooser.
388 ///
389 /// `changes` rather than a consult: a select settles on a value rather than
390 /// being typed into, so there is nothing to wait for. It carries the typed
391 /// filter forward on the action, because a field's write sends its own value
392 /// and nothing else.
393 fn folder_filter(folders: &[String], into: &str, name: &str, folder: &str) -> Field {
394 let mut options = vec![Choice::new("", "All folders")];
395 options.extend(
396 folders
397 .iter()
398 .map(|folder| Choice::new(folder, if folder.is_empty() { "(root)" } else { folder })),
399 );
400
401 let mut field = Field::select(FOLDER, "Folder", options).changes(
402 Action::get(GRID_PATH)
403 .carrying(INTO, into)
404 .carrying(NAME, name)
405 .replacing(grid_id(into)),
406 );
407 field.value = Some(folder.to_owned());
408 field
409 }
410
411 #[cfg(test)]
412 mod tests {
413 use super::*;
414
415 fn entry(filename: &str, folder: &str) -> Entry {
416 let reference = if folder.is_empty() {
417 format!("![]({filename})")
418 } else {
419 format!("![]({folder}/{filename})")
420 };
421 Entry {
422 id: "11111111-1111-1111-1111-111111111111".to_owned(),
423 filename: filename.to_owned(),
424 folder: folder.to_owned(),
425 reference,
426 url: format!("https://cdn.test/{filename}"),
427 image: true,
428 }
429 }
430
431 /// The whole point of the conversion, and the member it waited eleven days
432 /// for: a card names the box it writes into and what lands there.
433 #[test]
434 fn a_card_names_the_editor_it_writes_into_and_the_reference_it_deposits() {
435 let html = grid(&[entry("kick.png", "drums")], "body");
436
437 assert!(html.contains(r#"data-fills="body""#), "{html}");
438 assert!(
439 html.contains(r#"data-fill="![](drums/kick.png)""#),
440 "{html}"
441 );
442 // Local, so the press is not a request. The shipped picker made none
443 // either; this says so in the description rather than by omission.
444 assert!(html.contains("data-local"), "{html}");
445 assert!(!html.contains("hx-get"), "{html}");
446 }
447
448 /// The tile is two members and the picture is one of them. Half a tile is
449 /// pressable, which is the gap this conversion found: an `Act` is a label,
450 /// so nothing says a control reads as a picture.
451 #[test]
452 fn a_tile_shows_the_thumbnail_beside_the_control() {
453 let html = grid(&[entry("kick.png", "drums")], "body");
454
455 assert!(
456 html.contains(r#"src="https://cdn.test/kick.png""#),
457 "{html}"
458 );
459 // The alt is the file name, because choosing without the thumbnail is
460 // choosing between names, which is what the control below says too.
461 assert!(html.contains(r#"alt="kick.png""#), "{html}");
462 assert!(html.contains(r#"loading="lazy""#), "{html}");
463 // The fit is in the description, and it is what lets `style.css` cap
464 // the tile's height with `max-height` alone: the design system already
465 // owns this picture's width, height and background, and an app rule
466 // taking one of those is what `makeover-build`'s drift check refuses.
467 assert!(html.contains(r#"data-fit="contain""#), "{html}");
468 // And the tile is the control, since quasi 0.69.0: the picture is
469 // inside the element that answers the press, so aiming at the thumbnail
470 // picks the file. It was a `media-card-<id>` region holding the picture
471 // and the button as siblings, and only the button answered.
472 assert!(
473 !html.contains("media-card-"),
474 "the per-tile region is gone: {html}"
475 );
476 let control = html
477 .split_once("<button")
478 .and_then(|(_, rest)| rest.split_once("</button>"))
479 .map_or("", |(inner, _)| inner);
480 assert!(control.contains("<img"), "{html}");
481 assert!(control.contains("kick.png"), "{html}");
482 }
483
484 /// A video has no thumbnail to show, so it is the control alone rather than
485 /// a broken image.
486 #[test]
487 fn a_file_with_nothing_to_show_is_the_control_alone() {
488 let mut clip = entry("intro.mp4", "");
489 clip.image = false;
490 let html = grid(&[clip], "body");
491
492 assert!(!html.contains("<img"), "{html}");
493 assert!(html.contains("intro.mp4"), "{html}");
494 }
495
496 /// Defect 1 of the three in the header. `media-picker.js` reads
497 /// `f.file_name` and the JSON says `filename`, so every tile shipped
498 /// unlabelled.
499 #[test]
500 fn a_card_reads_as_its_file_name() {
501 let html = grid(&[entry("kick.png", "drums")], "body");
502 assert!(html.contains("kick.png"), "{html}");
503 }
504
505 /// Defect 3. `markdown_ref` already carries the wrapper, so what the
506 /// shipped button inserted was `![](![](drums/kick.png))`.
507 #[test]
508 fn a_reference_is_deposited_exactly_once() {
509 let html = grid(&[entry("kick.png", "drums")], "body");
510 assert!(!html.contains("![](![]("), "{html}");
511 }
512
513 /// A file name is a reader's string on its way into an attribute, which is
514 /// the one thing the shipped script got right and got right the hard way,
515 /// with `textContent` and `dataset`. Here it is the emitter's ordinary
516 /// escaping, in both places a name lands.
517 #[test]
518 fn a_crafted_file_name_stays_a_value() {
519 let html = grid(&[entry(r#"" onclick="alert(1)"#, "")], "body");
520
521 assert!(!html.contains(r#"" onclick=""#), "{html}");
522 assert!(html.contains("&quot;"), "{html}");
523 }
524
525 /// The filters ask for the grid and not for the modal, so the box being
526 /// typed into survives its own answer.
527 #[test]
528 fn a_filter_replaces_the_grid_and_leaves_the_box_it_was_typed_into() {
529 let html = picker(
530 &[entry("kick.png", "")],
531 &["drums".to_owned()],
532 "body",
533 "",
534 "",
535 );
536
537 assert!(
538 html.contains(r##"hx-target="#media-picker-body-grid""##),
539 "{html}"
540 );
541 assert!(html.contains(r#"hx-get="/media/picker/grid?"#), "{html}");
542 // And the wait, which is what makes a typed filter one question per
543 // pause rather than one per keystroke against Postgres.
544 assert!(html.contains("delay:150ms"), "{html}");
545 }
546
547 /// The picker is opened for a box, so every action it emits carries which
548 /// one. The shipped script held this in a module-level variable.
549 #[test]
550 fn every_action_carries_the_editor_the_picker_was_opened_for() {
551 let html = picker(&[entry("kick.png", "")], &[], "post-body", "", "");
552 assert!(html.contains("into"), "{html}");
553 assert!(html.contains("post-body"), "{html}");
554 }
555
556 /// A modal, said in a word every renderer reads rather than in a fixed
557 /// position and a scrim.
558 #[test]
559 fn the_picker_is_a_modal_and_says_so() {
560 let html = picker(&[], &[], "body", "", "");
561 assert!(html.contains(r#"role="dialog""#), "{html}");
562 assert!(html.contains(r#"aria-modal="true""#), "{html}");
563 }
564
565 /// Closed and never-opened are one state, so a page holds the same empty
566 /// region before the first press and after the last dismissal.
567 #[test]
568 fn a_dismissal_leaves_the_region_the_page_started_with() {
569 let opened = trigger("body");
570 assert!(opened.contains(&dismissed("body")), "{opened}");
571 assert!(
572 dismissed("body").contains(r#"id="media-picker-body""#),
573 "{}",
574 dismissed("body")
575 );
576 assert!(!dismissed("body").contains("kick.png"));
577 }
578
579 /// The trigger opens the picker into the region beside it, rather than
580 /// swapping a modal into the button that asked for it.
581 #[test]
582 fn the_trigger_aims_at_the_region_it_ships_with() {
583 let html = trigger("body");
584 assert!(
585 html.contains(r#"hx-get="/media/picker?into=body""#),
586 "{html}"
587 );
588 assert!(
589 html.contains(r##"hx-target="#media-picker-body""##),
590 "{html}"
591 );
592 }
593
594 /// An empty library and an empty filter result read the same, and neither
595 /// is a blank box.
596 #[test]
597 fn nothing_to_show_says_so() {
598 let html = grid(&[], "body");
599 assert!(html.contains("No media files match"), "{html}");
600 }
601 }
602