feat(15-02): create app_layout.templ and app_layout_helpers.go with sidebar sub-components
- Create backend/templates/app_layout_helpers.go: sidebarNavItem struct, sidebarNavItemClass, isActivePath, sidebarNavItemID, sidebarPrimaryNavItems - Create backend/templates/app_layout.templ: SidebarNavIcon, SidebarNavItemRow, SidebarProjectsSection, SidebarOrganizationFooter, DashboardSidebar, AppLayout - templ generate and go build exit 0; all existing tests pass
This commit is contained in:
parent
f533d53c74
commit
9b0d335329
2 changed files with 229 additions and 0 deletions
174
backend/templates/app_layout.templ
Normal file
174
backend/templates/app_layout.templ
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
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>
|
||||||
|
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.
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-primary">
|
||||||
|
<ul class="sidebar-list" role="list">
|
||||||
|
for _, item := range sidebarPrimaryNavItems(activePath) {
|
||||||
|
<li>
|
||||||
|
@SidebarNavItemRow(item)
|
||||||
|
</li>
|
||||||
|
if item.DividerAfter {
|
||||||
|
<li class="sidebar-divider"><hr role="separator"/></li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@SidebarProjectsSection(tablos)
|
||||||
|
@SidebarOrganizationFooter(user, csrfToken)
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<!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">
|
||||||
|
{ 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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
55
backend/templates/app_layout_helpers.go
Normal file
55
backend/templates/app_layout_helpers.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// sidebarNavItem describes one entry in the sidebar primary navigation list.
|
||||||
|
type sidebarNavItem struct {
|
||||||
|
Href string
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
Active bool
|
||||||
|
DividerAfter bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidebarNavItemClass returns the CSS class string for a nav item.
|
||||||
|
// Active items receive "sidebar-nav-item is-active"; inactive receive "sidebar-nav-item".
|
||||||
|
func sidebarNavItemClass(active bool) string {
|
||||||
|
if active {
|
||||||
|
return "sidebar-nav-item is-active"
|
||||||
|
}
|
||||||
|
return "sidebar-nav-item"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isActivePath reports whether activePath matches href.
|
||||||
|
// Returns false when activePath is empty or blank.
|
||||||
|
func isActivePath(activePath string, href string) bool {
|
||||||
|
return strings.TrimSpace(activePath) != "" && activePath == href
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidebarNavItemID returns a stable HTML id attribute for the given nav href.
|
||||||
|
func sidebarNavItemID(href string) string {
|
||||||
|
switch href {
|
||||||
|
case "/":
|
||||||
|
return "sidebar-nav-home"
|
||||||
|
default:
|
||||||
|
slug := strings.Trim(strings.ReplaceAll(href, "/", "-"), "-")
|
||||||
|
if slug == "" {
|
||||||
|
slug = "item"
|
||||||
|
}
|
||||||
|
return "sidebar-nav-" + slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidebarPrimaryNavItems returns the ordered list of primary nav items with
|
||||||
|
// active state computed from activePath.
|
||||||
|
//
|
||||||
|
// Per D-N01/D-N02: Tasks, Chat, and Files are visual-only (no Href) in Phase 15.
|
||||||
|
func sidebarPrimaryNavItems(activePath string) []sidebarNavItem {
|
||||||
|
return []sidebarNavItem{
|
||||||
|
{Href: "/", Label: "Dashboard", Icon: "panels", Active: isActivePath(activePath, "/"), DividerAfter: true},
|
||||||
|
{Href: "", Label: "Tasks", Icon: "tasks", Active: false},
|
||||||
|
{Href: "/planning", Label: "Planning", Icon: "planning", Active: isActivePath(activePath, "/planning")},
|
||||||
|
{Href: "", Label: "Chat", Icon: "chat", Active: false},
|
||||||
|
{Href: "", Label: "Files", Icon: "files", Active: false},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue