refactor(templates): replace manual JS with Datastar patterns

- Convert data.html search from manual fetch+debounce to Datastar @get
- Replace filterList() on checkin page with <searchable-select> component
- Convert account.html form show/hide from classList to data-show signals
- Rename _scanWaiting/_scanError to _extracting/_extractError for consistency
- Delete unused render_signals_fragment() and escape_html_attr() from support.rs
- Document Datastar vs JS guidelines in CLAUDE.md
This commit is contained in:
Jon Seager 2026-02-05 21:42:14 +00:00
parent ba71bb69c5
commit f1cdf75eaf
No known key found for this signature in database
5 changed files with 57 additions and 146 deletions

View file

@ -353,6 +353,28 @@ let (items, navigator) = build_page_view(page, request, RoasterView::from,
ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, search); ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, search);
``` ```
### When to Use Datastar vs JavaScript
**Use Datastar for:**
- Visibility toggling (`data-show` + signals) — replaces `classList.add/remove("hidden")`
- List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render
- Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides`
- AI extraction signal patching — server returns `application/json` signal patches via `render_signals_json()`
- Multi-step wizards — step signals (`$_step`) with `data-show="$_step === N"`
- Searchable selection lists — use `<searchable-select>` component with `data-on:change`
**Use JavaScript for:**
- Browser APIs: WebAuthn (`navigator.credentials`), clipboard (`navigator.clipboard`), geolocation (`navigator.geolocation`), FileReader
- Infinite scroll (`IntersectionObserver` in `base.html`) — no native Datastar equivalent
- Theme toggle — must run in `<head>` before DOM renders, manipulates `<html>` data-theme attribute + `localStorage`
- Any flow that requires `window.location.reload()` after completion (delete passkey, revoke token)
**Signal naming conventions for in-progress states:**
- `_extracting` — AI extraction in progress (consistent across home, add, check-in pages)
- `_submitting` — form save/create in progress
- `_extract-error` / `_error` — error message signals
- `_show-{thing}` — boolean visibility toggles (e.g. `_show-passkey-form`)
### Static Assets ### Static Assets
Static files live in `static/` and are compiled into the binary via `include_str!()`/`include_bytes!()`. Each file needs an explicit route in `application/routes/mod.rs`: Static files live in `static/` and are compiled into the binary via `include_str!()`/`include_bytes!()`. Each file needs an explicit route in `application/routes/mod.rs`:

View file

@ -279,35 +279,6 @@ pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static st
let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace")); let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace"));
} }
/// Render a `<div>` fragment with `data-signals` attributes for Datastar signal merging.
///
/// The selector must be a `#id` selector. Signal values are JSON-encoded and HTML-escaped
/// so they are safe to embed in HTML attributes and evaluate as JavaScript expressions.
pub fn render_signals_fragment(
selector: &'static str,
signals: &[(&str, serde_json::Value)],
) -> Result<Response, AppError> {
use std::fmt::Write;
let id = selector.strip_prefix('#').unwrap_or(selector);
let mut html = format!(r#"<div id="{id}""#);
for (name, value) in signals {
let js_expr = value.to_string();
let escaped = escape_html_attr(&js_expr);
// write! to String is infallible
let _ = write!(&mut html, r#" data-signals:{name}="{escaped}""#);
}
html.push_str("></div>");
let mut response = Html(html).into_response();
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/html"));
set_datastar_patch_headers(response.headers_mut(), selector);
Ok(response)
}
/// Return a JSON response that Datastar interprets as a signal patch. /// Return a JSON response that Datastar interprets as a signal patch.
/// ///
/// Signal names may use kebab-case (`_roaster-name`); they are automatically /// Signal names may use kebab-case (`_roaster-name`); they are automatically
@ -341,13 +312,6 @@ fn kebab_to_camel(s: &str) -> String {
result result
} }
fn escape_html_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -5,7 +5,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Passkeys --> <!-- Passkeys -->
<section> <section data-signals:_show-passkey-form="false">
<h2 class="text-lg font-semibold text-accent mb-3">Passkeys</h2> <h2 class="text-lg font-semibold text-accent mb-3">Passkeys</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
@ -43,7 +43,8 @@
{% endif %} {% endif %}
<!-- Add passkey form (hidden by default) --> <!-- Add passkey form (hidden by default) -->
<div id="add-passkey-form" class="mt-3 hidden rounded-lg border bg-surface p-5"> <div id="add-passkey-form" class="mt-3 rounded-lg border bg-surface p-5"
data-show="$_showPasskeyForm" style="display: none">
<div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div> <div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm"> <label class="flex-1 flex flex-col gap-1 text-sm">
@ -66,7 +67,7 @@
</button> </button>
<button <button
type="button" type="button"
onclick="hidePasskeyForm()" data-on:click="$_showPasskeyForm = false; document.getElementById('add-passkey-error').classList.add('hidden'); document.getElementById('passkey-name').value = ''"
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text" class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text"
> >
Cancel Cancel
@ -79,9 +80,9 @@
</div> </div>
<button <button
id="show-add-passkey"
type="button" type="button"
onclick="showPasskeyForm()" data-show="!$_showPasskeyForm"
data-on:click="$_showPasskeyForm = true; setTimeout(() => document.getElementById('passkey-name').focus(), 50)"
class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover" class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover"
> >
Add Passkey Add Passkey
@ -89,7 +90,7 @@
</section> </section>
<!-- API Tokens --> <!-- API Tokens -->
<section> <section data-signals:_show-token-form="false">
<h2 class="text-lg font-semibold text-accent mb-3">API Tokens</h2> <h2 class="text-lg font-semibold text-accent mb-3">API Tokens</h2>
{% if tokens.is_empty() %} {% if tokens.is_empty() %}
@ -125,7 +126,8 @@
{% endif %} {% endif %}
<!-- Create token form (hidden by default) --> <!-- Create token form (hidden by default) -->
<div id="create-token-form" class="mt-3 hidden rounded-lg border bg-surface p-5"> <div id="create-token-form" class="mt-3 rounded-lg border bg-surface p-5"
data-show="$_showTokenForm" style="display: none">
<div id="create-token-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div> <div id="create-token-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm"> <label class="flex-1 flex flex-col gap-1 text-sm">
@ -148,7 +150,7 @@
</button> </button>
<button <button
type="button" type="button"
onclick="hideTokenForm()" data-on:click="$_showTokenForm = false; document.getElementById('create-token-error').classList.add('hidden'); document.getElementById('token-name').value = ''"
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text" class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text"
> >
Cancel Cancel
@ -179,9 +181,9 @@
</div> </div>
<button <button
id="show-create-token"
type="button" type="button"
onclick="showTokenForm()" data-show="!$_showTokenForm"
data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)"
class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover" class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover"
> >
Create Token Create Token
@ -241,19 +243,6 @@
<script> <script>
// --- Passkey form --- // --- Passkey form ---
const showPasskeyForm = () => {
document.getElementById("add-passkey-form").classList.remove("hidden");
document.getElementById("show-add-passkey").classList.add("hidden");
document.getElementById("passkey-name").focus();
};
const hidePasskeyForm = () => {
document.getElementById("add-passkey-form").classList.add("hidden");
document.getElementById("show-add-passkey").classList.remove("hidden");
document.getElementById("add-passkey-error").classList.add("hidden");
document.getElementById("passkey-name").value = "";
};
const registerPasskey = async () => { const registerPasskey = async () => {
const name = document.getElementById("passkey-name").value.trim(); const name = document.getElementById("passkey-name").value.trim();
const errorEl = document.getElementById("add-passkey-error"); const errorEl = document.getElementById("add-passkey-error");
@ -283,19 +272,6 @@
// --- Token form --- // --- Token form ---
const showTokenForm = () => {
document.getElementById("create-token-form").classList.remove("hidden");
document.getElementById("show-create-token").classList.add("hidden");
document.getElementById("token-name").focus();
};
const hideTokenForm = () => {
document.getElementById("create-token-form").classList.add("hidden");
document.getElementById("show-create-token").classList.remove("hidden");
document.getElementById("create-token-error").classList.add("hidden");
document.getElementById("token-name").value = "";
};
const createToken = async () => { const createToken = async () => {
const name = document.getElementById("token-name").value.trim(); const name = document.getElementById("token-name").value.trim();
const errorEl = document.getElementById("create-token-error"); const errorEl = document.getElementById("create-token-error");
@ -323,7 +299,7 @@
const data = await response.json(); const data = await response.json();
document.getElementById("create-token-form").classList.add("hidden"); document.getElementById("create-token-form").style.display = "none";
document.getElementById("token-value").textContent = data.token; document.getElementById("token-value").textContent = data.token;
document.getElementById("token-created").classList.remove("hidden"); document.getElementById("token-created").classList.remove("hidden");
} catch (err) { } catch (err) {

View file

@ -2,15 +2,6 @@
{% block head %} {% block head %}
<script> <script>
const filterList = (input, listId) => {
const q = input.value.toLowerCase();
const list = document.getElementById(listId);
list.classList.toggle('hidden', !q);
list.querySelectorAll('button').forEach((b) => {
b.style.display = b.textContent.toLowerCase().includes(q) ? '' : 'none';
});
};
{% call location::location_search_js() %} {% call location::location_search_js() %}
</script> </script>
{% endblock %} {% endblock %}
@ -38,8 +29,8 @@ const filterList = (input, listId) => {
data-signals:_user-lng="0" data-signals:_user-lng="0"
data-signals:_city-name="''" data-signals:_city-name="''"
data-signals:_reviewing-cafe="false" data-signals:_reviewing-cafe="false"
data-signals:_scan-waiting="false" data-signals:_extracting="false"
data-signals:_scan-error="''" data-signals:_extract-error="''"
data-signals:_scan-success="''" data-signals:_scan-success="''"
> >
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
@ -188,26 +179,18 @@ const filterList = (input, listId) => {
{% if !cafe_options.is_empty() %} {% if !cafe_options.is_empty() %}
<div class="mt-3 border-t pt-3" data-show="!$_reviewingCafe"> <div class="mt-3 border-t pt-3" data-show="!$_reviewingCafe">
<p class="mb-2 text-xs text-text-muted">Or choose a saved cafe:</p> <p class="mb-2 text-xs text-text-muted">Or choose a saved cafe:</p>
<input <searchable-select name="saved_cafe_id" placeholder="Type to search saved cafes&hellip;"
type="text" data-on:change="$_cafeId = evt.detail.value; $_cafeName = evt.detail.display; $_cafeCity = evt.detail.data.city; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2"
class="input-field w-full text-sm"
placeholder="Type to search saved cafes&hellip;"
oninput="filterList(this, 'cafe-options')"
/>
<div id="cafe-options" class="hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface">
{% for cafe in cafe_options %}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
data-on:click="$_cafeId = '{{ cafe.id }}'; $_cafeName = el.dataset.name; $_cafeCity = el.dataset.city; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2"
data-name="{{ cafe.name }}"
data-city="{{ cafe.city }}"
> >
{% for cafe in cafe_options %}
<button type="button" value="{{ cafe.id }}" data-display="{{ cafe.name }}"
data-city="{{ cafe.city }}"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition">
<span class="font-medium text-text">{{ cafe.name }}</span> <span class="font-medium text-text">{{ cafe.name }}</span>
<span class="ml-2 text-xs text-text-muted">{{ cafe.city }}</span> <span class="ml-2 text-xs text-text-muted">{{ cafe.city }}</span>
</button> </button>
{% endfor %} {% endfor %}
</div> </searchable-select>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -219,13 +202,13 @@ const filterList = (input, listId) => {
<h3 class="text-base font-semibold text-text mb-3">What are you drinking?</h3> <h3 class="text-base font-semibold text-text mb-3">What are you drinking?</h3>
<div data-show="!$_scanSuccess" class="mb-4"> <div data-show="!$_scanSuccess" class="mb-4">
<p class="text-sm text-text-secondary mb-3" data-show="!$_scanWaiting">Scan a bag to identify the coffee, or select from your existing roasts below.</p> <p class="text-sm text-text-secondary mb-3" data-show="!$_extracting">Scan a bag to identify the coffee, or select from your existing roasts below.</p>
<form id="checkin-scan-form" <form id="checkin-scan-form"
data-on:submit="$_scanWaiting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})" data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanWaiting) return; if (evt.detail.type === 'finished') { $_scanWaiting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_scanWaiting = false; $_scanError = 'Scan failed. Please try again.' }" data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Scan failed. Please try again.' }"
> >
{% call scan::scan_input("checkin-scan-form", "checkin-image", "$_scanWaiting", "$_scanError", "Describe the coffee…", "Scanning…") %} {% call scan::scan_input("checkin-scan-form", "checkin-image", "$_extracting", "$_extractError", "Describe the coffee…", "Scanning…") %}
</form> </form>
</div> </div>
@ -240,26 +223,18 @@ const filterList = (input, listId) => {
{% if !roast_options.is_empty() %} {% if !roast_options.is_empty() %}
<div class="border-t pt-3"> <div class="border-t pt-3">
<p class="text-xs text-text-muted mb-2">Or select an existing roast:</p> <p class="text-xs text-text-muted mb-2">Or select an existing roast:</p>
<input <searchable-select name="roast_id" placeholder="Type to search existing roasts&hellip;"
type="text" data-on:change="$_roastId = evt.detail.value; $_roastName = evt.detail.display; $_roasterName = evt.detail.data.roaster; $_step = 3"
class="input-field w-full text-sm"
placeholder="Type to search existing roasts&hellip;"
oninput="filterList(this, 'roast-options')"
/>
<div id="roast-options" class="hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface">
{% for roast in roast_options %}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
data-on:click="$_roastId = '{{ roast.id }}'; $_roastName = el.dataset.name; $_roasterName = el.dataset.roaster; $_step = 3"
data-name="{{ roast.name }}"
data-roaster="{{ roast.roaster_name }}"
> >
{% for roast in roast_options %}
<button type="button" value="{{ roast.id }}" data-display="{{ roast.name }}"
data-roaster="{{ roast.roaster_name }}"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition">
<span class="font-medium text-text">{{ roast.name }}</span> <span class="font-medium text-text">{{ roast.name }}</span>
<span class="ml-2 text-xs text-text-muted">{{ roast.roaster_name }}</span> <span class="ml-2 text-xs text-text-muted">{{ roast.roaster_name }}</span>
</button> </button>
{% endfor %} {% endfor %}
</div> </searchable-select>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -19,14 +19,14 @@
<div class="rounded-lg border bg-surface"> <div class="rounded-lg border bg-surface">
{% include "partials/tab_bar.html" %} {% include "partials/tab_bar.html" %}
<!-- Search --> <!-- Search -->
<div class="border-t px-3 py-2"> <div class="border-t px-3 py-2" data-signals:_data-search="'{{ search_value }}'">
<input <input
type="search" type="search"
id="data-search" data-bind:_data-search
placeholder="Search..." placeholder="Search..."
value="{{ search_value }}"
class="input-field w-full text-sm" class="input-field w-full text-sm"
{% if !search_value.is_empty() %}data-init="el.focus(); el.selectionStart = el.value.length"{% endif %} data-on:input__debounce.300ms="history.pushState(null, '', '/data?type=' + $_activeTab + '&q=' + encodeURIComponent($_dataSearch)); @get('/data?type=' + $_activeTab + '&q=' + encodeURIComponent($_dataSearch), {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
{% if !search_value.is_empty() %}autofocus{% endif %}
/> />
</div> </div>
</div> </div>
@ -36,30 +36,4 @@
</div> </div>
<div id="detail-panel"></div> <div id="detail-panel"></div>
<script>
(() => {
const searchInput = document.getElementById('data-search');
if (!searchInput) return;
let debounceTimer;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const params = new URLSearchParams(window.location.search);
const activeTab = params.get('type') || 'brews';
const query = searchInput.value;
const url = `/data?type=${activeTab}&q=${encodeURIComponent(query)}`;
history.pushState(null, '', url);
fetch(url, { headers: { 'datastar-request': 'true' } })
.then((res) => res.text())
.then((html) => {
document.getElementById('data-content').innerHTML = html;
});
}, 300);
});
})();
</script>
{% endblock %} {% endblock %}