Compare commits

...

11 commits
v0.1.0 ... main

20 changed files with 534 additions and 66 deletions

42
CHANGELOG.md Normal file
View file

@ -0,0 +1,42 @@
## [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

192
Cargo.lock generated
View file

@ -400,6 +400,39 @@ dependencies = [
"litrs", "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]] [[package]]
name = "cc" name = "cc"
version = "1.2.29" version = "1.2.29"
@ -582,6 +615,41 @@ dependencies = [
"typenum", "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]] [[package]]
name = "debug-helper" name = "debug-helper"
version = "0.3.13" version = "0.3.13"
@ -609,6 +677,37 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "deunicode" name = "deunicode"
version = "1.6.2" version = "1.6.2"
@ -1189,6 +1288,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.4.0" version = "0.4.0"
@ -1455,7 +1560,7 @@ dependencies = [
[[package]] [[package]]
name = "mascarpone" name = "mascarpone"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@ -1484,12 +1589,14 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"time", "time",
"tokio", "tokio",
"tower",
"tower-http", "tower-http",
"tower-sessions", "tower-sessions",
"tower-sessions-sqlx-store", "tower-sessions-sqlx-store",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"vcard", "vcard",
"vergen-gitcl",
] ]
[[package]] [[package]]
@ -1649,9 +1756,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
@ -1683,6 +1790,15 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.37.3" version = "0.37.3"
@ -2213,6 +2329,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@ -2748,30 +2874,32 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.44" version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"libc",
"num-conv", "num-conv",
"num_threads",
"powerfmt", "powerfmt",
"serde", "serde_core",
"time-core", "time-core",
"time-macros", "time-macros",
] ]
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.6" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.24" version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@ -2858,9 +2986,9 @@ dependencies = [
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.2" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
@ -3194,6 +3322,46 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 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]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "mascarpone" name = "mascarpone"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[profile.release] [profile.release]
@ -35,7 +35,8 @@ sqlx = { version = "0.8", features = ["macros", "runtim
thiserror = "2.0.17" thiserror = "2.0.17"
time = "0.3.44" time = "0.3.44"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] }
tower-http = { version = "0.6.6", features = ["fs", "trace"] } tower = "0.5.3"
tower-http = { version = "0.6.6", features = ["fs", "set-header", "trace"] }
tower-sessions = { version = "0.14.0", features = ["signed"] } tower-sessions = { version = "0.14.0", features = ["signed"] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] } tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = { version = "0.1.41", features = ["attributes"] } tracing = { version = "0.1.41", features = ["attributes"] }
@ -44,3 +45,4 @@ vcard = "0.4.13"
[build-dependencies] [build-dependencies]
cache_bust = "0.2.0" cache_bust = "0.2.0"
vergen-gitcl = { version = "9.1.0", features = ["build", "cargo"] }

View file

@ -44,4 +44,16 @@ dev() {
find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve 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
}
"$@" "$@"

View file

@ -1,4 +1,5 @@
use cache_bust::CacheBust; use cache_bust::CacheBust;
use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder};
fn main() { fn main() {
println!("cargo:rerun-if-changed=migrations"); println!("cargo:rerun-if-changed=migrations");
@ -9,4 +10,14 @@ fn main() {
.build(); .build();
cache_bust.hash_dir().expect("Cache busting failed"); 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();
} }

94
cliff.toml Normal file
View file

@ -0,0 +1,94 @@
# 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 <REPO> with a URL.
#{ pattern = '<REPO>', 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}](<REPO>/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 = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->Documentation" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->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 = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = ".*", group = "<!-- 10 -->💼 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

View file

@ -4,7 +4,6 @@ import { login, verifyCreateUser, todate } from './util';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await verifyCreateUser(page, { names: ['Test Testerson'] }); await verifyCreateUser(page, { names: ['Test Testerson'] });
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
}); });
test('manual-freshen date is editable', async ({ page }) => { test('manual-freshen date is editable', async ({ page }) => {
@ -19,19 +18,21 @@ test('last-contact date on display resolves journal mentions and manual-freshen'
const entryBox = page.getByPlaceholder(/new entry/i); const entryBox = page.getByPlaceholder(/new entry/i);
await entryDate.fill("2025-05-05"); await entryDate.fill("2025-05-05");
await entryBox.fill("[[Test Testerson]]"); await entryBox.fill("[[Test Testerson]]");
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
await load;
await page.reload(); await page.reload();
await expect(page.locator('#fields')).toContainText("freshened2025-05-05"); await expect(page.locator('#fields')).toContainText("freshened2025-05-05");
}); });
test.skip("groups wrap nicely", async ({ page }) => { test.skip("groups wrap nicely", async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click(); 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); const groupBox = page.getByPlaceholder(/group name/i);
await groupBox.fill('this is a long group name'); await groupBox.fill('this is a long group name');
await page.getByRole('button', { name: /save/i }).click(); 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 // 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. // that the text is all on one line. Manual inspection looks good at time of writing.
@ -39,7 +40,6 @@ test.skip("groups wrap nicely", async ({ page }) => {
test('allow marking as inactive', async ({ page }) => { test('allow marking as inactive', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click(); 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.getByLabel('status').selectOption('Inactive');
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
@ -55,6 +55,7 @@ test('allow exempting from stale', async ({ page }) => {
await page.getByLabel('status').selectOption('Cannot go stale'); await page.getByLabel('status').selectOption('Cannot go stale');
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.waitForURL(/contact\/\d+$/);
await page.goto('/'); await page.goto('/');
await expect(page.locator('#freshness')).not.toContainText('Test Testersonnever'); await expect(page.locator('#freshness')).not.toContainText('Test Testersonnever');
}); });
@ -96,9 +97,5 @@ 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 journal: bullet points don't display
*/ */

View file

@ -17,6 +17,9 @@ test('has no contacts', async ({ page }) => {
test('can add contacts', async ({ page }) => { test('can add contacts', async ({ page }) => {
await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['John Contact'] });
await verifyCreateUser(page, { names: ['Jack 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); await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
}); });
@ -41,6 +44,10 @@ test('sidebar is sorted alphabetically', async ({ page }) => {
await verifyCreateUser(page, { names: ['Alfa'] }); await verifyCreateUser(page, { names: ['Alfa'] });
await verifyCreateUser(page, { names: ['Golf'] }); 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/); await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/);
}); });
@ -81,3 +88,48 @@ 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('#upcoming').getByRole('link')).toHaveCount(4);
await expect(page.locator('#recent').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);
});

View file

@ -6,7 +6,9 @@ test('can add journal entries', async ({ page }) => {
const entryBox = page.getByPlaceholder(/new entry/i); const entryBox = page.getByPlaceholder(/new entry/i);
await entryBox.fill('banana banana banana'); await entryBox.fill('banana banana banana');
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
await load;
await expect(entryBox).toBeEmpty(); await expect(entryBox).toBeEmpty();
await expect(page.getByText('banana banana banana')).toBeVisible(); await expect(page.getByText('banana banana banana')).toBeVisible();
@ -18,7 +20,9 @@ test('journal entries autolink', async ({ page }) => {
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]'); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible(); await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible();
}); });
@ -29,7 +33,9 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await verifyCreateUser(page, { names: ['Jack Contact'] }); await verifyCreateUser(page, { names: ['Jack Contact'] });
await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]'); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
const nav = page.getByRole('navigation'); const nav = page.getByRole('navigation');
const journal = page.locator('#journal'); const journal = page.locator('#journal');
@ -37,6 +43,9 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// add a new name // 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 nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); await page.getByRole('textbox', { name: 'New name' }).fill('JC');
@ -46,6 +55,9 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// delete an existing name // 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 nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click(); await page.getByRole('button', { name: '×', disabled: false }).click();
@ -54,6 +66,9 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// put it back, then... // 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 nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); await page.getByRole('textbox', { name: 'New name' }).fill('JC');
@ -63,6 +78,9 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// ...add a name that makes it no longer n=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 nav.getByRole("link", { name: 'Jack Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); await page.getByRole('textbox', { name: 'New name' }).fill('JC');
@ -71,6 +89,9 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// delete a name that makes it now n=1 // 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 nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click(); await page.getByRole('button', { name: '×', disabled: false }).click();
@ -85,7 +106,9 @@ test('can edit existing journal entries on home page', async ({ page }) => {
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana"); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
await page.reload(); await page.reload();
@ -108,7 +131,9 @@ test('can have multiple links', async ({ page }) => {
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids'); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
const journal = page.locator('#journal'); const journal = page.locator('#journal');
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1);

View file

@ -1,10 +1,12 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
export const login = async (page: Page) => { export const login = async (page: Page) => {
await page.goto('/'); await page.goto('/login');
await page.getByLabel("Username").fill("test"); await page.getByLabel("Username").fill("test");
await page.getByLabel("Password").fill("test"); await page.getByLabel("Password").fill("test");
await page.getByRole("button", { name: /login/i }).click(); await page.getByRole("button", { name: /login/i }).click();
await page.waitForURL('/');
}; };
export const todate = () => new Date().toISOString().split('T')[0]; export const todate = () => new Date().toISOString().split('T')[0];
@ -14,10 +16,11 @@ type UserFields = {
birthday?: string, birthday?: string,
}; };
export const verifyCreateUser = async (page: Page, fields: UserFields) => { 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.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; const { names, ...simple } = fields;
for (const name of (names ?? [])) { for (const name of (names ?? [])) {
@ -30,5 +33,6 @@ export const verifyCreateUser = async (page: Page, fields: UserFields) => {
} }
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.waitForURL(/contact\/\d+$/);
}; };

View file

@ -2,3 +2,4 @@
"rust-analyzer" = "latest" "rust-analyzer" = "latest"
"jj" = "latest" "jj" = "latest"
node = "24" node = "24"
git-cliff = "latest"

View file

@ -12,7 +12,9 @@ use std::sync::{Arc, RwLock};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::signal; use tokio::signal;
use tokio::task::AbortHandle; use tokio::task::AbortHandle;
use tower_http::services::{ServeDir,ServeFile}; use tower::ServiceBuilder;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key}; use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
use tower_sessions_sqlx_store::SqliteStore; use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -124,6 +126,8 @@ enum Commands {
ephemeral: bool, ephemeral: bool,
}, },
/// print version information
Version,
} }
async fn serve(port: &u32) -> Result<(), anyhow::Error> { async fn serve(port: &u32) -> Result<(), anyhow::Error> {
@ -180,8 +184,19 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
.route_layer(login_required!(Backend, login_url = "/login")) .route_layer(login_required!(Backend, login_url = "/login"))
.merge(auth::router()) .merge(auth::router())
.merge(ics::router()) .merge(ics::router())
.nest_service("/static", ServeDir::new("./hashed_static")) .nest_service(
.nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico")))) "/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"))),
)
.layer(auth_layer) .layer(auth_layer)
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state); .with_state(state);
@ -231,7 +246,10 @@ async fn main() -> Result<(), anyhow::Error> {
println!("No update was made; probably something went wrong."); println!("No update was made; probably something went wrong.");
} }
} }
Some(Commands::SetEphemeral { username, ephemeral }) => { Some(Commands::SetEphemeral {
username,
ephemeral,
}) => {
let users_db = { let users_db = {
let db_options = SqliteConnectOptions::from_str("users.db")? let db_options = SqliteConnectOptions::from_str("users.db")?
.create_if_missing(true) .create_if_missing(true)
@ -242,23 +260,24 @@ async fn main() -> Result<(), anyhow::Error> {
db db
}; };
let eph: Option<bool> = sqlx::query_scalar( let eph: Option<bool> =
"select ephemeral from users where username = ?" 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" });
} else {
let update = sqlx::query(
"update users set ephemeral=$1 where username = $2",
)
.bind(ephemeral)
.bind(&username) .bind(&username)
.execute(&users_db) .fetch_optional(&users_db)
.await?; .await?;
if let Some(eph) = eph {
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?;
if update.rows_affected() > 0 { if update.rows_affected() > 0 {
println!("Updated ephemerality for {}.", username); println!("Updated ephemerality for {}.", username);
@ -267,13 +286,19 @@ async fn main() -> Result<(), anyhow::Error> {
} }
} }
} else { } 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 }) => { Some(Commands::Serve { port }) => {
serve(port).await?; serve(port).await?;
} }
Some(Commands::Version) => {
println!("mascarpone v{}", env!("CARGO_PKG_VERSION"));
println!("from git commit {}", env!("VERGEN_GIT_SHA"));
}
None => { None => {
serve(&3000).await?; serve(&3000).await?;
} }

View file

@ -81,22 +81,28 @@ mod get {
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
title { "Mascarpone CRM" } title { "Mascarpone CRM" }
meta name="viewport" content="width=device-width"; meta name="viewport" content="width=device-width";
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} @if cfg!(debug_assertions) {
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.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 {}
}
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
title { "Mascarpone" } title { "Mascarpone" }
} }
body hx-ext="response-targets" { body hx-ext="response-targets" {
h1 { "Mascarpone" } h1 { "Mascarpone" }
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '' }" { form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '', htmx: false }" "x-on:htmx:load.document"="htmx = true" {
label for="username" { "Username" } label for="username" { "Username" }
input name="username" #username autofocus x-model="user"; input name="username" #username autofocus x-model="user" x-cloak;
label for="password" { "Password" } label for="password" { "Password" }
input name="password" #password type="password" x-model="pass"; input name="password" #password type="password" x-model="pass" x-cloak;
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length)"; input type="submit" value="login" x-bind:disabled="!(user.length && pass.length && htmx)" hx-disabled-elt;
#error {} #error {}
} }
} }

View file

@ -61,6 +61,20 @@ fn human_delta(span: &jiff::Span) -> String {
mod get { mod get {
use super::*; 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( pub async fn contact(
auth_session: AuthSession, auth_session: AuthSession,
State(state): State<AppState>, State(state): State<AppState>,
@ -125,7 +139,7 @@ mod get {
html! { html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" } a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
div id="fields" { div #fields x-init=(scroll_to(contact_id)) {
label { @if contact.names.len() > 1 { "names" } @else { "name" }} label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div { div {
@for name in &contact.names { @for name in &contact.names {
@ -249,7 +263,7 @@ mod get {
div #error; div #error;
} }
#fields x-data=(json!({ "status": contact.status() })){ #fields x-data=(json!({ "status": contact.status() })) x-init=(scroll_to(contact_id)) x-cloak {
label { @if contact.names.len() > 1 { "names" } @else { "name" }} label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) { div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
template x-for="(name, idx) in names" { template x-for="(name, idx) in names" {

View file

@ -129,7 +129,7 @@ pub async fn journal_section(
are now, or leave everything blank to default to 'today'. Entries will be 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." 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()" { 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 {
input name="date" placeholder=(Zoned::now().date().to_string()); input name="date" placeholder=(Zoned::now().date().to_string());
textarea name="value" placeholder="New entry..." autofocus {} textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry"; input type="submit" value="Add Entry";

View file

@ -50,7 +50,7 @@ impl FromRequestParts<AppState> for Layout {
left join names n on c.id = n.contact_id left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0 where n.sort is null or n.sort = 0
and c.active = true and c.active = true
order by name asc", order by name collate nocase asc",
) )
.fetch_all(&state.db(&user).pool) .fetch_all(&state.db(&user).pool)
.await?; .await?;
@ -63,7 +63,7 @@ impl FromRequestParts<AppState> for Layout {
left join names n on c.id = n.contact_id left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0 where n.sort is null or n.sort = 0
and c.active = false and c.active = false
order by name asc", order by name collate nocase asc",
) )
.fetch_all(&state.db(&user).pool) .fetch_all(&state.db(&user).pool)
.await?; .await?;
@ -94,9 +94,15 @@ impl Layout {
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width"; meta name="viewport" content="width=device-width";
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} @if cfg!(debug_assertions) {
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.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 {}
}
@if let Some(hrefs) = css { @if let Some(hrefs) = css {
@for href in hrefs { @for href in hrefs {
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href)); link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
@ -112,12 +118,12 @@ impl Layout {
a href="/logout" { "Logout" } a href="/logout" { "Logout" }
} }
section #content { section #content {
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" { nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" "x-on:click.self"="sidebar = !sidebar" {
ul { ul {
li { button hx-post="/contact/new" { "+ Add Contact" } } li { button hx-post="/contact/new" { "+ Add Contact" } }
@for link in &self.contact_links { @for link in &self.contact_links {
li { li {
a href=(format!("/contact/{}", link.contact_id)) { a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) {
(link.name) (link.name)
} }
} }
@ -145,7 +151,6 @@ impl Layout {
(content) (content)
} }
} }
template #alpine-loaded x-cloak {}
} }
} }
} }

5
static/alpinejs.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(function(){var f;var o="hx-target-";function g(e,r){return e.substring(0,r.length)===r}function s(e,r){if(!e||!r)return null;var t=r.toString();var s=[t,t.substring(0,2)+"*",t.substring(0,2)+"x",t.substring(0,1)+"*",t.substring(0,1)+"x",t.substring(0,1)+"**",t.substring(0,1)+"xx","*","x","***","xxx"];if(g(t,"4")||g(t,"5")){s.push("error")}for(var n=0;n<s.length;n++){var i=o+s[n];var a=f.getClosestAttributeValue(e,i);if(a){if(a==="this"){return f.findThisElement(e,i)}else{return f.querySelectorExt(e,a)}}}return null}function n(e){if(e.detail.isError){if(htmx.config.responseTargetUnsetsError){e.detail.isError=false}}else if(htmx.config.responseTargetSetsError){e.detail.isError=true}}htmx.defineExtension("response-targets",{init:function(e){f=e;if(htmx.config.responseTargetUnsetsError===undefined){htmx.config.responseTargetUnsetsError=true}if(htmx.config.responseTargetSetsError===undefined){htmx.config.responseTargetSetsError=false}if(htmx.config.responseTargetPrefersExisting===undefined){htmx.config.responseTargetPrefersExisting=false}if(htmx.config.responseTargetPrefersRetargetHeader===undefined){htmx.config.responseTargetPrefersRetargetHeader=true}},onEvent:function(e,r){if(e==="htmx:beforeSwap"&&r.detail.xhr&&r.detail.xhr.status!==200){if(r.detail.target){if(htmx.config.responseTargetPrefersExisting){r.detail.shouldSwap=true;n(r);return true}if(htmx.config.responseTargetPrefersRetargetHeader&&r.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)){r.detail.shouldSwap=true;n(r);return true}}if(!r.detail.requestConfig){return true}var t=s(r.detail.requestConfig.elt,r.detail.xhr.status);if(t){n(r);r.detail.shouldSwap=true;r.detail.target=t}return true}}})})();

1
static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -57,7 +57,8 @@ section#content {
height: 100%; height: 100%;
&.hide { &.hide {
display: none; left: -200%;
visibility: hidden;
} }
} }
@ -106,3 +107,5 @@ a, a:visited {
color: var(--link-color); color: var(--link-color);
text-decoration: underline dotted; text-decoration: underline dotted;
} }
[x-cloak] { display: none !important; }