Compare commits
11 commits
more-birth
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a178bc1cc0 | |||
| 18de4c5895 | |||
| 231eee3e10 | |||
| 7b70a10463 | |||
| bb8cb20b2e | |||
| 41972ca1ab | |||
| e82b6167ef | |||
| e74fe354d0 | |||
| 559c1f1760 | |||
| 1206e211d5 | |||
| 62b0efac04 |
20 changed files with 534 additions and 66 deletions
42
CHANGELOG.md
Normal file
42
CHANGELOG.md
Normal 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
192
Cargo.lock
generated
|
|
@ -400,6 +400,39 @@ 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"
|
||||
|
|
@ -582,6 +615,41 @@ 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"
|
||||
|
|
@ -609,6 +677,37 @@ 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"
|
||||
|
|
@ -1189,6 +1288,12 @@ 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"
|
||||
|
|
@ -1455,7 +1560,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mascarpone"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -1484,12 +1589,14 @@ dependencies = [
|
|||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tower-sessions-sqlx-store",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"vcard",
|
||||
"vergen-gitcl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1649,9 +1756,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
|
|
@ -1683,6 +1790,15 @@ 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"
|
||||
|
|
@ -2213,6 +2329,16 @@ 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"
|
||||
|
|
@ -2748,30 +2874,32 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.24"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
|
|
@ -2858,9 +2986,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
|
|
@ -3194,6 +3322,46 @@ 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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "mascarpone"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[profile.release]
|
||||
|
|
@ -35,7 +35,8 @@ 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-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-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
|
||||
tracing = { version = "0.1.41", features = ["attributes"] }
|
||||
|
|
@ -44,3 +45,4 @@ vcard = "0.4.13"
|
|||
|
||||
[build-dependencies]
|
||||
cache_bust = "0.2.0"
|
||||
vergen-gitcl = { version = "9.1.0", features = ["build", "cargo"] }
|
||||
|
|
|
|||
12
Taskfile
12
Taskfile
|
|
@ -44,4 +44,16 @@ 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
|
||||
}
|
||||
|
||||
"$@"
|
||||
|
|
|
|||
11
build.rs
11
build.rs
|
|
@ -1,4 +1,5 @@
|
|||
use cache_bust::CacheBust;
|
||||
use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
|
|
@ -9,4 +10,14 @@ 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();
|
||||
}
|
||||
|
|
|
|||
94
cliff.toml
Normal file
94
cliff.toml
Normal 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
|
||||
|
|
@ -4,7 +4,6 @@ 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 }) => {
|
||||
|
|
@ -19,19 +18,21 @@ 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.
|
||||
|
|
@ -39,7 +40,6 @@ 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,6 +55,7 @@ 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');
|
||||
});
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ 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);
|
||||
});
|
||||
|
||||
|
|
@ -41,6 +44,10 @@ 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/);
|
||||
});
|
||||
|
||||
|
|
@ -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('#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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ 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();
|
||||
|
|
@ -18,7 +20,9 @@ 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();
|
||||
});
|
||||
|
|
@ -29,7 +33,9 @@ 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');
|
||||
|
|
@ -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);
|
||||
|
||||
// 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');
|
||||
|
|
@ -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);
|
||||
|
||||
// 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();
|
||||
|
|
@ -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);
|
||||
|
||||
// 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');
|
||||
|
|
@ -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);
|
||||
|
||||
// ...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');
|
||||
|
|
@ -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);
|
||||
|
||||
// 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();
|
||||
|
|
@ -85,7 +106,9 @@ 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();
|
||||
|
||||
|
|
@ -108,7 +131,9 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const login = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/login');
|
||||
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];
|
||||
|
|
@ -14,10 +16,11 @@ 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();
|
||||
|
||||
// TODO this is stupid but playwright kept filling while alpine was initializing
|
||||
await page.waitForTimeout(200);
|
||||
await page.waitForURL(/contact\/\d+\/edit$/);
|
||||
|
||||
const { names, ...simple } = fields;
|
||||
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.waitForURL(/contact\/\d+$/);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
"rust-analyzer" = "latest"
|
||||
"jj" = "latest"
|
||||
node = "24"
|
||||
git-cliff = "latest"
|
||||
|
|
|
|||
67
src/main.rs
67
src/main.rs
|
|
@ -12,7 +12,9 @@ use std::sync::{Arc, RwLock};
|
|||
use tokio::net::TcpListener;
|
||||
use tokio::signal;
|
||||
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_sqlx_store::SqliteStore;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
|
@ -124,6 +126,8 @@ enum Commands {
|
|||
ephemeral: bool,
|
||||
},
|
||||
|
||||
/// print version information
|
||||
Version,
|
||||
}
|
||||
|
||||
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"))
|
||||
.merge(auth::router())
|
||||
.merge(ics::router())
|
||||
.nest_service("/static", ServeDir::new("./hashed_static"))
|
||||
.nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))))
|
||||
.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"))),
|
||||
)
|
||||
.layer(auth_layer)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
|
@ -231,7 +246,10 @@ 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)
|
||||
|
|
@ -242,23 +260,24 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
db
|
||||
};
|
||||
|
||||
let eph: Option<bool> = 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)
|
||||
let eph: Option<bool> =
|
||||
sqlx::query_scalar("select ephemeral from users where username = ?")
|
||||
.bind(&username)
|
||||
.execute(&users_db)
|
||||
.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)
|
||||
.execute(&users_db)
|
||||
.await?;
|
||||
|
||||
if update.rows_affected() > 0 {
|
||||
println!("Updated ephemerality for {}.", username);
|
||||
|
|
@ -267,13 +286,19 @@ 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?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,22 +81,28 @@ mod get {
|
|||
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
||||
title { "Mascarpone CRM" }
|
||||
meta name="viewport" content="width=device-width";
|
||||
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 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 {}
|
||||
}
|
||||
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: '' }" {
|
||||
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" }
|
||||
input name="username" #username autofocus x-model="user";
|
||||
input name="username" #username autofocus x-model="user" x-cloak;
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,20 @@ 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<AppState>,
|
||||
|
|
@ -125,7 +139,7 @@ mod get {
|
|||
html! {
|
||||
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" }}
|
||||
div {
|
||||
@for name in &contact.names {
|
||||
|
|
@ -249,7 +263,7 @@ mod get {
|
|||
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" }}
|
||||
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
|
||||
template x-for="(name, idx) in names" {
|
||||
|
|
|
|||
|
|
@ -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()" {
|
||||
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());
|
||||
textarea name="value" placeholder="New entry..." autofocus {}
|
||||
input type="submit" value="Add Entry";
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ impl FromRequestParts<AppState> 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 asc",
|
||||
order by name collate nocase asc",
|
||||
)
|
||||
.fetch_all(&state.db(&user).pool)
|
||||
.await?;
|
||||
|
|
@ -63,7 +63,7 @@ impl FromRequestParts<AppState> 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 asc",
|
||||
order by name collate nocase asc",
|
||||
)
|
||||
.fetch_all(&state.db(&user).pool)
|
||||
.await?;
|
||||
|
|
@ -94,9 +94,15 @@ 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";
|
||||
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 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 {}
|
||||
}
|
||||
@if let Some(hrefs) = css {
|
||||
@for href in hrefs {
|
||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
|
||||
|
|
@ -112,12 +118,12 @@ impl Layout {
|
|||
a href="/logout" { "Logout" }
|
||||
}
|
||||
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 {
|
||||
li { button hx-post="/contact/new" { "+ Add Contact" } }
|
||||
@for link in &self.contact_links {
|
||||
li {
|
||||
a href=(format!("/contact/{}", link.contact_id)) {
|
||||
a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) {
|
||||
(link.name)
|
||||
}
|
||||
}
|
||||
|
|
@ -145,7 +151,6 @@ impl Layout {
|
|||
(content)
|
||||
}
|
||||
}
|
||||
template #alpine-loaded x-cloak {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
static/alpinejs.min.js
vendored
Normal file
5
static/alpinejs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/htmx-ext-response-targets.js
Normal file
1
static/htmx-ext-response-targets.js
Normal 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
1
static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -57,7 +57,8 @@ section#content {
|
|||
height: 100%;
|
||||
|
||||
&.hide {
|
||||
display: none;
|
||||
left: -200%;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,3 +107,5 @@ a, a:visited {
|
|||
color: var(--link-color);
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue