Jelajahi Sumber

bug(ux): fix various front end bugs.

- Fixed song add form (3 HTMX bugs: reset on typeahead, wrong query param, artist-only find-or-create)
- Added Played checkbox to add form; inline edit (✎/Save/Cancel) for setlist rows
- View fixes: date format, location name instead of ID in jam list, notes on location detail, song count column, location name links to detail
- Title typeahead + cross-filtering (artist↔title filter each other)
- Fixed datalist accumulation bug: HTMX 2.0 inherits hx-swap from parent; added explicit hx-swap="innerHTML" on both inputs
- Setlist sort by id (ULID = insertion order); created rejected by PocketBase
- Location delete guard: OnRecordDelete hook blocks if jams exist; FK enforcement is app-level not SQLite-level

Loose ends to pick up:
- Focus-after-add not user-confirmed yet (setTimeout approach)
- jams.participants and setlist.position still in DB schema but unused in UI (optional migration to clean up)
- Logout flow and dashboard ranking not browser-tested
Frédéric G. MARAND 17 jam lalu
induk
melakukan
6f3321371d

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+bin/
+pb_data/
+pb_migrations_backup/

+ 10 - 0
.idea/go.imports.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="GoImports">
+    <option name="excludedPackages">
+      <array>
+        <option value="golang.org/x/net/context" />
+      </array>
+    </option>
+  </component>
+</project>

+ 9 - 0
.idea/jamtrack.iml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+  <component name="Go" enabled="true" />
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/jamtrack.iml" filepath="$PROJECT_DIR$/.idea/jamtrack.iml" />
+    </modules>
+  </component>
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="Git" />
+  </component>
+</project>

+ 49 - 0
CLAUDE.md

@@ -0,0 +1,49 @@
+# jamtrack — Project Guidelines
+
+Inherits all preferences from `~/.claude/CLAUDE.md`.
+
+## Project overview
+
+jamtrack is a PocketBase-backed web application for tracking jam sessions.
+It uses `templ` for type-safe HTML templates and `modernc.org/sqlite` (via PocketBase) for persistence.
+
+## Package layout
+
+Current structure (do not reorganise without discussion):
+
+```
+main.go          — single binary entry point; no /cmd/ subdirectory
+views/           — templ templates and generated *_templ.go files (root-level package)
+internal/query/  — domain query/ranking logic
+internal/web/    — HTTP handlers, middleware, static asset embedding
+migrations/      — PocketBase migration files (blank-import side-effect package)
+pb_data/         — PocketBase runtime data (not committed)
+bin/             — build output (not committed)
+```
+
+- Internal packages are domain-driven under `internal/`.
+- `views/` lives at the root because `go tool templ generate` targets it directly.
+- **Do not apply the "Go standard layout"** (the popular GitHub template). Follow official guidance from the Go blog, Go wiki, and go.dev instead.
+
+## Tooling
+
+- `just gen` — regenerate templ templates (`go tool templ generate`)
+- `just dev` — live-reload dev server with templ watch proxy
+- `just build` — generate + build binary to `bin/jamtrack`
+- `just test` — `go test -race ./...`
+- `go tool staticcheck ./...` — static analysis
+
+## Dependencies already approved
+
+These are in use and do not require further vetting:
+
+| Module | Role |
+|---|---|
+| `github.com/pocketbase/pocketbase` | Backend framework (auth, DB, admin UI, router) |
+| `github.com/a-h/templ` | Type-safe HTML templating |
+| `modernc.org/sqlite` | SQLite driver (pulled in by PocketBase) |
+| `github.com/spf13/cobra` | CLI framework (pulled in by PocketBase) |
+| `honnef.co/go/tools` (staticcheck) | Static analysis (go tool) |
+| `golang.org/x/...` | Extended stdlib |
+
+Any module not in this list requires explicit approval before being added.

+ 54 - 0
go.mod

@@ -1,3 +1,57 @@
 module code.osinet.fr/fgm/jamtrack
 
 go 1.26.4
+
+require (
+	github.com/a-h/templ v0.3.1020
+	github.com/pocketbase/dbx v1.12.0
+	github.com/pocketbase/pocketbase v0.39.3
+)
+
+require (
+	github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
+	github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
+	github.com/andybalholm/brotli v1.1.0 // indirect
+	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
+	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+	github.com/cli/browser v1.3.0 // indirect
+	github.com/disintegration/imaging v1.6.2 // indirect
+	github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/fatih/color v1.19.0 // indirect
+	github.com/fsnotify/fsnotify v1.7.0 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.13 // indirect
+	github.com/ganigeorgiev/fexpr v0.5.0 // indirect
+	github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
+	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/mattn/go-colorable v0.1.14 // indirect
+	github.com/mattn/go-isatty v0.0.22 // indirect
+	github.com/natefinch/atomic v1.0.1 // indirect
+	github.com/ncruces/go-strftime v1.0.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/spf13/cast v1.10.0 // indirect
+	github.com/spf13/cobra v1.10.2 // indirect
+	github.com/spf13/pflag v1.0.10 // indirect
+	golang.org/x/crypto v0.52.0 // indirect
+	golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
+	golang.org/x/image v0.41.0 // indirect
+	golang.org/x/mod v0.35.0 // indirect
+	golang.org/x/net v0.55.0 // indirect
+	golang.org/x/oauth2 v0.36.0 // indirect
+	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.45.0 // indirect
+	golang.org/x/text v0.37.0 // indirect
+	golang.org/x/tools v0.44.0 // indirect
+	honnef.co/go/tools v0.7.0 // indirect
+	modernc.org/libc v1.72.3 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.11.0 // indirect
+	modernc.org/sqlite v1.52.0 // indirect
+)
+
+tool (
+	github.com/a-h/templ/cmd/templ
+	honnef.co/go/tools/cmd/staticcheck
+)

+ 152 - 0
go.sum

@@ -0,0 +1,152 @@
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
+github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
+github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
+github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
+github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
+github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
+github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
+github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
+github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
+github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
+github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
+github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
+github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
+github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
+github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
+github.com/pocketbase/pocketbase v0.39.3 h1:v4tjZx+8i5W9p/cpsr90/3h2hPnXGoYT9OB3i85sF6E=
+github.com/pocketbase/pocketbase v0.39.3/go.mod h1:Quhg2FHvNdmFap4S98D9f8jaBrrquOwfyxw7wiV/kvg=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
+golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
+golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
+honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
+modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
+modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
+modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
+modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
+modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
+modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

+ 54 - 0
internal/query/ranking.go

@@ -0,0 +1,54 @@
+// Package query contains database queries for the jamtrack application.
+package query
+
+import (
+	"github.com/pocketbase/dbx"
+	"github.com/pocketbase/pocketbase/core"
+)
+
+// SongRank holds the aggregated play statistics for a single song.
+type SongRank struct {
+	ID            string `db:"id"`
+	Artist        string `db:"artist"`
+	Title         string `db:"title"`
+	PlayedCount   int    `db:"played_count"`
+	ProposedCount int    `db:"proposed_count"`
+}
+
+// FetchRanking returns songs ordered by play frequency (played_count desc,
+// proposed_count desc). If locationID is non-empty the results are filtered
+// to jams at that location.
+func FetchRanking(app core.App, locationID string) ([]SongRank, error) {
+	var rows []SongRank
+
+	if locationID != "" {
+		err := app.DB().
+			NewQuery(`
+				SELECT s.id, s.artist, s.title,
+				       COALESCE(SUM(sl.played), 0) AS played_count,
+				       COUNT(sl.id)                 AS proposed_count
+				FROM   setlist sl
+				JOIN   songs s  ON s.id  = sl.song
+				JOIN   jams  j  ON j.id  = sl.jam
+				WHERE  j.location = {:location}
+				GROUP  BY s.id
+				ORDER  BY played_count DESC, proposed_count DESC
+			`).
+			Bind(dbx.Params{"location": locationID}).
+			All(&rows)
+		return rows, err
+	}
+
+	err := app.DB().
+		NewQuery(`
+			SELECT s.id, s.artist, s.title,
+			       COALESCE(SUM(sl.played), 0) AS played_count,
+			       COUNT(sl.id)                 AS proposed_count
+			FROM   setlist sl
+			JOIN   songs s ON s.id = sl.song
+			GROUP  BY s.id
+			ORDER  BY played_count DESC, proposed_count DESC
+		`).
+		All(&rows)
+	return rows, err
+}

+ 147 - 0
internal/query/ranking_test.go

@@ -0,0 +1,147 @@
+package query_test
+
+import (
+	"testing"
+
+	"github.com/pocketbase/pocketbase/core"
+
+	"code.osinet.fr/fgm/jamtrack/internal/query"
+	_ "code.osinet.fr/fgm/jamtrack/migrations"
+	_ "github.com/pocketbase/pocketbase/migrations"
+)
+
+// newTestApp creates a bootstrapped PocketBase app in a temp directory with
+// all jamtrack migrations applied. The caller is responsible for calling
+// app.ResetBootstrapState() via t.Cleanup.
+func newTestApp(t *testing.T) core.App {
+	t.Helper()
+	app := core.NewBaseApp(core.BaseAppConfig{DataDir: t.TempDir()})
+	if err := app.Bootstrap(); err != nil {
+		t.Fatalf("Bootstrap: %v", err)
+	}
+	if err := app.RunAppMigrations(); err != nil {
+		t.Fatalf("RunAppMigrations: %v", err)
+	}
+	t.Cleanup(func() { app.ResetBootstrapState() })
+	return app
+}
+
+func mustSave(t *testing.T, app core.App, record *core.Record) {
+	t.Helper()
+	if err := app.Save(record); err != nil {
+		t.Fatalf("Save %s: %v", record.Collection().Name, err)
+	}
+}
+
+func newRecord(t *testing.T, app core.App, collection string) *core.Record {
+	t.Helper()
+	col, err := app.FindCollectionByNameOrId(collection)
+	if err != nil {
+		t.Fatalf("FindCollection %q: %v", collection, err)
+	}
+	return core.NewRecord(col)
+}
+
+func TestFetchRanking(t *testing.T) {
+	app := newTestApp(t)
+
+	// Seed: one location
+	loc := newRecord(t, app, "locations")
+	loc.Set("name", "The Rusty String")
+	mustSave(t, app, loc)
+
+	// Seed: three songs
+	songs := []struct{ artist, title string }{
+		{"Tom Petty", "Learning to Fly"},
+		{"Pink Floyd", "Learning to Fly"},
+		{"Eagles", "Hotel California"},
+	}
+	songRecords := make([]*core.Record, len(songs))
+	for i, s := range songs {
+		r := newRecord(t, app, "songs")
+		r.Set("artist", s.artist)
+		r.Set("title", s.title)
+		mustSave(t, app, r)
+		songRecords[i] = r
+	}
+
+	// Seed: two jams
+	newJam := func(date string) *core.Record {
+		j := newRecord(t, app, "jams")
+		j.Set("date", date)
+		j.Set("location", loc.Id)
+		mustSave(t, app, j)
+		return j
+	}
+	jam1 := newJam("2024-01-10")
+	jam2 := newJam("2024-02-14")
+
+	// Seed setlist entries.
+	// jam1: Tom Petty played, Pink Floyd proposed, Eagles played
+	// jam2: Tom Petty played, Eagles proposed
+	entries := []struct {
+		jam    *core.Record
+		song   *core.Record
+		played bool
+	}{
+		{jam1, songRecords[0], true},  // Tom Petty — played
+		{jam1, songRecords[1], false}, // Pink Floyd — proposed only
+		{jam1, songRecords[2], true},  // Eagles — played
+		{jam2, songRecords[0], true},  // Tom Petty — played again
+		{jam2, songRecords[2], false}, // Eagles — proposed only
+	}
+	for _, en := range entries {
+		sl := newRecord(t, app, "setlist")
+		sl.Set("jam", en.jam.Id)
+		sl.Set("song", en.song.Id)
+		sl.Set("played", en.played)
+		mustSave(t, app, sl)
+	}
+
+	t.Run("global ranking", func(t *testing.T) {
+		ranks, err := query.FetchRanking(app, "")
+		if err != nil {
+			t.Fatalf("FetchRanking: %v", err)
+		}
+		if len(ranks) != 3 {
+			t.Fatalf("want 3 rows, got %d", len(ranks))
+		}
+		// Tom Petty: played=2, proposed=2 → first
+		if ranks[0].Artist != "Tom Petty" || ranks[0].PlayedCount != 2 || ranks[0].ProposedCount != 2 {
+			t.Errorf("rank[0] = %+v, want Tom Petty played=2 proposed=2", ranks[0])
+		}
+		// Eagles: played=1, proposed=2 → second
+		if ranks[1].Artist != "Eagles" || ranks[1].PlayedCount != 1 || ranks[1].ProposedCount != 2 {
+			t.Errorf("rank[1] = %+v, want Eagles played=1 proposed=2", ranks[1])
+		}
+		// Pink Floyd: played=0, proposed=1 → last
+		if ranks[2].Artist != "Pink Floyd" || ranks[2].PlayedCount != 0 || ranks[2].ProposedCount != 1 {
+			t.Errorf("rank[2] = %+v, want Pink Floyd played=0 proposed=1", ranks[2])
+		}
+	})
+
+	t.Run("location filter", func(t *testing.T) {
+		// With location filter the results should be the same (all entries
+		// are at the same location), so just check the count and top entry.
+		ranks, err := query.FetchRanking(app, loc.Id)
+		if err != nil {
+			t.Fatalf("FetchRanking with location: %v", err)
+		}
+		if len(ranks) != 3 {
+			t.Fatalf("want 3 rows, got %d", len(ranks))
+		}
+		if ranks[0].Artist != "Tom Petty" {
+			t.Errorf("rank[0].Artist = %q, want Tom Petty", ranks[0].Artist)
+		}
+	})
+
+	t.Run("unknown location returns empty", func(t *testing.T) {
+		ranks, err := query.FetchRanking(app, "nonexistentid000")
+		if err != nil {
+			t.Fatalf("FetchRanking: %v", err)
+		}
+		if len(ranks) != 0 {
+			t.Errorf("want 0 rows for unknown location, got %d", len(ranks))
+		}
+	})
+}

+ 69 - 0
internal/web/auth.go

@@ -0,0 +1,69 @@
+package web
+
+import (
+	"net/http"
+
+	"github.com/pocketbase/pocketbase/core"
+
+	"code.osinet.fr/fgm/jamtrack/views"
+)
+
+const cookieName = "pb_auth"
+
+// LoginGet renders the login form.
+func LoginGet(e *core.RequestEvent) error {
+	return views.Login("").Render(e.Request.Context(), e.Response)
+}
+
+// LoginPost handles username/password authentication and sets the session cookie.
+func LoginPost(e *core.RequestEvent) error {
+	username := e.Request.FormValue("username")
+	password := e.Request.FormValue("password")
+
+	record, err := e.App.FindFirstRecordByData("users", "username", username)
+	if err != nil || !record.ValidatePassword(password) {
+		return views.Login("Invalid username or password.").Render(e.Request.Context(), e.Response)
+	}
+
+	token, err := record.NewAuthToken()
+	if err != nil {
+		return views.Login("Authentication error. Please try again.").Render(e.Request.Context(), e.Response)
+	}
+
+	e.SetCookie(&http.Cookie{
+		Name:     cookieName,
+		Value:    token,
+		Path:     "/",
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+	return e.Redirect(http.StatusFound, "/")
+}
+
+// Logout clears the session cookie and redirects to the login page.
+func Logout(e *core.RequestEvent) error {
+	e.SetCookie(&http.Cookie{
+		Name:     cookieName,
+		Value:    "",
+		Path:     "/",
+		MaxAge:   -1,
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+	return e.Redirect(http.StatusFound, "/login")
+}
+
+// RequireAuth is a middleware that validates the session cookie and populates
+// e.Auth. Unauthenticated requests are redirected to /login.
+func RequireAuth(e *core.RequestEvent) error {
+	cookie, err := e.Request.Cookie(cookieName)
+	if err != nil || cookie.Value == "" {
+		return e.Redirect(http.StatusFound, "/login")
+	}
+	record, err := e.App.FindAuthRecordByToken(cookie.Value, core.TokenTypeAuth)
+	if err != nil {
+		return e.Redirect(http.StatusFound, "/login")
+	}
+	e.Auth = record
+	return e.Next()
+}

+ 388 - 0
internal/web/handlers.go

@@ -0,0 +1,388 @@
+package web
+
+import (
+	"net/http"
+
+	"github.com/pocketbase/pocketbase/core"
+
+	"code.osinet.fr/fgm/jamtrack/internal/query"
+	"code.osinet.fr/fgm/jamtrack/views"
+)
+
+// Dashboard renders the practice-list ranking page.
+func Dashboard(e *core.RequestEvent) error {
+	locationID := e.Request.URL.Query().Get("location")
+	ranks, err := query.FetchRanking(e.App, locationID)
+	if err != nil {
+		return err
+	}
+	return views.Dashboard(ranks, locationID).Render(e.Request.Context(), e.Response)
+}
+
+// JamList renders the list of all jams.
+func JamList(e *core.RequestEvent) error {
+	jams, err := e.App.FindRecordsByFilter("jams", "", "-date", -1, 0)
+	if err != nil {
+		return err
+	}
+	locs, err := e.App.FindRecordsByFilter("locations", "", "name", -1, 0)
+	if err != nil {
+		return err
+	}
+	locationNames := make(map[string]string, len(locs))
+	for _, l := range locs {
+		locationNames[l.Id] = l.GetString("name")
+	}
+
+	type row struct {
+		JamID  string `db:"jam"`
+		Total  int    `db:"total"`
+		Played int    `db:"played"`
+	}
+	var counts []row
+	if err := e.App.DB().
+		NewQuery("SELECT jam, COUNT(*) AS total, SUM(played) AS played FROM setlist GROUP BY jam").
+		All(&counts); err != nil {
+		return err
+	}
+	songCounts := make(map[string]views.SongCount, len(counts))
+	for _, r := range counts {
+		songCounts[r.JamID] = views.SongCount{Played: r.Played, Total: r.Total}
+	}
+
+	return views.JamList(jams, locationNames, songCounts).Render(e.Request.Context(), e.Response)
+}
+
+// JamNew renders the new-jam form.
+func JamNew(e *core.RequestEvent) error {
+	locations, err := e.App.FindRecordsByFilter("locations", "", "name", -1, 0)
+	if err != nil {
+		return err
+	}
+	return views.JamNew(locations).Render(e.Request.Context(), e.Response)
+}
+
+// JamCreate handles the new-jam form submission.
+func JamCreate(e *core.RequestEvent) error {
+	collection, err := e.App.FindCollectionByNameOrId("jams")
+	if err != nil {
+		return err
+	}
+	record := core.NewRecord(collection)
+	record.Set("date", e.Request.FormValue("date"))
+	record.Set("location", e.Request.FormValue("location"))
+	record.Set("notes", e.Request.FormValue("notes"))
+	if err := e.App.Save(record); err != nil {
+		return err
+	}
+	return e.Redirect(http.StatusFound, "/jams/"+record.Id)
+}
+
+// JamDetail renders the detail view for a single jam and its setlist.
+func JamDetail(e *core.RequestEvent) error {
+	id := e.Request.PathValue("id")
+	jam, err := e.App.FindRecordById("jams", id)
+	if err != nil {
+		return err
+	}
+	location, err := e.App.FindRecordById("locations", jam.GetString("location"))
+	if err != nil {
+		return err
+	}
+	setlistRecords, err := e.App.FindRecordsByFilter(
+		"setlist", "jam={:jam}", "id", -1, 0,
+		map[string]any{"jam": id},
+	)
+	if err != nil {
+		return err
+	}
+
+	rows := make([]views.SetlistRow, 0, len(setlistRecords))
+	for _, sl := range setlistRecords {
+		song, err := e.App.FindRecordById("songs", sl.GetString("song"))
+		if err != nil {
+			return err
+		}
+		rows = append(rows, views.SetlistRow{
+			ID:     sl.Id,
+			Artist: song.GetString("artist"),
+			Title:  song.GetString("title"),
+			Played: sl.GetBool("played"),
+		})
+	}
+	return views.JamDetail(jam, location, rows).Render(e.Request.Context(), e.Response)
+}
+
+// LocationList renders the locations management page.
+func LocationList(e *core.RequestEvent) error {
+	locations, err := e.App.FindRecordsByFilter("locations", "", "name", -1, 0)
+	if err != nil {
+		return err
+	}
+	return views.LocationList(locations).Render(e.Request.Context(), e.Response)
+}
+
+// LocationCreate handles adding a new location.
+func LocationCreate(e *core.RequestEvent) error {
+	collection, err := e.App.FindCollectionByNameOrId("locations")
+	if err != nil {
+		return err
+	}
+	record := core.NewRecord(collection)
+	record.Set("name", e.Request.FormValue("name"))
+	record.Set("notes", e.Request.FormValue("notes"))
+	if err := e.App.Save(record); err != nil {
+		return err
+	}
+	return e.Redirect(http.StatusFound, "/locations")
+}
+
+// LocationDetail renders per-location jam history and practice ranking.
+func LocationDetail(e *core.RequestEvent) error {
+	id := e.Request.PathValue("id")
+	loc, err := e.App.FindRecordById("locations", id)
+	if err != nil {
+		return err
+	}
+	jams, err := e.App.FindRecordsByFilter(
+		"jams", "location={:loc}", "-date", -1, 0,
+		map[string]any{"loc": id},
+	)
+	if err != nil {
+		return err
+	}
+	ranks, err := query.FetchRanking(e.App, id)
+	if err != nil {
+		return err
+	}
+	return views.LocationDetail(loc, jams, ranks).Render(e.Request.Context(), e.Response)
+}
+
+// SongSearch is the HTMX typeahead endpoint. It dispatches on the "field"
+// query param: "title" returns title suggestions (filtered by artist when
+// provided); anything else returns distinct artist suggestions.
+func SongSearch(e *core.RequestEvent) error {
+	if e.Request.URL.Query().Get("field") == "title" {
+		return songTitleSearch(e)
+	}
+	return songArtistSearch(e)
+}
+
+func songArtistSearch(e *core.RequestEvent) error {
+	q := e.Request.URL.Query()
+	artistQ := "%" + q.Get("artist") + "%"
+	title := q.Get("title")
+
+	filter := "artist~{:artistQ}"
+	params := map[string]any{"artistQ": artistQ}
+	if title != "" {
+		filter += " && title={:title}"
+		params["title"] = title
+	}
+	records, err := e.App.FindRecordsByFilter("songs", filter, "artist", 20, 0, params)
+	if err != nil {
+		return err
+	}
+	seen := map[string]bool{}
+	artists := make([]string, 0, len(records))
+	for _, r := range records {
+		a := r.GetString("artist")
+		if !seen[a] {
+			seen[a] = true
+			artists = append(artists, a)
+		}
+	}
+	return views.SongOptions(artists).Render(e.Request.Context(), e.Response)
+}
+
+func songTitleSearch(e *core.RequestEvent) error {
+	q := e.Request.URL.Query()
+	artist := q.Get("artist")
+	titleQ := "%" + q.Get("title") + "%"
+
+	filter := "title~{:titleQ}"
+	params := map[string]any{"titleQ": titleQ}
+	if artist != "" {
+		filter = "artist={:artist} && " + filter
+		params["artist"] = artist
+	}
+	records, err := e.App.FindRecordsByFilter("songs", filter, "title", 20, 0, params)
+	if err != nil {
+		return err
+	}
+	seen := map[string]bool{}
+	titles := make([]string, 0, len(records))
+	for _, r := range records {
+		t := r.GetString("title")
+		if !seen[t] {
+			seen[t] = true
+			titles = append(titles, t)
+		}
+	}
+	return views.SongOptions(titles).Render(e.Request.Context(), e.Response)
+}
+
+// findOrCreateSong returns an existing song matching artist+title exactly, or
+// creates and saves a new one.
+func findOrCreateSong(app core.App, artist, title string) (*core.Record, error) {
+	existing, err := app.FindRecordsByFilter(
+		"songs", "artist={:artist} && title={:title}", "", 1, 0,
+		map[string]any{"artist": artist, "title": title},
+	)
+	if err == nil && len(existing) > 0 {
+		return existing[0], nil
+	}
+	col, err := app.FindCollectionByNameOrId("songs")
+	if err != nil {
+		return nil, err
+	}
+	song := core.NewRecord(col)
+	song.Set("artist", artist)
+	song.Set("title", title)
+	if err := app.Save(song); err != nil {
+		return nil, err
+	}
+	return song, nil
+}
+
+// SetlistAdd adds a song to a jam's setlist (creating the song if it doesn't
+// exist) and returns the new setlist row fragment.
+func SetlistAdd(e *core.RequestEvent) error {
+	jamID := e.Request.PathValue("id")
+	artist := e.Request.FormValue("artist")
+	title := e.Request.FormValue("title")
+
+	song, err := findOrCreateSong(e.App, artist, title)
+	if err != nil {
+		return err
+	}
+
+	played := e.Request.FormValue("played") == "on"
+
+	// Create the setlist entry.
+	slCol, err := e.App.FindCollectionByNameOrId("setlist")
+	if err != nil {
+		return err
+	}
+	sl := core.NewRecord(slCol)
+	sl.Set("jam", jamID)
+	sl.Set("song", song.Id)
+	sl.Set("played", played)
+	if err := e.App.Save(sl); err != nil {
+		return err
+	}
+
+	row := views.SetlistRow{
+		ID:     sl.Id,
+		Artist: artist,
+		Title:  title,
+		Played: played,
+	}
+	return views.SetlistRowFrag(jamID, row).Render(e.Request.Context(), e.Response)
+}
+
+// SetlistView returns the display row fragment for a setlist entry (used by
+// the Cancel button in the inline edit form).
+func SetlistView(e *core.RequestEvent) error {
+	id := e.Request.PathValue("id")
+	sl, err := e.App.FindRecordById("setlist", id)
+	if err != nil {
+		return err
+	}
+	song, err := e.App.FindRecordById("songs", sl.GetString("song"))
+	if err != nil {
+		return err
+	}
+	row := views.SetlistRow{
+		ID:     sl.Id,
+		Artist: song.GetString("artist"),
+		Title:  song.GetString("title"),
+		Played: sl.GetBool("played"),
+	}
+	return views.SetlistRowFrag(sl.GetString("jam"), row).Render(e.Request.Context(), e.Response)
+}
+
+// SetlistEditForm returns the inline edit form fragment for a setlist entry.
+func SetlistEditForm(e *core.RequestEvent) error {
+	id := e.Request.PathValue("id")
+	sl, err := e.App.FindRecordById("setlist", id)
+	if err != nil {
+		return err
+	}
+	song, err := e.App.FindRecordById("songs", sl.GetString("song"))
+	if err != nil {
+		return err
+	}
+	row := views.SetlistRow{
+		ID:     sl.Id,
+		Artist: song.GetString("artist"),
+		Title:  song.GetString("title"),
+		Played: sl.GetBool("played"),
+	}
+	return views.SetlistEditFrag(row).Render(e.Request.Context(), e.Response)
+}
+
+// SetlistUpdate updates the artist, title, and played status of a setlist
+// entry and returns the updated row fragment.
+func SetlistUpdate(e *core.RequestEvent) error {
+	id := e.Request.PathValue("id")
+	artist := e.Request.FormValue("artist")
+	title := e.Request.FormValue("title")
+	played := e.Request.FormValue("played") == "on"
+
+	sl, err := e.App.FindRecordById("setlist", id)
+	if err != nil {
+		return err
+	}
+	song, err := findOrCreateSong(e.App, artist, title)
+	if err != nil {
+		return err
+	}
+	sl.Set("song", song.Id)
+	sl.Set("played", played)
+	if err := e.App.Save(sl); err != nil {
+		return err
+	}
+	row := views.SetlistRow{
+		ID:     sl.Id,
+		Artist: artist,
+		Title:  title,
+		Played: played,
+	}
+	return views.SetlistRowFrag(sl.GetString("jam"), row).Render(e.Request.Context(), e.Response)
+}
+
+// SetlistToggle toggles the played bool on a setlist entry and returns the
+// updated row fragment.
+func SetlistToggle(e *core.RequestEvent) error {
+	id := e.Request.PathValue("id")
+	sl, err := e.App.FindRecordById("setlist", id)
+	if err != nil {
+		return err
+	}
+	sl.Set("played", !sl.GetBool("played"))
+	if err := e.App.Save(sl); err != nil {
+		return err
+	}
+	song, err := e.App.FindRecordById("songs", sl.GetString("song"))
+	if err != nil {
+		return err
+	}
+	row := views.SetlistRow{
+		ID:     sl.Id,
+		Artist: song.GetString("artist"),
+		Title:  song.GetString("title"),
+		Played: sl.GetBool("played"),
+	}
+	return views.SetlistRowFrag(sl.GetString("jam"), row).Render(e.Request.Context(), e.Response)
+}
+
+// SetlistDelete removes a song from a jam's setlist.
+func SetlistDelete(e *core.RequestEvent) error {
+	id := e.Request.PathValue("id")
+	sl, err := e.App.FindRecordById("setlist", id)
+	if err != nil {
+		return err
+	}
+	return e.App.Delete(sl)
+}

+ 8 - 0
internal/web/helpers.go

@@ -0,0 +1,8 @@
+package web
+
+import "net/http"
+
+// BadRequest writes a 400 response with the given message.
+func BadRequest(w http.ResponseWriter, msg string) {
+	http.Error(w, msg, http.StatusBadRequest)
+}

+ 22 - 0
internal/web/static.go

@@ -0,0 +1,22 @@
+// Package web contains HTTP handlers, middleware, and embedded static assets.
+package web
+
+import (
+	"embed"
+	"io/fs"
+	"net/http"
+
+	"github.com/pocketbase/pocketbase/core"
+)
+
+//go:embed static
+var staticFiles embed.FS
+
+// RegisterStatic registers the /static/* route serving embedded Bulma and htmx assets.
+func RegisterStatic(e *core.ServeEvent) {
+	sub, _ := fs.Sub(staticFiles, "static")
+	e.Router.GET("/static/{path...}", func(re *core.RequestEvent) error {
+		http.StripPrefix("/static", http.FileServerFS(sub)).ServeHTTP(re.Response, re.Request)
+		return nil
+	})
+}

File diff ditekan karena terlalu besar
+ 1 - 0
internal/web/static/bulma.min.css


File diff ditekan karena terlalu besar
+ 0 - 0
internal/web/static/htmx.min.js


+ 14 - 0
justfile

@@ -0,0 +1,14 @@
+gen:
+    go tool templ generate
+
+dev:
+    go tool templ generate --watch --proxy="http://127.0.0.1:8090" --cmd="env -u TEMPL_DEV_MODE go run . serve"
+
+build:
+    go tool templ generate && go build -o bin/jamtrack .
+
+serve:
+    ./bin/jamtrack serve
+
+test:
+    go test -race ./...

+ 79 - 0
main.go

@@ -0,0 +1,79 @@
+// Package main is the entry point for the jamtrack application.
+package main
+
+import (
+	"errors"
+	"log"
+	"os"
+	"strings"
+
+	"github.com/pocketbase/pocketbase"
+	"github.com/pocketbase/pocketbase/core"
+	"github.com/pocketbase/pocketbase/plugins/migratecmd"
+
+	"code.osinet.fr/fgm/jamtrack/internal/web"
+	_ "code.osinet.fr/fgm/jamtrack/migrations"
+)
+
+func main() {
+	app := pocketbase.New()
+
+	// Automigrate only when running via `go run` (development).
+	isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
+	migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
+		Automigrate: isGoRun,
+	})
+
+	// Block deletion of a location that still has jams referencing it.
+	app.OnRecordDelete("locations").BindFunc(func(e *core.RecordEvent) error {
+		jams, err := app.FindRecordsByFilter(
+			"jams", "location={:loc}", "", 1, 0,
+			map[string]any{"loc": e.Record.Id},
+		)
+		if err != nil {
+			return err
+		}
+		if len(jams) > 0 {
+			return errors.New("cannot delete a location that still has jams")
+		}
+		return e.Next()
+	})
+
+	app.OnServe().BindFunc(func(se *core.ServeEvent) error {
+		web.RegisterStatic(se)
+
+		// Open routes (no auth required)
+		se.Router.GET("/login", web.LoginGet)
+		se.Router.POST("/login", web.LoginPost)
+		se.Router.GET("/logout", web.Logout)
+
+		// Protected routes — all behind requireAuth middleware
+		protected := se.Router.Group("")
+		protected.BindFunc(web.RequireAuth)
+
+		protected.GET("/", web.Dashboard)
+
+		protected.GET("/jams", web.JamList)
+		protected.GET("/jams/new", web.JamNew)
+		protected.POST("/jams", web.JamCreate)
+		protected.GET("/jams/{id}", web.JamDetail)
+
+		protected.GET("/locations", web.LocationList)
+		protected.POST("/locations", web.LocationCreate)
+		protected.GET("/locations/{id}", web.LocationDetail)
+
+		protected.GET("/songs/search", web.SongSearch)
+		protected.POST("/jams/{id}/songs", web.SetlistAdd)
+		protected.GET("/setlist/{id}/view", web.SetlistView)
+		protected.GET("/setlist/{id}/edit", web.SetlistEditForm)
+		protected.PATCH("/setlist/{id}", web.SetlistUpdate)
+		protected.PATCH("/setlist/{id}/played", web.SetlistToggle)
+		protected.DELETE("/setlist/{id}", web.SetlistDelete)
+
+		return se.Next()
+	})
+
+	if err := app.Start(); err != nil {
+		log.Fatal(err)
+	}
+}

+ 109 - 0
migrations/1700000000_init.go

@@ -0,0 +1,109 @@
+// Package migrations contains PocketBase schema migrations for jamtrack.
+package migrations
+
+import (
+	"github.com/pocketbase/pocketbase/core"
+)
+
+func init() {
+	core.AppMigrations.Register(func(app core.App) error {
+		// locations — domain table for jam venues
+		locations := core.NewBaseCollection("locations")
+		locations.Fields.Add(&core.TextField{Name: "name", Required: true})
+		locations.Fields.Add(&core.TextField{Name: "notes"})
+		locations.AddIndex("idx_locations_name", true, "name", "")
+		if err := app.Save(locations); err != nil {
+			return err
+		}
+
+		// songs — catalog of all songs ever seen on a board
+		songs := core.NewBaseCollection("songs")
+		songs.Fields.Add(&core.TextField{Name: "title", Required: true})
+		songs.Fields.Add(&core.TextField{Name: "artist", Required: true})
+		songs.Fields.Add(&core.TextField{Name: "notes"})
+		songs.AddIndex("idx_songs_artist_title", true, "artist,title", "")
+		if err := app.Save(songs); err != nil {
+			return err
+		}
+
+		// jams — one record per jam session
+		jams := core.NewBaseCollection("jams")
+		jams.Fields.Add(&core.DateField{Name: "date", Required: true})
+		jams.Fields.Add(&core.RelationField{
+			Name:         "location",
+			Required:     true,
+			CollectionId: locations.Id,
+			MaxSelect:    1,
+		})
+		jams.Fields.Add(&core.TextField{Name: "participants"})
+		jams.Fields.Add(&core.FileField{
+			Name:      "board_photo",
+			MaxSelect: 1,
+			MimeTypes: []string{"image/jpeg", "image/png", "image/gif", "image/webp"},
+		})
+		jams.Fields.Add(&core.TextField{Name: "notes"})
+		if err := app.Save(jams); err != nil {
+			return err
+		}
+
+		// setlist — junction: one row per song proposed at a jam
+		setlist := core.NewBaseCollection("setlist")
+		setlist.Fields.Add(&core.RelationField{
+			Name:          "jam",
+			Required:      true,
+			CollectionId:  jams.Id,
+			MaxSelect:     1,
+			CascadeDelete: true,
+		})
+		setlist.Fields.Add(&core.RelationField{
+			Name:         "song",
+			Required:     true,
+			CollectionId: songs.Id,
+			MaxSelect:    1,
+		})
+		setlist.Fields.Add(&core.BoolField{Name: "played"})
+		setlist.Fields.Add(&core.NumberField{Name: "position"})
+		setlist.AddIndex("idx_setlist_jam_song", true, "jam,song", "")
+		if err := app.Save(setlist); err != nil {
+			return err
+		}
+
+		// configure the built-in users auth collection:
+		// - username identity (not email)
+		// - no self-registration (CreateRule = nil means API create is forbidden)
+		users, err := app.FindCollectionByNameOrId("users")
+		if err != nil {
+			return err
+		}
+		users.Fields.Add(&core.TextField{Name: "username", Required: true})
+		users.AddIndex("idx_users_username", true, "username", "")
+		users.PasswordAuth.Enabled = true
+		users.PasswordAuth.IdentityFields = []string{"username"}
+		users.CreateRule = nil
+		return app.Save(users)
+	}, func(app core.App) error {
+		for _, name := range []string{"setlist", "jams", "songs", "locations"} {
+			col, err := app.FindCollectionByNameOrId(name)
+			if err != nil {
+				return err
+			}
+			if err := app.Delete(col); err != nil {
+				return err
+			}
+		}
+
+		// revert users to email identity + allow self-registration
+		users, err := app.FindCollectionByNameOrId("users")
+		if err != nil {
+			return err
+		}
+		usernameField := users.Fields.GetByName("username")
+		if usernameField != nil {
+			users.Fields.RemoveById(usernameField.GetId())
+		}
+		users.RemoveIndex("idx_users_username")
+		users.PasswordAuth.IdentityFields = []string{"email"}
+		users.CreateRule = func() *string { s := ""; return &s }()
+		return app.Save(users)
+	})
+}

+ 44 - 0
views/dashboard.templ

@@ -0,0 +1,44 @@
+package views
+
+import (
+	"fmt"
+	"code.osinet.fr/fgm/jamtrack/internal/query"
+)
+
+// Dashboard renders the practice-list ranking table.
+templ Dashboard(ranks []query.SongRank, locationID string) {
+	@Layout("Practice List") {
+		<div class="level">
+			<div class="level-left">
+				<h1 class="title">Practice List</h1>
+			</div>
+			<div class="level-right">
+				<a class="button is-primary" href="/jams/new">+ New Jam</a>
+			</div>
+		</div>
+		if len(ranks) == 0 {
+			<p class="has-text-grey">No songs logged yet. <a href="/jams/new">Add a jam</a> to get started.</p>
+		} else {
+			<table class="table is-fullwidth is-striped is-hoverable">
+				<thead>
+					<tr>
+						<th>Artist</th>
+						<th>Title</th>
+						<th class="has-text-right">Played</th>
+						<th class="has-text-right">Proposed</th>
+					</tr>
+				</thead>
+				<tbody>
+					for _, r := range ranks {
+						<tr>
+							<td>{ r.Artist }</td>
+							<td>{ r.Title }</td>
+							<td class="has-text-right">{ fmt.Sprint(r.PlayedCount) }</td>
+							<td class="has-text-right">{ fmt.Sprint(r.ProposedCount) }</td>
+						</tr>
+					}
+				</tbody>
+			</table>
+		}
+	}
+}

+ 137 - 0
views/dashboard_templ.go

@@ -0,0 +1,137 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package views
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+	"code.osinet.fr/fgm/jamtrack/internal/query"
+	"fmt"
+)
+
+// Dashboard renders the practice-list ranking table.
+func Dashboard(ranks []query.SongRank, locationID string) templ.Component {
+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+			return templ_7745c5c3_CtxErr
+		}
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+		if !templ_7745c5c3_IsBuffer {
+			defer func() {
+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err == nil {
+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
+				}
+			}()
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+			templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+			templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+			if !templ_7745c5c3_IsBuffer {
+				defer func() {
+					templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+					if templ_7745c5c3_Err == nil {
+						templ_7745c5c3_Err = templ_7745c5c3_BufErr
+					}
+				}()
+			}
+			ctx = templ.InitializeContext(ctx)
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"level\"><div class=\"level-left\"><h1 class=\"title\">Practice List</h1></div><div class=\"level-right\"><a class=\"button is-primary\" href=\"/jams/new\">+ New Jam</a></div></div>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if len(ranks) == 0 {
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<p class=\"has-text-grey\">No songs logged yet. <a href=\"/jams/new\">Add a jam</a> to get started.</p>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			} else {
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<table class=\"table is-fullwidth is-striped is-hoverable\"><thead><tr><th>Artist</th><th>Title</th><th class=\"has-text-right\">Played</th><th class=\"has-text-right\">Proposed</th></tr></thead> <tbody>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				for _, r := range ranks {
+					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr><td>")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					var templ_7745c5c3_Var3 string
+					templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(r.Artist)
+					if templ_7745c5c3_Err != nil {
+						return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/dashboard.templ`, Line: 34, Col: 21}
+					}
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</td><td>")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					var templ_7745c5c3_Var4 string
+					templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
+					if templ_7745c5c3_Err != nil {
+						return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/dashboard.templ`, Line: 35, Col: 20}
+					}
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</td><td class=\"has-text-right\">")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					var templ_7745c5c3_Var5 string
+					templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(r.PlayedCount))
+					if templ_7745c5c3_Err != nil {
+						return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/dashboard.templ`, Line: 36, Col: 61}
+					}
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</td><td class=\"has-text-right\">")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					var templ_7745c5c3_Var6 string
+					templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(r.ProposedCount))
+					if templ_7745c5c3_Err != nil {
+						return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/dashboard.templ`, Line: 37, Col: 63}
+					}
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</td></tr>")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+				}
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</tbody></table>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			return nil
+		})
+		templ_7745c5c3_Err = Layout("Practice List").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return nil
+	})
+}
+
+var _ = templruntime.GeneratedTemplate

+ 25 - 0
views/helpers.go

@@ -0,0 +1,25 @@
+package views
+
+import "fmt"
+
+// SongCount holds the played/total song counts for a jam.
+type SongCount struct {
+	Played int
+	Total  int
+}
+
+// dateOnly extracts the YYYY-MM-DD prefix from a PocketBase datetime string.
+func dateOnly(s string) string {
+	if len(s) >= 10 {
+		return s[:10]
+	}
+	return s
+}
+
+// songCountDisplay formats a played/total pair as "X / Y", or "" when total is 0.
+func songCountDisplay(sc SongCount) string {
+	if sc.Total == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%d / %d", sc.Played, sc.Total)
+}

+ 240 - 0
views/jams.templ

@@ -0,0 +1,240 @@
+package views
+
+import "github.com/pocketbase/pocketbase/core"
+
+// JamList renders the list of all jams.
+templ JamList(jams []*core.Record, locationNames map[string]string, songCounts map[string]SongCount) {
+	@Layout("Jams") {
+		<div class="level">
+			<div class="level-left">
+				<h1 class="title">Jams</h1>
+			</div>
+			<div class="level-right">
+				<a class="button is-primary" href="/jams/new">+ New Jam</a>
+			</div>
+		</div>
+		if len(jams) == 0 {
+			<p class="has-text-grey">No jams logged yet.</p>
+		} else {
+			<table class="table is-fullwidth is-striped is-hoverable">
+				<thead>
+					<tr><th>Date</th><th>Location</th><th>Songs</th></tr>
+				</thead>
+				<tbody>
+					for _, j := range jams {
+						<tr>
+							<td><a href={ templ.SafeURL("/jams/" + j.Id) }>{ dateOnly(j.GetString("date")) }</a></td>
+							<td>{ locationNames[j.GetString("location")] }</td>
+							<td>{ songCountDisplay(songCounts[j.Id]) }</td>
+						</tr>
+					}
+				</tbody>
+			</table>
+		}
+	}
+}
+
+// JamNew renders the new-jam creation form.
+templ JamNew(locations []*core.Record) {
+	@Layout("New Jam") {
+		<h1 class="title">New Jam</h1>
+		<form method="POST" action="/jams" class="box" style="max-width:480px">
+			<div class="field">
+				<label class="label">Date</label>
+				<div class="control">
+					<input class="input" type="date" name="date" required/>
+				</div>
+			</div>
+			<div class="field">
+				<label class="label">Location</label>
+				<div class="control">
+					<div class="select is-fullwidth">
+						<select name="location" required>
+							<option value="">— select —</option>
+							for _, loc := range locations {
+								<option value={ loc.Id }>{ loc.GetString("name") }</option>
+							}
+						</select>
+					</div>
+				</div>
+			</div>
+			<div class="field">
+				<label class="label">Notes</label>
+				<div class="control">
+					<textarea class="textarea" name="notes" rows="2"></textarea>
+				</div>
+			</div>
+			<div class="field mt-4">
+				<div class="control">
+					<button class="button is-primary" type="submit">Create Jam</button>
+					<a class="button ml-2" href="/jams">Cancel</a>
+				</div>
+			</div>
+		</form>
+	}
+}
+
+// SetlistRow is the view model for a single row in the setlist table.
+type SetlistRow struct {
+	ID     string
+	Artist string
+	Title  string
+	Played bool
+}
+
+// JamDetail renders a single jam and its setlist.
+templ JamDetail(jam *core.Record, location *core.Record, rows []SetlistRow) {
+	@Layout("Jam — " + dateOnly(jam.GetString("date"))) {
+		<div class="level">
+			<div class="level-left">
+				<h1 class="title">{ dateOnly(jam.GetString("date")) } — <a href={ templ.SafeURL("/locations/" + location.Id) }>{ location.GetString("name") }</a></h1>
+			</div>
+		</div>
+		<div class="columns">
+			<div class="column is-two-thirds">
+				<h2 class="subtitle">Setlist</h2>
+				<table class="table is-fullwidth" id="setlist">
+					<thead>
+						<tr><th>Artist</th><th>Title</th><th>Played</th><th></th></tr>
+					</thead>
+					<tbody id="setlist-rows">
+						for _, row := range rows {
+							@SetlistRowFrag(jam.Id, row)
+						}
+					</tbody>
+				</table>
+				<div class="mt-4" id="song-add-form">
+					@SongAddForm(jam.Id)
+				</div>
+			</div>
+			<div class="column">
+				if jam.GetString("notes") != "" {
+					<p><strong>Notes:</strong> { jam.GetString("notes") }</p>
+				}
+			</div>
+		</div>
+	}
+}
+
+// SetlistRowFrag renders a single setlist row (used for HTMX swap).
+templ SetlistRowFrag(jamID string, row SetlistRow) {
+	<tr id={ "row-" + row.ID }>
+		<td>{ row.Artist }</td>
+		<td>{ row.Title }</td>
+		<td>
+			<input
+				type="checkbox"
+				if row.Played {
+					checked
+				}
+				hx-patch={ "/setlist/" + row.ID + "/played" }
+				hx-target={ "#row-" + row.ID }
+				hx-swap="outerHTML"
+			/>
+		</td>
+		<td>
+			<button
+				class="button is-small is-ghost"
+				hx-get={ "/setlist/" + row.ID + "/edit" }
+				hx-target={ "#row-" + row.ID }
+				hx-swap="outerHTML"
+			>✎</button>
+			<button
+				class="delete"
+				hx-delete={ "/setlist/" + row.ID }
+				hx-target={ "#row-" + row.ID }
+				hx-swap="outerHTML"
+				hx-confirm="Remove this song from the setlist?"
+			></button>
+		</td>
+	</tr>
+}
+
+// SetlistEditFrag renders a setlist row as an inline edit form.
+templ SetlistEditFrag(row SetlistRow) {
+	<tr id={ "row-" + row.ID }>
+		<td><input class="input is-small" type="text" name="artist" value={ row.Artist } required/></td>
+		<td><input class="input is-small" type="text" name="title" value={ row.Title } required/></td>
+		<td>
+			<input
+				type="checkbox"
+				name="played"
+				if row.Played {
+					checked
+				}
+			/>
+		</td>
+		<td>
+			<button
+				class="button is-small is-primary mr-1"
+				hx-patch={ "/setlist/" + row.ID }
+				hx-include="closest tr"
+				hx-target={ "#row-" + row.ID }
+				hx-swap="outerHTML"
+			>Save</button>
+			<button
+				class="button is-small"
+				hx-get={ "/setlist/" + row.ID + "/view" }
+				hx-target={ "#row-" + row.ID }
+				hx-swap="outerHTML"
+			>Cancel</button>
+		</td>
+	</tr>
+}
+
+// SongAddForm renders the add-song form for a jam. Artist has a typeahead
+// datalist (suggestions only; free-form text is always accepted).
+templ SongAddForm(jamID string) {
+	<form
+		hx-post={ "/jams/" + jamID + "/songs" }
+		hx-target="#setlist-rows"
+		hx-swap="beforeend"
+		hx-on--after-request="if(event.detail.requestConfig.verb==='post') { this.reset(); const a=this.querySelector('[name=artist]'); setTimeout(()=>{a.focus();a.scrollIntoView({behavior:'smooth',block:'nearest'});},0); }"
+	>
+		<div class="field has-addons">
+			<div class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					name="artist"
+					placeholder="Artist"
+					list="song-artist-list"
+					hx-get="/songs/search"
+					hx-trigger="input changed delay:300ms, change from:[name=title]"
+					hx-target="#song-artist-list"
+					hx-swap="innerHTML"
+					hx-include="[name=title]"
+					autocomplete="off"
+					required
+				/>
+				<datalist id="song-artist-list"></datalist>
+			</div>
+			<div class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					name="title"
+					placeholder="Title"
+					list="song-title-list"
+					hx-get="/songs/search"
+					hx-vals='{"field":"title"}'
+					hx-trigger="input changed delay:300ms, change from:[name=artist]"
+					hx-target="#song-title-list"
+					hx-swap="innerHTML"
+					hx-include="[name=artist]"
+					autocomplete="off"
+					required
+				/>
+				<datalist id="song-title-list"></datalist>
+			</div>
+			<div class="control">
+				<label class="checkbox" style="padding: 0.5em 0.75em">
+					<input type="checkbox" name="played"/> Played
+				</label>
+			</div>
+			<div class="control">
+				<button class="button is-link" type="submit">Add</button>
+			</div>
+		</div>
+	</form>
+}

File diff ditekan karena terlalu besar
+ 167 - 0
views/jams_templ.go


+ 32 - 0
views/layout.templ

@@ -0,0 +1,32 @@
+package views
+
+// Layout is the base HTML layout used by all authenticated pages.
+templ Layout(title string) {
+	<!DOCTYPE html>
+	<html lang="en">
+		<head>
+			<meta charset="UTF-8"/>
+			<meta name="viewport" content="width=device-width, initial-scale=1"/>
+			<title>{ title } — JamTrack</title>
+			<link rel="stylesheet" href="/static/bulma.min.css"/>
+			<script src="/static/htmx.min.js"></script>
+		</head>
+		<body>
+			<nav class="navbar is-dark" role="navigation">
+				<div class="navbar-brand">
+					<a class="navbar-item has-text-weight-bold" href="/">JamTrack</a>
+				</div>
+				<div class="navbar-menu">
+					<div class="navbar-end">
+						<a class="navbar-item" href="/jams">Jams</a>
+						<a class="navbar-item" href="/locations">Locations</a>
+						<a class="navbar-item" href="/logout">Logout</a>
+					</div>
+				</div>
+			</nav>
+			<main class="container mt-5">
+				{ children... }
+			</main>
+		</body>
+	</html>
+}

File diff ditekan karena terlalu besar
+ 45 - 0
views/layout_templ.go


+ 104 - 0
views/locations.templ

@@ -0,0 +1,104 @@
+package views
+
+import (
+	"fmt"
+	"github.com/pocketbase/pocketbase/core"
+	"code.osinet.fr/fgm/jamtrack/internal/query"
+)
+
+// LocationList renders the locations management page with an inline add form.
+templ LocationList(locations []*core.Record) {
+	@Layout("Locations") {
+		<div class="level">
+			<div class="level-left">
+				<h1 class="title">Locations</h1>
+			</div>
+		</div>
+		<div class="columns">
+			<div class="column is-half">
+				if len(locations) == 0 {
+					<p class="has-text-grey mb-4">No locations yet.</p>
+				} else {
+					<table class="table is-fullwidth is-striped mb-4">
+						<thead>
+							<tr><th>Name</th><th>Notes</th></tr>
+						</thead>
+						<tbody>
+							for _, loc := range locations {
+								<tr>
+									<td><a href={ templ.SafeURL("/locations/" + loc.Id) }>{ loc.GetString("name") }</a></td>
+									<td>{ loc.GetString("notes") }</td>
+								</tr>
+							}
+						</tbody>
+					</table>
+				}
+				<form method="POST" action="/locations">
+					<div class="field has-addons">
+						<div class="control is-expanded">
+							<input class="input" type="text" name="name" placeholder="Location name" required/>
+						</div>
+						<div class="control">
+							<button class="button is-primary" type="submit">Add</button>
+						</div>
+					</div>
+					<div class="field">
+						<div class="control">
+							<input class="input" type="text" name="notes" placeholder="Notes (optional)"/>
+						</div>
+					</div>
+				</form>
+			</div>
+		</div>
+	}
+}
+
+// LocationDetail renders a single location's jam history and per-location ranking.
+templ LocationDetail(loc *core.Record, jams []*core.Record, ranks []query.SongRank) {
+	@Layout(loc.GetString("name")) {
+		<h1 class="title">{ loc.GetString("name") }</h1>
+		if loc.GetString("notes") != "" {
+			<p class="subtitle is-6 has-text-grey">{ loc.GetString("notes") }</p>
+		}
+		<div class="columns">
+			<div class="column">
+				<h2 class="subtitle">Jams at this location</h2>
+				if len(jams) == 0 {
+					<p class="has-text-grey">No jams logged here yet.</p>
+				} else {
+					<table class="table is-fullwidth is-striped">
+						<thead><tr><th>Date</th></tr></thead>
+						<tbody>
+							for _, j := range jams {
+								<tr>
+									<td><a href={ templ.SafeURL("/jams/" + j.Id) }>{ dateOnly(j.GetString("date")) }</a></td>
+								</tr>
+							}
+						</tbody>
+					</table>
+				}
+			</div>
+			<div class="column">
+				<h2 class="subtitle">Practice ranking</h2>
+				if len(ranks) == 0 {
+					<p class="has-text-grey">No songs played here yet.</p>
+				} else {
+					<table class="table is-fullwidth is-striped">
+						<thead>
+							<tr><th>Artist</th><th>Title</th><th class="has-text-right">Played</th></tr>
+						</thead>
+						<tbody>
+							for _, r := range ranks {
+								<tr>
+									<td>{ r.Artist }</td>
+									<td>{ r.Title }</td>
+									<td class="has-text-right">{ fmt.Sprint(r.PlayedCount) }</td>
+								</tr>
+							}
+						</tbody>
+					</table>
+				}
+			</div>
+		</div>
+	}
+}

File diff ditekan karena terlalu besar
+ 114 - 0
views/locations_templ.go


+ 47 - 0
views/login.templ

@@ -0,0 +1,47 @@
+package views
+
+// Login renders the standalone login page (no nav layout).
+templ Login(flashErr string) {
+	<!DOCTYPE html>
+	<html lang="en">
+		<head>
+			<meta charset="UTF-8"/>
+			<meta name="viewport" content="width=device-width, initial-scale=1"/>
+			<title>Login — JamTrack</title>
+			<link rel="stylesheet" href="/static/bulma.min.css"/>
+		</head>
+		<body>
+			<section class="section">
+				<div class="container">
+					<div class="columns is-centered">
+						<div class="column is-narrow" style="min-width:320px">
+							<h1 class="title has-text-centered">JamTrack</h1>
+							if flashErr != "" {
+								<div class="notification is-danger is-light">{ flashErr }</div>
+							}
+							<form method="POST" action="/login">
+								<div class="field">
+									<label class="label">Username</label>
+									<div class="control">
+										<input class="input" type="text" name="username" autofocus required/>
+									</div>
+								</div>
+								<div class="field">
+									<label class="label">Password</label>
+									<div class="control">
+										<input class="input" type="password" name="password" required/>
+									</div>
+								</div>
+								<div class="field mt-4">
+									<div class="control">
+										<button class="button is-primary is-fullwidth" type="submit">Sign in</button>
+									</div>
+								</div>
+							</form>
+						</div>
+					</div>
+				</div>
+			</section>
+		</body>
+	</html>
+}

File diff ditekan karena terlalu besar
+ 32 - 0
views/login_templ.go


+ 8 - 0
views/partials.templ

@@ -0,0 +1,8 @@
+package views
+
+// SongOptions renders a list of <option> elements for a search datalist.
+templ SongOptions(values []string) {
+	for _, v := range values {
+		<option value={ v }></option>
+	}
+}

+ 56 - 0
views/partials_templ.go

@@ -0,0 +1,56 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package views
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+// SongOptions renders a list of <option> elements for a search datalist.
+func SongOptions(values []string) templ.Component {
+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+			return templ_7745c5c3_CtxErr
+		}
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+		if !templ_7745c5c3_IsBuffer {
+			defer func() {
+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err == nil {
+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
+				}
+			}()
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		for _, v := range values {
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<option value=\"")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var2 string
+			templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/partials.templ`, Line: 6, Col: 19}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></option>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		return nil
+	})
+}
+
+var _ = templruntime.GeneratedTemplate

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini

PANIC: session(release): write data/sessions/a/7/a70c573b13616af9: no space left on device

PANIC

session(release): write data/sessions/a/7/a70c573b13616af9: no space left on device
/my/cache/.heroku/go/go-path/pkg/mod/github.com/go-macaron/session@v1.0.3/session.go:204 (0xb13e07)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/context.go:80 (0x967b75)
/my/cache/.heroku/go/go-path/pkg/mod/github.com/go-macaron/inject@v0.0.0-20200308113650-138e5925c53b/inject.go:157 (0x9512ee)
/my/cache/.heroku/go/go-path/pkg/mod/github.com/go-macaron/inject@v0.0.0-20200308113650-138e5925c53b/inject.go:135 (0x951205)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/context.go:124 (0x967cc4)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/context.go:114 (0x967bf6)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/recovery.go:161 (0x15baec4)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/logger.go:40 (0x96b257)
/my/cache/.heroku/go/go-path/pkg/mod/github.com/go-macaron/inject@v0.0.0-20200308113650-138e5925c53b/inject.go:157 (0x9512ee)
/my/cache/.heroku/go/go-path/pkg/mod/github.com/go-macaron/inject@v0.0.0-20200308113650-138e5925c53b/inject.go:135 (0x951205)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/context.go:124 (0x967cc4)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/router.go:187 (0x972959)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/router.go:304 (0x973a01)
/my/cache/.heroku/go/go-path/pkg/mod/gopkg.in/macaron.v1@v1.5.1/macaron.go:218 (0x96c572)
/my/cache/.heroku/go/go1.26.3/go/src/net/http/server.go:3311 (0x85a5cd)
/my/cache/.heroku/go/go1.26.3/go/src/net/http/server.go:2073 (0x837f6f)
/my/cache/.heroku/go/go1.26.3/go/src/runtime/asm_amd64.s:1771 (0x493380)