diff --git a/README.md b/README.md
index 4ad0f04..830f8b2 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,53 @@
-# Rust web stack template
+# Entretien
-My favorite webapp stack right now
+
It means "maintenance" in French.
-
+I can't actually pronounce it correctly.
+
+---
+
+A task list for a relatively stable set of fundamentally-repeating task list, e.g., house cleaning and maintenance.
+
+Live at https://entretien.dukeceph.xyz/. Log in with `demo` as both the username and password to check it out!
+
+Contact me if you want an account there.
+
+Features:
+* Supports daily, weekly, fortnightly, monthly, seasonal, semiannual, and yearly target cadences.
+* Sorts in-section by least-recently-done first.
+* Allows for bulk completion-entry.
+
+Eventual planned features:
+* Review functionality wherein tasks' target cadences can be compared to their actual cadences.
+* Beeminder-consumable completion-stats view.
+
+### Development / self-hosting
+
+1. Clone the repo.
+2. Build for your system with `./Taskfile _cargo build --release`.
+3. Deploy the binary from `./target/release/entretien` to wherever you want that's in PATH
+ (or use it from here if you want)
+4. In the working directory that you want the server to save its databases in,
+ 1. Create a user for yourself with `entretien set-password YOUR_USERNAME`. This will create a `users.db` file.
+ 2. Run `mkdir dbs`.
+ 3. Copy the `hashed_static` directory from the code repository.
+5. Run `entretien serve [port]` from that working directory. The default port is 3000.
+
+Feel free to contact me if you need help.
+
+### Example systemd service file
+```
+[Unit]
+Description=Entretien
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/var/local/entretien/
+ExecStart=/usr/bin/entretien serve --port 6234
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+```
diff --git a/Taskfile b/Taskfile
index 937b63a..9cefb8a 100755
--- a/Taskfile
+++ b/Taskfile
@@ -1,5 +1,11 @@
#!/usr/bin/env bash
+fetch_3p_scripts() {
+ curl "https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" -o "static/htmx-ext-response-targets_2.0.4.min.js"
+ curl "https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" -o "static/htmx_2.0.7.min.js"
+ curl "https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js" -o "static/alpine_3.15.12.min.js"
+}
+
playwright:local() {
bash e2e/Taskfile playwright:local "$@"
}
diff --git a/migrations/demo.sql b/migrations/demo.sql
index 080f10c..c8b792c 100644
--- a/migrations/demo.sql
+++ b/migrations/demo.sql
@@ -23,3 +23,18 @@ insert into tasks (description, frequency_id) values
('Wash dish rack', 6),
('Oil hinges', 7),
('Wash patio', 7);
+
+insert into completions (task_id, datestamp) values
+ (1, date('now', '-7 days')),
+ (2, date('now', '-3 days')),
+ (3, date('now', '-11 days')),
+ (4, date('now', '-3 days')),
+ (6, date('now', '-13 days')),
+ (7, date('now', '-18 days')),
+ (8, date('now', '-35 days')),
+ (9, date('now', '-22 days')),
+ (10, date('now', '-43 days')),
+ (12, date('now', '-69 days')),
+ (13, date('now', '-43 days')),
+ (15, date('now', '-95 days')),
+ (17, date('now', '-204 days'));
diff --git a/migrations/each_user/0002_frequency_targets.sql b/migrations/each_user/0002_frequency_targets.sql
new file mode 100644
index 0000000..973680b
--- /dev/null
+++ b/migrations/each_user/0002_frequency_targets.sql
@@ -0,0 +1,36 @@
+-- foreign_keys can only up/down outside of transactions
+-- so we first pre-commit the one started by sqlx...
+COMMIT TRANSACTION;
+
+-- turn off foreign keys...
+PRAGMA foreign_keys=OFF;
+
+-- start our own transaction...
+BEGIN TRANSACTION;
+create table if not exists __replace_frequencies (
+ id integer primary key autoincrement,
+ description text not null default '',
+ target_period number not null
+);
+
+insert into __replace_frequencies (id, description, target_period) values
+ (1, 'daily', 1),
+ (2, 'weekly', 7),
+ (3, 'fortnightly', 14),
+ (4, 'monthly', 30),
+ (5, 'seasonally', 91),
+ (6, 'semiannually', 182),
+ (7, 'yearly', 365);
+
+drop table frequencies;
+alter table __replace_frequencies rename to frequencies;
+PRAGMA foreign_key_check;
+
+-- commit our own transaction...
+COMMIT TRANSACTION;
+
+-- put our own pragmas back...
+PRAGMA foreign_keys=ON;
+
+-- and start a dummy transaction so sqlx's COMMIT doesn't explode
+BEGIN TRANSACTION;
diff --git a/migrations/each_user/0003_user-settings.sql b/migrations/each_user/0003_user-settings.sql
new file mode 100644
index 0000000..2450dcf
--- /dev/null
+++ b/migrations/each_user/0003_user-settings.sql
@@ -0,0 +1,7 @@
+create table if not exists settings (
+ id integer primary key,
+ show_underdue boolean not null default true
+);
+
+insert into settings (id) values (1) on conflict (id) do nothing;
+
diff --git a/src/main.rs b/src/main.rs
index f63f703..da19049 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -182,6 +182,7 @@ async fn serve(
let app = Router::new()
.merge(web::home::router())
.merge(web::task::router())
+ .merge(web::settings::router())
.route_layer(login_required!(Backend, login_url = "/login"))
.merge(web::auth::router())
.nest_service(
diff --git a/src/web/auth.rs b/src/web/auth.rs
index e5934fb..530551f 100644
--- a/src/web/auth.rs
+++ b/src/web/auth.rs
@@ -29,7 +29,6 @@ pub fn router() -> Router {
mod post {
use super::*;
- #[axum::debug_handler]
pub async fn login(
mut auth_session: AuthSession,
State(mut state): State,
@@ -85,15 +84,9 @@ mod get {
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
title { "Entretien" }
meta name="viewport" content="width=device-width";
- @if cfg!(debug_assertions) {
- script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
- script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
- script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
- } @else {
- script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
- script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
- script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
- }
+ script defer src=(format!("/static/{}", asset!("htmx_2.0.7.min.js"))) {}
+ script defer src=(format!("/static/{}", asset!("htmx-ext-response-targets_2.0.4.min.js"))) {}
+ script defer src=(format!("/static/{}", asset!("alpine_3.15.12.min.js"))) {}
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
}
diff --git a/src/web/home.rs b/src/web/home.rs
index 680959b..378aa29 100644
--- a/src/web/home.rs
+++ b/src/web/home.rs
@@ -32,19 +32,17 @@ mod get {
}
#[derive(serde::Serialize, Debug, Clone)]
- struct TaskWithSince {
- task: Task,
- since: f64,
+ struct TaskDisplayData {
+ id: DbId,
+ href: String,
+ description: String,
+ frequency: String,
+ datestamp: String,
+ since: String, //stringified float to store infinities
+ target: i64,
}
- impl std::ops::Deref for TaskWithSince {
- type Target = Task;
- fn deref(&self) -> &Self::Target {
- &self.task
- }
- }
-
- fn since(datestamp: &Option) -> Result {
+ fn since(datestamp: &Option) -> Result {
if let Some(datestamp) = datestamp {
let now = Zoned::now();
let then =
@@ -52,10 +50,11 @@ mod get {
return Ok(now
.since(&then)?
.total(SpanTotal::from(Unit::Day).days_are_24_hours())?
- .floor());
+ .floor()
+ .to_string());
}
- Ok(f64::INFINITY)
+ Ok(String::from("Infinity"))
}
pub async fn home(
@@ -83,14 +82,30 @@ mod get {
.fetch_all(pool)
.await?;
- let mut by_freq = last_completions
+ let freq_tasks = sqlx::query!(
+ "select description, target_period from frequencies order by target_period asc"
+ )
+ .fetch_all(pool)
+ .await?;
+
+ let freq_targets: HashMap = freq_tasks
+ .iter()
+ .map(|freq| (freq.description.clone(), freq.target_period))
+ .collect();
+
+ let task_map = last_completions
.into_iter()
- .map(|task| TaskWithSince {
- since: since(&task.datestamp).unwrap(),
- task: task,
+ .map(|task| TaskDisplayData {
+ id: task.id,
+ href: format!("/task/{}", task.id),
+ description: PreEscaped(markdown::to_html(&task.description)).into_string(),
+ since: since(&task.datestamp).unwrap().to_string(),
+ datestamp: task.datestamp.unwrap_or("never".to_string()),
+ target: *freq_targets.get(&task.frequency).unwrap(),
+ frequency: task.frequency,
})
.fold(
- HashMap::>::new(),
+ HashMap::>::new(),
|mut map, each| {
map.entry(each.frequency.clone())
.or_insert_with(Vec::new)
@@ -98,26 +113,45 @@ mod get {
map
},
);
- for (_, list) in &mut by_freq {
- list.sort_by(|a, b| b.since.partial_cmp(&a.since).unwrap())
- }
- let frequencies = vec![
- "daily",
- "weekly",
- "fortnightly",
- "monthly",
- "seasonally",
- "semiannually",
- "yearly",
- ]
- .into_iter()
- .filter(|k| by_freq.contains_key(&k.to_string()));
+
+ let freq_tasks: Vec<(String, &Vec)> = freq_tasks
+ .into_iter()
+ .filter_map(|freq| {
+ task_map
+ .get(&freq.description)
+ .map(|tasks| (freq.description, tasks))
+ })
+ .collect();
+
+ let show_underdue: bool = sqlx::query_scalar!("select show_underdue from settings")
+ .fetch_one(pool)
+ .await?;
Ok(layout.render(
"Home",
Some(vec![asset!("home.css")]),
html! {
- form #new-task hx-post="/task" x-data=(json!({"description": ""})) hx-on::after-on-load="window.location.reload()" hx-target-error="find .error" {
+ script { (PreEscaped(format!("
+ document.addEventListener('alpine:init', () => {{
+ Alpine.store('show', {{
+ new_task: false,
+ bulk_complete: false,
+ underdue_tasks: {show_underdue},
+ }});
+ Alpine.store('toggle', (key) => {{
+ Alpine.store('show')[key] = !Alpine.store('show')[key];
+ }});
+ }})"
+ )))}
+ #controls x-data hx-swap="none" {
+ button x-on:click="$store.toggle('new_task')" { "new-task" };
+ button x-on:click="$store.toggle('bulk_complete')" { "bulk-complete" }
+ button
+ x-on:click="$store.toggle('underdue_tasks')"
+ x-text="`${$store.show.underdue_tasks ? 'hide' : 'show'} underdue tasks`"
+ hx-post="/settings/show_underdue/toggle";
+ }
+ form #new-task x-show="$store.show.new_task" hx-post="/task" x-data=(json!({"description": ""})) hx-on::after-on-load="window.location.reload()" hx-target-error="find .error" {
.col {
label for="new_task" { "new task" }
input id="new_task" name="description" x-model="description";
@@ -138,7 +172,7 @@ mod get {
.error {}
}
- form #bulk-complete
+ form #bulk-complete x-show="$store.show.bulk_complete"
hx-ext="response-targets"
x-data=(json!({ "regex": "", "date": "", "ct": 0 }))
hx-post="/task/bulk_completion"
@@ -160,28 +194,71 @@ mod get {
.error {}
}
- .table {
- @for frequency in frequencies {
- h1 .span { (frequency) }
- .th { "task" }
- .th { "since" }
- .th .span { "last completed" }
-
- @for task in by_freq.get(frequency).unwrap() {
- span {
- a href=(format!("/task/{}", task.id)) { (PreEscaped(markdown::to_html(&task.description))) }
+ table x-data=(json!({
+ "visible_frequencies": [],
+ "all_tasks": freq_tasks
+ }))
+ x-init="
+ all_tasks = all_tasks.map(([f, tasks]) => (
+ [f, tasks.map(task => ({ ...task, since: parseFloat(task.since) }))]
+ ))
+ "
+ x-effect="
+ if ($store.show.underdue_tasks) {
+ visible_frequencies = all_tasks.map(([freq]) => freq);
+ } else {
+ visible_frequencies = all_tasks.filter(([freq, tasks]) => (
+ tasks.some(task => task.since >= task.target)
+ )).map(([freq]) => freq);
+ }
+ visible_frequencies = new Set(visible_frequencies);
+ "
+ {
+ colgroup {
+ col .stretch;
+ col width="0*" .content;
+ col width="0*" .content;
+ col width="0*";
+ }
+ template x-for="[frequency, tasks] in all_tasks"" :key"="frequency" {
+ tbody
+ x-effect="tasks.sort((a, b) => b.since - a.since)"
+ x-show="visible_frequencies.has(frequency)"
+ "x-transition.opacity.duration.200ms"
+ {
+ tr .frequency { th x-text="frequency" colspan="4"; }
+ tr .heading {
+ th { "task" }
+ th { "since" }
+ th colspan="2" { "last completed" }
}
- span .since id=(format!("since-{}", task.id)) {
- (task.since.to_string())
- }
- span .datestamp {
- (task.datestamp.as_ref().map_or("never", |v| v))
- }
- form hx-post="/task/completion" hx-target="previous .datestamp" hx-vals=(json!({
- "task_id": task.id,
- "datestamp": Zoned::now().date().strftime("%F").to_string(),
- })) hx-on::after-on-load=(format!("document.getElementById('since-{}').innerText = 0", task.id)) {
- button { "Complete" }
+ template x-for="task in tasks" ":key"="task.id" {
+ tr x-data="{ datestamp: new Date().toISOString().split('T')[0] }"
+ x-show="$store.show.underdue_tasks || task.since >= task.target"
+ "x-transition.opacity.duration.200ms"
+ {
+ td { a x-bind:href="task.href" x-html="task.description"; }
+ td x-text="task.since === Infinity ? '∞' : task.since" x-bind:id="'since-' + task.id";
+ td x-text="task.datestamp" .datestamp;
+ td { button
+ x-bind:id="'complete-' + task.id"
+ x-on:click="
+ htmx.ajax('POST', `/task/${task.id}/complete`, {
+ 'swap': 'none',
+ 'values': { datestamp },
+ }).then(() => {
+ all_tasks = all_tasks.map(([desc, tasks]) => (
+ [desc, tasks.map(tk => tk.id === task.id
+ ? { ...tk, since: 0, datestamp }
+ : tk
+ )]
+ ))
+ })
+ " {
+ "Complete"
+ }
+ }
+ }
}
}
}
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 53a6d37..ed4b4d5 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -3,6 +3,7 @@ use axum::extract::FromRequestParts;
use cache_bust::asset;
use http::request::Parts;
use maud::{DOCTYPE, Markup, html};
+use serde_json::json;
use super::models::user::{AuthSession, User};
use super::{AppError, AppState};
@@ -10,6 +11,7 @@ use super::{AppError, AppState};
pub mod auth;
pub mod home;
+pub mod settings;
pub mod task;
#[derive(Debug)]
@@ -52,15 +54,10 @@ impl Layout {
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width";
- @if cfg!(debug_assertions) {
- script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
- script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
- script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
- } @else {
- script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
- script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
- script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
- }
+ meta name="htmx-config" content=(json!({"responseTargetUnsetsError": "false"}));
+ script defer src=(format!("/static/{}", asset!("htmx_2.0.7.min.js"))) {}
+ script defer src=(format!("/static/{}", asset!("htmx-ext-response-targets_2.0.4.min.js"))) {}
+ script defer src=(format!("/static/{}", asset!("alpine_3.15.12.min.js"))) {}
@if let Some(hrefs) = css {
@for href in hrefs {
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
@@ -72,7 +69,6 @@ impl Layout {
(content)
}
- script type="text/javascript" { "htmx.config.responseTargetUnsetsError = false" }
}
}
}
diff --git a/src/web/settings.rs b/src/web/settings.rs
new file mode 100644
index 0000000..4c3b43e
--- /dev/null
+++ b/src/web/settings.rs
@@ -0,0 +1,29 @@
+use axum::{Router, extract::State, response::IntoResponse, routing::post};
+
+use crate::models::user::AuthSession;
+use crate::{AppError, AppState};
+
+pub fn router() -> Router {
+ Router::new().route(
+ "/settings/show_underdue/toggle",
+ post(self::post::toggle_show_underdue),
+ )
+}
+
+mod post {
+ use super::*;
+
+ pub async fn toggle_show_underdue(
+ auth_session: AuthSession,
+ State(state): State,
+ ) -> Result {
+ let user = auth_session.user.unwrap();
+ let pool = &state.db(&user).pool;
+
+ sqlx::query!("update settings set show_underdue = NOT show_underdue")
+ .execute(pool)
+ .await?;
+
+ Ok("")
+ }
+}
diff --git a/src/web/task.rs b/src/web/task.rs
index 13e6bdf..512a2ca 100644
--- a/src/web/task.rs
+++ b/src/web/task.rs
@@ -18,13 +18,13 @@ use crate::{AppError, AppState, DbId};
pub fn router() -> Router {
Router::new()
- .route("/task/completion", post(self::post::task_completion))
.route(
"/task/bulk_completion",
post(self::post::bulk_task_completion),
)
.route("/task/description", post(self::post::task_description))
.route("/task/{task_id}", get(self::get::task))
+ .route("/task/{task_id}/complete", post(self::post::task_complete))
.route("/task", post(self::post::task))
}
@@ -61,7 +61,7 @@ mod get {
Some(vec![asset!("task.css")]),
html! {
header { a href="/" { "Home" } }
- main {
+ main hx-ext="response-targets" {
h1 x-data="{'edit':false}" {
span x-show="!edit" x-on:click="edit = true; $nextTick(() => { $refs.edit.focus(); $refs.edit.selectionStart = $refs.edit.selectionEnd = $refs.edit.value.length; })" {
(PreEscaped(markdown::to_html(&task.description)))
@@ -75,16 +75,19 @@ mod get {
x-on:blur="edit = false";
}
form
- hx-post="/task/completion"
- hx-vals=(json!({"task_id": task_id}))
+ hx-post=(format!("/task/{task_id}/complete"))
hx-target="#completions"
- hx-swap="afterbegin" {
+ hx-target-error="find .errors"
+ hx-on::after-request="console.log(event)"
+ hx-swap="afterbegin"
+ x-data=(json!({ "when": "" }))
+ {
.col {
label for="when" { "when" }
- input id="when" name="datestamp" type="date";
+ input id="when" name="datestamp" type="date" x-bind="when";
+ .errors {}
}
- #error {}
- button { "add completion" }
+ button x-bind:disabled="when.length == 0" { "add completion" }
}
#completions {
@if completions.is_empty() {
@@ -135,28 +138,35 @@ mod post {
}
#[derive(Deserialize)]
- pub struct PostTaskCompletionBody {
- task_id: DbId,
+ pub struct PostTaskCompleteBody {
datestamp: String,
}
- pub async fn task_completion(
+ pub async fn task_complete(
auth_session: AuthSession,
State(state): State,
- Form(payload): Form,
+ Path(task_id): Path,
+ Form(payload): Form,
) -> Result {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
+ if payload.datestamp.len() == 0 {
+ return Ok((
+ StatusCode::BAD_REQUEST,
+ PreEscaped("'when' must not be empty".to_string()),
+ ));
+ }
+
sqlx::query!(
"insert into completions (task_id, datestamp) values (?, ?)",
- payload.task_id,
+ task_id,
payload.datestamp
)
.execute(pool)
.await?;
- Ok(html! { div { (payload.datestamp) }})
+ Ok((StatusCode::OK, html! { div { (payload.datestamp) }}))
}
#[derive(Deserialize)]
diff --git a/static/alpine_3.15.12.min.js b/static/alpine_3.15.12.min.js
new file mode 100644
index 0000000..ab371ef
--- /dev/null
+++ b/static/alpine_3.15.12.min.js
@@ -0,0 +1,5 @@
+(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `