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 /// What one tile is called.
169 const TILE: &str = "media-tile";
170
171 /// A named assembly, which is the hook a stylesheet attaches to.
172 ///
173 /// `RegionKind::Widget` rather than `Group` on all three, because that is what
174 /// the member is for: "the hook a stylesheet or a script attaches to in order to
175 /// draw the assembly the way a browser does it", and a name that is app
176 /// vocabulary rather than a class this renderer owns. The ids stay unique per
177 /// destination and are what an answer is aimed at; the widget name is what the
178 /// grid and the tiles are drawn by, and it is the same string on every picker in
179 /// the document.
180 fn widget(name: &str) -> RegionKind {
181 RegionKind::Widget {
182 name: name.to_owned(),
183 }
184 }
185
186 /// How long the typed filter stands still before the grid is asked for.
187 ///
188 /// `discover_typeahead`'s number, and for its reason: it is the wait that makes
189 /// a typed filter one question per pause rather than one per keystroke.
190 const WAIT: std::time::Duration = std::time::Duration::from_millis(150);
191
192 /// One media file, as the picker needs it.
193 ///
194 /// A view of `MediaFileResponse` rather than that type, so this module names
195 /// what it draws and nothing else: the picker has no use for an id, a size or a
196 /// created-at, and taking the whole response would tie the description to the
197 /// JSON shape of an API route it does not call.
198 #[derive(Debug, Clone)]
199 pub struct Entry {
200 /// The media file's own id, which is the tile's region id.
201 ///
202 /// Only here because a tile is two members and the vocabulary has no way to
203 /// hold two things together without a region, and a region has an id. See
204 /// the header on the gap that costs.
205 pub id: String,
206 /// What is read on the tile.
207 pub filename: String,
208 /// Which folder it is in, empty for the root.
209 pub folder: String,
210 /// What lands in the editor. Already a markdown image reference; this
211 /// module never spells one.
212 pub reference: String,
213 /// Where the thumbnail comes from.
214 pub url: String,
215 /// Whether there is a thumbnail to show at all.
216 pub image: bool,
217 }
218
219 /// The button that opens the picker, and the empty region it lands in.
220 ///
221 /// Both together because they are one arrangement: a trigger whose answer has
222 /// nowhere to go is a button that swaps a modal into itself. `into` is the
223 /// [`Field::name`] of the editor the picked reference goes to.
224 #[must_use]
225 pub fn trigger(into: &str) -> String {
226 use quasi_axum::Serves as _;
227
228 let act = Act::new(
229 "Insert Image",
230 Action::get(PATH)
231 .carrying(INTO, into)
232 .replacing(region_id(into)),
233 );
234
235 format!(
236 "{}{}",
237 quasi_webview::Webview::new().fragment(&Node::Act(act)),
238 dismissed(into)
239 )
240 }
241
242 /// The picker itself: the filters, and the grid under them.
243 ///
244 /// `folders` is every folder the reader has, `name` and `folder` are the
245 /// filters as they currently stand, and `entries` is what those filters
246 /// already selected — the narrowing is the route's, not this module's, because
247 /// a filter the server applied is one query rather than a library sent to a
248 /// browser to hide most of.
249 #[must_use]
250 pub fn picker(
251 entries: &[Entry],
252 folders: &[String],
253 into: &str,
254 name: &str,
255 folder: &str,
256 ) -> String {
257 use quasi_axum::Serves as _;
258
259 let mut panel = Slot::new(panel_id(into), widget(PANEL))
260 .with(Node::section("Insert from Media Library"))
261 .with(Node::Field(Box::new(name_filter(into, name, folder))))
262 .with(Node::Field(Box::new(folder_filter(
263 folders, into, name, folder,
264 ))))
265 .with(Node::Region(grid_slot(entries, into)));
266
267 // Last, so the way out is the last thing a reader tabs to rather than the
268 // first. It is an ordinary act calling an ordinary route: the shipped
269 // dismissal was `display:none` plus a scrim click, and neither is
270 // describable — a region that is there and not shown is not a state the
271 // vocabulary has, and a scrim is not a control.
272 panel = panel.with(Node::Act(Act::new(
273 "Close",
274 Action::get(CLOSE_PATH)
275 .carrying(INTO, into)
276 .replacing(region_id(into)),
277 )));
278
279 let region = Slot::new(region_id(into), RegionKind::Modal).with(Node::Region(panel));
280
281 quasi_webview::Webview::new().fragment(&Node::Region(region))
282 }
283
284 /// The grid alone, for a filter that moved.
285 #[must_use]
286 pub fn grid(entries: &[Entry], into: &str) -> String {
287 use quasi_axum::Serves as _;
288
289 quasi_webview::Webview::new().fragment(&Node::Region(grid_slot(entries, into)))
290 }
291
292 /// The empty region, for a dismissal and for the page that has not opened one.
293 ///
294 /// The same markup in both cases on purpose: "closed" and "never opened" are
295 /// one state, so there is no third thing for a reader to be in.
296 #[must_use]
297 pub fn dismissed(into: &str) -> String {
298 use quasi_axum::Serves as _;
299
300 quasi_webview::Webview::new()
301 .fragment(&Node::Region(Slot::new(region_id(into), RegionKind::Group)))
302 }
303
304 /// The tiles, or the sentence that says why there are none.
305 fn grid_slot(entries: &[Entry], into: &str) -> Slot {
306 let mut slot = Slot::new(grid_id(into), widget(GRID_WIDGET));
307 if entries.is_empty() {
308 // Two different emptinesses read the same here, and deliberately: a
309 // library with nothing in it and a filter that matched nothing both
310 // leave the reader with the filters they can see and change.
311 return slot.with(Node::text(
312 "No media files match. Upload images in your Media Library tab first.",
313 ));
314 }
315 for entry in entries {
316 slot = slot.with(Node::Region(card(entry, into)));
317 }
318 slot
319 }
320
321 /// One tile: the thumbnail, and the control that deposits its reference.
322 ///
323 /// The control's label is the file name, which is the fact the shipped script
324 /// lost to a misspelled JSON key. Its deposit is `markdown_ref` verbatim, which
325 /// is the fact that script wrapped twice.
326 ///
327 /// `Action::local()`, so the press makes no request. The old picker made none
328 /// either; what it did instead was 155 lines of DOM building.
329 ///
330 /// # Two members and half a tile, which is the gap this conversion found
331 ///
332 /// The shipped card is one pressable box with a picture in it. Here the picture
333 /// and the control are siblings, and only the control answers a press, because
334 /// **an [`Act`] is a label and nothing in the vocabulary says a control reads as
335 /// a picture.** `Cell::new` and `Row::new` both take a string, so a picture
336 /// cannot sit beside a control in a table or a list either: [`Node::Image`]
337 /// goes in a region body and nowhere else.
338 ///
339 /// So a tile is a region, which is why [`Entry::id`] exists. Filed rather than
340 /// faked, the same way the caret was: a description that drew a pressable
341 /// picture by putting the picture in the label would be spelling markup in a
342 /// string.
343 fn card(entry: &Entry, into: &str) -> Slot {
344 let mut slot = Slot::new(format!("media-card-{}", entry.id), widget(TILE));
345 if entry.image {
346 // The alt is the file name, because that is what the tile is for: a
347 // reader who cannot see the thumbnail is choosing between file names,
348 // which is exactly what the control below says.
349 let mut picture = Picture::new(&entry.url, &entry.filename).lazy();
350 // The letterbox, which is what the shipped tile did with
351 // `object-fit: contain`: a thumbnail grid whose files are not all one
352 // shape, and the whole picture matters more than filling the square.
353 picture.fit = Fit::Contain;
354 slot = slot.with(Node::Image(picture));
355 }
356 slot.with(Node::Act(
357 Act::new(&entry.filename, Action::local()).filling(into, &entry.reference),
358 ))
359 }
360
361 /// The typed filter.
362 ///
363 /// It asks rather than writes: see this module's header on why a consult and
364 /// not `changes`. The folder rides along under [`Consult::sending`], so
365 /// narrowing by name inside a folder stays inside it.
366 ///
367 /// The folder is also carried on the action, which looks redundant and is the
368 /// fallback: htmx drops an address parameter whose name the payload repeats, so
369 /// the live select wins whenever it is found, and the carried value is what a
370 /// document with no select in reach sends instead.
371 ///
372 /// `sending` names a field document-wide, which is the vocabulary's own
373 /// reading. A document holding two open pickers would therefore match two
374 /// selects and send both values. Not guarded, because a picker is a modal
375 /// covering the viewport and a reader cannot reach the second trigger while the
376 /// first is open; recorded so the next reader knows it was seen rather than
377 /// missed.
378 fn name_filter(into: &str, name: &str, folder: &str) -> Field {
379 let mut field = Field::new(FieldKind::Text, NAME, "Filter by name").consulting(
380 Consult::new(
381 Action::get(GRID_PATH)
382 .carrying(INTO, into)
383 .carrying(FOLDER, folder)
384 .replacing(grid_id(into)),
385 )
386 .after(WAIT)
387 .sending([FOLDER]),
388 );
389 field.placeholder = Some("Filter by name...".to_owned());
390 field.value = Some(name.to_owned());
391 field
392 }
393
394 /// The folder chooser.
395 ///
396 /// `changes` rather than a consult: a select settles on a value rather than
397 /// being typed into, so there is nothing to wait for. It carries the typed
398 /// filter forward on the action, because a field's write sends its own value
399 /// and nothing else.
400 fn folder_filter(folders: &[String], into: &str, name: &str, folder: &str) -> Field {
401 let mut options = vec![Choice::new("", "All folders")];
402 options.extend(
403 folders
404 .iter()
405 .map(|folder| Choice::new(folder, if folder.is_empty() { "(root)" } else { folder })),
406 );
407
408 let mut field = Field::select(FOLDER, "Folder", options).changes(
409 Action::get(GRID_PATH)
410 .carrying(INTO, into)
411 .carrying(NAME, name)
412 .replacing(grid_id(into)),
413 );
414 field.value = Some(folder.to_owned());
415 field
416 }
417
418 #[cfg(test)]
419 mod tests {
420 use super::*;
421
422 fn entry(filename: &str, folder: &str) -> Entry {
423 let reference = if folder.is_empty() {
424 format!("![]({filename})")
425 } else {
426 format!("![]({folder}/{filename})")
427 };
428 Entry {
429 id: "11111111-1111-1111-1111-111111111111".to_owned(),
430 filename: filename.to_owned(),
431 folder: folder.to_owned(),
432 reference,
433 url: format!("https://cdn.test/{filename}"),
434 image: true,
435 }
436 }
437
438 /// The whole point of the conversion, and the member it waited eleven days
439 /// for: a card names the box it writes into and what lands there.
440 #[test]
441 fn a_card_names_the_editor_it_writes_into_and_the_reference_it_deposits() {
442 let html = grid(&[entry("kick.png", "drums")], "body");
443
444 assert!(html.contains(r#"data-fills="body""#), "{html}");
445 assert!(
446 html.contains(r#"data-fill="![](drums/kick.png)""#),
447 "{html}"
448 );
449 // Local, so the press is not a request. The shipped picker made none
450 // either; this says so in the description rather than by omission.
451 assert!(html.contains("data-local"), "{html}");
452 assert!(!html.contains("hx-get"), "{html}");
453 }
454
455 /// The tile is two members and the picture is one of them. Half a tile is
456 /// pressable, which is the gap this conversion found: an `Act` is a label,
457 /// so nothing says a control reads as a picture.
458 #[test]
459 fn a_tile_shows_the_thumbnail_beside_the_control() {
460 let html = grid(&[entry("kick.png", "drums")], "body");
461
462 assert!(
463 html.contains(r#"src="https://cdn.test/kick.png""#),
464 "{html}"
465 );
466 // The alt is the file name, because choosing without the thumbnail is
467 // choosing between names, which is what the control below says too.
468 assert!(html.contains(r#"alt="kick.png""#), "{html}");
469 assert!(html.contains(r#"loading="lazy""#), "{html}");
470 // The fit is in the description, and it is what lets `style.css` cap
471 // the tile's height with `max-height` alone: the design system already
472 // owns this picture's width, height and background, and an app rule
473 // taking one of those is what `makeover-build`'s drift check refuses.
474 assert!(html.contains(r#"data-fit="contain""#), "{html}");
475 // And the tile is a region, which is the only place a picture can sit
476 // beside a control. Its id is the file's, so a library of two hundred
477 // does not have two hundred elements answering to one name.
478 assert!(
479 html.contains(r#"id="media-card-11111111-1111-1111-1111-111111111111""#),
480 "{html}"
481 );
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