package templates import ( "backend/internal/auth" "backend/internal/db/sqlc" "backend/internal/web/ui" "strconv" ) // TablosDashboard renders the root authenticated dashboard with sidebar AppLayout. // Shows a project-card grid (or empty state) for the user's tablos. templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView, pageTitle string, breadcrumb []BreadcrumbItem) { @AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {

Mes Projets

@ui.Button(ui.ButtonProps{ Label: "Nouveau projet", 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", }, })
if len(cards) == 0 { @TablosEmptyState() } else { for _, card := range cards { @TabloProjectCard(card, csrfToken) } }
} } // TablosEmptyState renders the empty-state copy when a user has no tablos. // Copy strings are locked by UI-SPEC Copywriting Contract. // Uses ui.EmptyState for consistent styling across the app (Phase 13). templ TablosEmptyState() { @ui.EmptyState(ui.EmptyStateProps{ Title: "No tablos yet", Description: "Create your first tablo to get started.", Action: 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", }, }), }) } // TabloProjectCard renders a single tablo as a dual-element card+row wrapper. // The outer article.tablo-card-wrapper contains both a .project-card (grid view) // and a .tablo-list-row (list view, hidden by default). // Matches production design: status badge top-left, delete button top-right, // colored avatar with initial letter, title, date, and progress bar. // Uses display:contents on the wrapper so it is transparent to grid/flex layout. // Guards color rendering against null pgtype.Text values (Pitfall 6). // Uses .Time accessor on pgtype.Timestamptz (Pitfall 6). templ TabloProjectCard(card TabloCardView, csrfToken string) {
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
if len(card.Tablo.Title) > 0 { { string([]rune(card.Tablo.Title)[0:1]) } }
} else {
if len(card.Tablo.Title) > 0 { { string([]rune(card.Tablo.Title)[0:1]) } }
}

{ card.Tablo.Title }

if card.Tablo.Status == "archived" { Archivé } else { Actif }
@ui.IconButton(ui.IconButtonProps{ Label: "Delete tablo", Icon: "trash", Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{ "hx-get": "/tablos/" + card.Tablo.ID.String() + "/delete-confirm", "hx-target": "closest .tablo-delete-zone", "hx-swap": "outerHTML", }, })
if tabloDisplayStatus(card.Progress, card.TotalTasks) == "termine" { Terminé } else if tabloDisplayStatus(card.Progress, card.TotalTasks) == "en-cours" { En cours } else { À faire } { card.Tablo.Title } { card.Tablo.CreatedAt.Time.Format("2 Jan 2006") } { strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) }
@ui.IconButton(ui.IconButtonProps{ Label: "Delete tablo", Icon: "trash", Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{ "hx-get": "/tablos/" + card.Tablo.ID.String() + "/delete-confirm", "hx-target": "closest .tablo-delete-zone", "hx-swap": "outerHTML", }, })
} // 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) {
@ui.Card(ui.CardProps{Body: tabloCardBody(card, csrfToken)})
} templ tabloCardBody(card TabloCardView, csrfToken string) {

{ card.Tablo.Title }

@DiscussionUnreadBadge(card.DiscussionUnreadCount)
if card.Tablo.Description.Valid && card.Tablo.Description.String != "" {

{ card.Tablo.Description.String }

} if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
{ card.Tablo.Color.String }
}
@TabloDeleteButtonFragment(card.Tablo, csrfToken)
View
} templ DiscussionUnreadBadge(count int64) { if count > 0 { { DiscussionUnreadDisplay(count) } } } // 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) {
@ui.CSRFField(csrfToken) @GeneralError(errs.General)

Create a tablo

@FieldError(errs.Title)
@FieldError(errs.Color)
@ui.Button(ui.ButtonProps{ Label: "Create tablo", Variant: ui.ButtonVariantDefault, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "submit", }) Cancel
} // 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)
} // TabloDetailPage renders the full detail page for a single tablo with a 3-tab layout. // Tabs: Overview / Tasks / Files. activeTab selects the initially rendered tab content. // files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs). // activePath and sidebarTablos drive the AppLayout sidebar. // 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, activePath string, sidebarTablos []sqlc.Tablo, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, discussion DiscussionTabData, activeTab string, pageTitle string, breadcrumb []BreadcrumbItem) { @AppLayout("Tablos — Xtablo", user, csrfToken, activePath, sidebarTablos, pageTitle, breadcrumb, nil) {
if tablo.Color.Valid && tablo.Color.String != "" { if len(tablo.Title) > 0 { { string([]rune(tablo.Title)[0:1]) } } } else { if len(tablo.Title) > 0 { { string([]rune(tablo.Title)[0:1]) } } }
@TabloTitleDisplay(tablo, csrfToken)
@ui.IconButton(ui.IconButtonProps{ Label: "Discussion", Icon: "chat", Variant: ui.IconButtonVariantNeutral, Tone: ui.IconButtonToneGhost, Type: "button", }) @ui.Button(ui.ButtonProps{ Label: "Invite Member", Variant: ui.ButtonVariantDefault, Tone: ui.ButtonToneSoft, Size: ui.SizeMD, Type: "button", })
@ui.IconButton(ui.IconButtonProps{ Label: "Delete tablo", Icon: "trash", Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{ "hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm", "hx-target": "closest .tablo-delete-zone", "hx-swap": "outerHTML", }, })
@ui.Badge(ui.BadgeProps{Label: "In progress", Variant: ui.BadgeVariantPrimary})
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) }
} } // TabloOverviewTabFragment renders the overview tab content — description inline-edit zone. // Returned as a standalone fragment for HTMX tab-switch responses. // Description zone relocated here from the persistent header (Phase 16 Plan 02). templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) {
@TabloDescDisplay(tablo, csrfToken)
} // 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) {
@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)
} // 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) {

{ tablo.Title }

} // 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) {
@ui.CSRFField(csrfToken)
@FieldError(errs.Title)
@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", }, })
} // 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) {
if tablo.Description.Valid && tablo.Description.String != "" {

{ tablo.Description.String }

} else {

Add a description

}
} // 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) {
@ui.CSRFField(csrfToken)
@FieldError(errs.Description)
@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", }, })
} // 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) {
@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", }, })
} // 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) {

Delete tablo?

This cannot be undone.

@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", }, })
@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", }, })
} // 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). // activePath and sidebarTablos drive the AppLayout sidebar (pass "" and empty slice for not-found). // UI-SPEC Copywriting Contract: "Not found" + "This tablo doesn't exist or you don't have access." templ TabloNotFoundPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo) { @AppLayout("Not found", user, csrfToken, activePath, sidebarTablos, "Not found", nil, nil) {

Not found

This tablo doesn't exist or you don't have access.

Back to tablos
} }