max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+193 insertions,
-52 deletions
| @@ -5390,9 +5390,9 @@ | |||
| 5390 | 5390 | ||
| 5391 | 5391 | [[package]] | |
| 5392 | 5392 | name = "makeover-build" | |
| 5393 | - | version = "0.55.0" | |
| 5393 | + | version = "0.56.0" | |
| 5394 | 5394 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5395 | - | checksum = "725a4e9fad30d4d9fa840c408a70dfea583f0d5ada611a95a131a63429b4391a" | |
| 5395 | + | checksum = "56c8903ef617932723aea44f7ada31c7ce4f89d008e8d25ebaee956b2258bb91" | |
| 5396 | 5396 | dependencies = [ | |
| 5397 | 5397 | "makeover", | |
| 5398 | 5398 | "makeover-geometry", | |
| @@ -5408,9 +5408,9 @@ | |||
| 5408 | 5408 | ||
| 5409 | 5409 | [[package]] | |
| 5410 | 5410 | name = "makeover-layout" | |
| 5411 | - | version = "0.36.0" | |
| 5411 | + | version = "0.37.0" | |
| 5412 | 5412 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5413 | - | checksum = "9ffed28fd6f311d8603b9fe02cffc14bacf29b840c486f84e8ff4866a4748514" | |
| 5413 | + | checksum = "369bc3c3e0ee91d24b3552f48f1ecb1764bca953725fb817df33b2dc80f2d34f" | |
| 5414 | 5414 | ||
| 5415 | 5415 | [[package]] | |
| 5416 | 5416 | name = "makeover-timing" | |
| @@ -5423,9 +5423,9 @@ | |||
| 5423 | 5423 | ||
| 5424 | 5424 | [[package]] | |
| 5425 | 5425 | name = "makeover-touch" | |
| 5426 | - | version = "0.26.0" | |
| 5426 | + | version = "0.27.0" | |
| 5427 | 5427 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5428 | - | checksum = "941676f114fdb8b8e2dbd2e35dd0149d409212982b37139de831d9865f664d77" | |
| 5428 | + | checksum = "b180e22917189e05d291bfec0e930b5e6c2952dc7bf6e2f7a2262b903431cc72" | |
| 5429 | 5429 | dependencies = [ | |
| 5430 | 5430 | "makeover-geometry", | |
| 5431 | 5431 | "makeover-layout", | |
| @@ -5433,9 +5433,9 @@ | |||
| 5433 | 5433 | ||
| 5434 | 5434 | [[package]] | |
| 5435 | 5435 | name = "makeover-webview" | |
| 5436 | - | version = "0.62.0" | |
| 5436 | + | version = "0.63.0" | |
| 5437 | 5437 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5438 | - | checksum = "546fb283399000bcd303b1d4d59b7d4c560eea8d6c40c4bed172f0c32a50c8e1" | |
| 5438 | + | checksum = "9149f06aa7b8f4660609a680169d76075d91def63c23127bd84af9b183be22fa" | |
| 5439 | 5439 | dependencies = [ | |
| 5440 | 5440 | "makeover-geometry", | |
| 5441 | 5441 | "makeover-layout", | |
| @@ -6636,7 +6636,7 @@ | |||
| 6636 | 6636 | ||
| 6637 | 6637 | [[package]] | |
| 6638 | 6638 | name = "quasi-axum" | |
| 6639 | - | version = "0.71.0" | |
| 6639 | + | version = "0.72.0" | |
| 6640 | 6640 | dependencies = [ | |
| 6641 | 6641 | "axum", | |
| 6642 | 6642 | "http 1.4.2", | |
| @@ -6647,7 +6647,7 @@ | |||
| 6647 | 6647 | ||
| 6648 | 6648 | [[package]] | |
| 6649 | 6649 | name = "quasi-basics" | |
| 6650 | - | version = "0.71.0" | |
| 6650 | + | version = "0.72.0" | |
| 6651 | 6651 | dependencies = [ | |
| 6652 | 6652 | "makeover-layout", | |
| 6653 | 6653 | "quasi-router", | |
| @@ -6655,7 +6655,7 @@ | |||
| 6655 | 6655 | ||
| 6656 | 6656 | [[package]] | |
| 6657 | 6657 | name = "quasi-http" | |
| 6658 | - | version = "0.71.0" | |
| 6658 | + | version = "0.72.0" | |
| 6659 | 6659 | dependencies = [ | |
| 6660 | 6660 | "form_urlencoded", | |
| 6661 | 6661 | "http 1.4.2", | |
| @@ -6664,14 +6664,14 @@ | |||
| 6664 | 6664 | ||
| 6665 | 6665 | [[package]] | |
| 6666 | 6666 | name = "quasi-router" | |
| 6667 | - | version = "0.71.0" | |
| 6667 | + | version = "0.72.0" | |
| 6668 | 6668 | dependencies = [ | |
| 6669 | 6669 | "makeover-layout", | |
| 6670 | 6670 | ] | |
| 6671 | 6671 | ||
| 6672 | 6672 | [[package]] | |
| 6673 | 6673 | name = "quasi-type" | |
| 6674 | - | version = "0.1.0" | |
| 6674 | + | version = "0.1.1" | |
| 6675 | 6675 | dependencies = [ | |
| 6676 | 6676 | "brotli", | |
| 6677 | 6677 | "font-types", | |
| @@ -6687,7 +6687,7 @@ | |||
| 6687 | 6687 | ||
| 6688 | 6688 | [[package]] | |
| 6689 | 6689 | name = "quasi-webview" | |
| 6690 | - | version = "0.71.0" | |
| 6690 | + | version = "0.72.0" | |
| 6691 | 6691 | dependencies = [ | |
| 6692 | 6692 | "docengine", | |
| 6693 | 6693 | "makeover-layout", | |
| @@ -10759,14 +10759,6 @@ | |||
| 10759 | 10759 | "pkg-config", | |
| 10760 | 10760 | ] | |
| 10761 | 10761 | ||
| 10762 | - | [[patch.unused]] | |
| 10763 | - | name = "synckit-client" | |
| 10764 | - | version = "0.9.1" | |
| 10765 | - | ||
| 10766 | - | [[patch.unused]] | |
| 10767 | - | name = "synckit-config" | |
| 10768 | - | version = "0.2.0" | |
| 10769 | - | ||
| 10770 | 10762 | [[patch.unused]] | |
| 10771 | 10763 | name = "kberg" | |
| 10772 | 10764 | version = "0.1.0" | |
| @@ -10779,13 +10771,21 @@ | |||
| 10779 | 10771 | name = "painhours" | |
| 10780 | 10772 | version = "0.1.0" | |
| 10781 | 10773 | ||
| 10774 | + | [[patch.unused]] | |
| 10775 | + | name = "synckit-client" | |
| 10776 | + | version = "0.9.1" | |
| 10777 | + | ||
| 10778 | + | [[patch.unused]] | |
| 10779 | + | name = "synckit-config" | |
| 10780 | + | version = "0.2.0" | |
| 10781 | + | ||
| 10782 | 10782 | [[patch.unused]] | |
| 10783 | 10783 | name = "quasi-immediate" | |
| 10784 | - | version = "0.71.0" | |
| 10784 | + | version = "0.72.0" | |
| 10785 | 10785 | ||
| 10786 | 10786 | [[patch.unused]] | |
| 10787 | 10787 | name = "quasi-notifs" | |
| 10788 | - | version = "0.71.0" | |
| 10788 | + | version = "0.72.0" | |
| 10789 | 10789 | ||
| 10790 | 10790 | [[patch.unused]] | |
| 10791 | 10791 | name = "quasi-store" | |
| @@ -10793,4 +10793,4 @@ | |||
| 10793 | 10793 | ||
| 10794 | 10794 | [[patch.unused]] | |
| 10795 | 10795 | name = "quasi-tauri" | |
| 10796 | - | version = "0.71.0" | |
| 10796 | + | version = "0.72.0" |
| @@ -163,29 +163,29 @@ | |||
| 163 | 163 | # through quasi beside its Askama equivalent, so the cost of describing a screen | |
| 164 | 164 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 165 | 165 | # See wiki look-wave-2, tier G. | |
| 166 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" } | |
| 166 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" } | |
| 167 | 167 | # The description vocabulary quasi's screen types are built from. Pinned here | |
| 168 | 168 | # rather than reached through quasi-router's re-export because a described | |
| 169 | 169 | # screen names FieldKind and Tone directly; it has to track what quasi-router | |
| 170 | 170 | # resolves or the two `layout::` paths are different crates. | |
| 171 | - | makeover-layout = "0.36.0" | |
| 171 | + | makeover-layout = "0.37.0" | |
| 172 | 172 | # The webview renderer's field emitter, called directly by | |
| 173 | 173 | # `quasi::rich_field` so one markdown editor's markup comes from the same | |
| 174 | 174 | # place a whole described screen's would. Pinned here rather than reached | |
| 175 | 175 | # through quasi-webview for makeover-layout's reason: two `form::` paths from | |
| 176 | 176 | # two resolutions are two crates, and the `data-format="markdown"` rules in | |
| 177 | 177 | # `static/layout.css` are generated from this one. | |
| 178 | - | makeover-webview = "0.62.0" | |
| 178 | + | makeover-webview = "0.63.0" | |
| 179 | 179 | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 180 | 180 | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 181 | 181 | http = "1.3.1" | |
| 182 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" } | |
| 183 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" } | |
| 182 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" } | |
| 183 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" } | |
| 184 | 184 | # The first-party widget set. The carousel was proved here against three pages | |
| 185 | 185 | # and then moved there; what is left in `quasi/widgets/carousel.rs` is the | |
| 186 | 186 | # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it | |
| 187 | 187 | # sits above quasi-router rather than inside the description suite. | |
| 188 | - | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" } | |
| 188 | + | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" } | |
| 189 | 189 | ||
| 190 | 190 | # Git source browser | |
| 191 | 191 | gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] } | |
| @@ -254,7 +254,7 @@ | |||
| 254 | 254 | # Emits static/geometry.css (makeover-geometry) and static/layout.css | |
| 255 | 255 | # (makeover-webview). The same generator GO and BB run; only the output paths | |
| 256 | 256 | # differ, since the server serves its stylesheets rather than bundling them. | |
| 257 | - | makeover-build = "0.55.0" | |
| 257 | + | makeover-build = "0.56.0" | |
| 258 | 258 | # Read directly for the embeds' own copy of the spacing layer, which they need | |
| 259 | 259 | # because an iframe cannot link a stylesheet. makeover-build does not re-export | |
| 260 | 260 | # it, so the pin lives here and has to track the one makeover-build resolves. | |
| @@ -269,7 +269,7 @@ | |||
| 269 | 269 | # and the terminal bundles. The three move together, in one pass, or a user sees | |
| 270 | 270 | # two Quasi Monos. Under the tree's `[patch]` block the rev is ignored and the | |
| 271 | 271 | # working copy is what builds. | |
| 272 | - | quasi-type = { git = "https://makenot.work/git/max/quasi-type.git", version = "0.1", rev = "e40330602e68fbdd2a9d2917bdec33a7ce573a1f" } | |
| 272 | + | quasi-type = { git = "https://makenot.work/git/max/quasi-type.git", version = "0.1.1", rev = "e57a57f35563252b5ed00a8caf1262517f3d51f4" } | |
| 273 | 273 | # Writes the two scripts the renderer ships into `static/`, from the constants | |
| 274 | 274 | # the crate carries. They are the renderer's behaviour rather than the app's -- | |
| 275 | 275 | # a bulk bar's count, a readout that keeps ticking -- and the shell has always | |
| @@ -277,7 +277,7 @@ | |||
| 277 | 277 | # Written from the crate rather than copied into the repo for the reason the | |
| 278 | 278 | # constants exist: a stale copy in a static directory is a silently dead bulk | |
| 279 | 279 | # bar, and the version has to be the one the emitter agrees with. | |
| 280 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" } | |
| 280 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" } | |
| 281 | 281 | ||
| 282 | 282 | [dev-dependencies] | |
| 283 | 283 | tower = { version = "0.5.3", features = ["util"] } |
| @@ -547,7 +547,7 @@ | |||
| 547 | 547 | /// and unserved -- a 404 per document, with nothing in the log -- and | |
| 548 | 548 | /// `quasi-awaiting.js` would have joined them the moment it shipped. Found | |
| 549 | 549 | /// adding the third, which is the only reason the other two were. | |
| 550 | - | const GENERATED_STATIC_JS: [(&str, &str); 8] = [ | |
| 550 | + | const GENERATED_STATIC_JS: [(&str, &str); 9] = [ | |
| 551 | 551 | ("quasi-selection.js", quasi_webview::SELECTION_JS), | |
| 552 | 552 | ("quasi-clock.js", quasi_webview::CLOCK_JS), | |
| 553 | 553 | ("quasi-download.js", quasi_webview::DOWNLOAD_JS), | |
| @@ -556,6 +556,7 @@ | |||
| 556 | 556 | ("quasi-copy.js", quasi_webview::COPY_JS), | |
| 557 | 557 | ("quasi-repeat.js", quasi_webview::REPEAT_JS), | |
| 558 | 558 | ("quasi-awaiting.js", quasi_webview::AWAITING_JS), | |
| 559 | + | ("quasi-instant.js", quasi_webview::INSTANT_JS), | |
| 559 | 560 | ]; | |
| 560 | 561 | ||
| 561 | 562 | /// Every file that can carry a class name. |
| @@ -55,6 +55,7 @@ | |||
| 55 | 55 | pub mod project_overview; | |
| 56 | 56 | pub mod project_tabs; | |
| 57 | 57 | pub mod rich_field; | |
| 58 | + | pub mod schedule_field; | |
| 58 | 59 | pub mod settings_tabs; | |
| 59 | 60 | pub mod ssh_keys; | |
| 60 | 61 | pub mod upload_field; |
| @@ -213,7 +213,7 @@ | |||
| 213 | 213 | }); | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | - | /** Install the `data-after` / `data-after-always` / `data-config` dispatcher. */ | |
| 216 | + | /** Install the `data-after` / `data-after-always` dispatcher. */ | |
| 217 | 217 | export function initAfterRequestDispatcher(): void { | |
| 218 | 218 | registerAfterVerbs(); | |
| 219 | 219 | ||
| @@ -228,14 +228,4 @@ | |||
| 228 | 228 | if ((evt.detail.ctx.response?.status ?? 0) >= 400) return; | |
| 229 | 229 | runList(el, 'data-after', evt); | |
| 230 | 230 | }); | |
| 231 | - | ||
| 232 | - | document.body.addEventListener('htmx:config:request', (e) => { | |
| 233 | - | const evt = e as CustomEvent<HxDetail>; | |
| 234 | - | const el = evt.detail.ctx.sourceElement; | |
| 235 | - | if (el?.getAttribute?.('data-config') === 'publish-at-iso') { | |
| 236 | - | const body = evt.detail.ctx.request.body; | |
| 237 | - | const v = body?.get('publish_at'); | |
| 238 | - | if (typeof v === 'string' && v) body?.set('publish_at', new Date(v).toISOString()); | |
| 239 | - | } | |
| 240 | - | }); | |
| 241 | 231 | } |
| @@ -318,15 +318,15 @@ | |||
| 318 | 318 | data-after="click" data-target="tab-details">Publish Now</button> | |
| 319 | 319 | <button class="btn-secondary" data-action="show" data-target="schedule-form">Schedule</button> | |
| 320 | 320 | </div> | |
| 321 | + | {# The field is described: `crate::quasi::schedule_field`. It carries the | |
| 322 | + | label, the hint, that it is required, and that the value is submitted as | |
| 323 | + | the moment it names rather than as the characters typed. That last was | |
| 324 | + | `data-config="publish-at-iso"` and four lines of app JS; it is | |
| 325 | + | makeover-layout's `Field::as_instant` now, and this was its last site. #} | |
| 321 | 326 | <form id="schedule-form" class="hidden mt-section" | |
| 322 | 327 | hx-put="/api/items/{{ item.id }}" | |
| 323 | - | data-config="publish-at-iso" | |
| 324 | 328 | data-after="click" data-target="tab-details"> | |
| 325 | - | <div class="form-group"> | |
| 326 | - | <label for="publish-at">Publish at</label> | |
| 327 | - | <input type="datetime-local" id="publish-at" name="publish_at" required> | |
| 328 | - | <div class="hint">Uses your computer's time zone.</div> | |
| 329 | - | </div> | |
| 329 | + | {{ crate::quasi::schedule_field::publish_at()|safe }} | |
| 330 | 330 | <button type="submit" class="btn-primary">Schedule Publish</button> | |
| 331 | 331 | </form> | |
| 332 | 332 | {% endif %} |
| @@ -1,0 +1,82 @@ | |||
| 1 | + | //! The Askama entry point for the described publish-at field. | |
| 2 | + | //! | |
| 3 | + | //! One field, and it is the last site of `data-config` — a private per-app | |
| 4 | + | //! attribute vocabulary that existed for exactly one behaviour: a | |
| 5 | + | //! `datetime-local` box submits the characters a reader typed, and | |
| 6 | + | //! `PUT /api/items/{id}` stores a moment. Something had to convert, and the | |
| 7 | + | //! conversion was four lines in `frontend/src/core/dispatch.ts` keyed on an | |
| 8 | + | //! attribute only this app understood. | |
| 9 | + | //! | |
| 10 | + | //! `makeover_layout::Field::as_instant` is the described version of the same | |
| 11 | + | //! request: the value is submitted as the moment it names, and how is the | |
| 12 | + | //! renderer's, because the browser is the only party that knows what "your | |
| 13 | + | //! computer's time zone" means. `quasi-webview` ships the script; | |
| 14 | + | //! `makeover-webview` marks the input; this file is the site that asks. | |
| 15 | + | //! | |
| 16 | + | //! # What is described here and what stays the host's | |
| 17 | + | //! | |
| 18 | + | //! Described: the kind, the name, the label, the standing hint, that it is | |
| 19 | + | //! required, and that the value is submitted as an instant. | |
| 20 | + | //! | |
| 21 | + | //! Host: the form around it. `item_details.html` keeps the `<form>` with its | |
| 22 | + | //! `hx-put` and its `data-after`, the same division `upload_field` makes — the | |
| 23 | + | //! wrapper is the app's and the group inside it is the renderer's. Converting | |
| 24 | + | //! the whole item-details tab is a screen conversion and not this. | |
| 25 | + | //! | |
| 26 | + | //! No wire contract moves. The route was already receiving an instant; what | |
| 27 | + | //! changed is who computed it. | |
| 28 | + | ||
| 29 | + | use makeover_layout::FieldKind; | |
| 30 | + | use quasi_router::{Field, Node}; | |
| 31 | + | ||
| 32 | + | /// The publish-at field for an item's schedule form. | |
| 33 | + | /// | |
| 34 | + | /// The hint is the one the template carried, and it is still true for the same | |
| 35 | + | /// reason it was: the reader types a time in their own zone. What is no longer | |
| 36 | + | /// true is that nothing said so to the renderer. | |
| 37 | + | #[must_use] | |
| 38 | + | pub fn publish_at() -> String { | |
| 39 | + | use quasi_axum::Serves as _; | |
| 40 | + | ||
| 41 | + | let field = Field::new(FieldKind::DateTime, "publish_at", "Publish at") | |
| 42 | + | .required() | |
| 43 | + | .hint("Uses your computer's time zone.") | |
| 44 | + | .submits_instant(); | |
| 45 | + | ||
| 46 | + | // No shell: a fragment landing inside a document Askama already built. | |
| 47 | + | quasi_webview::Webview::new().fragment(&Node::field(field)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | #[cfg(test)] | |
| 51 | + | mod tests { | |
| 52 | + | /// The assertion the whole change is for. Without the mark the form | |
| 53 | + | /// submits `2026-09-01T14:30` and the route stores whatever it makes of | |
| 54 | + | /// that, which is the bug `data-config` was patching. | |
| 55 | + | #[test] | |
| 56 | + | fn the_field_asks_to_be_submitted_as_a_moment() { | |
| 57 | + | let html = super::publish_at(); | |
| 58 | + | ||
| 59 | + | assert!(html.contains(r#"type="datetime-local""#), "{html}"); | |
| 60 | + | assert!(html.contains(r#"name="publish_at""#), "{html}"); | |
| 61 | + | assert!(html.contains(r#"data-instant="true""#), "{html}"); | |
| 62 | + | assert!(html.contains(" required"), "{html}"); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// The private vocabulary does not come back in through the renderer. | |
| 66 | + | #[test] | |
| 67 | + | fn nothing_here_emits_data_config() { | |
| 68 | + | assert!(!super::publish_at().contains("data-config"), "gone"); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// The label and the hint are the template's, moved rather than rewritten. | |
| 72 | + | #[test] | |
| 73 | + | fn the_question_and_its_standing_help_survived_the_move() { | |
| 74 | + | let html = super::publish_at(); | |
| 75 | + | ||
| 76 | + | assert!(html.contains("Publish at"), "{html}"); | |
| 77 | + | assert!( | |
| 78 | + | html.contains("Uses your computer's time zone."), | |
| 79 | + | "{html}" | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | } |
| @@ -1,0 +1,67 @@ | |||
| 1 | + | // Submitting the moment a reader meant, rather than the words they typed. | |
| 2 | + | // | |
| 3 | + | // A `datetime-local` box asks for a time the way a person says one -- "the 14th | |
| 4 | + | // at half past two" -- and that names a different moment in Denver than it does | |
| 5 | + | // in Berlin. A route storing an instant needs the moment, and nothing in HTML | |
| 6 | + | // converts one to the other: the box submits the characters. So somebody | |
| 7 | + | // converts, and in this renderer that is here. | |
| 8 | + | // | |
| 9 | + | // It is here rather than above the renderer because the browser is the only | |
| 10 | + | // party that knows what "your computer's time zone" means. A description cannot | |
| 11 | + | // answer it; a server can only guess; a terminal and an egui host would each | |
| 12 | + | // answer it their own way. `makeover_layout::Field::as_instant` is the | |
| 13 | + | // description saying it wants the conversion and declining to say how, and this | |
| 14 | + | // file is the how for one host. | |
| 15 | + | // | |
| 16 | + | // It reads one attribute the form renderer writes, `data-instant`, and it is | |
| 17 | + | // the whole of the per-app publish-at plumbing this stack exists to delete. The | |
| 18 | + | // MNW server carried its own copy under a private `data-config` vocabulary. | |
| 19 | + | (() => { | |
| 20 | + | "use strict"; | |
| 21 | + | ||
| 22 | + | // The mark makeover-webview puts on a datetime input whose description | |
| 23 | + | // asked for an instant. Only that kind is marked: a date and a time are | |
| 24 | + | // each half a moment and cannot name one on their own. | |
| 25 | + | const MARK = "data-instant"; | |
| 26 | + | ||
| 27 | + | /** | |
| 28 | + | * The absolute instant a local wall-clock string names, as RFC 3339. | |
| 29 | + | * | |
| 30 | + | * `new Date(v)` reads a bare `YYYY-MM-DDTHH:mm` in the browser's own zone, | |
| 31 | + | * which is exactly the reading wanted. A value the browser cannot parse is | |
| 32 | + | * left alone rather than replaced with `Invalid Date`: an unparseable value | |
| 33 | + | * is a validation failure the route should see and answer, and rewriting it | |
| 34 | + | * here would turn a legible error into an unreadable one. | |
| 35 | + | */ | |
| 36 | + | const instant = (local) => { | |
| 37 | + | const at = new Date(local); | |
| 38 | + | return Number.isNaN(at.getTime()) ? null : at.toISOString(); | |
| 39 | + | }; | |
| 40 | + | ||
| 41 | + | // Rewrite on the way out, on the request htmx is about to make, so nothing | |
| 42 | + | // the reader can see changes: the box keeps showing the time they typed and | |
| 43 | + | // the wire carries the moment it names. | |
| 44 | + | // | |
| 45 | + | // The walk is over the submitting element's own marked inputs rather than | |
| 46 | + | // over the document, so a page with two forms on it converts the one being | |
| 47 | + | // submitted. `htmx:config:request` is htmx 4's name; 2.x spelled it | |
| 48 | + | // `htmx:configRequest`. | |
| 49 | + | document.body.addEventListener("htmx:config:request", (event) => { | |
| 50 | + | const source = event.detail?.ctx?.sourceElement; | |
| 51 | + | const body = event.detail?.ctx?.request?.body; | |
| 52 | + | if (!source?.querySelectorAll || !body) return; | |
| 53 | + | ||
| 54 | + | const marked = | |
| 55 | + | source.matches?.(`[${MARK}]`) === true | |
| 56 | + | ? [source] | |
| 57 | + | : source.querySelectorAll(`[${MARK}]`); | |
| 58 | + | for (const input of marked) { | |
| 59 | + | const name = input.getAttribute("name"); | |
| 60 | + | if (!name) continue; | |
| 61 | + | const local = body.get(name); | |
| 62 | + | if (typeof local !== "string" || !local) continue; | |
| 63 | + | const moment = instant(local); | |
| 64 | + | if (moment !== null) body.set(name, moment); | |
| 65 | + | } | |
| 66 | + | }); | |
| 67 | + | })(); |