feat(03-02): tablos templates — dashboard, empty state, card, create form, OOB-clear
- Create backend/templates/tablos.templ with TablosDashboard, TablosEmptyState, TabloCard, TabloCreateFormFragment, TabloCardWithOOBFormClear components - Create backend/templates/tablos_forms.go declaring TabloCreateForm and TabloCreateErrors types (mirrors auth_forms.go pattern) - Update layout.templ footer: "Phase 2 · Authentication" → "Phase 3 · Tablos" - TabloCardWithOOBFormClear emits OOB div as top-level sibling (Pitfall 5) - TabloCard guards description/color rendering with pgtype.Text null checks - All UI-SPEC copywriting copy strings present; templ generate succeeds
This commit is contained in:
parent
2f22d68776
commit
43ddf25364
3 changed files with 196 additions and 1 deletions
|
|
@ -49,7 +49,7 @@ templ Layout(title string, user *auth.User, csrfToken string) {
|
||||||
{ children... }
|
{ children... }
|
||||||
</main>
|
</main>
|
||||||
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
||||||
Phase 2 · Authentication
|
Phase 3 · Tablos
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
179
backend/templates/tablos.templ
Normal file
179
backend/templates/tablos.templ
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/auth"
|
||||||
|
"backend/internal/db/sqlc"
|
||||||
|
"backend/internal/web/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TablosDashboard renders the root authenticated dashboard: heading, "New tablo"
|
||||||
|
// button, create-form slot, and the list of tablo cards (or empty state).
|
||||||
|
// UI-SPEC §1 Interaction Contract — GET /.
|
||||||
|
templ TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) {
|
||||||
|
@Layout("Tablos — Xtablo", user, csrfToken) {
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-[28px] font-semibold leading-tight">Your Tablos</h1>
|
||||||
|
@ui.Button(ui.ButtonProps{
|
||||||
|
Label: "New tablo",
|
||||||
|
Variant: ui.ButtonVariantDefault,
|
||||||
|
Tone: ui.ButtonToneSolid,
|
||||||
|
Size: ui.SizeMD,
|
||||||
|
Type: "button",
|
||||||
|
Attrs: templ.Attributes{
|
||||||
|
"hx-get": "/tablos/new",
|
||||||
|
"hx-target": "#create-form-slot",
|
||||||
|
"hx-swap": "innerHTML",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
<div id="create-form-slot"></div>
|
||||||
|
<div id="tablos-list">
|
||||||
|
if len(tablos) == 0 {
|
||||||
|
@TablosEmptyState()
|
||||||
|
} else {
|
||||||
|
for _, tablo := range tablos {
|
||||||
|
@TabloCard(tablo, csrfToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TablosEmptyState renders the empty-state copy when a user has no tablos.
|
||||||
|
// Copy strings are locked by UI-SPEC Copywriting Contract.
|
||||||
|
templ TablosEmptyState() {
|
||||||
|
<div class="text-center py-16">
|
||||||
|
<h2 class="text-xl font-semibold leading-snug text-slate-800">No tablos yet</h2>
|
||||||
|
<p class="mt-2 text-base text-slate-600">Create your first tablo to get started.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
@ui.Button(ui.ButtonProps{
|
||||||
|
Label: "New tablo",
|
||||||
|
Variant: ui.ButtonVariantDefault,
|
||||||
|
Tone: ui.ButtonToneSolid,
|
||||||
|
Size: ui.SizeMD,
|
||||||
|
Type: "button",
|
||||||
|
Attrs: templ.Attributes{
|
||||||
|
"hx-get": "/tablos/new",
|
||||||
|
"hx-target": "#create-form-slot",
|
||||||
|
"hx-swap": "innerHTML",
|
||||||
|
"aria-label": "Create your first tablo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabloCard renders a single tablo as a ui.Card on the dashboard.
|
||||||
|
// Guards description and color rendering against null pgtype.Text values (Pitfall 6).
|
||||||
|
// Wraps the Delete button in a .tablo-delete-zone div for Plan 03's delete-confirm swap.
|
||||||
|
templ TabloCard(tablo sqlc.Tablo, csrfToken string) {
|
||||||
|
@ui.Card(templ.Attributes{"id": "tablo-" + tablo.ID.String()}) {
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold leading-snug">{ tablo.Title }</h2>
|
||||||
|
if tablo.Description.Valid && tablo.Description.String != "" {
|
||||||
|
<p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
|
||||||
|
}
|
||||||
|
if tablo.Color.Valid && tablo.Color.String != "" {
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block w-2.5 h-2.5 rounded-full"
|
||||||
|
style={ "background-color: " + tablo.Color.String }
|
||||||
|
></span>
|
||||||
|
<span class="text-sm text-slate-500">{ tablo.Color.String }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="tablo-delete-zone">
|
||||||
|
@ui.Button(ui.ButtonProps{
|
||||||
|
Label: "Delete",
|
||||||
|
Variant: ui.ButtonVariantDanger,
|
||||||
|
Tone: ui.ButtonToneSoft,
|
||||||
|
Size: ui.SizeMD,
|
||||||
|
Type: "button",
|
||||||
|
Attrs: templ.Attributes{
|
||||||
|
"hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm",
|
||||||
|
"hx-target": "closest .tablo-delete-zone",
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabloCreateFormFragment renders the inline create form loaded into #create-form-slot
|
||||||
|
// via HTMX. Falls back to a plain POST /tablos for non-JS paths.
|
||||||
|
// UI-SPEC §2 Interaction Contract — GET /tablos/new + POST /tablos.
|
||||||
|
templ TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) {
|
||||||
|
<form
|
||||||
|
id="create-form"
|
||||||
|
method="POST"
|
||||||
|
action="/tablos"
|
||||||
|
hx-post="/tablos"
|
||||||
|
hx-target="#create-form-slot"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="mb-6 space-y-4 rounded border border-slate-200 bg-slate-50 p-6"
|
||||||
|
>
|
||||||
|
@ui.CSRFField(csrfToken)
|
||||||
|
@GeneralError(errs.General)
|
||||||
|
<h2 class="text-xl font-semibold leading-snug">Create a tablo</h2>
|
||||||
|
<div>
|
||||||
|
<label for="title" class="block text-sm font-medium text-slate-700">Title</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={ form.Title }
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
||||||
|
placeholder="My tablo"
|
||||||
|
/>
|
||||||
|
@FieldError(errs.Title)
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-slate-700">Description <span class="text-slate-400">(optional)</span></label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="3"
|
||||||
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
||||||
|
placeholder="What is this tablo for?"
|
||||||
|
>{ form.Description }</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="color" class="block text-sm font-medium text-slate-700">Color <span class="text-slate-400">(optional)</span></label>
|
||||||
|
<input
|
||||||
|
id="color"
|
||||||
|
type="text"
|
||||||
|
name="color"
|
||||||
|
value={ form.Color }
|
||||||
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
||||||
|
placeholder="#6366f1 or indigo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
@ui.Button(ui.ButtonProps{
|
||||||
|
Label: "Create tablo",
|
||||||
|
Variant: ui.ButtonVariantDefault,
|
||||||
|
Tone: ui.ButtonToneSolid,
|
||||||
|
Size: ui.SizeMD,
|
||||||
|
Type: "submit",
|
||||||
|
})
|
||||||
|
<a href="/" class="text-sm text-slate-600 hover:underline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabloCardWithOOBFormClear renders a TabloCard as the primary swap target
|
||||||
|
// AND an OOB element that clears #create-form-slot in the same response.
|
||||||
|
// The OOB div MUST be a top-level sibling of TabloCard — NOT nested (Pitfall 5).
|
||||||
|
// HTMX applies the primary swap (HX-Retarget: #tablos-list, afterbegin) AND
|
||||||
|
// the OOB swap (#create-form-slot → empty) from a single response.
|
||||||
|
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
|
||||||
|
@TabloCard(tablo, csrfToken)
|
||||||
|
<div id="create-form-slot" hx-swap-oob="true"></div>
|
||||||
|
}
|
||||||
16
backend/templates/tablos_forms.go
Normal file
16
backend/templates/tablos_forms.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
// TabloCreateForm carries the submitted field values back to the template so
|
||||||
|
// inputs can be repopulated on validation failure.
|
||||||
|
type TabloCreateForm struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Color string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabloCreateErrors holds per-field and general error messages for the tablo
|
||||||
|
// create form. A field with an empty string means "no error for this field".
|
||||||
|
type TabloCreateErrors struct {
|
||||||
|
Title string
|
||||||
|
General string
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue