Compare commits
21 Commits
a520251dab
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 792c9bf9ec | |||
| f513061405 | |||
| 9f844bac2e | |||
| 6912653906 | |||
| bb4d07055f | |||
| af328deac1 | |||
| 381f273f10 | |||
| 2a628db521 | |||
| 2f635c9b24 | |||
| fb936a64b0 | |||
| 2bfd0c7984 | |||
| bfaea4a4b0 | |||
| 2b702d88e4 | |||
| e89cf48b34 | |||
| aa31e1bdcd | |||
| ff39680106 | |||
| 35b82fc5b3 | |||
| c4e9076b11 | |||
| 7bd27d9b9c | |||
| 13031347cc | |||
| d13df586dd |
@@ -1,5 +1,6 @@
|
||||
/target
|
||||
/app
|
||||
/app/node_modules
|
||||
/app/build
|
||||
.superpowers/
|
||||
.git/
|
||||
.claude/
|
||||
|
||||
42
.gitea/workflows/ci.yml
Normal file
42
.gitea/workflows/ci.yml
Normal 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
80
.github/workflows/ci.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
/target
|
||||
.env
|
||||
.superpowers/
|
||||
.superpowers/
|
||||
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
415
Cargo.lock
generated
415
Cargo.lock
generated
@@ -17,6 +17,15 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -24,26 +33,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
name = "api-types"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"common",
|
||||
"domain",
|
||||
"persistence",
|
||||
"rand 0.10.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"domain",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ug-parser",
|
||||
"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]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -70,6 +88,20 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"jsonwebtoken",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -183,6 +215,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -241,14 +282,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.0",
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -270,23 +314,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -337,15 +364,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -420,6 +438,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -458,12 +482,11 @@ dependencies = [
|
||||
name = "domain"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"rand 0.10.0",
|
||||
"chrono",
|
||||
"email_address",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -509,6 +532,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -745,7 +777,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -960,6 +991,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
@@ -1081,6 +1136,10 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infra-wiring"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1169,6 +1228,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1350,6 +1424,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -1366,6 +1450,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -1437,6 +1527,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -1452,23 +1563,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
@@ -1569,6 +1663,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1584,6 +1684,29 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -1701,17 +1824,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -1750,12 +1862,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1774,6 +1880,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -2126,7 +2244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -2137,7 +2255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -2176,6 +2294,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
@@ -2226,6 +2356,18 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
@@ -2580,6 +2722,36 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -2801,12 +2973,10 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
name = "ug-parser"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"domain",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -2885,6 +3055,42 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
@@ -3119,6 +3325,41 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
31
Cargo.toml
31
Cargo.toml
@@ -1,29 +1,42 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/api",
|
||||
"crates/common",
|
||||
"crates/adapters/auth",
|
||||
"crates/adapters/sqlite",
|
||||
"crates/adapters/ug-parser",
|
||||
"crates/api-types",
|
||||
"crates/application",
|
||||
"crates/domain",
|
||||
"crates/infrastructure/persistence",
|
||||
"crates/infrastructure/ug-parser",
|
||||
"crates/infra-wiring",
|
||||
"crates/presentation",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.102"
|
||||
reqwest = "0.13.2"
|
||||
tokio = { version = "1.51.0", features = ["full"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
anyhow = "1.0.102"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.51.0", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
async-trait = "0.1.89"
|
||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||
rand = "0.10.0"
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
|
||||
async-trait = "0.1.89"
|
||||
reqwest = "0.13.2"
|
||||
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]
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
lto = true
|
||||
|
||||
24
Dockerfile
24
Dockerfile
@@ -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
|
||||
COPY . .
|
||||
|
||||
# Build the release binary
|
||||
RUN cargo build --release -p api
|
||||
RUN cargo build --release -p presentation
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL, CA certs
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
ca-certificates \
|
||||
libsqlite3-0 \
|
||||
&& 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
|
||||
|
||||
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
||||
ENV SPA_DIR=/app/spa
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["./api"]
|
||||
CMD ["./presentation"]
|
||||
|
||||
22
LICENSE
22
LICENSE
@@ -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
44
Makefile
Normal 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
|
||||
78
README.md
78
README.md
@@ -1,3 +1,79 @@
|
||||
# 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).
|
||||
|
||||
@@ -1 +1 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_API_URL=/api
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## 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:
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
Create a production build:
|
||||
## Build
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# 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.
|
||||
- React 19 + React Router 7 (SPA mode)
|
||||
- Tailwind CSS 4 + shadcn/ui
|
||||
- Tonal (music theory / chord voicings)
|
||||
- Vite
|
||||
|
||||
114
app/app/components/auto-scroll-controls.tsx
Normal file
114
app/app/components/auto-scroll-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,104 @@
|
||||
import { useState } from "react";
|
||||
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 { cn } from "~/lib/utils";
|
||||
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() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className="border-t bg-background shrink-0">
|
||||
<div className="max-w-lg mx-auto flex items-center">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Music className="w-5 h-5" />
|
||||
<span>Library</span>
|
||||
</NavLink>
|
||||
<>
|
||||
<nav className="border-t bg-background shrink-0">
|
||||
<div className="max-w-lg mx-auto flex items-center">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Music className="w-5 h-5" />
|
||||
<span>Library</span>
|
||||
</NavLink>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{resolvedTheme === "dark" ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="flex items-center gap-1 mr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{resolvedTheme === "dark" ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ function ChordRow({
|
||||
onChordClick?: (chord: string) => void;
|
||||
}) {
|
||||
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) => (
|
||||
<span
|
||||
key={i}
|
||||
@@ -85,15 +85,17 @@ function LineBlock({
|
||||
|
||||
function SectionBlock({
|
||||
section,
|
||||
index,
|
||||
sizeClass,
|
||||
onChordClick,
|
||||
}: {
|
||||
section: Section;
|
||||
index: number;
|
||||
sizeClass: string;
|
||||
onChordClick?: (chord: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-6" id={`section-${index}`}>
|
||||
{section.label && (
|
||||
<p className="text-xs text-muted-foreground mb-1">[{section.label}]</p>
|
||||
)}
|
||||
@@ -111,7 +113,7 @@ export function ChordChart({ sections, fontSize, onChordClick }: Props) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
{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>
|
||||
);
|
||||
|
||||
91
app/app/components/login-sheet.tsx
Normal file
91
app/app/components/login-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
app/app/components/register-sheet.tsx
Normal file
106
app/app/components/register-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
app/app/components/section-nav.tsx
Normal file
46
app/app/components/section-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Link } from "react-router";
|
||||
import { Star } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import type { SongSummary } from "~/lib/types";
|
||||
|
||||
interface Props {
|
||||
song: SongSummary;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export function SongCard({ song }: Props) {
|
||||
export function SongCard({ song, isFavorite }: Props) {
|
||||
return (
|
||||
<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">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{song.preview_chords.map((chord) => (
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} 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 type { SongMeta } from "~/lib/types";
|
||||
|
||||
@@ -13,41 +27,100 @@ interface Props {
|
||||
onOffsetChange: (offset: number) => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
fontSize?: 'sm' | 'base' | 'lg';
|
||||
onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void;
|
||||
fontSize?: "sm" | "base" | "lg";
|
||||
onFontSizeChange?: (size: "sm" | "base" | "lg") => void;
|
||||
capo?: number;
|
||||
applyCapo?: boolean;
|
||||
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 label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
|
||||
|
||||
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" />
|
||||
const keyDisplay =
|
||||
meta.original_key && offset !== 0
|
||||
? `Key: ${meta.original_key} → ${transposedKey(meta.original_key, offset)}`
|
||||
: meta.original_key
|
||||
? `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>
|
||||
</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;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
@@ -55,7 +128,12 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
||||
<span className="text-sm font-semibold truncate">{meta.title}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -71,8 +149,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
||||
<span className="text-sm text-muted-foreground">{meta.artist}</span>
|
||||
</div>
|
||||
<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}
|
||||
<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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -80,16 +190,19 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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 ? (
|
||||
<button
|
||||
onClick={onToggleCapo}
|
||||
className={cn(
|
||||
"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>
|
||||
) : meta.capo != null ? (
|
||||
<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">
|
||||
{onFontSizeChange && (
|
||||
<div className="flex items-center gap-1">
|
||||
{(['sm', 'base', 'lg'] as const).map((s) => (
|
||||
{(["sm", "base", "lg"] as const).map((s) => (
|
||||
<button
|
||||
key={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",
|
||||
fontSize === s
|
||||
? "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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm font-mono font-semibold">{label}</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.min(11, offset + 1))}>
|
||||
<span className="w-8 text-center text-sm font-mono font-semibold">
|
||||
{label}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
57
app/app/hooks/use-favorites.ts
Normal file
57
app/app/hooks/use-favorites.ts
Normal 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 };
|
||||
}
|
||||
22
app/app/hooks/use-fullscreen.tsx
Normal file
22
app/app/hooks/use-fullscreen.tsx
Normal 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);
|
||||
}
|
||||
28
app/app/hooks/use-wake-lock.ts
Normal file
28
app/app/hooks/use-wake-lock.ts
Normal 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(() => {});
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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[]> {
|
||||
const params = new URLSearchParams();
|
||||
@@ -13,9 +94,16 @@ export async function listSongs(q = "", sort = "date", order = "desc"): Promise<
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getSong(id: string, applyCapo = false): Promise<Song | null> {
|
||||
const url = applyCapo
|
||||
? `${API_BASE}/songs/${id}?apply_capo=true`
|
||||
export async function getSong(
|
||||
id: string,
|
||||
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}`;
|
||||
const res = await fetch(url);
|
||||
if (res.status === 404) return null;
|
||||
@@ -23,11 +111,10 @@ export async function getSong(id: string, applyCapo = false): Promise<Song | nul
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createSong(body: {
|
||||
source?: string;
|
||||
html?: string;
|
||||
}): Promise<StoredSong> {
|
||||
const res = await fetch(`${API_BASE}/songs`, {
|
||||
// --- Songs (mutations — auth required) ---
|
||||
|
||||
export async function createSong(body: { source?: string; html?: string }): Promise<StoredSong> {
|
||||
const res = await authFetch(`${API_BASE}/songs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
@@ -40,12 +127,12 @@ export async function createSong(body: {
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
|
||||
76
app/app/lib/auth.tsx
Normal file
76
app/app/lib/auth.tsx
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -28,11 +28,9 @@ export interface Song {
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
// Trimmed version used in the library grid
|
||||
export interface SongSummary {
|
||||
id: string;
|
||||
meta: SongMeta;
|
||||
// First 5 unique chord names from the song, in order of appearance
|
||||
preview_chords: string[];
|
||||
}
|
||||
|
||||
@@ -46,3 +44,16 @@ export interface UpdateSongRequest {
|
||||
artist?: 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;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,18 @@ import {
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
import { AuthProvider } from "./lib/auth";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||
});
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
@@ -42,13 +48,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,74 +1,83 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams, useRevalidator } from "react-router";
|
||||
import type { Route } from "./+types/home";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
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 { AddSongSheet } from "~/components/add-song-sheet";
|
||||
import { listSongs } from "~/lib/api";
|
||||
import { useAuth } from "~/lib/auth";
|
||||
import { useFavorites } from "~/hooks/use-favorites";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { SongSummary } from "~/lib/types";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: "PocketChords" },
|
||||
{ name: "description", content: "Your personal chord chart library" },
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const sort = url.searchParams.get("sort") ?? "date";
|
||||
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;
|
||||
export default function Home() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isFavorite } = useFavorites();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
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 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 (initialSort !== "date") next.sort = initialSort;
|
||||
if (initialOrder !== "desc") next.order = initialOrder;
|
||||
setSearchParams(next, { replace: true });
|
||||
}, 300);
|
||||
}, [setSearchParams, initialSort, initialOrder]);
|
||||
const { data: songs = [], isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["songs", q, sort, order],
|
||||
queryFn: () => listSongs(q, sort, order),
|
||||
});
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<h1 className="text-lg font-bold">PocketChords</h1>
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
{isAuthenticated && (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-4 pb-3">
|
||||
<Input
|
||||
placeholder="Search songs..."
|
||||
@@ -79,11 +88,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
key={val}
|
||||
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> = {};
|
||||
if (inputValue.trim()) next.q = inputValue.trim();
|
||||
next.sort = val;
|
||||
@@ -92,57 +114,70 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
}}
|
||||
className={cn(
|
||||
"text-xs px-2 py-1 rounded-full border transition-colors",
|
||||
initialSort === val
|
||||
sort === val
|
||||
? "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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{isError && (
|
||||
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Couldn't load your songs. Is the API running?
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revalidator.revalidate()}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allSongs.map((song) => (
|
||||
<SongCard key={song.id} song={song} />
|
||||
))}
|
||||
<Card
|
||||
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
|
||||
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>
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allSongs.map((song) => (
|
||||
<SongCard
|
||||
key={song.id}
|
||||
song={song}
|
||||
isFavorite={isFavorite(song.id)}
|
||||
/>
|
||||
))}
|
||||
{isAuthenticated && (
|
||||
<Card
|
||||
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
|
||||
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>
|
||||
|
||||
<AddSongSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
onSongAdded={(summary) => setLocalSongs((prev) => [...prev, summary])}
|
||||
onSongAdded={() =>
|
||||
queryClient.invalidateQueries({ queryKey: ["songs"] })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Outlet } from "react-router";
|
||||
import { Toaster } from "sonner";
|
||||
import { BottomNav } from "~/components/bottom-nav";
|
||||
import { FullscreenProvider, useFullscreen } from "~/hooks/use-fullscreen";
|
||||
|
||||
function LayoutInner() {
|
||||
const { isFullscreen } = useFullscreen();
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
{!isFullscreen && <BottomNav />}
|
||||
<Toaster position="top-center" richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<LayoutInner />
|
||||
</FullscreenProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { data, Link } from "react-router";
|
||||
import type { Route } from "./+types/songs.$id";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { TransposeBar } from "~/components/transpose-bar";
|
||||
import { ChordChart } from "~/components/chord-chart";
|
||||
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 { EditSongSheet } from "~/components/edit-song-sheet";
|
||||
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 { 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) {
|
||||
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';
|
||||
type FontSize = "sm" | "base" | "lg";
|
||||
|
||||
function initFontSize(): FontSize {
|
||||
try {
|
||||
const v = localStorage.getItem('fontSize');
|
||||
if (v === 'sm' || v === 'base' || v === 'lg') return v;
|
||||
} catch { /* noop */ }
|
||||
return 'sm';
|
||||
const v = localStorage.getItem("fontSize");
|
||||
if (v === "sm" || v === "base" || v === "lg") return v;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return "sm";
|
||||
}
|
||||
|
||||
function initInstrument(): Instrument {
|
||||
try {
|
||||
const v = localStorage.getItem('chordDiagramInstrument');
|
||||
if (v === 'piano' || v === 'guitar') return v;
|
||||
} catch { /* noop */ }
|
||||
return 'piano';
|
||||
const v = localStorage.getItem("chordDiagramInstrument");
|
||||
if (v === "piano" || v === "guitar") return v;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return "piano";
|
||||
}
|
||||
|
||||
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { song: initialSong, id } = loaderData;
|
||||
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
|
||||
const [displayedSong, setDisplayedSong] = useState<Song | null>(initialSong ?? null);
|
||||
function initOffset(id: string): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`transpose:${id}`);
|
||||
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 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 [editOpen, setEditOpen] = 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 scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (applyCapo && baseSong?.meta.capo) {
|
||||
getSong(id, true).then((s) => { if (s) setDisplayedSong(s); });
|
||||
} else {
|
||||
setDisplayedSong(baseSong);
|
||||
}
|
||||
}, [applyCapo]); // eslint-disable-line
|
||||
const { data: baseSong, isLoading } = useQuery({
|
||||
queryKey: ["song", id],
|
||||
queryFn: () => getSong(id),
|
||||
});
|
||||
|
||||
const { data: displayedSong } = useQuery({
|
||||
queryKey: ["song", id, "view", offset, applyCapo],
|
||||
queryFn: () => getSong(id, { applyCapo, transpose: offset }),
|
||||
enabled: !!baseSong,
|
||||
});
|
||||
|
||||
function handleOffsetChange(newOffset: number) {
|
||||
setOffset(newOffset);
|
||||
try { localStorage.setItem(`transpose:${id}`, String(newOffset)); } catch { /* noop */ }
|
||||
try {
|
||||
localStorage.setItem(`transpose:${id}`, String(newOffset));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function handleFontSizeChange(size: FontSize) {
|
||||
setFontSize(size);
|
||||
try { localStorage.setItem('fontSize', size); } catch { /* noop */ }
|
||||
try {
|
||||
localStorage.setItem("fontSize", size);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function handleInstrumentChange(i: Instrument) {
|
||||
setInstrument(i);
|
||||
try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ }
|
||||
try {
|
||||
localStorage.setItem("chordDiagramInstrument", i);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Song not found or unavailable.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
← Back to library
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayed = transposeSong(displayedSong, offset);
|
||||
const uniqueChords = extractUniqueChords(displayed.sections);
|
||||
const uniqueChords = extractUniqueChords(song.sections);
|
||||
const sectionItems = song.sections.map((s, i) => ({
|
||||
label: s.label,
|
||||
index: i,
|
||||
}));
|
||||
const handleChordClick = (chord: string) => setActiveChord(chord);
|
||||
|
||||
function handleUpdated(summary: SongSummary) {
|
||||
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
||||
setDisplayedSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
||||
// meta-only update; react-query will refetch on next focus
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -129,18 +164,20 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
meta={baseSong.meta}
|
||||
offset={offset}
|
||||
onOffsetChange={handleOffsetChange}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
onEdit={isAuthenticated ? () => setEditOpen(true) : undefined}
|
||||
onDelete={isAuthenticated ? () => setDeleteOpen(true) : undefined}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
capo={baseSong.meta.capo ?? undefined}
|
||||
applyCapo={applyCapo}
|
||||
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">
|
||||
{/* Left / main column */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
ref={scrollRef}
|
||||
@@ -148,12 +185,11 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
>
|
||||
<div className="max-w-lg mx-auto lg:max-w-none">
|
||||
<ChordChart
|
||||
sections={displayed.sections}
|
||||
sections={song.sections}
|
||||
fontSize={fontSize}
|
||||
onChordClick={handleChordClick}
|
||||
/>
|
||||
|
||||
{/* Mobile bottom chord grid (hidden on desktop) */}
|
||||
<div className="lg:hidden border-t border-border">
|
||||
<ChordGrid
|
||||
chords={uniqueChords}
|
||||
@@ -164,7 +200,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop side column (hidden on mobile) */}
|
||||
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
|
||||
<ChordGrid
|
||||
chords={uniqueChords}
|
||||
@@ -174,16 +209,22 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
||||
</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 && (
|
||||
<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} />
|
||||
<button
|
||||
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => setActiveChord(null)}
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
27
app/package-lock.json
generated
27
app/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@react-router/node": "7.14.0",
|
||||
"@react-router/serve": "7.14.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -4045,6 +4046,32 @@
|
||||
"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": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@react-router/node": "7.14.0",
|
||||
"@react-router/serve": "7.14.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { Config } from "@react-router/dev/config";
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: true,
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
|
||||
64
architecture.mmd
Normal file
64
architecture.mmd
Normal 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
|
||||
14
crates/adapters/auth/Cargo.toml
Normal file
14
crates/adapters/auth/Cargo.toml
Normal 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 }
|
||||
100
crates/adapters/auth/src/lib.rs
Normal file
100
crates/adapters/auth/src/lib.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
12
crates/adapters/sqlite/Cargo.toml
Normal file
12
crates/adapters/sqlite/Cargo.toml
Normal 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 }
|
||||
7
crates/adapters/sqlite/migrations/002_users.sql
Normal file
7
crates/adapters/sqlite/migrations/002_users.sql
Normal 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
|
||||
);
|
||||
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal file
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal 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);
|
||||
7
crates/adapters/sqlite/src/lib.rs
Normal file
7
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod refresh_sessions;
|
||||
pub mod repository;
|
||||
mod row;
|
||||
mod search;
|
||||
mod users;
|
||||
|
||||
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};
|
||||
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong,
|
||||
SortField, SortOrder, song_preview_chords,
|
||||
DomainError, Song, SongRepositoryPort, SongSummary, SortField, SortOrder, StoredSong,
|
||||
song_preview_chords,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteSongRepository {
|
||||
@@ -15,7 +16,9 @@ pub struct SqliteSongRepository {
|
||||
|
||||
impl SqliteSongRepository {
|
||||
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?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
@@ -23,14 +26,14 @@ impl SqliteSongRepository {
|
||||
|
||||
#[async_trait]
|
||||
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_str = id.to_string();
|
||||
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_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();
|
||||
|
||||
sqlx::query(
|
||||
@@ -44,12 +47,19 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
.bind(&body)
|
||||
.execute(&self.pool)
|
||||
.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!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
||||
sort_clause(sort, order)
|
||||
@@ -57,41 +67,41 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||
.fetch_all(&self.pool)
|
||||
.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()
|
||||
}
|
||||
|
||||
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 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)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(r) => {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
||||
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
|
||||
let id_str = id.to_string();
|
||||
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
Err(RepositoryError::NotFound)
|
||||
Err(DomainError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -103,32 +113,38 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
title: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
original_key: Option<&str>,
|
||||
) -> Result<SongSummary, RepositoryError> {
|
||||
) -> Result<SongSummary, DomainError> {
|
||||
let id_str = id.to_string();
|
||||
|
||||
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)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?
|
||||
.ok_or(RepositoryError::NotFound)?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
|
||||
let mut song: Song = serde_json::from_str(&row.body)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
if let Some(t) = title { song.meta.title = t.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()); }
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
if let Some(t) = title {
|
||||
song.meta.title = t.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)
|
||||
.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_artist = artist.unwrap_or(&row.artist);
|
||||
let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
|
||||
|
||||
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_artist)
|
||||
@@ -137,10 +153,10 @@ impl SongRepositoryPort for SqliteSongRepository {
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.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)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(SongSummary {
|
||||
id,
|
||||
@@ -1,4 +1,4 @@
|
||||
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder};
|
||||
use domain::{DomainError, SongMeta, SongSummary, SortField, SortOrder};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -13,20 +13,20 @@ pub(crate) struct SongRow {
|
||||
|
||||
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
||||
match (field, order) {
|
||||
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
||||
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
||||
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
||||
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
||||
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
||||
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
||||
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
||||
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> {
|
||||
let id = Uuid::parse_str(&row.id)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, DomainError> {
|
||||
let id =
|
||||
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)
|
||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(SongSummary {
|
||||
id,
|
||||
meta: SongMeta {
|
||||
@@ -1,13 +1,21 @@
|
||||
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::row::{SongRow, sort_clause, row_to_summary};
|
||||
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||
|
||||
#[async_trait]
|
||||
impl SongSearchPort for SqliteSongRepository {
|
||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
sort: SortField,
|
||||
order: SortOrder,
|
||||
) -> Result<Vec<SongSummary>, DomainError> {
|
||||
let escaped = query
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_");
|
||||
let pattern = format!("%{}%", escaped);
|
||||
let sql = format!(
|
||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
||||
@@ -19,7 +27,7 @@ impl SongSearchPort for SqliteSongRepository {
|
||||
.bind(&pattern)
|
||||
.fetch_all(&self.pool)
|
||||
.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()
|
||||
}
|
||||
81
crates/adapters/sqlite/src/users.rs
Normal file
81
crates/adapters/sqlite/src/users.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { path = "../../domain" }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
scraper = { workspace = true }
|
||||
52
crates/adapters/ug-parser/src/fetcher.rs
Normal file
52
crates/adapters/ug-parser/src/fetcher.rs
Normal 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;
|
||||
@@ -127,6 +127,14 @@ impl UgHtmlParser {
|
||||
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
|
||||
if let Some(sec) = current_section.as_mut() {
|
||||
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.
|
||||
/// Walks text nodes and span[data-name] elements in order to compute offsets.
|
||||
fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> {
|
||||
@@ -199,65 +231,5 @@ impl TabParserPort for UgHtmlParser {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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("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)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "tests/parser.rs"]
|
||||
mod tests;
|
||||
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal file
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal 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\""));
|
||||
}
|
||||
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal file
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal 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());
|
||||
}
|
||||
8
crates/api-types/Cargo.toml
Normal file
8
crates/api-types/Cargo.toml
Normal 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"] }
|
||||
71
crates/api-types/src/lib.rs
Normal file
71
crates/api-types/src/lib.rs
Normal 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,
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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() }))),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
10
crates/application/Cargo.toml
Normal file
10
crates/application/Cargo.toml
Normal 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 }
|
||||
18
crates/application/src/auth/commands.rs
Normal file
18
crates/application/src/auth/commands.rs
Normal 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,
|
||||
}
|
||||
27
crates/application/src/auth/deps.rs
Normal file
27
crates/application/src/auth/deps.rs
Normal 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>,
|
||||
}
|
||||
55
crates/application/src/auth/login.rs
Normal file
55
crates/application/src/auth/login.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
9
crates/application/src/auth/logout.rs
Normal file
9
crates/application/src/auth/logout.rs
Normal 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
|
||||
}
|
||||
6
crates/application/src/auth/mod.rs
Normal file
6
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod refresh;
|
||||
pub mod register;
|
||||
52
crates/application/src/auth/refresh.rs
Normal file
52
crates/application/src/auth/refresh.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
38
crates/application/src/auth/register.rs
Normal file
38
crates/application/src/auth/register.rs
Normal 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(())
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod songs;
|
||||
pub mod tabs;
|
||||
17
crates/application/src/songs/commands.rs
Normal file
17
crates/application/src/songs/commands.rs
Normal 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>,
|
||||
}
|
||||
10
crates/application/src/songs/delete_song.rs
Normal file
10
crates/application/src/songs/delete_song.rs
Normal 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(())
|
||||
}
|
||||
11
crates/application/src/songs/deps.rs
Normal file
11
crates/application/src/songs/deps.rs
Normal 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>,
|
||||
}
|
||||
12
crates/application/src/songs/get_song.rs
Normal file
12
crates/application/src/songs/get_song.rs
Normal 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
|
||||
}
|
||||
12
crates/application/src/songs/list_songs.rs
Normal file
12
crates/application/src/songs/list_songs.rs
Normal 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
|
||||
}
|
||||
9
crates/application/src/songs/mod.rs
Normal file
9
crates/application/src/songs/mod.rs
Normal 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;
|
||||
17
crates/application/src/songs/queries.rs
Normal file
17
crates/application/src/songs/queries.rs
Normal 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,
|
||||
}
|
||||
15
crates/application/src/songs/save_song.rs
Normal file
15
crates/application/src/songs/save_song.rs
Normal 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)
|
||||
}
|
||||
14
crates/application/src/songs/search_songs.rs
Normal file
14
crates/application/src/songs/search_songs.rs
Normal 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
|
||||
}
|
||||
22
crates/application/src/songs/update_meta.rs
Normal file
22
crates/application/src/songs/update_meta.rs
Normal 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)
|
||||
}
|
||||
4
crates/application/src/tabs/commands.rs
Normal file
4
crates/application/src/tabs/commands.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub struct ParseTabCommand {
|
||||
pub source: Option<String>,
|
||||
pub html: Option<String>,
|
||||
}
|
||||
7
crates/application/src/tabs/deps.rs
Normal file
7
crates/application/src/tabs/deps.rs
Normal 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>,
|
||||
}
|
||||
3
crates/application/src/tabs/mod.rs
Normal file
3
crates/application/src/tabs/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod parse_tab;
|
||||
45
crates/application/src/tabs/parse_tab.rs
Normal file
45
crates/application/src/tabs/parse_tab.rs
Normal 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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
email_address = "0.2"
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
19
crates/domain/src/errors/mod.rs
Normal file
19
crates/domain/src/errors/mod.rs
Normal 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),
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
pub mod note;
|
||||
pub mod chord;
|
||||
pub mod song;
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
pub mod ports;
|
||||
pub mod transposer;
|
||||
pub mod services;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use note::Note;
|
||||
pub use chord::Chord;
|
||||
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
|
||||
pub use song::{song_preview_chords, StoredSong, SongSummary};
|
||||
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
|
||||
pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder};
|
||||
pub use transposer::{ChordTransposer, TransposeError};
|
||||
pub use errors::DomainError;
|
||||
pub use models::{
|
||||
ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta,
|
||||
SongSummary, StoredSong, User, song_preview_chords,
|
||||
};
|
||||
pub use ports::{
|
||||
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,
|
||||
};
|
||||
|
||||
7
crates/domain/src/models/mod.rs
Normal file
7
crates/domain/src/models/mod.rs
Normal 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::*;
|
||||
17
crates/domain/src/models/refresh_session.rs
Normal file
17
crates/domain/src/models/refresh_session.rs
Normal 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>,
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::Chord;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::value_objects::Chord;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChordPosition {
|
||||
@@ -16,8 +18,14 @@ pub struct LyricLine {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SectionKind {
|
||||
Verse, Chorus, Bridge, PreChorus,
|
||||
Intro, Outro, Break, Tab,
|
||||
Verse,
|
||||
Chorus,
|
||||
Bridge,
|
||||
PreChorus,
|
||||
Intro,
|
||||
Outro,
|
||||
Break,
|
||||
Tab,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -60,8 +68,6 @@ pub struct Song {
|
||||
pub sections: Vec<Section>,
|
||||
}
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredSong {
|
||||
pub id: Uuid,
|
||||
@@ -95,28 +101,5 @@ pub fn song_preview_chords(song: &Song) -> Vec<String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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()));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/song.rs"]
|
||||
mod tests;
|
||||
50
crates/domain/src/models/user.rs
Normal file
50
crates/domain/src/models/user.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
34
crates/domain/src/ports/auth.rs
Normal file
34
crates/domain/src/ports/auth.rs
Normal 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>;
|
||||
}
|
||||
7
crates/domain/src/ports/mod.rs
Normal file
7
crates/domain/src/ports/mod.rs
Normal 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::*;
|
||||
35
crates/domain/src/ports/repository.rs
Normal file
35
crates/domain/src/ports/repository.rs
Normal 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>;
|
||||
}
|
||||
37
crates/domain/src/ports/tab_source.rs
Normal file
37
crates/domain/src/ports/tab_source.rs
Normal 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
Reference in New Issue
Block a user