max / makenotwork
| 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 | //! `` (`media.rs:109-113`) and `_mediaPickerSelect` |
| 33 | //! inserts `''`, so what lands in the box is |
| 34 | //! `)`. |
| 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 ; |
| 72 | use Act; |
| 73 | use ; |
| 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 | |
| 89 | |
| 90 | format! |
| 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 | |
| 101 | |
| 102 | format! |
| 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 | |
| 110 | |
| 111 | format! |
| 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 | |
| 126 | |
| 127 | !into.is_empty |
| 128 | && into.len <= 64 |
| 129 | && into |
| 130 | .bytes |
| 131 | .all |
| 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 | |
| 181 | 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: Duration = from_millis; |
| 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 | |
| 199 | |
| 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 | |
| 225 | |
| 226 | use Serves as _; |
| 227 | |
| 228 | let act = new |
| 229 | "Insert Image", |
| 230 | get |
| 231 | .carrying |
| 232 | .replacing, |
| 233 | ; |
| 234 | |
| 235 | format! |
| 236 | "{}{}" |
| 237 | new.fragment, |
| 238 | dismissed |
| 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 | |
| 250 | |
| 251 | entries: &[Entry], |
| 252 | folders: &[String], |
| 253 | into: &str, |
| 254 | name: &str, |
| 255 | folder: &str, |
| 256 | |
| 257 | use Serves as _; |
| 258 | |
| 259 | let mut panel = new |
| 260 | .with |
| 261 | .with |
| 262 | .with |
| 263 | folders, into, name, folder, |
| 264 | |
| 265 | .with; |
| 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 |
| 273 | "Close", |
| 274 | get |
| 275 | .carrying |
| 276 | .replacing, |
| 277 | ; |
| 278 | |
| 279 | let region = new.with; |
| 280 | |
| 281 | new.fragment |
| 282 | |
| 283 | |
| 284 | /// The grid alone, for a filter that moved. |
| 285 | |
| 286 | |
| 287 | use Serves as _; |
| 288 | |
| 289 | new.fragment |
| 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 | |
| 297 | |
| 298 | use Serves as _; |
| 299 | |
| 300 | new |
| 301 | .fragment |
| 302 | |
| 303 | |
| 304 | /// The tiles, or the sentence that says why there are none. |
| 305 | |
| 306 | let mut slot = new; |
| 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 |
| 312 | "No media files match. Upload images in your Media Library tab first.", |
| 313 | ; |
| 314 | |
| 315 | for entry in entries |
| 316 | slot = slot.with; |
| 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 | |
| 344 | let mut slot = new; |
| 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 = new.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 = Contain; |
| 354 | slot = slot.with; |
| 355 | |
| 356 | slot.with |
| 357 | new.filling, |
| 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 | |
| 379 | let mut field = new.consulting |
| 380 | new |
| 381 | get |
| 382 | .carrying |
| 383 | .carrying |
| 384 | .replacing, |
| 385 | |
| 386 | .after |
| 387 | .sending, |
| 388 | ; |
| 389 | field.placeholder = Some; |
| 390 | field.value = Some; |
| 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 | |
| 401 | let mut options = vec!; |
| 402 | options.extend |
| 403 | folders |
| 404 | .iter |
| 405 | .map, |
| 406 | ; |
| 407 | |
| 408 | let mut field = select.changes |
| 409 | get |
| 410 | .carrying |
| 411 | .carrying |
| 412 | .replacing, |
| 413 | ; |
| 414 | field.value = Some; |
| 415 | field |
| 416 | |
| 417 | |
| 418 | |
| 419 | |
| 420 | use *; |
| 421 | |
| 422 | |
| 423 | let reference = if folder.is_empty |
| 424 | format! |
| 425 | else |
| 426 | format! |
| 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!, |
| 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 | |
| 441 | |
| 442 | let html = grid; |
| 443 | |
| 444 | assert!; |
| 445 | assert! |
| 446 | html.contains, |
| 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!; |
| 452 | assert!; |
| 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 | |
| 459 | |
| 460 | let html = grid; |
| 461 | |
| 462 | assert! |
| 463 | html.contains, |
| 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!; |
| 469 | assert!; |
| 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!; |
| 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, |
| 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 | |
| 487 | |
| 488 | let mut clip = entry; |
| 489 | clip.image = false; |
| 490 | let html = grid; |
| 491 | |
| 492 | assert!; |
| 493 | assert!; |
| 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 | |
| 500 | |
| 501 | let html = grid; |
| 502 | assert!; |
| 503 | |
| 504 | |
| 505 | /// Defect 3. `markdown_ref` already carries the wrapper, so what the |
| 506 | /// shipped button inserted was `)`. |
| 507 | |
| 508 | |
| 509 | let html = grid; |
| 510 | assert!; |
| 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 | |
| 518 | |
| 519 | let html = grid; |
| 520 | |
| 521 | assert!; |
| 522 | assert!; |
| 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 | |
| 528 | |
| 529 | let html = picker |
| 530 | &, |
| 531 | &, |
| 532 | "body", |
| 533 | "", |
| 534 | "", |
| 535 | ; |
| 536 | |
| 537 | assert! |
| 538 | html.contains, |
| 539 | "{html}" |
| 540 | ; |
| 541 | assert!; |
| 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!; |
| 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 | |
| 550 | |
| 551 | let html = picker; |
| 552 | assert!; |
| 553 | assert!; |
| 554 | |
| 555 | |
| 556 | /// A modal, said in a word every renderer reads rather than in a fixed |
| 557 | /// position and a scrim. |
| 558 | |
| 559 | |
| 560 | let html = picker; |
| 561 | assert!; |
| 562 | assert!; |
| 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 | |
| 568 | |
| 569 | let opened = trigger; |
| 570 | assert!; |
| 571 | assert! |
| 572 | dismissed.contains, |
| 573 | "{}", |
| 574 | dismissed |
| 575 | ; |
| 576 | assert!; |
| 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 | |
| 582 | |
| 583 | let html = trigger; |
| 584 | assert! |
| 585 | html.contains, |
| 586 | "{html}" |
| 587 | ; |
| 588 | assert! |
| 589 | html.contains, |
| 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 | |
| 597 | |
| 598 | let html = grid; |
| 599 | assert!; |
| 600 | |
| 601 | |
| 602 |