xtablo-source/backend/templates/tablos.templ
Arthur Belleville 6953536dd8
feat(15-03): restyle tablo detail page header and tab nav to match reference design
- Replace plain back-link + title with structured header: title row + metadata row
- Title uses larger md:text-3xl font-bold style with inline-edit preserved
- Add Discussion and Invite action buttons in header (purple brand colors)
- Add metadata row: created date, hardcoded En cours status badge, 0% progress bar
- Replace plain slate tab nav with purple-accent tab bar (icon + label per tab)
- Tab active state: text-[#804EEC] border-[#804EEC]; inactive: text-[#667085]
- Tab bar is sticky (top-0 z-40) with horizontal scroll and hidden scrollbar
- Keep all HTMX attributes, hx-push-url, hx-target="#tab-content" logic unchanged
- tablo-title-zone, tablo-desc-zone, tablo-delete-zone elements retained
2026-05-16 22:15:49 +02:00

596 lines
26 KiB
Text

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 []TabloCardView) {
@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).
// Delegates delete-zone rendering to TabloDeleteButtonFragment (single source of truth).
templ TabloCard(card TabloCardView, csrfToken string) {
<div id={ "tablo-" + card.Tablo.ID.String() }>
@ui.Card(ui.CardProps{Body: tabloCardBody(card, csrfToken)})
</div>
}
templ tabloCardBody(card TabloCardView, csrfToken string) {
<div class="flex items-start justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-semibold leading-snug">{ card.Tablo.Title }</h2>
@DiscussionUnreadBadge(card.DiscussionUnreadCount)
</div>
if card.Tablo.Description.Valid && card.Tablo.Description.String != "" {
<p class="mt-2 text-base text-slate-600">{ card.Tablo.Description.String }</p>
}
if card.Tablo.Color.Valid && card.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: " + card.Tablo.Color.String }
></span>
<span class="text-sm text-slate-500">{ card.Tablo.Color.String }</span>
</div>
}
</div>
@TabloDeleteButtonFragment(card.Tablo, csrfToken)
</div>
<div class="mt-4">
<a href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
</div>
}
templ DiscussionUnreadBadge(count int64) {
if count > 0 {
<span class="inline-flex min-w-6 items-center justify-center rounded-full border border-blue-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold leading-none text-blue-700" aria-label={ DiscussionUnreadAriaLabel(count) }>{ DiscussionUnreadDisplay(count) }</span>
}
}
// 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"
/>
@FieldError(errs.Color)
</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(TabloCardFromTablo(tablo), csrfToken)
<div id="create-form-slot" hx-swap-oob="true"></div>
}
// TabloDetailPage renders the full detail page for a single tablo with a tab layout.
// Tabs: Overview / Tasks / Files / Events / Discussion. activeTab selects the initially rendered tab content.
// files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs).
// UI-SPEC §3 Interaction Contract — GET /tablos/{id}.
// D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url.
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, discussion DiscussionTabData, activeTab string) {
@Layout("Tablos — Xtablo", user, csrfToken) {
<!-- Header section: title row + metadata row -->
<div class="px-0 pt-0">
<!-- Title row: h1 + action buttons -->
<div class="flex flex-col md:flex-row items-start justify-between mb-6 border-b border-[#F2F4F7] dark:border-gray-700 pb-5 gap-5 md:gap-0">
<div class="flex items-center gap-4">
<!-- tablo-title-zone: inline-edit display + form for title (UI-SPEC §4) -->
@TabloTitleDisplay(tablo, csrfToken)
</div>
<div class="flex items-center gap-3 w-full sm:w-auto">
<!-- Discussion action button -->
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/discussion") }
hx-get={ "/tablos/" + tablo.ID.String() + "/discussion" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/discussion" }
class="bg-[#804EEC] hover:bg-[#6f3fd4] text-white font-medium py-2.5 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors flex-1 sm:flex-none min-h-[44px] text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path></svg>
Discussion
</a>
<!-- Invite button (placeholder) -->
<button
type="button"
class="border border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10 font-medium py-2.5 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors flex-1 sm:flex-none min-h-[44px] text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" x2="19" y1="8" y2="14"></line><line x1="22" x2="16" y1="11" y2="11"></line></svg>
Invite
</button>
<!-- tablo-delete-zone: delete button (UI-SPEC §3) -->
<div class="tablo-delete-zone">
@TabloDeleteButtonFragment(tablo, csrfToken)
</div>
</div>
</div>
<!-- Metadata row: created date, status badge, progress -->
<div class="flex flex-wrap items-center gap-3 sm:gap-6 text-sm border-b border-[#F2F4F7] dark:border-gray-700 pb-4 mb-4">
<div class="flex items-center gap-2 sm:border-r sm:border-[#F2F4F7] sm:dark:border-gray-700 sm:pr-6">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[#667085]"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"></rect><line x1="16" x2="16" y1="2" y2="6"></line><line x1="8" x2="8" y1="2" y2="6"></line><line x1="3" x2="21" y1="10" y2="10"></line></svg>
<span class="text-[#667085]">Créé le</span>
<span class="text-foreground font-medium">{ tablo.CreatedAt.Time.Format("Jan 2, 2006") }</span>
</div>
<div class="flex items-center gap-2 sm:border-r sm:border-[#F2F4F7] sm:dark:border-gray-700 sm:pr-6">
<span class="text-[#667085]">Statut</span>
<span class="px-3 py-1 rounded-full text-xs font-medium bg-yellow-50 text-yellow-700 border border-yellow-200">En cours</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[#667085]">Progression</span>
<div class="relative w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
<div class="absolute inset-y-0 left-0 bg-green-500" style="width: 0%;"></div>
</div>
<span class="text-foreground font-medium">0%</span>
</div>
</div>
<!-- tablo-desc-zone: inline-edit display + form for description (UI-SPEC §4) -->
<div class="tablo-desc-zone mb-2">
@TabloDescDisplay(tablo, csrfToken)
</div>
</div>
<!-- Sticky tab navigation bar (D-07, D-08) -->
<div class="w-full bg-white dark:bg-background sticky top-0 z-40">
<div class="py-2">
<nav class="flex items-center gap-4 sm:gap-6 mb-4 border-b border-[#F2F4F7] dark:border-gray-700 overflow-x-auto -mx-4 px-4" style="scrollbar-width: none;">
<!-- Overview tab -->
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
hx-get={ "/tablos/" + tablo.ID.String() }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() }
if activeTab == "overview" || activeTab == "" {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 shrink-0 min-h-[44px] text-[#804EEC] border-[#804EEC]"
} else {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 border-transparent shrink-0 min-h-[44px] text-[#667085] hover:text-gray-900"
}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"></rect><rect width="7" height="5" x="14" y="3" rx="1"></rect><rect width="7" height="9" x="14" y="12" rx="1"></rect><rect width="7" height="5" x="3" y="16" rx="1"></rect></svg>
Overview
</a>
<!-- Tasks tab -->
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/tasks") }
hx-get={ "/tablos/" + tablo.ID.String() + "/tasks" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/tasks" }
if activeTab == "tasks" {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 shrink-0 min-h-[44px] text-[#804EEC] border-[#804EEC]"
} else {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 border-transparent shrink-0 min-h-[44px] text-[#667085] hover:text-gray-900"
}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="m9 12 2 2 4-4"></path></svg>
Étapes
</a>
<!-- Files tab -->
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/files") }
hx-get={ "/tablos/" + tablo.ID.String() + "/files" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/files" }
if activeTab == "files" {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 shrink-0 min-h-[44px] text-[#804EEC] border-[#804EEC]"
} else {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 border-transparent shrink-0 min-h-[44px] text-[#667085] hover:text-gray-900"
}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"></path></svg>
Files
</a>
<!-- Discussion tab -->
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/discussion") }
hx-get={ "/tablos/" + tablo.ID.String() + "/discussion" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/discussion" }
if activeTab == "discussion" {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 shrink-0 min-h-[44px] text-[#804EEC] border-[#804EEC]"
} else {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 border-transparent shrink-0 min-h-[44px] text-[#667085] hover:text-gray-900"
}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path></svg>
Discussion
</a>
<!-- Events tab -->
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/events") }
hx-get={ "/tablos/" + tablo.ID.String() + "/events" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/events" }
if activeTab == "events" {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 shrink-0 min-h-[44px] text-[#804EEC] border-[#804EEC]"
} else {
class="flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 border-transparent shrink-0 min-h-[44px] text-[#667085] hover:text-gray-900"
}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"></rect><line x1="16" x2="16" y1="2" y2="6"></line><line x1="8" x2="8" y1="2" y2="6"></line><line x1="3" x2="21" y1="10" y2="10"></line></svg>
Events
</a>
</nav>
</div>
</div>
<!-- Tab content area — HTMX tab switches target this div -->
<div id="tab-content" class="px-0 sm:px-0 pt-6 pb-8">
if activeTab == "tasks" {
@TasksTabFragment(tablo, tasks, etapes, counts, filter, csrfToken)
} else if activeTab == "files" {
@FilesTabFragment(tablo, files, csrfToken)
} else if activeTab == "events" {
@EventsTabFragment(tablo, events, csrfToken)
} else if activeTab == "discussion" {
@DiscussionTabFragment(tablo, discussion, DiscussionForm{}, DiscussionErrors{}, csrfToken)
} else {
@TabloOverviewTabFragment(tablo, csrfToken)
}
</div>
}
}
// TabloOverviewTabFragment renders the overview tab content — description display.
// Returned as a standalone fragment for HTMX tab-switch responses.
templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) {
<div class="overview-tab">
if tablo.Description.Valid && tablo.Description.String != "" {
<p class="text-base text-slate-600">{ tablo.Description.String }</p>
} else {
<p class="text-sm text-slate-400 italic">No description.</p>
}
</div>
}
// TasksTabFragment wraps the KanbanBoard for use as a standalone HTMX tab fragment.
// Returned by TabloTasksTabHandler on HX-Request == "true".
// Lives in tablos.templ (tablo-level concern) per plan D-07.
templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, csrfToken string) {
<div id="tasks-tab">
@EtapeStrip(tablo.ID, etapes, counts, filter, csrfToken, false)
@KanbanBoard(tablo.ID, csrfToken, tasks, filter)
</div>
}
// TabloTitleDisplay renders the tablo title as a clickable element that swaps
// to the edit form on click. The outermost element carries class tablo-title-zone
// so hx-target="closest .tablo-title-zone" + hx-swap="outerHTML" round-trips cleanly.
// UI-SPEC §4 Interaction Contract — title inline-edit display state.
templ TabloTitleDisplay(tablo sqlc.Tablo, csrfToken string) {
<h1
class="tablo-title-zone text-xl md:text-3xl font-bold text-foreground cursor-pointer hover:text-slate-600"
hx-get={ "/tablos/" + tablo.ID.String() + "/edit-title" }
hx-target="closest .tablo-title-zone"
hx-swap="outerHTML"
role="button"
aria-label="Edit title"
>{ tablo.Title }</h1>
}
// TabloTitleEditFragment renders the inline title edit form. Carries class
// tablo-title-zone on the form element so outerHTML round-trips work correctly.
// Includes hidden description field (current value) + hidden _zone="title" for
// TabloUpdateHandler to know which display fragment to render on success.
// UI-SPEC §4 Interaction Contract — title inline-edit edit state.
templ TabloTitleEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string) {
<form
class="tablo-title-zone space-y-3"
method="POST"
action={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
hx-post={ "/tablos/" + tablo.ID.String() }
hx-target="closest .tablo-title-zone"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
<input type="hidden" name="_zone" value="title"/>
<input type="hidden" name="description" value={ tablo.Description.String }/>
<div>
<label for="title" class="block text-sm font-medium text-slate-700">Title</label>
<input
id="title"
type="text"
name="title"
value={ tablo.Title }
required
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-xl font-bold placeholder-slate-400 focus:border-[#804EEC] focus:outline-none"
/>
@FieldError(errs.Title)
</div>
<div class="flex items-center gap-3">
@ui.Button(ui.ButtonProps{
Label: "Save changes",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
@ui.Button(ui.ButtonProps{
Label: "Discard changes",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tablo.ID.String() + "/show-title",
"hx-target": "closest .tablo-title-zone",
"hx-swap": "outerHTML",
},
})
</div>
</form>
}
// TabloDescDisplay renders the tablo description as a clickable element that
// swaps to the edit form on click. Carries class tablo-desc-zone on the outermost
// element for clean outerHTML round-trips.
// UI-SPEC §4 Interaction Contract — description inline-edit display state.
templ TabloDescDisplay(tablo sqlc.Tablo, csrfToken string) {
<div
class="tablo-desc-zone mt-4 cursor-pointer hover:text-slate-600"
hx-get={ "/tablos/" + tablo.ID.String() + "/edit-desc" }
hx-target="closest .tablo-desc-zone"
hx-swap="outerHTML"
role="button"
aria-label="Edit description"
>
if tablo.Description.Valid && tablo.Description.String != "" {
<p class="text-base text-slate-600">{ tablo.Description.String }</p>
} else {
<p class="text-sm text-slate-400 italic">Add a description</p>
}
</div>
}
// TabloDescEditFragment renders the inline description edit form. Carries class
// tablo-desc-zone on the form element so outerHTML round-trips work correctly.
// Includes hidden title field (current value) + hidden _zone="desc" for
// TabloUpdateHandler to know which display fragment to render on success.
// UI-SPEC §4 Interaction Contract — description inline-edit edit state.
templ TabloDescEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string) {
<form
class="tablo-desc-zone mt-4 space-y-3"
method="POST"
action={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
hx-post={ "/tablos/" + tablo.ID.String() }
hx-target="closest .tablo-desc-zone"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
<input type="hidden" name="_zone" value="desc"/>
<input type="hidden" name="title" value={ tablo.Title }/>
<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"
>{ tablo.Description.String }</textarea>
@FieldError(errs.Description)
</div>
<div class="flex items-center gap-3">
@ui.Button(ui.ButtonProps{
Label: "Save changes",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
@ui.Button(ui.ButtonProps{
Label: "Discard changes",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tablo.ID.String() + "/show-desc",
"hx-target": "closest .tablo-desc-zone",
"hx-swap": "outerHTML",
},
})
</div>
</form>
}
// TabloDeleteButtonFragment renders the delete trigger button wrapped in a
// .tablo-delete-zone div. This is the canonical single source of truth for the
// delete zone shape — TabloCard delegates to this component (no duplication).
// UI-SPEC §5 Interaction Contract — delete display state.
templ TabloDeleteButtonFragment(tablo sqlc.Tablo, csrfToken string) {
<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",
"aria-label": "Delete tablo",
},
})
</div>
}
// TabloDeleteConfirmFragment renders the inline confirmation dialog that appears
// when the user clicks Delete. Carries class tablo-delete-zone for outerHTML
// round-trips. "Yes, delete" submits the DELETE action; "Keep tablo" restores
// the original Delete button via TabloDeleteCancelHandler.
// UI-SPEC §5 Interaction Contract — delete confirmation state.
templ TabloDeleteConfirmFragment(tablo sqlc.Tablo, csrfToken string) {
<div class="tablo-delete-zone space-y-3">
<p class="text-base font-semibold text-slate-800">Delete tablo?</p>
<p class="text-sm text-slate-600">This cannot be undone.</p>
<div class="flex items-center gap-3">
<form
method="POST"
action={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/delete") }
hx-post={ "/tablos/" + tablo.ID.String() + "/delete" }
hx-target="closest .tablo-delete-zone"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
@ui.Button(ui.ButtonProps{
Label: "Yes, delete",
Variant: ui.ButtonVariantDanger,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
Attrs: templ.Attributes{
"aria-label": "Confirm delete tablo",
},
})
</form>
@ui.Button(ui.ButtonProps{
Label: "Keep tablo",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tablo.ID.String() + "/delete-cancel",
"hx-target": "closest .tablo-delete-zone",
"hx-swap": "outerHTML",
"aria-label": "Keep tablo",
},
})
</div>
</div>
}
// TabloNotFoundPage renders a 404 page for tablos that don't exist or are not
// accessible by the current user (D-04: 404 not 403 to avoid existence leakage).
// user may be nil when called from an unauthenticated context — Layout handles nil.
// UI-SPEC Copywriting Contract: "Not found" + "This tablo doesn't exist or you don't have access."
templ TabloNotFoundPage(user *auth.User, csrfToken string) {
@Layout("Not found", user, csrfToken) {
<div class="py-16 text-center">
<h1 class="text-2xl font-semibold leading-snug text-slate-800">Not found</h1>
<p class="mt-2 text-base text-slate-600">This tablo doesn&#39;t exist or you don&#39;t have access.</p>
<div class="mt-6">
<a href="/" class="text-sm font-medium text-blue-600 hover:underline">Back to tablos</a>
</div>
</div>
}
}