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