Compare commits

..

21 Commits

Author SHA1 Message Date
792c9bf9ec style: rustfmt
All checks were successful
CI / Check / Test (push) Successful in 11m29s
2026-07-11 22:58:00 +02:00
f513061405 fix(app): auto-scroll on iOS using scrollBy with integer accumulator 2026-07-11 22:54:17 +02:00
9f844bac2e fix(app): chord overflow clip, section nav scroll on mobile 2026-07-11 22:49:05 +02:00
6912653906 fix(app): cache useFavorites snapshot to prevent infinite re-render 2026-07-11 22:39:46 +02:00
bb4d07055f refactor(app): react-query for data fetching, fix infinite re-render 2026-07-11 22:37:11 +02:00
af328deac1 refactor: backend-only transpose, remove client-side transpose logic 2026-07-11 22:30:59 +02:00
381f273f10 feat(app): section nav, auto-scroll, favorites, fullscreen, wake lock, transposed key 2026-07-11 22:24:35 +02:00
2a628db521 feat: slash chord support (G/B parses+transposes bass note)
All checks were successful
CI / Check / Test (push) Successful in 11m54s
2026-07-11 22:12:03 +02:00
2f635c9b24 chore: enable LTO for release builds 2026-07-11 22:05:11 +02:00
fb936a64b0 clean up
All checks were successful
CI / Check / Test (push) Successful in 12m2s
2026-07-11 22:00:59 +02:00
2bfd0c7984 chore: gitignore sqlite files, update lockfile 2026-07-11 22:00:31 +02:00
bfaea4a4b0 style: rustfmt 2026-07-11 21:59:47 +02:00
2b702d88e4 feat: add tracing throughout, HTTP TraceLayer, detailed error logs 2026-07-11 21:58:58 +02:00
e89cf48b34 fix: sqlite create_if_missing on connect 2026-07-11 21:51:34 +02:00
aa31e1bdcd docs: update LICENSE, README, architecture diagram 2026-07-11 21:49:49 +02:00
ff39680106 ci: add Gitea + GitHub Actions workflows 2026-07-11 21:44:39 +02:00
35b82fc5b3 chore: add dev/deploy targets to Makefile, deploy.sh 2026-07-11 21:43:55 +02:00
c4e9076b11 feat(app): auth UI, SPA mode, /api base URL
- auth: login/register bottom sheets, token storage, authFetch
- AuthProvider context w/ auto-refresh on mount
- hide add/edit/delete for guests, sign-in button in nav
- SPA mode (ssr:false), loaders→client-side useEffect
- API base URL /api (same-origin), loading spinners
2026-07-11 21:40:57 +02:00
7bd27d9b9c feat: JWT auth, /api prefix, SPA serving, OpenAPI, lean main.rs
- auth: register/login/refresh/logout w/ JWT+Argon2, protected mutations
- domain: User, RefreshSession, auth ports, Unauthorized/Forbidden errors
- presentation: context/state/factory/errors/extractors/openapi modules
- routes behind /api, SPA served from root w/ fallback
- OpenAPI Scalar at /docs
- frontend ssr:false, single-binary Dockerfile
2026-07-11 21:28:52 +02:00
13031347cc samples 2026-07-11 21:02:28 +02:00
d13df586dd refactor: DDD/CQRS architecture, unified crate layout
- crates: common→application, api→presentation, infrastructure/*→adapters/*
- new crates: api-types, infra-wiring
- domain: errors/, models/, value_objects/, ports/, services/
- application: CQRS use cases (songs/, tabs/) w/ commands, queries, deps
- unified DomainError replaces RepositoryError
- workspace deps, unused dep cleanup
- fix: parse plain-text chord lines (UG drops spans mid-song)
- tests extracted to separate modules (tests/ dirs)
2026-07-11 21:02:10 +02:00
140 changed files with 17276 additions and 4268 deletions

View File

@@ -1,5 +1,6 @@
/target /target
/app /app/node_modules
/app/build
.superpowers/ .superpowers/
.git/ .git/
.claude/ .claude/

42
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,42 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
ci:
name: Check / Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
- name: test
run: cargo test

80
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
ci:
name: Check / Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
- name: test
run: cargo test
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: ci
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
/target /target
.env .env
.superpowers/ .superpowers/
*.db
*.db-shm
*.db-wal

415
Cargo.lock generated
View File

@@ -17,6 +17,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -24,26 +33,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "api" name = "api-types"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"axum",
"common",
"domain",
"persistence",
"rand 0.10.0",
"serde", "serde",
"serde_json", "utoipa",
"thiserror 2.0.18", ]
"tokio",
"tower-http", [[package]]
name = "application"
version = "0.1.0"
dependencies = [
"chrono",
"domain",
"tracing", "tracing",
"tracing-subscriber",
"ug-parser",
"uuid", "uuid",
] ]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -70,6 +88,20 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auth"
version = "0.1.0"
dependencies = [
"argon2",
"async-trait",
"chrono",
"domain",
"jsonwebtoken",
"rand_core 0.6.4",
"serde",
"uuid",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@@ -183,6 +215,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -241,14 +282,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chrono"
version = "0.10.0" version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [ dependencies = [
"cfg-if", "iana-time-zone",
"cpufeatures 0.3.0", "js-sys",
"rand_core 0.10.0", "num-traits",
"serde",
"wasm-bindgen",
"windows-link",
] ]
[[package]] [[package]]
@@ -270,23 +314,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "common"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"domain",
"rand 0.10.0",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -337,15 +364,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc" name = "crc"
version = "3.4.0" version = "3.4.0"
@@ -420,6 +438,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -458,12 +482,11 @@ dependencies = [
name = "domain" name = "domain"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"async-trait", "async-trait",
"rand 0.10.0", "chrono",
"email_address",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing",
"uuid", "uuid",
] ]
@@ -509,6 +532,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -745,7 +777,6 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2", "wasip2",
"wasip3", "wasip3",
] ]
@@ -960,6 +991,30 @@ dependencies = [
"windows-registry", "windows-registry",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.2.0" version = "2.2.0"
@@ -1081,6 +1136,10 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "infra-wiring"
version = "0.1.0"
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -1169,6 +1228,21 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1350,6 +1424,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.6" version = "0.8.6"
@@ -1366,6 +1450,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -1437,6 +1527,27 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -1452,23 +1563,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "persistence"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"common",
"domain",
"rand 0.10.0",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.11.3" version = "0.11.3"
@@ -1569,6 +1663,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1584,6 +1684,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "presentation"
version = "0.1.0"
dependencies = [
"api-types",
"application",
"async-trait",
"auth",
"axum",
"domain",
"infra-wiring",
"serde",
"sqlite",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"ug-parser",
"utoipa",
"utoipa-scalar",
"uuid",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -1701,17 +1824,6 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.0",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.3.1" version = "0.3.1"
@@ -1750,12 +1862,6 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -1774,6 +1880,18 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -2126,7 +2244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures",
"digest", "digest",
] ]
@@ -2137,7 +2255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures",
"digest", "digest",
] ]
@@ -2176,6 +2294,18 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.2" version = "1.0.2"
@@ -2226,6 +2356,18 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "sqlite"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"domain",
"serde_json",
"sqlx",
"uuid",
]
[[package]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.8.6" version = "0.8.6"
@@ -2580,6 +2722,36 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "time"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -2801,12 +2973,10 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
name = "ug-parser" name = "ug-parser"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"async-trait", "async-trait",
"domain", "domain",
"reqwest", "reqwest",
"scraper", "scraper",
"thiserror 2.0.18",
"tokio", "tokio",
] ]
@@ -2885,6 +3055,42 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utoipa"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "utoipa-scalar"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
dependencies = [
"axum",
"serde",
"serde_json",
"utoipa",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.0" version = "1.23.0"
@@ -3119,6 +3325,41 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"

View File

@@ -1,29 +1,42 @@
[workspace] [workspace]
members = [ members = [
"crates/api", "crates/adapters/auth",
"crates/common", "crates/adapters/sqlite",
"crates/adapters/ug-parser",
"crates/api-types",
"crates/application",
"crates/domain", "crates/domain",
"crates/infrastructure/persistence", "crates/infra-wiring",
"crates/infrastructure/ug-parser", "crates/presentation",
] ]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0.102" tokio = { version = "1.51.0", features = ["full"] }
reqwest = "0.13.2"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
anyhow = "1.0.102"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.51.0", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
async-trait = "0.1.89"
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
rand = "0.10.0" rand = "0.10.0"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] } reqwest = "0.13.2"
async-trait = "0.1.89"
scraper = "0.23" scraper = "0.23"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
axum = { version = "0.8.8", features = ["macros"] }
domain = { path = "crates/domain" }
application = { path = "crates/application" }
api-types = { path = "crates/api-types" }
infra-wiring = { path = "crates/infra-wiring" }
auth = { path = "crates/adapters/auth" }
sqlite = { path = "crates/adapters/sqlite" }
ug-parser = { path = "crates/adapters/ug-parser" }
[profile.release] [profile.release]
strip = true strip = true
codegen-units = 1 codegen-units = 1
opt-level = 3 opt-level = 3
lto = true

View File

@@ -1,30 +1,36 @@
FROM rust:1.92 AS builder FROM node:22-slim AS frontend
WORKDIR /app/frontend
COPY app/package.json app/package-lock.json ./
RUN npm ci
COPY app/ .
ENV VITE_API_URL=/api
RUN npm run build
FROM rust:1.97 AS backend
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN cargo build --release -p presentation
# Build the release binary
RUN cargo build --release -p api
FROM debian:trixie-slim FROM debian:trixie-slim
WORKDIR /app WORKDIR /app
# Install OpenSSL, CA certs
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \ libssl3 \
ca-certificates \ ca-certificates \
libsqlite3-0 \ libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/api . COPY --from=backend /app/target/release/presentation .
COPY --from=frontend /app/frontend/build/client ./spa
# Create data directory for SQLite
RUN mkdir -p /app/data RUN mkdir -p /app/data
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
ENV SPA_DIR=/app/spa
EXPOSE 8000 EXPOSE 8000
CMD ["./api"] CMD ["./presentation"]

22
LICENSE
View File

@@ -1 +1,21 @@
MIT MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

44
Makefile Normal file
View File

@@ -0,0 +1,44 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test
@echo "✅ All checks passed"
# Apply rustfmt to all files.
fmt:
cargo fmt
# Check formatting without modifying files (CI-safe).
fmt-check:
cargo fmt --check
# Run Clippy and treat warnings as errors.
clippy:
cargo clippy -- -D warnings
# Run the test suite.
test:
cargo test
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
# Build the frontend SPA.
build-app:
cd app && npm run build
# Run the backend (builds frontend first if needed).
dev: build-app
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
# Run backend only (skip frontend build, assumes build-app was run).
dev-api:
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
# Build and push Docker image to private registry.
deploy:
./deploy.sh
.PHONY: check fmt fmt-check clippy test fix build-app dev dev-api deploy

View File

@@ -1,3 +1,79 @@
# PocketChords # PocketChords
A rip-off of [TabsUltimate](https://www.tabultimateguitar.com/) with a focus on mobile users, without any ads or subscription. It is open source and free to use. A rip-off of [Ultimate Guitar](https://www.ultimate-guitar.com/) with a focus on mobile users, without any ads or subscription. Self-hosted, open source and free to use.
## Features
- Import chord sheets from Ultimate Guitar URLs or HTML files (bulk import supported)
- Transpose chords up/down with one tap
- Piano keyboard and guitar fretboard chord diagrams
- Capo support with sounding-key display
- Adjustable font size (S/M/L) for readability while playing
- Search and sort your library
- Dark/light theme
- PWA — add to home screen for native feel
- JWT auth with registration/login
- OpenAPI docs at `/docs`
## Architecture
Hexagonal / ports-and-adapters with CQRS in the application layer. See `architecture.mmd` for the full diagram.
```
crates/
domain/ # entities, value objects, ports, domain services
application/ # use cases: songs/, tabs/, auth/ (commands, queries, deps)
presentation/ # axum HTTP server, routes, extractors, OpenAPI
api-types/ # request/response DTOs
infra-wiring/ # shared config (AppConfig)
adapters/
sqlite/ # SQLite persistence (songs, users, refresh sessions)
ug-parser/ # Ultimate Guitar HTML parser
auth/ # JWT + Argon2 password hashing
app/ # React Router SPA (Tailwind, shadcn/ui)
```
## Quick start
```bash
# prerequisites: rust, node
# run locally (builds frontend, starts backend on :8000)
make dev
# or backend only (if frontend already built)
make dev-api
# run checks (fmt, clippy, tests)
make check
```
## Deployment
Single Docker image serves both API and SPA:
```bash
# build and push to private registry
make deploy
# or with a specific tag
./deploy.sh --tag v1.0.0
```
### Environment variables
| Variable | Default | Description |
| ---------------------- | ----------------------------- | ------------------------------ |
| `DATABASE_URL` | `sqlite://./pocket-chords.db` | SQLite connection string |
| `HOST` | `0.0.0.0` | Bind address |
| `PORT` | `8000` | Bind port |
| `JWT_SECRET` | _(required)_ | Secret for signing JWTs |
| `JWT_TTL_SECONDS` | `900` | Access token TTL (15 min) |
| `REFRESH_TTL_SECONDS` | `2592000` | Refresh token TTL (30 days) |
| `ALLOW_REGISTRATION` | `false` | Enable user registration |
| `CORS_ALLOWED_ORIGINS` | `*` | Comma-separated origins or `*` |
| `SPA_DIR` | `./app/build/client` | Path to SPA static files |
## License
MIT (see [LICENSE](LICENSE) for details).

View File

@@ -1 +1 @@
VITE_API_URL=http://localhost:8000 VITE_API_URL=/api

View File

@@ -1,87 +1,32 @@
# Welcome to React Router! # PocketChords — Frontend
A modern, production-ready template for building full-stack React applications using React Router. React Router v7 SPA with Tailwind CSS and shadcn/ui. Mobile-first design for viewing chord charts while playing.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) ## Setup
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash ```bash
npm install npm install
``` ```
### Development ## Development
Start the development server with HMR:
```bash ```bash
npm run dev npm run dev
``` ```
Your application will be available at `http://localhost:5173`. Opens at `http://localhost:5173`. Set `VITE_API_URL` in `.env` to point to the backend (defaults to `/api`).
## Building for Production ## Build
Create a production build:
```bash ```bash
npm run build npm run build
``` ```
## Deployment Outputs static files to `build/client/`. The Rust backend serves these in production.
### Docker Deployment ## Stack
To build and run using Docker: - React 19 + React Router 7 (SPA mode)
- Tailwind CSS 4 + shadcn/ui
```bash - Tonal (music theory / chord voicings)
docker build -t my-app . - Vite
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

View File

@@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pause, Play } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Slider } from "~/components/ui/slider";
interface Props {
scrollRef: React.RefObject<HTMLDivElement | null>;
}
function loadSpeed(): number {
try {
const v = localStorage.getItem("autoScrollSpeed");
if (v) return parseFloat(v);
} catch {
/* noop */
}
return 30;
}
export function AutoScrollControls({ scrollRef }: Props) {
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(loadSpeed);
const rafRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const cancelledByUser = useRef(false);
const accumulatorRef = useRef(0);
const tick = useCallback(
(time: number) => {
const el = scrollRef.current;
if (!el) return;
if (lastTimeRef.current) {
const dt = (time - lastTimeRef.current) / 1000;
accumulatorRef.current += speed * dt;
const px = Math.floor(accumulatorRef.current);
if (px >= 1) {
accumulatorRef.current -= px;
el.scrollBy({ top: px });
}
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 1) {
setPlaying(false);
return;
}
}
lastTimeRef.current = time;
rafRef.current = requestAnimationFrame(tick);
},
[scrollRef, speed],
);
useEffect(() => {
if (playing) {
lastTimeRef.current = 0;
accumulatorRef.current = 0;
cancelledByUser.current = false;
rafRef.current = requestAnimationFrame(tick);
} else {
cancelAnimationFrame(rafRef.current);
}
return () => cancelAnimationFrame(rafRef.current);
}, [playing, tick]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
function handleUserScroll() {
if (playing && !cancelledByUser.current) {
cancelledByUser.current = true;
setPlaying(false);
}
}
el.addEventListener("touchstart", handleUserScroll, { passive: true });
return () => el.removeEventListener("touchstart", handleUserScroll);
}, [scrollRef, playing]);
function handleSpeedChange(value: number[]) {
const v = value[0];
setSpeed(v);
try {
localStorage.setItem("autoScrollSpeed", String(v));
} catch {
/* noop */
}
}
return (
<div className="flex items-center gap-2 px-3 py-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setPlaying((p) => !p)}
>
{playing ? (
<Pause className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
</Button>
<Slider
min={10}
max={80}
step={5}
value={[speed]}
onValueChange={handleSpeedChange}
className="w-24"
/>
</div>
);
}

View File

@@ -1,45 +1,104 @@
import { useState } from "react";
import { NavLink } from "react-router"; import { NavLink } from "react-router";
import { Music, Sun, Moon } from "lucide-react"; import { LogOut, Music, Sun, Moon, User } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { useAuth } from "~/lib/auth";
import { LoginSheet } from "~/components/login-sheet";
import { RegisterSheet } from "~/components/register-sheet";
export function BottomNav() { export function BottomNav() {
const { resolvedTheme, setTheme } = useTheme(); const { resolvedTheme, setTheme } = useTheme();
const { isAuthenticated, logout } = useAuth();
const [loginOpen, setLoginOpen] = useState(false);
const [registerOpen, setRegisterOpen] = useState(false);
return ( return (
<nav className="border-t bg-background shrink-0"> <>
<div className="max-w-lg mx-auto flex items-center"> <nav className="border-t bg-background shrink-0">
<NavLink <div className="max-w-lg mx-auto flex items-center">
to="/" <NavLink
end to="/"
className={({ isActive }) => end
cn( className={({ isActive }) =>
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors", cn(
isActive "flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
? "text-primary" isActive
: "text-muted-foreground hover:text-foreground" ? "text-primary"
) : "text-muted-foreground hover:text-foreground",
} )
> }
<Music className="w-5 h-5" /> >
<span>Library</span> <Music className="w-5 h-5" />
</NavLink> <span>Library</span>
</NavLink>
<Button <div className="flex items-center gap-1 mr-2">
variant="ghost" <Button
size="icon" variant="ghost"
className="mr-2 text-muted-foreground hover:text-foreground" size="icon"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")} className="text-muted-foreground hover:text-foreground"
aria-label="Toggle theme" onClick={() =>
> setTheme(resolvedTheme === "dark" ? "light" : "dark")
{resolvedTheme === "dark" ? ( }
<Sun className="w-5 h-5" /> aria-label="Toggle theme"
) : ( >
<Moon className="w-5 h-5" /> {resolvedTheme === "dark" ? (
)} <Sun className="w-5 h-5" />
</Button> ) : (
</div> <Moon className="w-5 h-5" />
</nav> )}
</Button>
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<User className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="w-4 h-4 mr-2" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setLoginOpen(true)}
>
Sign in
</Button>
)}
</div>
</div>
</nav>
<LoginSheet
open={loginOpen}
onOpenChange={setLoginOpen}
onSwitchToRegister={() => setRegisterOpen(true)}
/>
<RegisterSheet
open={registerOpen}
onOpenChange={setRegisterOpen}
onSwitchToLogin={() => setLoginOpen(true)}
/>
</>
); );
} }

View File

@@ -45,7 +45,7 @@ function ChordRow({
onChordClick?: (chord: string) => void; onChordClick?: (chord: string) => void;
}) { }) {
return ( return (
<div className={`relative font-mono ${sizeClass} text-primary`} style={{ height: '1.5em' }}> <div className={`relative font-mono ${sizeClass} text-primary overflow-hidden`} style={{ height: '1.5em' }}>
{chords.map(({ offset, chord }, i) => ( {chords.map(({ offset, chord }, i) => (
<span <span
key={i} key={i}
@@ -85,15 +85,17 @@ function LineBlock({
function SectionBlock({ function SectionBlock({
section, section,
index,
sizeClass, sizeClass,
onChordClick, onChordClick,
}: { }: {
section: Section; section: Section;
index: number;
sizeClass: string; sizeClass: string;
onChordClick?: (chord: string) => void; onChordClick?: (chord: string) => void;
}) { }) {
return ( return (
<div className="mb-6"> <div className="mb-6" id={`section-${index}`}>
{section.label && ( {section.label && (
<p className="text-xs text-muted-foreground mb-1">[{section.label}]</p> <p className="text-xs text-muted-foreground mb-1">[{section.label}]</p>
)} )}
@@ -111,7 +113,7 @@ export function ChordChart({ sections, fontSize, onChordClick }: Props) {
return ( return (
<div className="px-4 py-3"> <div className="px-4 py-3">
{sections.map((section, i) => ( {sections.map((section, i) => (
<SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} /> <SectionBlock key={i} section={section} index={i} sizeClass={sizeClass} onChordClick={onChordClick} />
))} ))}
</div> </div>
); );

View File

@@ -0,0 +1,91 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import { useAuth } from "~/lib/auth";
import { toast } from "sonner";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSwitchToRegister: () => void;
}
export function LoginSheet({ open, onOpenChange, onSwitchToRegister }: Props) {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
onOpenChange(false);
setEmail("");
setPassword("");
toast.success("Logged in");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="rounded-t-xl">
<SheetHeader>
<SheetTitle>Sign in</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4 pb-6">
<div className="flex flex-col gap-2">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
autoComplete="current-password"
placeholder="********"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Signing in..." : "Sign in"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-sm text-muted-foreground"
onClick={() => {
onOpenChange(false);
onSwitchToRegister();
}}
>
Don't have an account? Register
</Button>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import { useAuth } from "~/lib/auth";
import { toast } from "sonner";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSwitchToLogin: () => void;
}
export function RegisterSheet({ open, onOpenChange, onSwitchToLogin }: Props) {
const { register } = useAuth();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await register(email, username, password);
onOpenChange(false);
setEmail("");
setUsername("");
setPassword("");
toast.success("Account created");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="rounded-t-xl">
<SheetHeader>
<SheetTitle>Create account</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4 pb-6">
<div className="flex flex-col gap-2">
<Label htmlFor="register-email">Email</Label>
<Input
id="register-email"
type="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="register-username">Username</Label>
<Input
id="register-username"
type="text"
autoComplete="username"
placeholder="johndoe"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="register-password">Password</Label>
<Input
id="register-password"
type="password"
autoComplete="new-password"
placeholder="min. 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Creating account..." : "Register"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-sm text-muted-foreground"
onClick={() => {
onOpenChange(false);
onSwitchToLogin();
}}
>
Already have an account? Sign in
</Button>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,46 @@
import { Button } from "~/components/ui/button";
import { ScrollArea, ScrollBar } from "~/components/ui/scroll-area";
interface Props {
sections: Array<{ label: string | null; index: number }>;
onJump: (index: number) => void;
}
function abbreviate(label: string): string {
const lower = label.toLowerCase().replace(/[-_ ]/g, "");
if (lower.startsWith("verse")) return label.replace(/verse\s*/i, "V");
if (lower.startsWith("chorus") || lower.startsWith("refrain")) return "C";
if (lower.startsWith("bridge")) return "Br";
if (lower.startsWith("prechorus")) return "PC";
if (lower.startsWith("intro")) return "In";
if (lower.startsWith("outro")) return "Out";
if (lower.startsWith("instrumental") || lower.startsWith("interlude"))
return "Int";
if (lower.startsWith("solo")) return "Sol";
if (label.length > 4) return label.slice(0, 3);
return label;
}
export function SectionNav({ sections, onJump }: Props) {
const labeled = sections.filter((s) => s.label);
if (labeled.length === 0) return null;
return (
<ScrollArea className="min-w-0 flex-1">
<div className="flex gap-1 px-3 py-1.5">
{labeled.map((s) => (
<Button
key={s.index}
variant="secondary"
size="sm"
className="h-6 px-2 text-xs shrink-0"
onClick={() => onJump(s.index)}
>
{abbreviate(s.label!)}
</Button>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}

View File

@@ -1,16 +1,21 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { Star } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import type { SongSummary } from "~/lib/types"; import type { SongSummary } from "~/lib/types";
interface Props { interface Props {
song: SongSummary; song: SongSummary;
isFavorite?: boolean;
} }
export function SongCard({ song }: Props) { export function SongCard({ song, isFavorite }: Props) {
return ( return (
<Link to={`/songs/${song.id}`}> <Link to={`/songs/${song.id}`}>
<Card className="h-full hover:bg-accent transition-colors cursor-pointer"> <Card className="h-full hover:bg-accent transition-colors cursor-pointer relative">
{isFavorite && (
<Star className="absolute top-2 right-2 w-3.5 h-3.5 fill-primary text-primary" />
)}
<CardContent className="p-3 flex flex-col gap-1"> <CardContent className="p-3 flex flex-col gap-1">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{song.preview_chords.map((chord) => ( {song.preview_chords.map((chord) => (

View File

@@ -1,9 +1,23 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import {
ChevronUp,
ChevronDown,
Maximize,
Minus,
Minimize,
MoreHorizontal,
Pencil,
Plus,
Star,
Trash2,
} from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { SongMeta } from "~/lib/types"; import type { SongMeta } from "~/lib/types";
@@ -13,41 +27,100 @@ interface Props {
onOffsetChange: (offset: number) => void; onOffsetChange: (offset: number) => void;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
fontSize?: 'sm' | 'base' | 'lg'; fontSize?: "sm" | "base" | "lg";
onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void; onFontSizeChange?: (size: "sm" | "base" | "lg") => void;
capo?: number; capo?: number;
applyCapo?: boolean; applyCapo?: boolean;
onToggleCapo?: () => void; onToggleCapo?: () => void;
isFavorite?: boolean;
onToggleFavorite?: () => void;
fullscreen?: boolean;
onToggleFullscreen?: () => void;
} }
export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, fontSize, onFontSizeChange, capo, applyCapo, onToggleCapo }: Props) { const NOTES_SHARP = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const NOTES_FLAT = ["C","Db","D","Eb","E","F","Gb","G","Ab","A","Bb","B"];
function transposedKey(key: string, offset: number): string {
const root = key.length >= 2 && (key[1] === "#" || key[1] === "b") ? key.slice(0, 2) : key.slice(0, 1);
const suffix = key.slice(root.length);
const notes = offset > 0 ? NOTES_SHARP : NOTES_FLAT;
const idx = NOTES_SHARP.indexOf(root) !== -1 ? NOTES_SHARP.indexOf(root) : NOTES_FLAT.indexOf(root);
if (idx === -1) return key;
const newIdx = ((idx + offset) % 12 + 12) % 12;
return notes[newIdx] + suffix;
}
export function TransposeBar({
meta,
offset,
onOffsetChange,
onEdit,
onDelete,
fontSize,
onFontSizeChange,
capo,
applyCapo,
onToggleCapo,
isFavorite,
onToggleFavorite,
fullscreen,
onToggleFullscreen,
}: Props) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
const menuButton = (onEdit || onDelete) ? ( const keyDisplay =
<DropdownMenu> meta.original_key && offset !== 0
<DropdownMenuTrigger asChild> ? `Key: ${meta.original_key}${transposedKey(meta.original_key, offset)}`
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0"> : meta.original_key
<MoreHorizontal className="w-4 h-4" /> ? `Key: ${meta.original_key}`
: null;
const menuButton =
onEdit || onDelete ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null;
if (fullscreen) {
return (
<div className="flex items-center justify-between px-4 py-1.5 border-b bg-background">
<span className="text-sm font-semibold truncate">{meta.title}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onToggleFullscreen}
>
<Minimize className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </div>
<DropdownMenuContent align="end"> );
{onEdit && ( }
<DropdownMenuItem onClick={onEdit}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null;
if (!expanded) { if (!expanded) {
return ( return (
@@ -55,7 +128,12 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<span className="text-sm font-semibold truncate">{meta.title}</span> <span className="text-sm font-semibold truncate">{meta.title}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{menuButton} {menuButton}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(true)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setExpanded(true)}
>
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -71,8 +149,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<span className="text-sm text-muted-foreground">{meta.artist}</span> <span className="text-sm text-muted-foreground">{meta.artist}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onToggleFavorite && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={onToggleFavorite}
>
<Star
className={cn(
"w-4 h-4",
isFavorite
? "fill-primary text-primary"
: "text-muted-foreground",
)}
/>
</Button>
)}
{onToggleFullscreen && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={onToggleFullscreen}
>
<Maximize className="w-4 h-4" />
</Button>
)}
{menuButton} {menuButton}
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setExpanded(false)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setExpanded(false)}
>
<ChevronUp className="w-4 h-4" /> <ChevronUp className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -80,16 +190,19 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-3 text-xs text-muted-foreground"> <div className="flex gap-3 text-xs text-muted-foreground">
{meta.original_key && <span>Key: {meta.original_key}</span>} {keyDisplay && <span>{keyDisplay}</span>}
{capo != null && onToggleCapo ? ( {capo != null && onToggleCapo ? (
<button <button
onClick={onToggleCapo} onClick={onToggleCapo}
className={cn( className={cn(
"text-xs transition-colors", "text-xs transition-colors",
applyCapo ? "text-primary" : "text-muted-foreground hover:text-foreground" applyCapo
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)} )}
> >
Capo {capo}{applyCapo ? " · sounding" : ""} Capo {capo}
{applyCapo ? " · sounding" : ""}
</button> </button>
) : meta.capo != null ? ( ) : meta.capo != null ? (
<span>Capo: {meta.capo}</span> <span>Capo: {meta.capo}</span>
@@ -99,7 +212,7 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onFontSizeChange && ( {onFontSizeChange && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{(['sm', 'base', 'lg'] as const).map((s) => ( {(["sm", "base", "lg"] as const).map((s) => (
<button <button
key={s} key={s}
onClick={() => onFontSizeChange(s)} onClick={() => onFontSizeChange(s)}
@@ -107,21 +220,31 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
"text-xs px-1.5 py-0.5 rounded transition-colors", "text-xs px-1.5 py-0.5 rounded transition-colors",
fontSize === s fontSize === s
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground",
)} )}
> >
{s === 'sm' ? 'S' : s === 'base' ? 'M' : 'L'} {s === "sm" ? "S" : s === "base" ? "M" : "L"}
</button> </button>
))} ))}
</div> </div>
)} )}
<Button variant="ghost" size="icon" className="h-8 w-8" <Button
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}> variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
>
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
</Button> </Button>
<span className="w-8 text-center text-sm font-mono font-semibold">{label}</span> <span className="w-8 text-center text-sm font-mono font-semibold">
<Button variant="ghost" size="icon" className="h-8 w-8" {label}
onClick={() => onOffsetChange(Math.min(11, offset + 1))}> </span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,57 @@
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_KEY = "favorites";
let listeners: Array<() => void> = [];
let cachedRaw: string | null = null;
let cachedParsed: string[] = [];
function getSnapshot(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw !== cachedRaw) {
cachedRaw = raw;
cachedParsed = raw ? JSON.parse(raw) : [];
}
} catch {
cachedParsed = [];
}
return cachedParsed;
}
function subscribe(cb: () => void) {
listeners.push(cb);
return () => {
listeners = listeners.filter((l) => l !== cb);
};
}
const EMPTY: string[] = [];
function getServerSnapshot(): string[] {
return EMPTY;
}
function notify() {
cachedRaw = null;
listeners.forEach((l) => l());
}
export function useFavorites() {
const favorites = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const isFavorite = useCallback(
(id: string) => favorites.includes(id),
[favorites],
);
const toggle = useCallback((id: string) => {
const current = getSnapshot();
const next = current.includes(id)
? current.filter((f) => f !== id)
: [...current, id];
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
notify();
}, []);
return { favorites, isFavorite, toggle };
}

View File

@@ -0,0 +1,22 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
interface FullscreenState {
isFullscreen: boolean;
toggle: () => void;
}
const FullscreenContext = createContext<FullscreenState>({
isFullscreen: false,
toggle: () => {},
});
export function FullscreenProvider({ children }: { children: ReactNode }) {
const [isFullscreen, setIsFullscreen] = useState(false);
const toggle = useCallback(() => setIsFullscreen((v) => !v), []);
const value = useMemo(() => ({ isFullscreen, toggle }), [isFullscreen, toggle]);
return <FullscreenContext value={value}>{children}</FullscreenContext>;
}
export function useFullscreen() {
return useContext(FullscreenContext);
}

View File

@@ -0,0 +1,28 @@
import { useEffect, useRef } from "react";
export function useWakeLock() {
const lockRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
async function acquire() {
try {
lockRef.current = await navigator.wakeLock.request("screen");
} catch {
// not supported or denied
}
}
acquire();
function handleVisibility() {
if (document.visibilityState === "visible") acquire();
}
document.addEventListener("visibilitychange", handleVisibility);
return () => {
document.removeEventListener("visibilitychange", handleVisibility);
lockRef.current?.release().catch(() => {});
};
}, []);
}

View File

@@ -1,6 +1,87 @@
import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types"; import type { LoginResponse, RefreshResponse, Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; const API_BASE = import.meta.env.VITE_API_URL ?? "/api";
const TOKEN_KEY = "pocket_chords_token";
const REFRESH_KEY = "pocket_chords_refresh_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_KEY);
}
export function setTokens(token: string, refreshToken: string) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(REFRESH_KEY, refreshToken);
}
export function clearTokens() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
}
async function authFetch(url: string, init: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers = new Headers(init.headers);
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(url, { ...init, headers });
if (res.status === 401) clearTokens();
return res;
}
// --- Auth ---
export async function apiLogin(email: string, password: string): Promise<LoginResponse> {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? `Login failed: ${res.status}`);
}
return res.json();
}
export async function apiRegister(email: string, username: string, password: string): Promise<void> {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, username, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? `Registration failed: ${res.status}`);
}
}
export async function apiRefresh(refreshToken: string): Promise<RefreshResponse> {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
clearTokens();
throw new Error("Session expired");
}
return res.json();
}
export async function apiLogout(refreshToken: string): Promise<void> {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
}).catch(() => {});
clearTokens();
}
// --- Songs (read — public) ---
export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> { export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -13,9 +94,16 @@ export async function listSongs(q = "", sort = "date", order = "desc"): Promise<
return res.json(); return res.json();
} }
export async function getSong(id: string, applyCapo = false): Promise<Song | null> { export async function getSong(
const url = applyCapo id: string,
? `${API_BASE}/songs/${id}?apply_capo=true` opts: { applyCapo?: boolean; transpose?: number } = {},
): Promise<Song | null> {
const params = new URLSearchParams();
if (opts.applyCapo) params.set("apply_capo", "true");
if (opts.transpose && opts.transpose !== 0)
params.set("transpose", String(opts.transpose));
const url = params.size
? `${API_BASE}/songs/${id}?${params}`
: `${API_BASE}/songs/${id}`; : `${API_BASE}/songs/${id}`;
const res = await fetch(url); const res = await fetch(url);
if (res.status === 404) return null; if (res.status === 404) return null;
@@ -23,11 +111,10 @@ export async function getSong(id: string, applyCapo = false): Promise<Song | nul
return res.json(); return res.json();
} }
export async function createSong(body: { // --- Songs (mutations — auth required) ---
source?: string;
html?: string; export async function createSong(body: { source?: string; html?: string }): Promise<StoredSong> {
}): Promise<StoredSong> { const res = await authFetch(`${API_BASE}/songs`, {
const res = await fetch(`${API_BASE}/songs`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -40,12 +127,12 @@ export async function createSong(body: {
} }
export async function deleteSong(id: string): Promise<void> { export async function deleteSong(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/songs/${id}`, { method: "DELETE" }); const res = await authFetch(`${API_BASE}/songs/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Failed to delete song: HTTP ${res.status}`); if (!res.ok) throw new Error(`Failed to delete song: HTTP ${res.status}`);
} }
export async function updateSong(id: string, patch: UpdateSongRequest): Promise<SongSummary> { export async function updateSong(id: string, patch: UpdateSongRequest): Promise<SongSummary> {
const res = await fetch(`${API_BASE}/songs/${id}`, { const res = await authFetch(`${API_BASE}/songs/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch), body: JSON.stringify(patch),

76
app/app/lib/auth.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { apiLogin, apiLogout, apiRefresh, apiRegister, clearTokens, getRefreshToken, getToken, setTokens } from "./api";
interface AuthState {
userId: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [userId, setUserId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = getToken();
const refresh = getRefreshToken();
if (!token || !refresh) {
setIsLoading(false);
return;
}
apiRefresh(refresh)
.then((res) => {
setTokens(res.token, res.refresh_token);
const payload = JSON.parse(atob(res.token.split(".")[1]));
setUserId(payload.sub);
})
.catch(() => {
clearTokens();
setUserId(null);
})
.finally(() => setIsLoading(false));
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await apiLogin(email, password);
setTokens(res.token, res.refresh_token);
setUserId(res.user_id);
}, []);
const register = useCallback(async (email: string, username: string, password: string) => {
await apiRegister(email, username, password);
await login(email, password);
}, [login]);
const logout = useCallback(async () => {
const refresh = getRefreshToken();
if (refresh) await apiLogout(refresh);
setUserId(null);
}, []);
const value = useMemo<AuthState>(
() => ({
userId,
isAuthenticated: !!userId,
isLoading,
login,
register,
logout,
}),
[userId, isLoading, login, register, logout],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -1,34 +0,0 @@
import type { Song } from "./types";
const NOTES_SHARP = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const NOTES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
function transposeChord(chord: string, semitones: number): string {
const match = chord.match(/^([A-G][#b]?)(.*)/);
if (!match) return chord;
const [, root, descriptor] = match;
const idx = NOTES_SHARP.indexOf(root) !== -1
? NOTES_SHARP.indexOf(root)
: NOTES_FLAT.indexOf(root);
if (idx === -1) return chord;
const newIdx = ((idx + semitones) % 12 + 12) % 12;
const notes = semitones >= 0 ? NOTES_SHARP : NOTES_FLAT;
return notes[newIdx] + descriptor;
}
export function transposeSong(song: Song, semitones: number): Song {
if (semitones === 0) return song;
return {
...song,
sections: song.sections.map((section) => ({
...section,
lines: section.lines.map((line) => ({
...line,
chords: line.chords.map((cp) => ({
...cp,
chord: transposeChord(cp.chord, semitones),
})),
})),
})),
};
}

View File

@@ -28,11 +28,9 @@ export interface Song {
sections: Section[]; sections: Section[];
} }
// Trimmed version used in the library grid
export interface SongSummary { export interface SongSummary {
id: string; id: string;
meta: SongMeta; meta: SongMeta;
// First 5 unique chord names from the song, in order of appearance
preview_chords: string[]; preview_chords: string[];
} }
@@ -46,3 +44,16 @@ export interface UpdateSongRequest {
artist?: string; artist?: string;
original_key?: string; original_key?: string;
} }
export interface LoginResponse {
token: string;
refresh_token: string;
user_id: string;
expires_at: string;
}
export interface RefreshResponse {
token: string;
refresh_token: string;
expires_at: string;
}

View File

@@ -6,12 +6,18 @@ import {
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from "react-router"; } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import "./app.css"; import "./app.css";
import { AuthProvider } from "./lib/auth";
import { TooltipProvider } from "./components/ui/tooltip"; import { TooltipProvider } from "./components/ui/tooltip";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ {
@@ -42,13 +48,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links /> <Links />
</head> </head>
<body> <body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <QueryClientProvider client={queryClient}>
<TooltipProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children} <AuthProvider>
<ScrollRestoration /> <TooltipProvider>
<Scripts /> {children}
</TooltipProvider> <ScrollRestoration />
</ThemeProvider> <Scripts />
</TooltipProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,74 +1,83 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams, useRevalidator } from "react-router"; import { useSearchParams } from "react-router";
import type { Route } from "./+types/home"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { Plus } from "lucide-react"; import { Loader2, Plus } from "lucide-react";
import { SongCard } from "~/components/song-card"; import { SongCard } from "~/components/song-card";
import { AddSongSheet } from "~/components/add-song-sheet"; import { AddSongSheet } from "~/components/add-song-sheet";
import { listSongs } from "~/lib/api"; import { listSongs } from "~/lib/api";
import { useAuth } from "~/lib/auth";
import { useFavorites } from "~/hooks/use-favorites";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { SongSummary } from "~/lib/types";
export function meta({}: Route.MetaArgs) { export function meta() {
return [ return [
{ title: "PocketChords" }, { title: "PocketChords" },
{ name: "description", content: "Your personal chord chart library" }, { name: "description", content: "Your personal chord chart library" },
]; ];
} }
export async function loader({ request }: Route.LoaderArgs) { export default function Home() {
const url = new URL(request.url); const { isAuthenticated } = useAuth();
const q = url.searchParams.get("q") ?? ""; const { isFavorite } = useFavorites();
const sort = url.searchParams.get("sort") ?? "date"; const queryClient = useQueryClient();
const order = url.searchParams.get("order") ?? "desc";
try {
const songs = await listSongs(q, sort, order);
return { songs, q, sort, order, error: false };
} catch {
return { songs: [], q, sort, order, error: true };
}
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { songs, q: initialQ, sort: initialSort, order: initialOrder, error } = loaderData;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
const revalidator = useRevalidator();
const [inputValue, setInputValue] = useState(initialQ); const q = searchParams.get("q") ?? "";
const sort = searchParams.get("sort") ?? "date";
const order = searchParams.get("order") ?? "desc";
const [inputValue, setInputValue] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearch = useCallback((value: string) => { const { data: songs = [], isLoading, isError, refetch } = useQuery({
setInputValue(value); queryKey: ["songs", q, sort, order],
if (debounceRef.current) clearTimeout(debounceRef.current); queryFn: () => listSongs(q, sort, order),
debounceRef.current = setTimeout(() => { });
const next: Record<string, string> = {};
if (value.trim()) next.q = value.trim();
if (initialSort !== "date") next.sort = initialSort;
if (initialOrder !== "desc") next.order = initialOrder;
setSearchParams(next, { replace: true });
}, 300);
}, [setSearchParams, initialSort, initialOrder]);
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []); const handleSearch = useCallback(
(value: string) => {
setInputValue(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const next: Record<string, string> = {};
if (value.trim()) next.q = value.trim();
if (sort !== "date") next.sort = sort;
if (order !== "desc") next.order = order;
setSearchParams(next, { replace: true });
}, 300);
},
[setSearchParams, sort, order],
);
const allSongs = [...songs, ...localSongs]; useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const allSongs = songs.toSorted((a, b) => {
const af = isFavorite(a.id) ? 0 : 1;
const bf = isFavorite(b.id) ? 0 : 1;
return af - bf;
});
return ( return (
<div className="flex flex-col h-full max-w-lg mx-auto"> <div className="flex flex-col h-full max-w-lg mx-auto">
<div className="flex items-center justify-between px-4 pt-4 pb-2"> <div className="flex items-center justify-between px-4 pt-4 pb-2">
<h1 className="text-lg font-bold">PocketChords</h1> <h1 className="text-lg font-bold">PocketChords</h1>
<Button size="sm" onClick={() => setSheetOpen(true)}> {isAuthenticated && (
<Plus className="w-4 h-4 mr-1" /> <Button size="sm" onClick={() => setSheetOpen(true)}>
Add <Plus className="w-4 h-4 mr-1" />
</Button> Add
</Button>
)}
</div> </div>
<div className="px-4 pb-3"> <div className="px-4 pb-3">
<Input <Input
placeholder="Search songs..." placeholder="Search songs..."
@@ -79,11 +88,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</div> </div>
<div className="flex gap-1 px-4 pb-2"> <div className="flex gap-1 px-4 pb-2">
{([["date", "Date"], ["title", "Title"], ["artist", "Artist"]] as const).map(([val, label]) => ( {(
[
["date", "Date"],
["title", "Title"],
["artist", "Artist"],
] as const
).map(([val, label]) => (
<button <button
key={val} key={val}
onClick={() => { onClick={() => {
const newOrder = initialSort === val ? (initialOrder === "asc" ? "desc" : "asc") : (val === "date" ? "desc" : "asc"); const newOrder =
sort === val
? order === "asc"
? "desc"
: "asc"
: val === "date"
? "desc"
: "asc";
const next: Record<string, string> = {}; const next: Record<string, string> = {};
if (inputValue.trim()) next.q = inputValue.trim(); if (inputValue.trim()) next.q = inputValue.trim();
next.sort = val; next.sort = val;
@@ -92,57 +114,70 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}} }}
className={cn( className={cn(
"text-xs px-2 py-1 rounded-full border transition-colors", "text-xs px-2 py-1 rounded-full border transition-colors",
initialSort === val sort === val
? "bg-primary text-primary-foreground border-primary" ? "bg-primary text-primary-foreground border-primary"
: "text-muted-foreground border-border" : "text-muted-foreground border-border",
)} )}
> >
{label}{initialSort === val ? (initialOrder === "asc" ? " ↑" : " ↓") : ""} {label}
{sort === val ? (order === "asc" ? " ↑" : " ↓") : ""}
</button> </button>
))} ))}
</div> </div>
{error && ( {isError && (
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center"> <div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Couldn't load your songs. Is the API running? Couldn't load your songs. Is the API running?
</p> </p>
<Button <Button variant="outline" size="sm" onClick={() => refetch()}>
variant="outline"
size="sm"
onClick={() => revalidator.revalidate()}
>
Retry Retry
</Button> </Button>
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto px-4 pb-4"> <div className="flex-1 overflow-y-auto px-4 pb-4">
{!error && allSongs.length === 0 && ( {isLoading && !isError && (
<div className="flex justify-center pt-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && !isError && allSongs.length === 0 && (
<p className="text-sm text-muted-foreground text-center pt-8 pb-4"> <p className="text-sm text-muted-foreground text-center pt-8 pb-4">
{initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."} {q
? "No songs match your search."
: "No songs yet. Tap Add to get started."}
</p> </p>
)} )}
<div className="grid grid-cols-2 gap-3"> {!isLoading && (
{allSongs.map((song) => ( <div className="grid grid-cols-2 gap-3">
<SongCard key={song.id} song={song} /> {allSongs.map((song) => (
))} <SongCard
<Card key={song.id}
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors" song={song}
onClick={() => setSheetOpen(true)} isFavorite={isFavorite(song.id)}
> />
<CardContent className="p-3 flex items-center justify-center h-full min-h-[80px]"> ))}
<Plus className="w-6 h-6 text-muted-foreground" /> {isAuthenticated && (
</CardContent> <Card
</Card> className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
</div> onClick={() => setSheetOpen(true)}
>
<CardContent className="p-3 flex items-center justify-center h-full min-h-[80px]">
<Plus className="w-6 h-6 text-muted-foreground" />
</CardContent>
</Card>
)}
</div>
)}
</div> </div>
<AddSongSheet <AddSongSheet
open={sheetOpen} open={sheetOpen}
onOpenChange={setSheetOpen} onOpenChange={setSheetOpen}
onSongAdded={(summary) => setLocalSongs((prev) => [...prev, summary])} onSongAdded={() =>
queryClient.invalidateQueries({ queryKey: ["songs"] })
}
/> />
</div> </div>
); );

View File

@@ -1,15 +1,26 @@
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { BottomNav } from "~/components/bottom-nav"; import { BottomNav } from "~/components/bottom-nav";
import { FullscreenProvider, useFullscreen } from "~/hooks/use-fullscreen";
function LayoutInner() {
const { isFullscreen } = useFullscreen();
export default function Layout() {
return ( return (
<div className="flex flex-col h-dvh"> <div className="flex flex-col h-dvh">
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Outlet /> <Outlet />
</div> </div>
<BottomNav /> {!isFullscreen && <BottomNav />}
<Toaster position="top-center" richColors /> <Toaster position="top-center" richColors />
</div> </div>
); );
} }
export default function Layout() {
return (
<FullscreenProvider>
<LayoutInner />
</FullscreenProvider>
);
}

View File

@@ -1,6 +1,8 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import { data, Link } from "react-router"; import { Link, useParams } from "react-router";
import type { Route } from "./+types/songs.$id"; import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import { TransposeBar } from "~/components/transpose-bar"; import { TransposeBar } from "~/components/transpose-bar";
import { ChordChart } from "~/components/chord-chart"; import { ChordChart } from "~/components/chord-chart";
import { ChordGrid } from "~/components/chord-diagram/chord-grid"; import { ChordGrid } from "~/components/chord-diagram/chord-grid";
@@ -8,69 +10,65 @@ import { ChordDiagram } from "~/components/chord-diagram/chord-diagram";
import type { Instrument } from "~/components/chord-diagram/chord-diagram"; import type { Instrument } from "~/components/chord-diagram/chord-diagram";
import { EditSongSheet } from "~/components/edit-song-sheet"; import { EditSongSheet } from "~/components/edit-song-sheet";
import { DeleteSongDialog } from "~/components/delete-song-dialog"; import { DeleteSongDialog } from "~/components/delete-song-dialog";
import { transposeSong } from "~/lib/transpose"; import { SectionNav } from "~/components/section-nav";
import { AutoScrollControls } from "~/components/auto-scroll-controls";
import { extractUniqueChords } from "~/lib/song-utils"; import { extractUniqueChords } from "~/lib/song-utils";
import { getSong } from "~/lib/api"; import { getSong } from "~/lib/api";
import type { Song, SongSummary } from "~/lib/types"; import { useAuth } from "~/lib/auth";
import { useFavorites } from "~/hooks/use-favorites";
import { useFullscreen } from "~/hooks/use-fullscreen";
import { useWakeLock } from "~/hooks/use-wake-lock";
import type { SongSummary } from "~/lib/types";
export function meta({ data }: Route.MetaArgs) { type FontSize = "sm" | "base" | "lg";
if (!data?.song) return [{ title: "PocketChords" }];
return [
{ title: `${data.song.meta.title} — PocketChords` },
{ name: "description", content: data.song.meta.artist },
];
}
export async function loader({ params }: Route.LoaderArgs) {
const id = params.id ?? "";
try {
const song = await getSong(id);
if (!song) throw data("Song not found", { status: 404 });
return { song, id };
} catch (err: unknown) {
if (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404) {
throw err;
}
return { song: null as Song | null, id };
}
}
type FontSize = 'sm' | 'base' | 'lg';
function initFontSize(): FontSize { function initFontSize(): FontSize {
try { try {
const v = localStorage.getItem('fontSize'); const v = localStorage.getItem("fontSize");
if (v === 'sm' || v === 'base' || v === 'lg') return v; if (v === "sm" || v === "base" || v === "lg") return v;
} catch { /* noop */ } } catch {
return 'sm'; /* noop */
}
return "sm";
} }
function initInstrument(): Instrument { function initInstrument(): Instrument {
try { try {
const v = localStorage.getItem('chordDiagramInstrument'); const v = localStorage.getItem("chordDiagramInstrument");
if (v === 'piano' || v === 'guitar') return v; if (v === "piano" || v === "guitar") return v;
} catch { /* noop */ } } catch {
return 'piano'; /* noop */
}
return "piano";
} }
export default function SongDetail({ loaderData }: Route.ComponentProps) { function initOffset(id: string): number {
const { song: initialSong, id } = loaderData; try {
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null); const v = localStorage.getItem(`transpose:${id}`);
const [displayedSong, setDisplayedSong] = useState<Song | null>(initialSong ?? null); if (v !== null) {
const n = parseInt(v, 10);
if (!isNaN(n)) return n;
}
} catch {
/* noop */
}
return 0;
}
export function meta() {
return [{ title: "PocketChords" }];
}
export default function SongDetail() {
const { id = "" } = useParams();
const { isAuthenticated } = useAuth();
const { isFavorite, toggle: toggleFavorite } = useFavorites();
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen();
useWakeLock();
const [offset, setOffset] = useState(() => initOffset(id));
const [applyCapo, setApplyCapo] = useState(false); const [applyCapo, setApplyCapo] = useState(false);
const initOffset = (() => {
try {
const v = localStorage.getItem(`transpose:${id}`);
if (v !== null) {
const n = parseInt(v, 10);
if (!isNaN(n)) return n;
}
} catch { /* noop */ }
return 0;
})();
const [offset, setOffset] = useState(initOffset);
const [fontSize, setFontSize] = useState<FontSize>(initFontSize); const [fontSize, setFontSize] = useState<FontSize>(initFontSize);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@@ -78,49 +76,86 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
const [instrument, setInstrument] = useState<Instrument>(initInstrument); const [instrument, setInstrument] = useState<Instrument>(initInstrument);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const { data: baseSong, isLoading } = useQuery({
if (applyCapo && baseSong?.meta.capo) { queryKey: ["song", id],
getSong(id, true).then((s) => { if (s) setDisplayedSong(s); }); queryFn: () => getSong(id),
} else { });
setDisplayedSong(baseSong);
} const { data: displayedSong } = useQuery({
}, [applyCapo]); // eslint-disable-line queryKey: ["song", id, "view", offset, applyCapo],
queryFn: () => getSong(id, { applyCapo, transpose: offset }),
enabled: !!baseSong,
});
function handleOffsetChange(newOffset: number) { function handleOffsetChange(newOffset: number) {
setOffset(newOffset); setOffset(newOffset);
try { localStorage.setItem(`transpose:${id}`, String(newOffset)); } catch { /* noop */ } try {
localStorage.setItem(`transpose:${id}`, String(newOffset));
} catch {
/* noop */
}
} }
function handleFontSizeChange(size: FontSize) { function handleFontSizeChange(size: FontSize) {
setFontSize(size); setFontSize(size);
try { localStorage.setItem('fontSize', size); } catch { /* noop */ } try {
localStorage.setItem("fontSize", size);
} catch {
/* noop */
}
} }
function handleInstrumentChange(i: Instrument) { function handleInstrumentChange(i: Instrument) {
setInstrument(i); setInstrument(i);
try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ } try {
localStorage.setItem("chordDiagramInstrument", i);
} catch {
/* noop */
}
} }
const handleScroll = useCallback(() => setActiveChord(null), []); const handleScroll = useCallback(() => setActiveChord(null), []);
if (!baseSong || !displayedSong) { function handleSectionJump(index: number) {
const el = document.getElementById(`section-${index}`);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
const song = displayedSong ?? baseSong;
if (!baseSong || !song) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4"> <div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground text-sm">Song not found or unavailable.</p> <p className="text-muted-foreground text-sm">
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline"> Song not found or unavailable.
</p>
<Link
to="/"
className="text-sm text-primary underline-offset-4 hover:underline"
>
Back to library Back to library
</Link> </Link>
</div> </div>
); );
} }
const displayed = transposeSong(displayedSong, offset); const uniqueChords = extractUniqueChords(song.sections);
const uniqueChords = extractUniqueChords(displayed.sections); const sectionItems = song.sections.map((s, i) => ({
label: s.label,
index: i,
}));
const handleChordClick = (chord: string) => setActiveChord(chord); const handleChordClick = (chord: string) => setActiveChord(chord);
function handleUpdated(summary: SongSummary) { function handleUpdated(summary: SongSummary) {
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); // meta-only update; react-query will refetch on next focus
setDisplayedSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
} }
return ( return (
@@ -129,18 +164,20 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
meta={baseSong.meta} meta={baseSong.meta}
offset={offset} offset={offset}
onOffsetChange={handleOffsetChange} onOffsetChange={handleOffsetChange}
onEdit={() => setEditOpen(true)} onEdit={isAuthenticated ? () => setEditOpen(true) : undefined}
onDelete={() => setDeleteOpen(true)} onDelete={isAuthenticated ? () => setDeleteOpen(true) : undefined}
fontSize={fontSize} fontSize={fontSize}
onFontSizeChange={handleFontSizeChange} onFontSizeChange={handleFontSizeChange}
capo={baseSong.meta.capo ?? undefined} capo={baseSong.meta.capo ?? undefined}
applyCapo={applyCapo} applyCapo={applyCapo}
onToggleCapo={() => setApplyCapo((v) => !v)} onToggleCapo={() => setApplyCapo((v) => !v)}
isFavorite={isFavorite(id)}
onToggleFavorite={() => toggleFavorite(id)}
fullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/> />
{/* Body: single column on mobile, two columns on desktop */}
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row"> <div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
{/* Left / main column */}
<div <div
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
ref={scrollRef} ref={scrollRef}
@@ -148,12 +185,11 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
> >
<div className="max-w-lg mx-auto lg:max-w-none"> <div className="max-w-lg mx-auto lg:max-w-none">
<ChordChart <ChordChart
sections={displayed.sections} sections={song.sections}
fontSize={fontSize} fontSize={fontSize}
onChordClick={handleChordClick} onChordClick={handleChordClick}
/> />
{/* Mobile bottom chord grid (hidden on desktop) */}
<div className="lg:hidden border-t border-border"> <div className="lg:hidden border-t border-border">
<ChordGrid <ChordGrid
chords={uniqueChords} chords={uniqueChords}
@@ -164,7 +200,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
</div> </div>
</div> </div>
{/* Desktop side column (hidden on mobile) */}
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0"> <div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
<ChordGrid <ChordGrid
chords={uniqueChords} chords={uniqueChords}
@@ -174,16 +209,22 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
</div> </div>
</div> </div>
{/* Mobile inline popup — fixed bottom, dismissed on scroll */} <div className="lg:hidden border-t border-border bg-background flex items-center">
<SectionNav sections={sectionItems} onJump={handleSectionJump} />
<AutoScrollControls scrollRef={scrollRef} />
</div>
{activeChord && ( {activeChord && (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3"> <div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
<ChordDiagram chord={activeChord} instrument={instrument} /> <ChordDiagram chord={activeChord} instrument={instrument} />
<button <Button
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline" variant="ghost"
size="sm"
className="ml-auto text-xs text-muted-foreground"
onClick={() => setActiveChord(null)} onClick={() => setActiveChord(null)}
> >
close close
</button> </Button>
</div> </div>
)} )}

27
app/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0", "@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0", "@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -4045,6 +4046,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
"integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
"integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tonaljs/abc-notation": { "node_modules/@tonaljs/abc-notation": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",

View File

@@ -14,6 +14,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.14.0", "@react-router/node": "7.14.0",
"@react-router/serve": "7.14.0", "@react-router/serve": "7.14.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",

View File

@@ -3,5 +3,5 @@ import type { Config } from "@react-router/dev/config";
export default { export default {
// Config options... // Config options...
// Server-side render by default, to enable SPA mode set this to `false` // Server-side render by default, to enable SPA mode set this to `false`
ssr: true, ssr: false,
} satisfies Config; } satisfies Config;

64
architecture.mmd Normal file
View File

@@ -0,0 +1,64 @@
graph TD
subgraph Presentation
MAIN[main.rs]
ROUTES[routes/]
EXTRACT[extractors]
OPENAPI[OpenAPI / Scalar]
SPA[SPA static files]
end
subgraph Application
SONGS_UC[songs/]
TABS_UC[tabs/]
AUTH_UC[auth/]
end
subgraph Domain
MODELS[models/]
VO[value_objects/]
PORTS[ports/]
SERVICES[services/]
ERRORS[errors/]
end
subgraph Adapters
SQLITE[(SQLite)]
UG[UG Parser]
JWT[JWT + Argon2]
end
subgraph "Shared Crates"
API_TYPES[api-types]
INFRA[infra-wiring]
end
subgraph Frontend
REACT[React SPA]
end
MAIN --> ROUTES
MAIN --> OPENAPI
ROUTES --> EXTRACT
ROUTES --> SONGS_UC
ROUTES --> TABS_UC
ROUTES --> AUTH_UC
ROUTES --> API_TYPES
SONGS_UC --> PORTS
TABS_UC --> PORTS
AUTH_UC --> PORTS
PORTS --> MODELS
PORTS --> VO
PORTS --> ERRORS
SERVICES --> MODELS
SERVICES --> VO
SQLITE -.->|implements| PORTS
UG -.->|implements| PORTS
JWT -.->|implements| PORTS
MAIN --> INFRA
MAIN --> SPA
REACT -->|/api/*| ROUTES
SPA -->|serves| REACT

View File

@@ -0,0 +1,14 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { workspace = true }

View File

@@ -0,0 +1,100 @@
use std::sync::Arc;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher as ArgonHasher, PasswordVerifier};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use domain::errors::DomainError;
use domain::models::GeneratedToken;
use domain::value_objects::UserId;
pub struct JwtAuthService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
ttl_seconds: i64,
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
impl JwtAuthService {
pub fn new(secret: &str, ttl_seconds: u64) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
ttl_seconds: ttl_seconds as i64,
}
}
}
#[async_trait]
impl domain::ports::AuthService for JwtAuthService {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds);
let claims = Claims {
sub: user_id.value().to_string(),
exp: expires_at.timestamp() as usize,
};
let token = jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(GeneratedToken { token, expires_at })
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let data =
jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
Ok(UserId::from_uuid(uuid))
}
}
pub struct Argon2PasswordHasher;
#[async_trait]
impl domain::ports::PasswordHasher for Argon2PasswordHasher {
async fn hash(
&self,
plain_password: &str,
) -> Result<domain::value_objects::PasswordHash, DomainError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(plain_password.as_bytes(), &salt)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_string();
domain::value_objects::PasswordHash::new(hash)
}
async fn verify(
&self,
plain_password: &str,
hash: &domain::value_objects::PasswordHash,
) -> Result<bool, DomainError> {
let parsed = PasswordHash::new(hash.value())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain_password.as_bytes(), &parsed)
.is_ok())
}
}
pub fn create(
secret: &str,
ttl_seconds: u64,
) -> (
Arc<dyn domain::ports::AuthService>,
Arc<dyn domain::ports::PasswordHasher>,
) {
(
Arc::new(JwtAuthService::new(secret, ttl_seconds)),
Arc::new(Argon2PasswordHasher),
)
}

View File

@@ -0,0 +1,12 @@
[package]
name = "sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
sqlx = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
domain = { workspace = true }

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS refresh_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);

View File

@@ -0,0 +1,7 @@
mod refresh_sessions;
pub mod repository;
mod row;
mod search;
mod users;
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};

View File

@@ -0,0 +1,96 @@
use async_trait::async_trait;
use chrono::DateTime;
use domain::errors::DomainError;
use domain::models::RefreshSession;
use domain::value_objects::UserId;
use crate::repository::SqliteSongRepository;
#[derive(sqlx::FromRow)]
struct RefreshSessionRow {
id: String,
user_id: String,
token: String,
expires_at: String,
created_at: String,
}
fn row_to_session(row: RefreshSessionRow) -> Result<RefreshSession, DomainError> {
let id = uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let user_id = uuid::Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let expires_at = DateTime::parse_from_rfc3339(&row.expires_at)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_utc();
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_utc();
Ok(RefreshSession {
id,
user_id: UserId::from_uuid(user_id),
token: row.token,
expires_at,
created_at,
})
}
#[async_trait]
impl domain::ports::RefreshSessionRepository for SqliteSongRepository {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(session.id.to_string())
.bind(session.user_id.value().to_string())
.bind(&session.token)
.bind(session.expires_at.to_rfc3339())
.bind(session.created_at.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
let row = sqlx::query_as::<_, RefreshSessionRow>(
"SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?",
)
.bind(token)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_session).transpose()
}
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
.bind(token)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
.bind(user_id.value().to_string())
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let now = chrono::Utc::now().to_rfc3339();
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(result.rows_affected())
}
}

View File

@@ -1,12 +1,13 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong, DomainError, Song, SongRepositoryPort, SongSummary, SortField, SortOrder, StoredSong,
SortField, SortOrder, song_preview_chords, song_preview_chords,
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::str::FromStr;
use uuid::Uuid; use uuid::Uuid;
use crate::row::{SongRow, sort_clause, row_to_summary}; use crate::row::{SongRow, row_to_summary, sort_clause};
#[derive(Clone)] #[derive(Clone)]
pub struct SqliteSongRepository { pub struct SqliteSongRepository {
@@ -15,7 +16,9 @@ pub struct SqliteSongRepository {
impl SqliteSongRepository { impl SqliteSongRepository {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> { pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
let pool = SqlitePool::connect(database_url).await?; let opts =
sqlx::sqlite::SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
let pool = SqlitePool::connect_with(opts).await?;
sqlx::migrate!("./migrations").run(&pool).await?; sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self { pool }) Ok(Self { pool })
} }
@@ -23,14 +26,14 @@ impl SqliteSongRepository {
#[async_trait] #[async_trait]
impl SongRepositoryPort for SqliteSongRepository { impl SongRepositoryPort for SqliteSongRepository {
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> { async fn save(&self, song: &Song) -> Result<StoredSong, DomainError> {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let id_str = id.to_string(); let id_str = id.to_string();
let body = serde_json::to_string(song) let body = serde_json::to_string(song)
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let preview = song_preview_chords(song); let preview = song_preview_chords(song);
let preview_json = serde_json::to_string(&preview) let preview_json = serde_json::to_string(&preview)
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let original_key = song.meta.original_key.as_deref(); let original_key = song.meta.original_key.as_deref();
sqlx::query( sqlx::query(
@@ -44,12 +47,19 @@ impl SongRepositoryPort for SqliteSongRepository {
.bind(&body) .bind(&body)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(StoredSong { id, song: song.clone() }) Ok(StoredSong {
id,
song: song.clone(),
})
} }
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> { async fn list(
&self,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, DomainError> {
let sql = format!( let sql = format!(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}", "SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
sort_clause(sort, order) sort_clause(sort, order)
@@ -57,41 +67,41 @@ impl SongRepositoryPort for SqliteSongRepository {
let rows = sqlx::query_as::<_, SongRow>(&sql) let rows = sqlx::query_as::<_, SongRow>(&sql)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
rows.into_iter().map(row_to_summary).collect() rows.into_iter().map(row_to_summary).collect()
} }
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> { async fn get(&self, id: Uuid) -> Result<Option<Song>, DomainError> {
let id_str = id.to_string(); let id_str = id.to_string();
let row = sqlx::query_as::<_, SongRow>( let row = sqlx::query_as::<_, SongRow>(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?",
) )
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row { match row {
None => Ok(None), None => Ok(None),
Some(r) => { Some(r) => {
let song: Song = serde_json::from_str(&r.body) let song: Song = serde_json::from_str(&r.body)
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Some(song)) Ok(Some(song))
} }
} }
} }
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> { async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let id_str = id.to_string(); let id_str = id.to_string();
let result = sqlx::query("DELETE FROM songs WHERE id = ?") let result = sqlx::query("DELETE FROM songs WHERE id = ?")
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
Err(RepositoryError::NotFound) Err(DomainError::NotFound)
} else { } else {
Ok(()) Ok(())
} }
@@ -103,32 +113,38 @@ impl SongRepositoryPort for SqliteSongRepository {
title: Option<&str>, title: Option<&str>,
artist: Option<&str>, artist: Option<&str>,
original_key: Option<&str>, original_key: Option<&str>,
) -> Result<SongSummary, RepositoryError> { ) -> Result<SongSummary, DomainError> {
let id_str = id.to_string(); let id_str = id.to_string();
let row = sqlx::query_as::<_, SongRow>( let row = sqlx::query_as::<_, SongRow>(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?",
) )
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))? .map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(DomainError::NotFound)?;
let mut song: Song = serde_json::from_str(&row.body) let mut song: Song = serde_json::from_str(&row.body)
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
if let Some(t) = title { song.meta.title = t.to_string(); } if let Some(t) = title {
if let Some(a) = artist { song.meta.artist = a.to_string(); } song.meta.title = t.to_string();
if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } }
if let Some(a) = artist {
song.meta.artist = a.to_string();
}
if let Some(k) = original_key {
song.meta.original_key = Some(k.to_string());
}
let new_body = serde_json::to_string(&song) let new_body = serde_json::to_string(&song)
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let new_title = title.unwrap_or(&row.title); let new_title = title.unwrap_or(&row.title);
let new_artist = artist.unwrap_or(&row.artist); let new_artist = artist.unwrap_or(&row.artist);
let new_key: Option<&str> = original_key.or(row.original_key.as_deref()); let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
sqlx::query( sqlx::query(
"UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?",
) )
.bind(new_title) .bind(new_title)
.bind(new_artist) .bind(new_artist)
@@ -137,10 +153,10 @@ impl SongRepositoryPort for SqliteSongRepository {
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords) let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(SongSummary { Ok(SongSummary {
id, id,

View File

@@ -1,4 +1,4 @@
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder}; use domain::{DomainError, SongMeta, SongSummary, SortField, SortOrder};
use uuid::Uuid; use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -13,20 +13,20 @@ pub(crate) struct SongRow {
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str { pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
match (field, order) { match (field, order) {
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC", (SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC", (SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC", (SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC", (SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC", (SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC", (SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
} }
} }
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> { pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, DomainError> {
let id = Uuid::parse_str(&row.id) let id =
.map_err(|e| RepositoryError::Internal(e.to_string()))?; Uuid::parse_str(&row.id).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords) let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(SongSummary { Ok(SongSummary {
id, id,
meta: SongMeta { meta: SongMeta {

View File

@@ -1,13 +1,21 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder}; use domain::{DomainError, SongSearchPort, SongSummary, SortField, SortOrder};
use crate::repository::SqliteSongRepository; use crate::repository::SqliteSongRepository;
use crate::row::{SongRow, sort_clause, row_to_summary}; use crate::row::{SongRow, row_to_summary, sort_clause};
#[async_trait] #[async_trait]
impl SongSearchPort for SqliteSongRepository { impl SongSearchPort for SqliteSongRepository {
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> { async fn search(
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); &self,
query: &str,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, DomainError> {
let escaped = query
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let pattern = format!("%{}%", escaped); let pattern = format!("%{}%", escaped);
let sql = format!( let sql = format!(
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \ "SELECT id, title, artist, original_key, preview_chords, body FROM songs \
@@ -19,7 +27,7 @@ impl SongSearchPort for SqliteSongRepository {
.bind(&pattern) .bind(&pattern)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| RepositoryError::Internal(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
rows.into_iter().map(row_to_summary).collect() rows.into_iter().map(row_to_summary).collect()
} }

View File

@@ -0,0 +1,81 @@
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::models::User;
use domain::value_objects::{Email, PasswordHash, UserId, Username};
use crate::repository::SqliteSongRepository;
#[derive(sqlx::FromRow)]
struct UserRow {
id: String,
email: String,
username: String,
password_hash: String,
}
fn row_to_user(row: UserRow) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(User::from_persistence(
UserId::from_uuid(id),
Email::new(&row.email)?,
Username::new(&row.username)?,
PasswordHash::new(row.password_hash)?,
))
}
#[async_trait]
impl domain::ports::UserRepository for SqliteSongRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE email = ?",
)
.bind(email.value())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE username = ?",
)
.bind(username.value())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE id = ?",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(user.id().value().to_string())
.bind(user.email().value())
.bind(user.username().value())
.bind(user.password_hash().value())
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
}

View File

@@ -4,9 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
domain = { path = "../../domain" } domain = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
scraper = { workspace = true } scraper = { workspace = true }

View File

@@ -0,0 +1,52 @@
use async_trait::async_trait;
use domain::{FetchError, TabFetcherPort, TabSource};
pub struct UgTabFetcher {
client: reqwest::Client,
}
impl UgTabFetcher {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl Default for UgTabFetcher {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TabFetcherPort for UgTabFetcher {
async fn fetch(&self, source: TabSource) -> Result<String, FetchError> {
match source {
TabSource::File(path) => Ok(tokio::fs::read_to_string(&path).await?),
TabSource::Url(url) => {
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| FetchError::Network(e.to_string()))?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !content_type.contains("text/html") {
return Err(FetchError::InvalidContentType);
}
resp.text()
.await
.map_err(|e| FetchError::Network(e.to_string()))
}
}
}
}
#[cfg(test)]
#[path = "tests/fetcher.rs"]
mod tests;

View File

@@ -127,6 +127,14 @@ impl UgHtmlParser {
continue; continue;
} }
// Plain-text chord line (UG sometimes drops spans for later sections)
if let Some(parsed) = Self::try_parse_plain_chord_line(trimmed)
&& !parsed.is_empty()
{
pending_chords = parsed;
continue;
}
// Lyric line // Lyric line
if let Some(sec) = current_section.as_mut() { if let Some(sec) = current_section.as_mut() {
sec.lines.push(LyricLine { sec.lines.push(LyricLine {
@@ -159,6 +167,30 @@ impl UgHtmlParser {
} }
} }
/// Detect a plain-text chord line: every non-whitespace token must be a valid chord.
/// Returns None if the line contains non-chord words (i.e. it's a lyric line).
pub(crate) fn try_parse_plain_chord_line(line: &str) -> Option<Vec<ChordPosition>> {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.is_empty() {
return None;
}
let mut chords = Vec::new();
let mut pos = 0;
for token in &tokens {
let token_start = line[pos..].find(token).map(|i| pos + i)?;
pos = token_start + token.len();
let chord = Chord::parse(token)?;
chords.push(ChordPosition {
offset: token_start,
chord,
});
}
Some(chords)
}
/// Parse a chord line (raw HTML) into chord positions. /// Parse a chord line (raw HTML) into chord positions.
/// Walks text nodes and span[data-name] elements in order to compute offsets. /// Walks text nodes and span[data-name] elements in order to compute offsets.
fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> { fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> {
@@ -199,65 +231,5 @@ impl TabParserPort for UgHtmlParser {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/parser.rs"]
use super::*; mod tests;
use domain::TabParserPort;
fn sample_html(name: &str) -> String {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join(format!("samples/{}", name));
std::fs::read_to_string(path).unwrap()
}
#[test]
fn parses_artist_and_title() {
let parser = UgHtmlParser;
let html = sample_html("A DROP IN THE OCEAN.html");
let song = parser.parse(&html).unwrap();
assert_eq!(song.meta.artist, "Ron Pope");
assert_eq!(song.meta.title, "A Drop In The Ocean");
}
#[test]
fn capo_is_none_when_no_capo() {
let parser = UgHtmlParser;
let html = sample_html("A DROP IN THE OCEAN.html");
let song = parser.parse(&html).unwrap();
assert_eq!(song.meta.capo, None);
}
#[test]
fn parses_sections() {
let parser = UgHtmlParser;
let html = sample_html("A DROP IN THE OCEAN.html");
let song = parser.parse(&html).unwrap();
assert!(
song.sections.len() >= 3,
"expected >=3 sections, got {}",
song.sections.len()
);
assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus);
}
#[test]
fn parses_chord_positions() {
let parser = UgHtmlParser;
let html = sample_html("A DROP IN THE OCEAN.html");
let song = parser.parse(&html).unwrap();
// First section, first line: "A drop in the ocean,"
// Chord "Em" should be at offset 0 (or small offset from leading whitespace)
let first_line = &song.sections[0].lines[0];
assert_eq!(first_line.text, "A drop in the ocean,");
assert!(
first_line.chords[0].chord.name(true) == "Em",
"expected Em chord, got {}",
first_line.chords[0].chord.name(true)
);
}
}

View File

@@ -0,0 +1,19 @@
use super::*;
use domain::TabSource;
use std::path::PathBuf;
#[tokio::test]
async fn fetch_local_file() {
let fetcher = UgTabFetcher::new();
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("samples/drop_in_the_ocean.html");
let html = fetcher.fetch(TabSource::File(path)).await.unwrap();
assert!(html.contains("[Chorus]"));
assert!(html.contains("data-name=\"Em\""));
}

View File

@@ -0,0 +1,77 @@
use super::*;
use domain::TabParserPort;
fn sample_html(name: &str) -> String {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join(format!("samples/{}", name));
std::fs::read_to_string(path).unwrap()
}
#[test]
fn parses_artist_and_title() {
let parser = UgHtmlParser;
let html = sample_html("drop_in_the_ocean.html");
let song = parser.parse(&html).unwrap();
assert_eq!(song.meta.artist, "Ron Pope");
assert_eq!(song.meta.title, "A Drop In The Ocean");
}
#[test]
fn capo_is_none_when_no_capo() {
let parser = UgHtmlParser;
let html = sample_html("drop_in_the_ocean.html");
let song = parser.parse(&html).unwrap();
assert_eq!(song.meta.capo, None);
}
#[test]
fn parses_sections() {
let parser = UgHtmlParser;
let html = sample_html("drop_in_the_ocean.html");
let song = parser.parse(&html).unwrap();
assert!(
song.sections.len() >= 3,
"expected >=3 sections, got {}",
song.sections.len()
);
assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus);
}
#[test]
fn parses_chord_positions() {
let parser = UgHtmlParser;
let html = sample_html("drop_in_the_ocean.html");
let song = parser.parse(&html).unwrap();
let first_line = &song.sections[0].lines[0];
assert_eq!(first_line.text, "A drop in the ocean,");
assert!(
first_line.chords[0].chord.name(true) == "Em",
"expected Em chord, got {}",
first_line.chords[0].chord.name(true)
);
}
#[test]
fn parses_plain_text_chord_lines() {
let parser = UgHtmlParser;
let html = sample_html("drop_in_the_ocean.html");
let song = parser.parse(&html).unwrap();
let last_section = song.sections.last().unwrap();
let has_chords = last_section.lines.iter().any(|l| !l.chords.is_empty());
assert!(
has_chords,
"last section should have chords parsed from plain text"
);
}
#[test]
fn plain_chord_detection_does_not_eat_lyrics() {
assert!(UgHtmlParser::try_parse_plain_chord_line("A drop in the ocean").is_none());
assert!(UgHtmlParser::try_parse_plain_chord_line("Am G D").is_some());
}

View File

@@ -0,0 +1,8 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
utoipa = { version = "5", features = ["axum_extras"] }

View File

@@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
#[derive(Deserialize, ToSchema)]
pub struct ParseRequest {
pub source: Option<String>,
pub html: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListQuery {
pub q: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateSongRequest {
pub title: Option<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct GetSongQuery {
pub apply_capo: Option<bool>,
pub transpose: Option<i8>,
}
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize, ToSchema)]
pub struct LoginResponse {
pub token: String,
pub refresh_token: String,
pub user_id: String,
pub expires_at: String,
}
#[derive(Deserialize, ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
}
#[derive(Deserialize, ToSchema)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Serialize, ToSchema)]
pub struct RefreshResponse {
pub token: String,
pub refresh_token: String,
pub expires_at: String,
}
#[derive(Deserialize, ToSchema)]
pub struct LogoutRequest {
pub refresh_token: String,
}

View File

@@ -1,27 +0,0 @@
[package]
name = "api"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
axum = { version = "0.8.8", features = ["macros"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tower-http = { version = "0.6.8", features = [
"cors",
"fs",
"trace",
"tracing",
] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
rand = { workspace = true }
persistence = { path = "../infrastructure/persistence" }
common = { path = "../common" }
domain = { path = "../domain" }
ug-parser = { path = "../infrastructure/ug-parser" }

View File

@@ -1,52 +0,0 @@
use std::env;
#[derive(Debug)]
pub struct Config {
pub database_url: String,
pub host: String,
pub port: u16,
/// Parsed CORS origin policy
pub cors_origins: CorsOrigins,
}
#[derive(Debug)]
pub enum CorsOrigins {
/// Allow any origin (`CORS_ALLOWED_ORIGINS=*`)
Any,
/// Allow specific origins (`CORS_ALLOWED_ORIGINS=https://a.com,https://b.com`)
List(Vec<String>),
}
impl Config {
pub fn from_env() -> Self {
let database_url = env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
let port = env::var("PORT")
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(8000);
let cors_origins = match env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| "*".into())
.trim()
.to_string()
{
s if s == "*" => CorsOrigins::Any,
s => CorsOrigins::List(
s.split(',')
.map(|o| o.trim().to_string())
.filter(|o| !o.is_empty())
.collect(),
),
};
Self { database_url, host, port, cors_origins }
}
pub fn bind_addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}

View File

@@ -1,63 +0,0 @@
mod config;
mod routes;
use axum::{Router, http::HeaderValue, routing::{get, post}};
use common::{SongSearchService, SongService};
use config::{Config, CorsOrigins};
use persistence::SqliteRepositoryFactory;
use routes::songs::{create_song, delete_song, get_song, list_songs, update_song};
use routes::tabs::{AppState, parse_tab};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use ug_parser::{UgHtmlParser, UgTabFetcher};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let config = Config::from_env();
tracing::info!(?config, "starting with config");
let repo = SqliteRepositoryFactory::create(&config.database_url)
.await
.expect("failed to connect to database");
let songs = SongService::new(Box::new(repo.clone()));
let search = SongSearchService::new(Box::new(repo));
let state = Arc::new(AppState {
fetcher: Box::new(UgTabFetcher::new()),
parser: Box::new(UgHtmlParser),
songs,
search,
});
let cors = match config.cors_origins {
CorsOrigins::Any => CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
CorsOrigins::List(ref origins) => {
let parsed: Vec<HeaderValue> = origins
.iter()
.map(|o| o.parse().unwrap_or_else(|_| panic!("invalid CORS origin: {o}")))
.collect();
CorsLayer::new()
.allow_origin(parsed)
.allow_methods(Any)
.allow_headers(Any)
}
};
let app = Router::new()
.route("/tabs/parse", post(parse_tab))
.route("/songs", post(create_song).get(list_songs))
.route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song))
.layer(cors)
.with_state(state);
let addr = config.bind_addr();
let listener = tokio::net::TcpListener::bind(&addr).await
.unwrap_or_else(|e| panic!("failed to bind {addr}: {e}"));
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}

View File

@@ -1,142 +0,0 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use domain::{ChordTransposer, RepositoryError, SortField, SortOrder};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct ListQuery {
pub q: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
}
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
pub async fn create_song(
State(state): State<Arc<AppState>>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
let html = resolve_html(&state, body).await.map_err(|e| {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
})?;
let song = state.parser.parse(&html).map_err(|e| {
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
})?;
let stored = state.songs.save(&song).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))
})?;
Ok(Json(stored))
}
pub async fn list_songs(
State(state): State<Arc<AppState>>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
let sort = match params.sort.as_deref() {
Some("title") => SortField::Title,
Some("artist") => SortField::Artist,
_ => SortField::Date,
};
let order = match params.order.as_deref() {
Some("asc") => SortOrder::Asc,
_ => SortOrder::Desc,
};
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
state.search.search(&q, sort, order).await
} else {
state.songs.list(sort, order).await
};
result
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })))
}
#[derive(serde::Deserialize)]
pub struct UpdateSongRequest {
pub title: Option<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}
pub async fn update_song(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<UpdateSongRequest>,
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
let uuid = Uuid::parse_str(&id).map_err(|_| {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
})?;
state.songs
.update_meta(
uuid,
body.title.as_deref(),
body.artist.as_deref(),
body.original_key.as_deref(),
)
.await
.map(Json)
.map_err(|e| match e {
domain::RepositoryError::NotFound =>
(StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })),
e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })),
})
}
#[derive(Deserialize)]
pub struct GetSongQuery {
pub apply_capo: Option<bool>,
}
pub async fn get_song(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<GetSongQuery>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
let uuid = Uuid::parse_str(&id).map_err(|_| {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
})?;
let song = match state.songs.get(uuid).await {
Ok(Some(s)) => s,
Ok(None) => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
};
let song = if params.apply_capo.unwrap_or(false) {
if let Some(capo) = song.meta.capo {
ChordTransposer.transpose_song(&song, capo as i8)
} else {
song
}
} else {
song
};
Ok(Json(song))
}
pub async fn delete_song(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let uuid = Uuid::parse_str(&id).map_err(|_| {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
})?;
match state.songs.delete(uuid).await {
Ok(()) => Ok(StatusCode::NO_CONTENT),
Err(RepositoryError::NotFound) => {
Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })))
}
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
}
}

View File

@@ -1,53 +0,0 @@
use axum::{extract::State, http::StatusCode, Json};
use domain::{TabFetcherPort, TabParserPort, TabSource};
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, sync::Arc};
pub struct AppState {
pub fetcher: Box<dyn TabFetcherPort>,
pub parser: Box<dyn TabParserPort>,
pub songs: common::SongService,
pub search: common::SongSearchService,
}
#[derive(Deserialize)]
pub struct ParseRequest {
pub source: Option<String>,
pub html: Option<String>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
pub async fn resolve_html(state: &AppState, body: ParseRequest) -> Result<String, String> {
if let Some(raw_html) = body.html {
Ok(raw_html)
} else if let Some(source) = body.source {
let tab_source = if source.starts_with("file://") {
let path = source.trim_start_matches("file://");
TabSource::File(PathBuf::from(path))
} else {
TabSource::Url(source)
};
state.fetcher.fetch(tab_source).await.map_err(|e| e.to_string())
} else {
Err("Provide either 'source' or 'html'".into())
}
}
pub async fn parse_tab(
State(state): State<Arc<AppState>>,
Json(body): Json<ParseRequest>,
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
let html = resolve_html(&state, body).await.map_err(|e| {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
})?;
let song = state.parser.parse(&html).map_err(|e| {
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
})?;
Ok(Json(song))
}

View File

@@ -0,0 +1,10 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
uuid = { workspace = true }
tracing = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
domain = { workspace = true }

View File

@@ -0,0 +1,18 @@
pub struct RegisterCommand {
pub email: String,
pub username: String,
pub password: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RefreshCommand {
pub refresh_token: String,
}
pub struct LogoutCommand {
pub refresh_token: String,
}

View File

@@ -0,0 +1,27 @@
use std::sync::Arc;
use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository};
pub struct RegisterDeps {
pub user_repo: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub allow_registration: bool,
}
pub struct LoginDeps {
pub user_repo: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub auth_service: Arc<dyn AuthService>,
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
pub refresh_ttl_seconds: u64,
}
pub struct RefreshDeps {
pub auth_service: Arc<dyn AuthService>,
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
pub refresh_ttl_seconds: u64,
}
pub struct LogoutDeps {
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
}

View File

@@ -0,0 +1,55 @@
use chrono::{Duration, Utc};
use domain::errors::DomainError;
use domain::models::RefreshSession;
use domain::value_objects::{Email, UserId};
use uuid::Uuid;
use super::commands::LoginCommand;
use super::deps::LoginDeps;
pub struct LoginResult {
pub access_token: String,
pub refresh_token: String,
pub user_id: UserId,
pub expires_at: String,
}
pub async fn execute(deps: &LoginDeps, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
let email = Email::new(&cmd.email)?;
let user = deps.user_repo.find_by_email(&email).await?.ok_or_else(|| {
tracing::warn!(email = cmd.email, "login attempt with unknown email");
DomainError::Unauthorized("invalid credentials".into())
})?;
let valid = deps
.password_hasher
.verify(&cmd.password, user.password_hash())
.await?;
if !valid {
tracing::warn!(email = cmd.email, "login attempt with wrong password");
return Err(DomainError::Unauthorized("invalid credentials".into()));
}
let generated = deps.auth_service.generate_token(user.id()).await?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
let session = RefreshSession {
id: Uuid::new_v4(),
user_id: *user.id(),
token: refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
deps.refresh_repo.create(&session).await?;
tracing::info!(user_id = %user.id().value(), "user logged in");
Ok(LoginResult {
access_token: generated.token,
refresh_token,
user_id: *user.id(),
expires_at: generated.expires_at.to_rfc3339(),
})
}

View File

@@ -0,0 +1,9 @@
use domain::errors::DomainError;
use super::commands::LogoutCommand;
use super::deps::LogoutDeps;
pub async fn execute(deps: &LogoutDeps, cmd: LogoutCommand) -> Result<(), DomainError> {
tracing::debug!("user logged out");
deps.refresh_repo.revoke(&cmd.refresh_token).await
}

View File

@@ -0,0 +1,6 @@
pub mod commands;
pub mod deps;
pub mod login;
pub mod logout;
pub mod refresh;
pub mod register;

View File

@@ -0,0 +1,52 @@
use chrono::{Duration, Utc};
use domain::errors::DomainError;
use domain::models::RefreshSession;
use uuid::Uuid;
use super::commands::RefreshCommand;
use super::deps::RefreshDeps;
pub struct RefreshResult {
pub access_token: String,
pub refresh_token: String,
pub expires_at: String,
}
pub async fn execute(
deps: &RefreshDeps,
cmd: RefreshCommand,
) -> Result<RefreshResult, DomainError> {
let session = deps
.refresh_repo
.get_by_token(&cmd.refresh_token)
.await?
.ok_or_else(|| DomainError::Unauthorized("invalid refresh token".into()))?;
if session.expires_at < Utc::now() {
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
return Err(DomainError::Unauthorized("refresh token expired".into()));
}
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
let generated = deps.auth_service.generate_token(&session.user_id).await?;
let new_refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
let new_session = RefreshSession {
id: Uuid::new_v4(),
user_id: session.user_id,
token: new_refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
deps.refresh_repo.create(&new_session).await?;
tracing::debug!(user_id = %session.user_id.value(), "token refreshed");
Ok(RefreshResult {
access_token: generated.token,
refresh_token: new_refresh_token,
expires_at: generated.expires_at.to_rfc3339(),
})
}

View File

@@ -0,0 +1,38 @@
use domain::errors::DomainError;
use domain::models::User;
use domain::value_objects::{Email, Password, Username};
use super::commands::RegisterCommand;
use super::deps::RegisterDeps;
pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), DomainError> {
if !deps.allow_registration {
tracing::warn!("registration attempt while disabled");
return Err(DomainError::Unauthorized("registration is disabled".into()));
}
let password = Password::new(&cmd.password)?;
let email = Email::new(&cmd.email)?;
let username = Username::new(&cmd.username)?;
if deps.user_repo.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError(
"email already registered".into(),
));
}
if deps.user_repo.find_by_username(&username).await?.is_some() {
return Err(DomainError::ValidationError(
"username already taken".into(),
));
}
let hash = deps.password_hasher.hash(password.value()).await?;
let user = User::new(email, username, hash);
tracing::info!(user_id = %user.id().value(), "new user registered");
deps.user_repo.save(&user).await?;
Ok(())
}

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod songs; pub mod songs;
pub mod tabs; pub mod tabs;

View File

@@ -0,0 +1,17 @@
use domain::models::Song;
use uuid::Uuid;
pub struct SaveSongCommand {
pub song: Song,
}
pub struct DeleteSongCommand {
pub id: Uuid,
}
pub struct UpdateSongMetaCommand {
pub id: Uuid,
pub title: Option<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}

View File

@@ -0,0 +1,10 @@
use domain::errors::DomainError;
use super::commands::DeleteSongCommand;
use super::deps::SongCommandDeps;
pub async fn execute(deps: &SongCommandDeps, cmd: DeleteSongCommand) -> Result<(), DomainError> {
deps.repo.delete(cmd.id).await?;
tracing::debug!(id = %cmd.id, "song deleted");
Ok(())
}

View File

@@ -0,0 +1,11 @@
use domain::ports::{SongRepositoryPort, SongSearchPort};
use std::sync::Arc;
pub struct SongCommandDeps {
pub repo: Arc<dyn SongRepositoryPort>,
}
pub struct SongQueryDeps {
pub repo: Arc<dyn SongRepositoryPort>,
pub search: Arc<dyn SongSearchPort>,
}

View File

@@ -0,0 +1,12 @@
use domain::errors::DomainError;
use domain::models::Song;
use super::deps::SongQueryDeps;
use super::queries::GetSongQuery;
pub async fn execute(
deps: &SongQueryDeps,
query: GetSongQuery,
) -> Result<Option<Song>, DomainError> {
deps.repo.get(query.id).await
}

View File

@@ -0,0 +1,12 @@
use domain::errors::DomainError;
use domain::models::SongSummary;
use super::deps::SongQueryDeps;
use super::queries::ListSongsQuery;
pub async fn execute(
deps: &SongQueryDeps,
query: ListSongsQuery,
) -> Result<Vec<SongSummary>, DomainError> {
deps.repo.list(query.sort, query.order).await
}

View File

@@ -0,0 +1,9 @@
pub mod commands;
pub mod delete_song;
pub mod deps;
pub mod get_song;
pub mod list_songs;
pub mod queries;
pub mod save_song;
pub mod search_songs;
pub mod update_meta;

View File

@@ -0,0 +1,17 @@
use domain::value_objects::{SortField, SortOrder};
use uuid::Uuid;
pub struct ListSongsQuery {
pub sort: SortField,
pub order: SortOrder,
}
pub struct GetSongQuery {
pub id: Uuid,
}
pub struct SearchSongsQuery {
pub query: String,
pub sort: SortField,
pub order: SortOrder,
}

View File

@@ -0,0 +1,15 @@
use domain::errors::DomainError;
use domain::models::StoredSong;
use super::commands::SaveSongCommand;
use super::deps::SongCommandDeps;
pub async fn execute(
deps: &SongCommandDeps,
cmd: SaveSongCommand,
) -> Result<StoredSong, DomainError> {
let title = cmd.song.meta.title.clone();
let result = deps.repo.save(&cmd.song).await?;
tracing::debug!(id = %result.id, title, "song saved");
Ok(result)
}

View File

@@ -0,0 +1,14 @@
use domain::errors::DomainError;
use domain::models::SongSummary;
use super::deps::SongQueryDeps;
use super::queries::SearchSongsQuery;
pub async fn execute(
deps: &SongQueryDeps,
query: SearchSongsQuery,
) -> Result<Vec<SongSummary>, DomainError> {
deps.search
.search(&query.query, query.sort, query.order)
.await
}

View File

@@ -0,0 +1,22 @@
use domain::errors::DomainError;
use domain::models::SongSummary;
use super::commands::UpdateSongMetaCommand;
use super::deps::SongCommandDeps;
pub async fn execute(
deps: &SongCommandDeps,
cmd: UpdateSongMetaCommand,
) -> Result<SongSummary, DomainError> {
let result = deps
.repo
.update_meta(
cmd.id,
cmd.title.as_deref(),
cmd.artist.as_deref(),
cmd.original_key.as_deref(),
)
.await?;
tracing::debug!(id = %cmd.id, "song meta updated");
Ok(result)
}

View File

@@ -0,0 +1,4 @@
pub struct ParseTabCommand {
pub source: Option<String>,
pub html: Option<String>,
}

View File

@@ -0,0 +1,7 @@
use domain::ports::{TabFetcherPort, TabParserPort};
use std::sync::Arc;
pub struct ParseTabDeps {
pub fetcher: Arc<dyn TabFetcherPort>,
pub parser: Arc<dyn TabParserPort>,
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod deps;
pub mod parse_tab;

View File

@@ -0,0 +1,45 @@
use std::path::PathBuf;
use domain::errors::DomainError;
use domain::models::Song;
use domain::ports::TabSource;
use super::commands::ParseTabCommand;
use super::deps::ParseTabDeps;
pub async fn execute(deps: &ParseTabDeps, cmd: ParseTabCommand) -> Result<Song, DomainError> {
let html = if let Some(raw_html) = cmd.html {
tracing::debug!("parsing from raw HTML input");
Ok(raw_html)
} else if let Some(source) = cmd.source {
let tab_source = if source.starts_with("file://") {
let path = source.trim_start_matches("file://");
TabSource::File(PathBuf::from(path))
} else {
tracing::debug!(url = source, "fetching tab from URL");
TabSource::Url(source)
};
deps.fetcher
.fetch(tab_source)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
} else {
Err(DomainError::ValidationError(
"Provide either 'source' or 'html'".into(),
))
}?;
let song = deps
.parser
.parse(&html)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
tracing::debug!(
title = song.meta.title,
artist = song.meta.artist,
sections = song.sections.len(),
"tab parsed"
);
Ok(song)
}

View File

@@ -1,17 +0,0 @@
[package]
name = "common"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
rand = { workspace = true }
async-trait = { workspace = true }
domain = { path = "../domain" }

View File

@@ -1,52 +0,0 @@
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder};
use uuid::Uuid;
pub struct SongService {
repo: Box<dyn SongRepositoryPort>,
}
impl SongService {
pub fn new(repo: Box<dyn SongRepositoryPort>) -> Self {
Self { repo }
}
pub async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
self.repo.save(song).await
}
pub async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
self.repo.list(sort, order).await
}
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
self.repo.get(id).await
}
pub async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
self.repo.delete(id).await
}
pub async fn update_meta(
&self,
id: Uuid,
title: Option<&str>,
artist: Option<&str>,
original_key: Option<&str>,
) -> Result<domain::SongSummary, domain::RepositoryError> {
self.repo.update_meta(id, title, artist, original_key).await
}
}
pub struct SongSearchService {
search: Box<dyn SongSearchPort>,
}
impl SongSearchService {
pub fn new(search: Box<dyn SongSearchPort>) -> Self {
Self { search }
}
pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
self.search.search(query, sort, order).await
}
}

View File

@@ -4,10 +4,9 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
rand = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
email_address = "0.2"

View File

@@ -1,92 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::Note;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct Chord {
pub root: Note,
pub descriptor: Option<String>,
}
impl Chord {
pub fn parse(s: &str) -> Option<Self> {
let (root, consumed) = Note::parse_prefix(s)?;
let descriptor = if consumed < s.len() {
Some(s[consumed..].to_string())
} else {
None
};
Some(Chord { root, descriptor })
}
/// Display chord name. use_sharps=true → "F#m", false → "Gbm".
pub fn name(&self, use_sharps: bool) -> String {
let root_str = if use_sharps {
self.root.to_sharp_str()
} else {
self.root.to_flat_str()
};
match &self.descriptor {
Some(d) => format!("{}{}", root_str, d),
None => root_str.to_string(),
}
}
}
impl From<Chord> for String {
fn from(c: Chord) -> String {
c.name(true)
}
}
impl TryFrom<String> for Chord {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple() {
let c = Chord::parse("Em").unwrap();
assert_eq!(c.root, crate::Note::E);
assert_eq!(c.descriptor.as_deref(), Some("m"));
}
#[test]
fn parse_no_descriptor() {
let c = Chord::parse("G").unwrap();
assert_eq!(c.root, crate::Note::G);
assert!(c.descriptor.is_none());
}
#[test]
fn parse_flat_root() {
let c = Chord::parse("Bb").unwrap();
assert_eq!(c.root, crate::Note::ASharpBFlat);
assert!(c.descriptor.is_none());
}
#[test]
fn name_sharp() {
let c = Chord { root: crate::Note::FSharpGFlat, descriptor: Some("m".into()) };
assert_eq!(c.name(true), "F#m");
}
#[test]
fn name_flat() {
let c = Chord { root: crate::Note::ASharpBFlat, descriptor: None };
assert_eq!(c.name(false), "Bb");
}
#[test]
fn parse_flat_with_descriptor() {
let c = Chord::parse("Bbm").unwrap();
assert_eq!(c.root, crate::Note::ASharpBFlat);
assert_eq!(c.descriptor.as_deref(), Some("m"));
}
}

View File

@@ -0,0 +1,19 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DomainError {
#[error("Entity not found")]
NotFound,
#[error("Business rule violation: {0}")]
ValidationError(String),
#[error("Infrastructure failure: {0}")]
InfrastructureError(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
}

View File

@@ -1,13 +1,19 @@
pub mod note; pub mod errors;
pub mod chord; pub mod models;
pub mod song;
pub mod ports; pub mod ports;
pub mod transposer; pub mod services;
pub mod value_objects;
pub use note::Note; pub use errors::DomainError;
pub use chord::Chord; pub use models::{
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song}; ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta,
pub use song::{song_preview_chords, StoredSong, SongSummary}; SongSummary, StoredSong, User, song_preview_chords,
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource}; };
pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder}; pub use ports::{
pub use transposer::{ChordTransposer, TransposeError}; AuthService, FetchError, ParseError, PasswordHasher, RefreshSessionRepository,
SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource, UserRepository,
};
pub use services::{ChordTransposer, TransposeError};
pub use value_objects::{
Chord, Email, Note, Password, PasswordHash, SortField, SortOrder, UserId, Username,
};

View File

@@ -0,0 +1,7 @@
pub mod refresh_session;
pub mod song;
pub mod user;
pub use refresh_session::*;
pub use song::*;
pub use user::*;

View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::value_objects::UserId;
pub struct GeneratedToken {
pub token: String,
pub expires_at: DateTime<Utc>,
}
pub struct RefreshSession {
pub id: Uuid,
pub user_id: UserId,
pub token: String,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::Chord; use uuid::Uuid;
use crate::value_objects::Chord;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChordPosition { pub struct ChordPosition {
@@ -16,8 +18,14 @@ pub struct LyricLine {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SectionKind { pub enum SectionKind {
Verse, Chorus, Bridge, PreChorus, Verse,
Intro, Outro, Break, Tab, Chorus,
Bridge,
PreChorus,
Intro,
Outro,
Break,
Tab,
Other(String), Other(String),
} }
@@ -60,8 +68,6 @@ pub struct Song {
pub sections: Vec<Section>, pub sections: Vec<Section>,
} }
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredSong { pub struct StoredSong {
pub id: Uuid, pub id: Uuid,
@@ -95,28 +101,5 @@ pub fn song_preview_chords(song: &Song) -> Vec<String> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/song.rs"]
use super::*; mod tests;
use crate::{Chord, Note};
#[test]
fn lyric_line_chord_positions() {
let line = LyricLine {
text: "A drop in the ocean".into(),
chords: vec![
ChordPosition { offset: 0, chord: Chord { root: Note::E, descriptor: Some("m".into()) } },
ChordPosition { offset: 8, chord: Chord { root: Note::C, descriptor: None } },
],
};
assert_eq!(line.chords[0].offset, 0);
assert_eq!(line.chords[1].offset, 8);
}
#[test]
fn section_kind_from_label() {
assert_eq!(SectionKind::from_label("Chorus"), SectionKind::Chorus);
assert_eq!(SectionKind::from_label("Pre-Chorus"), SectionKind::PreChorus);
assert_eq!(SectionKind::from_label("Tab"), SectionKind::Tab);
assert_eq!(SectionKind::from_label("Riff"), SectionKind::Other("Riff".into()));
}
}

View File

@@ -0,0 +1,50 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username};
#[derive(Debug, Clone)]
pub struct User {
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
}
impl User {
pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self {
Self {
id: UserId::generate(),
email,
username,
password_hash,
}
}
pub fn from_persistence(
id: UserId,
email: Email,
username: Username,
password_hash: PasswordHash,
) -> Self {
Self {
id,
email,
username,
password_hash,
}
}
pub fn id(&self) -> &UserId {
&self.id
}
pub fn email(&self) -> &Email {
&self.email
}
pub fn username(&self) -> &Username {
&self.username
}
pub fn password_hash(&self) -> &PasswordHash {
&self.password_hash
}
}

View File

@@ -1,111 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Note {
A, ASharpBFlat, B, C, CSharpDFlat, D,
DSharpEFlat, E, F, FSharpGFlat, G, GSharpAFlat,
}
impl Note {
pub fn semitone(&self) -> u8 {
match self {
Note::C => 0, Note::CSharpDFlat => 1, Note::D => 2,
Note::DSharpEFlat => 3, Note::E => 4, Note::F => 5,
Note::FSharpGFlat => 6, Note::G => 7, Note::GSharpAFlat => 8,
Note::A => 9, Note::ASharpBFlat => 10, Note::B => 11,
}
}
pub fn from_semitone(s: u8) -> Note {
match s % 12 {
0 => Note::C, 1 => Note::CSharpDFlat, 2 => Note::D,
3 => Note::DSharpEFlat, 4 => Note::E, 5 => Note::F,
6 => Note::FSharpGFlat, 7 => Note::G, 8 => Note::GSharpAFlat,
9 => Note::A, 10 => Note::ASharpBFlat, 11 => Note::B,
_ => unreachable!(),
}
}
pub fn to_sharp_str(&self) -> &'static str {
match self {
Note::C => "C", Note::CSharpDFlat => "C#", Note::D => "D",
Note::DSharpEFlat => "D#", Note::E => "E", Note::F => "F",
Note::FSharpGFlat => "F#", Note::G => "G", Note::GSharpAFlat => "G#",
Note::A => "A", Note::ASharpBFlat => "A#", Note::B => "B",
}
}
pub fn to_flat_str(&self) -> &'static str {
match self {
Note::C => "C", Note::CSharpDFlat => "Db", Note::D => "D",
Note::DSharpEFlat => "Eb", Note::E => "E", Note::F => "F",
Note::FSharpGFlat => "Gb", Note::G => "G", Note::GSharpAFlat => "Ab",
Note::A => "A", Note::ASharpBFlat => "Bb", Note::B => "B",
}
}
/// Parse just the note portion from the start of a string.
/// Returns (Note, chars_consumed) or None.
pub fn parse_prefix(s: &str) -> Option<(Note, usize)> {
let mut chars = s.chars();
let root = match chars.next()? {
'A' => Note::A, 'B' => Note::B, 'C' => Note::C, 'D' => Note::D,
'E' => Note::E, 'F' => Note::F, 'G' => Note::G, _ => return None,
};
match chars.next() {
Some('#') => Some((Self::sharp_of(root), 2)),
Some('b') if s.len() > 1 => {
let flatted = Self::flat_of(root)?;
Some((flatted, 2))
}
_ => Some((root, 1)),
}
}
pub fn parse(s: &str) -> Option<Note> {
let (note, consumed) = Self::parse_prefix(s)?;
if consumed == s.len() { Some(note) } else { None }
}
fn sharp_of(root: Note) -> Note {
Note::from_semitone((root.semitone() + 1) % 12)
}
fn flat_of(root: Note) -> Option<Note> {
Some(Note::from_semitone((root.semitone() + 11) % 12))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semitone_roundtrip() {
assert_eq!(Note::from_semitone(Note::A.semitone()), Note::A);
assert_eq!(Note::from_semitone(Note::FSharpGFlat.semitone()), Note::FSharpGFlat);
}
#[test]
fn parse_cb_enharmonic() {
assert_eq!(Note::parse("Cb"), Some(Note::B));
}
#[test]
fn sharp_display() {
assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#");
assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#");
}
#[test]
fn flat_display() {
assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb");
assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db");
}
#[test]
fn parse_note() {
assert_eq!(Note::parse("F#"), Some(Note::FSharpGFlat));
assert_eq!(Note::parse("Gb"), Some(Note::FSharpGFlat));
assert_eq!(Note::parse("A"), Some(Note::A));
assert_eq!(Note::parse("X"), None);
}
}

View File

@@ -1,83 +0,0 @@
use std::path::PathBuf;
use thiserror::Error;
use async_trait::async_trait;
use crate::song::Song;
#[derive(Debug, Clone)]
pub enum TabSource {
File(PathBuf),
Url(String),
}
#[derive(Debug, Error)]
pub enum FetchError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Network error: {0}")]
Network(String),
#[error("Response is not HTML")]
InvalidContentType,
}
#[derive(Debug, Error)]
pub enum ParseError {
#[error("Tab content not found in HTML")]
MissingContent,
#[error("Malformed HTML: {0}")]
MalformedHtml(String),
}
#[async_trait]
pub trait TabFetcherPort: Send + Sync {
async fn fetch(&self, source: TabSource) -> Result<String, FetchError>;
}
pub trait TabParserPort: Send + Sync {
fn parse(&self, html: &str) -> Result<Song, ParseError>;
}
use uuid::Uuid;
use crate::song::{StoredSong, SongSummary};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortField {
#[default]
Date,
Title,
Artist,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
#[default]
Desc,
Asc,
}
#[derive(Debug, Error)]
pub enum RepositoryError {
#[error("Song not found")]
NotFound,
#[error("Database error: {0}")]
Internal(String),
}
#[async_trait]
pub trait SongRepositoryPort: Send + Sync {
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
async fn update_meta(
&self,
id: Uuid,
title: Option<&str>,
artist: Option<&str>,
original_key: Option<&str>,
) -> Result<SongSummary, RepositoryError>;
}
#[async_trait]
pub trait SongSearchPort: Send + Sync {
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
}

View File

@@ -0,0 +1,34 @@
use async_trait::async_trait;
use crate::errors::DomainError;
use crate::models::{GeneratedToken, RefreshSession, User};
use crate::value_objects::{Email, PasswordHash, UserId, Username};
#[async_trait]
pub trait AuthService: Send + Sync {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
}
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
}
#[async_trait]
pub trait RefreshSessionRepository: Send + Sync {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
async fn delete_expired(&self) -> Result<u64, DomainError>;
}

View File

@@ -0,0 +1,7 @@
pub mod auth;
pub mod repository;
pub mod tab_source;
pub use auth::*;
pub use repository::*;
pub use tab_source::*;

View File

@@ -0,0 +1,35 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::errors::DomainError;
use crate::models::{Song, SongSummary, StoredSong};
use crate::value_objects::{SortField, SortOrder};
#[async_trait]
pub trait SongRepositoryPort: Send + Sync {
async fn save(&self, song: &Song) -> Result<StoredSong, DomainError>;
async fn list(
&self,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, DomainError>;
async fn get(&self, id: Uuid) -> Result<Option<Song>, DomainError>;
async fn delete(&self, id: Uuid) -> Result<(), DomainError>;
async fn update_meta(
&self,
id: Uuid,
title: Option<&str>,
artist: Option<&str>,
original_key: Option<&str>,
) -> Result<SongSummary, DomainError>;
}
#[async_trait]
pub trait SongSearchPort: Send + Sync {
async fn search(
&self,
query: &str,
sort: SortField,
order: SortOrder,
) -> Result<Vec<SongSummary>, DomainError>;
}

View File

@@ -0,0 +1,37 @@
use crate::models::Song;
use async_trait::async_trait;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Clone)]
pub enum TabSource {
File(PathBuf),
Url(String),
}
#[derive(Debug, Error)]
pub enum FetchError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Network error: {0}")]
Network(String),
#[error("Response is not HTML")]
InvalidContentType,
}
#[derive(Debug, Error)]
pub enum ParseError {
#[error("Tab content not found in HTML")]
MissingContent,
#[error("Malformed HTML: {0}")]
MalformedHtml(String),
}
#[async_trait]
pub trait TabFetcherPort: Send + Sync {
async fn fetch(&self, source: TabSource) -> Result<String, FetchError>;
}
pub trait TabParserPort: Send + Sync {
fn parse(&self, html: &str) -> Result<Song, ParseError>;
}

Some files were not shown because too many files have changed in this diff Show More