xtablo-source/backend/templates/app_layout.templ
Arthur Belleville c190723538
feat(18-03): add PageHeader component and wire into AppLayout
- Add PageHeader templ component with three-zone layout (breadcrumb left, search placeholder center, bell/inbox/avatar right)
- Avatar dropdown uses native details/summary HTML (D-06, no Alpine.js)
- Logout form inside dropdown with CSRF token (T-18-03-01 mitigated)
- Remove Plan 01 breadcrumb/headerActions stubs from AppLayout body
- Wire @PageHeader call inside dashboard-main before children
- Add inline JS for avatar dropdown outside-click close behavior
2026-05-17 15:33:17 +02:00

306 lines
12 KiB
Text

package templates
import (
"backend/internal/auth"
"backend/internal/db/sqlc"
"backend/internal/web/ui"
)
// SidebarNavIcon renders the SVG icon for the given sidebar icon kind.
// SVG paths ported verbatim from go-backend/internal/web/views/icons.templ SidebarIcon.
templ SidebarNavIcon(kind string) {
switch kind {
case "panels":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M3 9h18"></path>
<path d="M9 21V9"></path>
</svg>
case "tasks":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="5" width="6" height="6" rx="1"></rect>
<path d="m3 17 2 2 4-4"></path>
<path d="M13 6h8"></path>
<path d="M13 12h8"></path>
<path d="M13 18h8"></path>
</svg>
case "layers":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"></path>
<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"></path>
<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"></path>
</svg>
case "planning":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M8 7v7"></path>
<path d="M12 7v4"></path>
<path d="M16 7v9"></path>
</svg>
case "chat":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
</svg>
case "files":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
</svg>
case "team":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
default:
<span></span>
}
}
// SidebarNavItemRow renders one nav list item.
// If item.Href is empty, renders as a non-interactive div (visual-only, per D-N02).
// If item.Href is non-empty, renders as an anchor tag.
templ SidebarNavItemRow(item sidebarNavItem) {
if item.Href == "" {
<div class={ sidebarNavItemClass(item.Active) } style="cursor: default">
<div class="sidebar-nav-link-inner">
<span class="sidebar-nav-icon">
@SidebarNavIcon(item.Icon)
</span>
<div class="sidebar-nav-label">{ item.Label }</div>
</div>
</div>
} else {
<a href={ templ.SafeURL(item.Href) } class={ sidebarNavItemClass(item.Active) }>
<div class="sidebar-nav-link-inner">
<span class="sidebar-nav-icon">
@SidebarNavIcon(item.Icon)
</span>
<div class="sidebar-nav-label">{ item.Label }</div>
</div>
</a>
}
}
// SidebarProjectsSection renders the projects list in the sidebar.
templ SidebarProjectsSection(tablos []sqlc.Tablo) {
<div class="sidebar-projects">
<hr role="separator"/>
<div class="sidebar-section-label">Projects</div>
<ul class="sidebar-project-list">
for _, tablo := range tablos {
<li>
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="sidebar-project-link">
if tablo.Color.Valid && tablo.Color.String != "" {
<span class="sidebar-project-icon" style={ "background-color: " + tablo.Color.String }></span>
} else {
<span class="sidebar-project-icon"></span>
}
<span class="sidebar-project-label">{ tablo.Title }</span>
</a>
</li>
}
</ul>
</div>
}
// SidebarOrganizationFooter renders the user/org info at the bottom of the sidebar.
// Per D-F01/D-F02/D-F03: shows user email initial as avatar, email as name, and logout form.
templ SidebarOrganizationFooter(user *auth.User, csrfToken string) {
<div class="sidebar-organization">
<div class="organization-button">
<span class="organization-avatar">{ string([]rune(user.Email)[:1]) }</span>
<span class="organization-copy">
<span class="organization-name">{ user.Email }</span>
</span>
</div>
<form method="POST" action="/logout">
@ui.CSRFField(csrfToken)
<button type="submit" class="text-sm">Log out</button>
</form>
</div>
}
// DashboardSidebar renders the full sidebar with brand, nav, projects, and footer.
// Rebuilt in Phase 18 Plan 02 per D-07/D-08/D-09: two-section structure (GENERAL + PROJECTS),
// collapse button wired via inline JS (no server round-trip, resets on reload per D-09).
// SidebarOrganizationFooter moves to avatar dropdown in Plan 03.
templ DashboardSidebar(activePath string, tablos []sqlc.Tablo, user *auth.User, csrfToken string) {
<aside class="dashboard-sidebar">
<nav aria-label="Main navigation" class="sidebar-nav-shell">
<!-- Brand / logo -->
<div class="sidebar-brand">
<a class="sidebar-brand-link" href="/" aria-label="Home">
<img class="sidebar-brand-logo" src="/static/logo_dark.png" alt="Logo XTablo"/>
<h1 class="sidebar-brand-title">XTablo</h1>
</a>
<button
class="sidebar-collapse-button"
aria-label="Toggle sidebar"
onclick="(function(btn){var shell=document.querySelector('.dashboard-shell');if(shell){shell.classList.toggle('sidebar-is-collapsed');}})(this)"
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16">
<path d="m15 18-6-6 6-6"/>
</svg>
</button>
</div>
<!-- GENERAL section -->
<div class="sidebar-primary">
<div class="sidebar-section-label">General</div>
<ul class="sidebar-list" role="list">
for _, item := range sidebarPrimaryNavItems(activePath) {
<li>
@SidebarNavItemRow(item)
</li>
}
</ul>
<!-- PROJECTS section (tablo list) -->
<div class="sidebar-projects">
<div class="sidebar-section-label">Projects</div>
<ul class="sidebar-project-list">
for _, tablo := range tablos {
<li>
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="sidebar-project-link">
if tablo.Color.Valid && tablo.Color.String != "" {
<span class="sidebar-project-icon" style={ "background-color: " + tablo.Color.String }></span>
} else {
<span class="sidebar-project-icon"></span>
}
<span class="sidebar-project-label">{ tablo.Title }</span>
</a>
</li>
}
</ul>
</div>
</div>
</nav>
</aside>
}
// PageHeader renders the full-width top bar with three zones:
// left (breadcrumb), center (search placeholder), right (bell, inbox, avatar dropdown).
// The avatar dropdown uses native HTML details/summary — no Alpine.js (D-06).
templ PageHeader(pageTitle string, breadcrumb []BreadcrumbItem, headerActions templ.Component, user *auth.User, csrfToken string) {
<header class="page-header">
<!-- LEFT: breadcrumb zone -->
<div class="page-header-left">
<nav class="breadcrumb" aria-label="Breadcrumb">
for i, crumb := range breadcrumb {
if i > 0 {
<span class="breadcrumb-separator" aria-hidden="true">/</span>
}
if crumb.Href != "" {
<a href={ templ.SafeURL(crumb.Href) } class="breadcrumb-item breadcrumb-item--link">{ crumb.Label }</a>
} else {
<span class="breadcrumb-item breadcrumb-item--current" aria-current="page">{ crumb.Label }</span>
}
}
if len(breadcrumb) == 0 {
<span class="breadcrumb-item breadcrumb-item--current">{ pageTitle }</span>
}
</nav>
</div>
<!-- CENTER: search placeholder -->
<div class="page-header-center">
<div class="header-search-placeholder" aria-label="Search (coming soon)" role="search">
<svg class="header-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<span class="header-search-text">Search...</span>
</div>
</div>
<!-- RIGHT: bell, inbox, avatar -->
<div class="page-header-right">
<!-- Bell placeholder -->
<button class="header-icon-button" aria-label="Notifications (coming soon)" type="button" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="18" height="18">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"></path>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path>
</svg>
</button>
<!-- Inbox placeholder -->
<button class="header-icon-button" aria-label="Inbox (coming soon)" type="button" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="18" height="18">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline>
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
</svg>
</button>
<!-- Avatar dropdown using native details/summary (D-06) -->
<details class="header-avatar-menu">
<summary class="header-avatar-button" aria-label="User menu">
<span class="header-avatar-initial">{ string([]rune(user.Email)[:1]) }</span>
</summary>
<div class="header-avatar-dropdown">
<!-- Workspace / user info -->
<div class="avatar-dropdown-workspace">
<span class="avatar-dropdown-workspace-name">{ user.Email }</span>
<span class="avatar-dropdown-workspace-meta">1 member</span>
</div>
<hr class="avatar-dropdown-divider"/>
<!-- Settings -->
<a href="/settings" class="avatar-dropdown-item">Settings</a>
<hr class="avatar-dropdown-divider"/>
<!-- Logout -->
<form method="POST" action="/logout" class="avatar-dropdown-logout-form">
@ui.CSRFField(csrfToken)
<button type="submit" class="avatar-dropdown-item avatar-dropdown-item--danger">Log out</button>
</form>
</div>
</details>
</div>
<!-- Per-page header actions slot (Phase 19-22 will populate) -->
if headerActions != nil {
<div class="page-header-actions">
@headerActions
</div>
}
</header>
}
// AppLayout is the authenticated HTML shell for all dashboard pages.
// It replaces Layout for all authenticated routes in Phase 15+.
//
// Differences from Layout:
// - Uses .dashboard-shell grid with sidebar + main content
// - DashboardSidebar renders nav, projects, and org footer
// - main#app-main-content.dashboard-main wraps page children
// - Scripts: htmx.min.js, sortable.min.js, discussion-sse.js (per D-L04)
templ AppLayout(title string, user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, pageTitle string, breadcrumb []BreadcrumbItem, headerActions templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/tailwind.css"/>
</head>
<body>
<div class="dashboard-shell">
@DashboardSidebar(activePath, tablos, user, csrfToken)
<main id="app-main-content" class="dashboard-main">
@PageHeader(pageTitle, breadcrumb, headerActions, user, csrfToken)
{ children... }
</main>
</div>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/sortable.min.js" defer></script>
<script src="/static/discussion-sse.js" defer></script>
<script>
document.addEventListener('click', function(e) {
document.querySelectorAll('details.header-avatar-menu').forEach(function(d) {
if (!d.contains(e.target)) { d.removeAttribute('open'); }
});
});
</script>
</body>
</html>
}