jamtrack/internal/web/handlers.go
Frédéric G. MARAND 6f3321371d 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
2026-06-15 14:12:57 +02:00

388 lines
10 KiB
Go

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)
}