- 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
388 lines
10 KiB
Go
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)
|
|
}
|