diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d1ceba7..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,42 +0,0 @@ -## [0.2.0] - 2026-04-08 - -### Features - -- Scroll to current contact in sidebar by default -- Clicking off sidebar on small windows closes it -- Sort contacts sidebar ignoring case -- Report version information - -### Refactor - -- Cloak input elements while alpine.js loads - -### Performance - -- *(test)* Test performance improvements -- Large cache time on immutable hashed statics - -### Testing - -- Deflake by waiting for Save response -- Open sidebar on mobile - -### ⚙️ Miscellaneous Tasks - -- Prepare for tagged releases - -## [0.1.0] - 2026-04-05 - -# Features -* In-app contacts -* For each contact: - * Names - * Birthday - * Last-contact-time mapping - * Address as single field (plus code? lat/long? go crazy!) - * Free-text-entry field - * Desired contact periodicity -* Journal with Obsidian-like `[[link]]` syntax -* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner") -* ical server for birthday reminders -* Mark contacts as inactive or prevent stale checks diff --git a/Cargo.lock b/Cargo.lock index 79b598d..3c03174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,39 +400,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "cargo_metadata" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.17", -] - [[package]] name = "cc" version = "1.2.29" @@ -615,41 +582,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.106", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.106", -] - [[package]] name = "debug-helper" version = "0.3.13" @@ -677,37 +609,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.106", -] - [[package]] name = "deunicode" version = "1.6.2" @@ -1288,12 +1189,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "0.4.0" @@ -1560,7 +1455,7 @@ dependencies = [ [[package]] name = "mascarpone" -version = "0.2.0" +version = "0.1.0" dependencies = [ "anyhow", "axum", @@ -1589,14 +1484,12 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", - "tower", "tower-http", "tower-sessions", "tower-sessions-sqlx-store", "tracing", "tracing-subscriber", "vcard", - "vergen-gitcl", ] [[package]] @@ -1756,9 +1649,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-integer" @@ -1790,15 +1683,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "object" version = "0.37.3" @@ -2329,16 +2213,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde" version = "1.0.228" @@ -2874,32 +2748,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -2986,9 +2858,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", @@ -3322,46 +3194,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vergen" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" -dependencies = [ - "anyhow", - "cargo_metadata", - "derive_builder", - "regex", - "rustversion", - "time", - "vergen-lib", -] - -[[package]] -name = "vergen-gitcl" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen", - "vergen-lib", -] - -[[package]] -name = "vergen-lib" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", -] - [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index b348f60..5b03dc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mascarpone" -version = "0.2.0" +version = "0.1.0" edition = "2024" [profile.release] @@ -35,8 +35,7 @@ sqlx = { version = "0.8", features = ["macros", "runtim thiserror = "2.0.17" time = "0.3.44" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] } -tower = "0.5.3" -tower-http = { version = "0.6.6", features = ["fs", "set-header", "trace"] } +tower-http = { version = "0.6.6", features = ["fs", "trace"] } tower-sessions = { version = "0.14.0", features = ["signed"] } tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] } tracing = { version = "0.1.41", features = ["attributes"] } @@ -45,4 +44,3 @@ vcard = "0.4.13" [build-dependencies] cache_bust = "0.2.0" -vergen-gitcl = { version = "9.1.0", features = ["build", "cargo"] } diff --git a/Taskfile b/Taskfile index 9ca36c9..9fa64cf 100755 --- a/Taskfile +++ b/Taskfile @@ -44,16 +44,4 @@ dev() { find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve } -release() { - set -euo pipefail - bash e2e/Taskfile playwright:ci - _cargo test - new_tag=$(git-cliff --unreleased --bumped-version) - git tag -m "$new_tag" "$new_tag" - cargo set-version "${new_tag#v}" - mv CHANGELOG.md CHANGELOG.old - cat <(git cliff) <(printf "\n") CHANGELOG.old > CHANGELOG.md - rm CHANGELOG.old -} - "$@" diff --git a/build.rs b/build.rs index 2c984c9..7195630 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,4 @@ use cache_bust::CacheBust; -use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder}; fn main() { println!("cargo:rerun-if-changed=migrations"); @@ -10,14 +9,4 @@ fn main() { .build(); cache_bust.hash_dir().expect("Cache busting failed"); - - let build = BuildBuilder::all_build().expect("build information failed"); - let gitcl = GitclBuilder::all_git().expect("gitcl information failed"); - Emitter::default() - .add_instructions(&build) - .unwrap() - .add_instructions(&gitcl) - .unwrap() - .emit() - .unwrap(); } diff --git a/cliff.toml b/cliff.toml deleted file mode 100644 index 8228887..0000000 --- a/cliff.toml +++ /dev/null @@ -1,94 +0,0 @@ -# git-cliff ~ configuration file -# https://git-cliff.org/docs/configuration - - -[changelog] -# A Tera template to be rendered for each release in the changelog. -# See https://keats.github.io/tera/docs/#introduction -body = """ -{% if version %}\ - ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} -{% else %}\ - ## [unreleased] -{% endif %}\ -{% for group, commits in commits | group_by(attribute="group") %} - ### {{ group | striptags | trim | upper_first }} - {% for commit in commits %} - - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ - {% if commit.breaking %}[**breaking**] {% endif %}\ - {{ commit.message | upper_first }}\ - {% endfor %} -{% endfor %} -""" -# Remove leading and trailing whitespaces from the changelog's body. -trim = true -# Render body even when there are no releases to process. -render_always = true -# An array of regex based postprocessors to modify the changelog. -postprocessors = [ - # Replace the placeholder with a URL. - #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, -] -# render body even when there are no releases to process -# render_always = true -# output file path -# output = "test.md" - -[git] -# Parse commits according to the conventional commits specification. -# See https://www.conventionalcommits.org -conventional_commits = true -# Exclude commits that do not match the conventional commits specification. -filter_unconventional = true -# Require all commits to be conventional. -# Takes precedence over filter_unconventional. -require_conventional = false -# Split commits on newlines, treating each line as an individual commit. -split_commits = false -# An array of regex based parsers to modify commit messages prior to further processing. -commit_preprocessors = [ - # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. - #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, - # Check spelling of the commit message using https://github.com/crate-ci/typos. - # If the spelling is incorrect, it will be fixed automatically. - #{ pattern = '.*', replace_command = 'typos --write-changes -' }, -] -# Prevent commits that are breaking from being excluded by commit parsers. -protect_breaking_commits = false -# An array of regex based parsers for extracting data from the commit message. -# Assigns commits to groups. -# Optionally sets the commit's scope and can decide to exclude commits from further processing. -commit_parsers = [ - { message = "^feat", group = "Features" }, - { message = "^fix", group = "Bug Fixes" }, - { message = "^doc", group = "Documentation" }, - { message = "^perf", group = "Performance" }, - { message = "^refactor", group = "Refactor" }, - { message = "^style", group = "Styling" }, - { message = "^test", group = "Testing" }, - { message = "^chore\\(release\\): prepare for", skip = true }, - { message = "^chore\\(deps.*\\)", skip = true }, - { message = "^chore\\(pr\\)", skip = true }, - { message = "^chore\\(pull\\)", skip = true }, - { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, - { body = ".*security", group = "🛡️ Security" }, - { message = "^revert", group = "◀️ Revert" }, - { message = ".*", group = "💼 Other" }, -] -# Exclude commits that are not matched by any commit parser. -filter_commits = false -# Fail on a commit that is not matched by any commit parser. -fail_on_unmatched_commit = false -# An array of link parsers for extracting external references, and turning them into URLs, using regex. -link_parsers = [] -# Include only the tags that belong to the current branch. -use_branch_tags = false -# Order releases topologically instead of chronologically. -topo_order = false -# Order commits topologically instead of chronologically. -topo_order_commits = true -# Order of commits in each group/release within the changelog. -# Allowed values: newest, oldest -sort_commits = "oldest" -# Process submodules commits -recurse_submodules = false diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts index ba32a1b..8b4318d 100644 --- a/e2e/pages/contact.spec.ts +++ b/e2e/pages/contact.spec.ts @@ -4,6 +4,7 @@ import { login, verifyCreateUser, todate } from './util'; test.beforeEach(async ({ page }) => { await login(page); await verifyCreateUser(page, { names: ['Test Testerson'] }); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); }); test('manual-freshen date is editable', async ({ page }) => { @@ -18,21 +19,19 @@ test('last-contact date on display resolves journal mentions and manual-freshen' const entryBox = page.getByPlaceholder(/new entry/i); await entryDate.fill("2025-05-05"); await entryBox.fill("[[Test Testerson]]"); - - let load = page.waitForResponse('/journal_entry'); await page.getByRole('button', { name: /add entry/i }).click(); - await load; - await page.reload(); await expect(page.locator('#fields')).toContainText("freshened2025-05-05"); }); test.skip("groups wrap nicely", async ({ page }) => { await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); const groupBox = page.getByPlaceholder(/group name/i); await groupBox.fill('this is a long group name'); await page.getByRole('button', { name: /save/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); // TODO: this drives to the right location but i can't figure out how to assert // that the text is all on one line. Manual inspection looks good at time of writing. @@ -40,6 +39,7 @@ test.skip("groups wrap nicely", async ({ page }) => { test('allow marking as inactive', async ({ page }) => { await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); await page.getByLabel('status').selectOption('Inactive'); await page.getByRole('button', { name: /save/i }).click(); @@ -55,7 +55,6 @@ test('allow exempting from stale', async ({ page }) => { await page.getByLabel('status').selectOption('Cannot go stale'); await page.getByRole('button', { name: /save/i }).click(); - await page.waitForURL(/contact\/\d+$/); await page.goto('/'); await expect(page.locator('#freshness')).not.toContainText('Test Testersonnever'); }); @@ -97,5 +96,9 @@ test('bullet points in free text display well', async ({ page }) => { }); +home: contact list scrolls in screen, not off screen +home: clicking off contact list closes it +home: contact list is sorted ignoring case +home: contact list should scroll to current contact in center of view journal: bullet points don't display */ diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 6981632..8ec39dd 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -17,9 +17,6 @@ test('has no contacts', async ({ page }) => { test('can add contacts', async ({ page }) => { await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['Jack Contact'] }); - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); }); @@ -44,10 +41,6 @@ test('sidebar is sorted alphabetically', async ({ page }) => { await verifyCreateUser(page, { names: ['Alfa'] }); await verifyCreateUser(page, { names: ['Golf'] }); - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } - await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/); }); @@ -88,48 +81,3 @@ test('upcoming and recent show at least one birthday a week away', async ({ page await expect(page.locator('#upcoming').getByRole('link')).toHaveCount(4); await expect(page.locator('#recent').getByRole('link')).toHaveCount(4); }); - - -test('contact list scrolls (independently) to current contact in center of view', async ({ page }) => { - for (let count = 0; count < 30; count++) { - await verifyCreateUser(page, { names: [`Contact${count < 10 ? '0' + count : count}`] }); - } - - await page.goto('/contact/28'); - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } - await expect(page.getByRole('navigation').getByRole('link', { name: /Contact28/ })).toBeVisible(); - expect(await page.locator('main').evaluate(e => e.scrollTop)).toEqual(0); - - await page.goto('/contact/16'); - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } - await expect(page.locator('#nav-link-16')).toBeVisible(); - const linkPos: number = await page.locator('#nav-link-16').evaluate(e => e.getBoundingClientRect().y); - - // roughly centered is fine, not that fussy about headers and whatnot - expect(await page.getByRole('navigation').evaluate(e => e.scrollTop)).not.toEqual(0); - expect(Math.abs(linkPos - (await page.evaluate('window.innerHeight/2') as number))).toBeLessThan(300); -}); - -test('clicking off contact list when expanded closes it', async ({ page }) => { - await page.setViewportSize({ - width: 640, - height: 1000, - }); - // TODO aria-label - await page.locator('#sidebar-show-hide').click(); - await expect(page.getByRole('button', { name: /add contact/i })).toBeVisible(); - await page.mouse.click(600, 500); - await expect(page.getByRole('button', { name: /add contact/i })).not.toBeVisible(); -}); - -test('contact list is sorted ignoring case', async ({ page }) => { - await verifyCreateUser(page, { names: ['Alfa'] }); - await verifyCreateUser(page, { names: ['bob'] }); - await verifyCreateUser(page, { names: ['Charlie'] }); - - await expect(page.locator('#contacts-sidebar')).toContainText(/alfa\s*bob\s*charlie/i); -}); diff --git a/e2e/pages/journal.spec.ts b/e2e/pages/journal.spec.ts index 329644a..fe9ecb9 100644 --- a/e2e/pages/journal.spec.ts +++ b/e2e/pages/journal.spec.ts @@ -6,9 +6,7 @@ test('can add journal entries', async ({ page }) => { const entryBox = page.getByPlaceholder(/new entry/i); await entryBox.fill('banana banana banana'); - let load = page.waitForResponse('/journal_entry'); await page.getByRole('button', { name: /add entry/i }).click(); - await load; await expect(entryBox).toBeEmpty(); await expect(page.getByText('banana banana banana')).toBeVisible(); @@ -20,9 +18,7 @@ test('journal entries autolink', async ({ page }) => { await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]'); - let load = page.waitForResponse('/journal_entry'); await page.getByRole('button', { name: /add entry/i }).click(); - await load; await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible(); }); @@ -33,9 +29,7 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await verifyCreateUser(page, { names: ['Jack Contact'] }); await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]'); - let load = page.waitForResponse('/journal_entry'); await page.getByRole('button', { name: /add entry/i }).click(); - await load; const nav = page.getByRole('navigation'); const journal = page.locator('#journal'); @@ -43,9 +37,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); // add a new name - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } await nav.getByRole("link", { name: 'John Contact' }).click(); await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('textbox', { name: 'New name' }).fill('JC'); @@ -55,9 +46,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); // delete an existing name - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } await nav.getByRole("link", { name: 'John Contact' }).click(); await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('button', { name: '×', disabled: false }).click(); @@ -66,9 +54,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); // put it back, then... - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } await nav.getByRole("link", { name: 'John Contact' }).click(); await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('textbox', { name: 'New name' }).fill('JC'); @@ -78,9 +63,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); // ...add a name that makes it no longer n=1 - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } await nav.getByRole("link", { name: 'Jack Contact' }).click(); await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('textbox', { name: 'New name' }).fill('JC'); @@ -89,9 +71,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); // delete a name that makes it now n=1 - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } await nav.getByRole("link", { name: 'John Contact' }).click(); await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('button', { name: '×', disabled: false }).click(); @@ -106,9 +85,7 @@ test('can edit existing journal entries on home page', async ({ page }) => { await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana"); - let load = page.waitForResponse('/journal_entry'); await page.getByRole('button', { name: /add entry/i }).click(); - await load; await page.reload(); @@ -131,9 +108,7 @@ test('can have multiple links', async ({ page }) => { await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids'); - let load = page.waitForResponse('/journal_entry'); await page.getByRole('button', { name: /add entry/i }).click(); - await load; const journal = page.locator('#journal'); await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1); diff --git a/e2e/pages/util.ts b/e2e/pages/util.ts index 9d872b8..f73c74a 100644 --- a/e2e/pages/util.ts +++ b/e2e/pages/util.ts @@ -1,12 +1,10 @@ -import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; export const login = async (page: Page) => { - await page.goto('/login'); + await page.goto('/'); await page.getByLabel("Username").fill("test"); await page.getByLabel("Password").fill("test"); await page.getByRole("button", { name: /login/i }).click(); - await page.waitForURL('/'); }; export const todate = () => new Date().toISOString().split('T')[0]; @@ -16,11 +14,10 @@ type UserFields = { birthday?: string, }; export const verifyCreateUser = async (page: Page, fields: UserFields) => { - if (await page.locator('#sidebar-show-hide').isVisible()) { - await page.locator('#sidebar-show-hide').click(); - } await page.getByRole('button', { name: /add contact/i }).click(); - await page.waitForURL(/contact\/\d+\/edit$/); + + // TODO this is stupid but playwright kept filling while alpine was initializing + await page.waitForTimeout(200); const { names, ...simple } = fields; for (const name of (names ?? [])) { @@ -33,6 +30,5 @@ export const verifyCreateUser = async (page: Page, fields: UserFields) => { } await page.getByRole('button', { name: /save/i }).click(); - await page.waitForURL(/contact\/\d+$/); }; diff --git a/mise.toml b/mise.toml index fe79931..6846e14 100644 --- a/mise.toml +++ b/mise.toml @@ -2,4 +2,3 @@ "rust-analyzer" = "latest" "jj" = "latest" node = "24" -git-cliff = "latest" diff --git a/src/main.rs b/src/main.rs index 330c4a7..9c86f81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,9 +12,7 @@ use std::sync::{Arc, RwLock}; use tokio::net::TcpListener; use tokio::signal; use tokio::task::AbortHandle; -use tower::ServiceBuilder; -use tower_http::services::{ServeDir, ServeFile}; -use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::services::{ServeDir,ServeFile}; use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key}; use tower_sessions_sqlx_store::SqliteStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -126,8 +124,6 @@ enum Commands { ephemeral: bool, }, - /// print version information - Version, } async fn serve(port: &u32) -> Result<(), anyhow::Error> { @@ -184,19 +180,8 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> { .route_layer(login_required!(Backend, login_url = "/login")) .merge(auth::router()) .merge(ics::router()) - .nest_service( - "/static", - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::overriding( - http::header::CACHE_CONTROL, - http::header::HeaderValue::from_static("public, max-age=31536000, immutable"), - )) - .service(ServeDir::new("./hashed_static")), - ) - .nest_service( - "/favicon.ico", - ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))), - ) + .nest_service("/static", ServeDir::new("./hashed_static")) + .nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico")))) .layer(auth_layer) .layer(tower_http::trace::TraceLayer::new_for_http()) .with_state(state); @@ -246,10 +231,7 @@ async fn main() -> Result<(), anyhow::Error> { println!("No update was made; probably something went wrong."); } } - Some(Commands::SetEphemeral { - username, - ephemeral, - }) => { + Some(Commands::SetEphemeral { username, ephemeral }) => { let users_db = { let db_options = SqliteConnectOptions::from_str("users.db")? .create_if_missing(true) @@ -260,24 +242,23 @@ async fn main() -> Result<(), anyhow::Error> { db }; - let eph: Option = - sqlx::query_scalar("select ephemeral from users where username = ?") - .bind(&username) - .fetch_optional(&users_db) - .await?; + let eph: Option = sqlx::query_scalar( + "select ephemeral from users where username = ?" + ) + .bind(&username) + .fetch_optional(&users_db) + .await?; if let Some(eph) = eph { - if eph == *ephemeral { - println!( - "User {} is already {}.", - username, - if eph { "ephemeral" } else { "not ephemeral" } - ); + if eph == *ephemeral { + println!("User {} is already {}.", username, if eph { "ephemeral" } else { "not ephemeral" }); } else { - let update = sqlx::query("update users set ephemeral=$1 where username = $2") - .bind(ephemeral) - .bind(&username) - .execute(&users_db) - .await?; + let update = sqlx::query( + "update users set ephemeral=$1 where username = $2", + ) + .bind(ephemeral) + .bind(&username) + .execute(&users_db) + .await?; if update.rows_affected() > 0 { println!("Updated ephemerality for {}.", username); @@ -286,19 +267,13 @@ async fn main() -> Result<(), anyhow::Error> { } } } else { - println!( - "User {} does not exist. Create them first with set-password.", - username - ); + println!("User {} does not exist. Create them first with set-password.", username); } + } Some(Commands::Serve { port }) => { serve(port).await?; } - Some(Commands::Version) => { - println!("mascarpone v{}", env!("CARGO_PKG_VERSION")); - println!("from git commit {}", env!("VERGEN_GIT_SHA")); - } None => { serve(&3000).await?; } diff --git a/src/web/auth.rs b/src/web/auth.rs index 1416511..42890cc 100644 --- a/src/web/auth.rs +++ b/src/web/auth.rs @@ -81,28 +81,22 @@ mod get { link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); title { "Mascarpone CRM" } 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 src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} + script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} + script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css"))); title { "Mascarpone" } } body hx-ext="response-targets" { h1 { "Mascarpone" } - form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '', htmx: false }" "x-on:htmx:load.document"="htmx = true" { + form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '' }" { label for="username" { "Username" } - input name="username" #username autofocus x-model="user" x-cloak; + input name="username" #username autofocus x-model="user"; label for="password" { "Password" } - input name="password" #password type="password" x-model="pass" x-cloak; + input name="password" #password type="password" x-model="pass"; - input type="submit" value="login" x-bind:disabled="!(user.length && pass.length && htmx)" hx-disabled-elt; + input type="submit" value="login" x-bind:disabled="!(user.length && pass.length)"; #error {} } } diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index d3a3cca..b99abdb 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -61,20 +61,6 @@ fn human_delta(span: &jiff::Span) -> String { mod get { use super::*; - fn scroll_to(id: DbId) -> String { - format!( - "\ - const top=document\ - .getElementById('nav-link-{}')\ - ?.getBoundingClientRect()\ - ?.top;\ - top&&document\ - .getElementById('contacts-sidebar')\ - .scrollTo({{top:top-window.innerHeight/2,left:0,behavior:'instant'}});", - id - ) - } - pub async fn contact( auth_session: AuthSession, State(state): State, @@ -139,7 +125,7 @@ mod get { html! { a href=(format!("/contact/{}/edit", contact_id)) { "Edit" } - div #fields x-init=(scroll_to(contact_id)) { + div id="fields" { label { @if contact.names.len() > 1 { "names" } @else { "name" }} div { @for name in &contact.names { @@ -263,7 +249,7 @@ mod get { div #error; } - #fields x-data=(json!({ "status": contact.status() })) x-init=(scroll_to(contact_id)) x-cloak { + #fields x-data=(json!({ "status": contact.status() })){ label { @if contact.names.len() > 1 { "names" } @else { "name" }} div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) { template x-for="(name, idx) in names" { diff --git a/src/web/home.rs b/src/web/home.rs index d396d79..49fbb4a 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -129,7 +129,7 @@ pub async fn journal_section( are now, or leave everything blank to default to 'today'. Entries will be added to the top of the list regardless of date; refresh the page to re-sort." } - form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" x-cloak { + form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" { input name="date" placeholder=(Zoned::now().date().to_string()); textarea name="value" placeholder="New entry..." autofocus {} input type="submit" value="Add Entry"; diff --git a/src/web/mod.rs b/src/web/mod.rs index a622bb0..a7f755d 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -50,7 +50,7 @@ impl FromRequestParts for Layout { left join names n on c.id = n.contact_id where n.sort is null or n.sort = 0 and c.active = true - order by name collate nocase asc", + order by name asc", ) .fetch_all(&state.db(&user).pool) .await?; @@ -63,7 +63,7 @@ impl FromRequestParts for Layout { left join names n on c.id = n.contact_id where n.sort is null or n.sort = 0 and c.active = false - order by name collate nocase asc", + order by name asc", ) .fetch_all(&state.db(&user).pool) .await?; @@ -94,15 +94,9 @@ 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 {} - } + script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} + script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} + script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} @if let Some(hrefs) = css { @for href in hrefs { link rel="stylesheet" type="text/css" href=(format!("/static/{}", href)); @@ -118,12 +112,12 @@ impl Layout { a href="/logout" { "Logout" } } section #content { - nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" "x-on:click.self"="sidebar = !sidebar" { + nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" { ul { li { button hx-post="/contact/new" { "+ Add Contact" } } @for link in &self.contact_links { li { - a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) { + a href=(format!("/contact/{}", link.contact_id)) { (link.name) } } @@ -151,6 +145,7 @@ impl Layout { (content) } } + template #alpine-loaded x-cloak {} } } } diff --git a/static/alpinejs.min.js b/static/alpinejs.min.js deleted file mode 100644 index 0acdcef..0000000 --- a/static/alpinejs.min.js +++ /dev/null @@ -1,5 +0,0 @@ -(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[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)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=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(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(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 In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} - -${r?'Expression: "'+r+`" - -`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{let s=t.apply(z([n,...e]),i);Ne(r,s)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,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] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=z([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>re(u,r,t));n.finished?(Ne(i,n.result,c,s,r),n.result=void 0):l.then(u=>{Ne(i,u,c,s,r)}).catch(u=>re(u,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `