jamtrack/views/jams.templ
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

240 lines
6.5 KiB
Text

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