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:
parent
ba71bb69c5
commit
f1cdf75eaf
5 changed files with 57 additions and 146 deletions
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -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`:
|
||||||
|
|
|
||||||
|
|
@ -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('&', "&")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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…"
|
||||||
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…"
|
|
||||||
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…"
|
||||||
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…"
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue