Files
workspace/apps/go-crm/internal/templates/ui.go
gabspereira 9c3bfd131d feat(go-crm): industrial terminal-core UI redesign
Complete visual overhaul of the go-crm web interface:

- New shared layout system (internal/templates/ui.go) with dark zinc
  industrial theme, JetBrains Mono typography, grid/noise textures
- Redesigned all pages: Dashboard, Login/Signup, Clients, Customers,
  Services, Scheduling, Payments, Questions, Answers, Leads,
  Review Queue, Report, Keyword Mapping
- Tailwind CSS via CDN with custom color palette (amber/emerald/rose/sky)
- HTMX-powered interactions with CSS swap animations
- Status pills, KPI cards, data tables, empty states, inline forms
- Mobile-responsive sidebar with collapsible navigation
- All existing tests updated and passing
- Zero new build dependencies — works with existing go run/air workflow
2026-05-23 18:00:32 -03:00

491 lines
24 KiB
Go

package templates
import (
"bytes"
"fmt"
"html/template"
"io"
"strings"
)
// Page renders a full HTML page with the industrial terminal layout.
type Page struct {
Title string
ActiveNav string
Content template.HTML
ExtraHead template.HTML
}
// NavItem defines a navigation entry.
type NavItem struct {
Label string
Href string
Icon string
ID string
}
var navItems = []NavItem{
{Label: "Dashboard", Href: "/", Icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", ID: "dashboard"},
{Label: "Review Queue", Href: "/leads/review", Icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", ID: "review"},
{Label: "All Leads", Href: "/leads/all", Icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z", ID: "leads"},
{Label: "Report", Href: "/report", Icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6m10 0v-6a2 2 0 012-2h2a2 2 0 012 2v6m-6 0V7a2 2 0 012-2h2a2 2 0 012 2v12", ID: "report"},
{Label: "Clients", Href: "/clients", Icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5", ID: "clients"},
{Label: "Customers", Href: "/customers", Icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", ID: "customers"},
{Label: "Services", Href: "/services", Icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37 1.608.536 3.135-.493 3.35-2.572zM15 12a3 3 0 11-6 0 3 3 0 016 0z", ID: "services"},
{Label: "Scheduling", Href: "/scheduling", Icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", ID: "scheduling"},
{Label: "Payments", Href: "/payments", Icon: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2-1.343-2-3-2zm0 0v1m0 0v1m0-1h1m-1 0H9m12 0a2 2 0 012 2v4.5a2.5 2.5 0 01-2.5 2.5h-15a2.5 2.5 0 01-2.5-2.5V10a2 2 0 012-2h15z", ID: "payments"},
{Label: "Questions", Href: "/questions", Icon: "M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", ID: "questions"},
{Label: "Answers", Href: "/answers", Icon: "M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z", ID: "answers"},
}
const layoutTemplate = `<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — go-crm</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/loading-states.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
zinc: { 950: '#0a0a0f', 900: '#111118', 850: '#15151e', 800: '#1a1a24', 750: '#1e1e2a', 700: '#272736', 600: '#3f3f50', 500: '#5a5a6e', 400: '#7a7a8f', 300: '#a1a1b3', 200: '#c4c4d4', 100: '#e2e2eb', 50: '#f4f4f8' },
amber: { 450: '#d97706', 400: '#f59e0b', 300: '#fbbf24' },
emerald: { 450: '#059669', 400: '#10b981', 300: '#34d399' },
rose: { 450: '#e11d48', 400: '#f43f5e', 300: '#fb7185' },
sky: { 450: '#0284c7', 400: '#0ea5e9', 300: '#38bdf8' },
},
fontFamily: {
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
sans: ['"Inter"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
},
}
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Industrial Terminal Aesthetic */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #111118;
--bg-tertiary: #1a1a24;
--bg-elevated: #1e1e2a;
--border-subtle: rgba(255,255,255,0.06);
--border-medium: rgba(255,255,255,0.1);
--border-strong: rgba(255,255,255,0.15);
--text-primary: #e2e2eb;
--text-secondary: #a1a1b3;
--text-muted: #7a7a8f;
--accent-amber: #f59e0b;
--accent-emerald: #10b981;
--accent-rose: #f43f5e;
--accent-sky: #0ea5e9;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, sans-serif;
}
/* Grid overlay */
.grid-overlay {
background-image:
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
background-size: 40px 40px;
}
/* Subtle noise texture */
.noise-overlay::before {
content: '';
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
opacity: 0.025;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
z-index: 9999;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--bg-elevated); }
/* HTMX transitions */
.htmx-swapping { opacity: 0.5; transition: opacity 200ms ease; }
.htmx-settling { opacity: 0; animation: fadeIn 250ms ease forwards; }
@keyframes fadeIn { to { opacity: 1; } }
/* Loading spinner */
.htmx-request .spinner { display: inline-block !important; }
.spinner { display: none; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.1); border-top-color: var(--accent-amber); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Table styles */
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; }
.data-table th { background: var(--bg-secondary); color: var(--text-muted); font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 500; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-medium); text-align: left; white-space: nowrap; }
.data-table td { padding: 0.875rem 1rem; border-bottom: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: 0.875rem; vertical-align: middle; }
.data-table tbody tr:hover td { background: rgba(255,255,255,0.02); }
.data-table tbody tr:last-child td { border-bottom: none; }
/* Form inputs */
.input-industrial {
background: var(--bg-secondary);
border: 1px solid var(--border-medium);
color: var(--text-primary);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 150ms, box-shadow 150ms;
outline: none;
width: 100%;
}
.input-industrial:focus { border-color: var(--accent-amber); box-shadow: 0 0 0 2px rgba(245,158,11,0.1); }
.input-industrial::placeholder { color: var(--text-muted); }
select.input-industrial { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%237a7a8f'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.5rem center; background-size: 1.25rem; padding-right: 2.5rem; }
/* Buttons */
.btn-primary {
background: var(--accent-amber);
color: #0a0a0f;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.025em;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: all 150ms;
font-family: 'JetBrains Mono', monospace;
display: inline-flex; align-items: center; gap: 0.5rem;
}
.btn-primary:hover { background: #fbbf24; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(245,158,11,0.2); }
.btn-primary:active { transform: translateY(0); }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-medium);
font-weight: 500;
font-size: 0.8rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 150ms;
display: inline-flex; align-items: center; gap: 0.5rem;
}
.btn-ghost:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); border-color: var(--border-strong); }
.btn-danger {
background: rgba(244,63,94,0.1);
color: var(--accent-rose);
border: 1px solid rgba(244,63,94,0.2);
font-weight: 500;
font-size: 0.8rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 150ms;
}
.btn-danger:hover { background: rgba(244,63,94,0.2); }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
/* Cards */
.card-industrial {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 0.5rem;
position: relative;
overflow: hidden;
}
.card-industrial::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
}
/* Status pills */
.pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.pill-amber { background: rgba(245,158,11,0.1); color: var(--accent-amber); border: 1px solid rgba(245,158,11,0.15); }
.pill-emerald { background: rgba(16,185,129,0.1); color: var(--accent-emerald); border: 1px solid rgba(16,185,129,0.15); }
.pill-rose { background: rgba(244,63,94,0.1); color: var(--accent-rose); border: 1px solid rgba(244,63,94,0.15); }
.pill-sky { background: rgba(14,165,233,0.1); color: var(--accent-sky); border: 1px solid rgba(14,165,233,0.15); }
.pill-zinc { background: rgba(255,255,255,0.05); color: var(--text-muted); border: 1px solid var(--border-medium); }
/* Nav */
.nav-link {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: 0.375rem;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 500;
transition: all 150ms;
text-decoration: none;
border: 1px solid transparent;
}
.nav-link:hover { background: rgba(255,255,255,0.03); color: var(--text-secondary); }
.nav-link.active { background: rgba(245,158,11,0.08); color: var(--accent-amber); border-color: rgba(245,158,11,0.15); }
.nav-link svg { width: 18px; height: 18px; flex-shrink: 0; }
/* Animation */
@keyframes slideUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.animate-slide-up { animation: slideUp 0.4s ease forwards; }
.stagger-1 { animation-delay: 0.05s; opacity: 0; }
.stagger-2 { animation-delay: 0.1s; opacity: 0; }
.stagger-3 { animation-delay: 0.15s; opacity: 0; }
.stagger-4 { animation-delay: 0.2s; opacity: 0; }
.stagger-5 { animation-delay: 0.25s; opacity: 0; }
/* Mobile */
@media (max-width: 1024px) {
.sidebar { transform: translateX(-100%); transition: transform 300ms ease; }
.sidebar.open { transform: translateX(0); }
.sidebar-backdrop { display: none; }
.sidebar.open + .sidebar-backdrop { display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 30; }
}
</style>
{{.ExtraHead}}
</head>
<body class="noise-overlay grid-overlay min-h-screen antialiased">
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside id="sidebar" class="sidebar fixed lg:static inset-y-0 left-0 w-64 bg-zinc-900 border-r border-white/[0.06] z-40 flex flex-col">
<div class="p-5 border-b border-white/[0.06]">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded bg-amber-400/10 border border-amber-400/20 flex items-center justify-center">
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<div class="font-mono text-sm font-bold text-zinc-100 tracking-tight">go-crm</div>
<div class="font-mono text-[10px] text-zinc-500 uppercase tracking-wider">v2.0.0-industrial</div>
</div>
</div>
</div>
<nav class="flex-1 overflow-y-auto p-3 space-y-0.5">
{{range .NavItems}}
<a href="{{.Href}}" class="nav-link {{if .Active}}active{{end}}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="{{.Icon}}"/></svg>
<span>{{.Label}}</span>
</a>
{{end}}
</nav>
<div class="p-4 border-t border-white/[0.06]">
<a href="/auth/account" class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/[0.03] transition-colors">
<div class="w-8 h-8 rounded-full bg-zinc-800 border border-white/[0.08] flex items-center justify-center">
<svg class="w-4 h-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-zinc-300 truncate">Account</div>
<div class="text-[10px] text-zinc-500">Settings</div>
</div>
</a>
<form method="POST" action="/auth/logout" class="mt-2">
<button type="submit" class="w-full flex items-center gap-2 px-3 py-2 text-xs text-zinc-500 hover:text-rose-400 hover:bg-rose-400/5 rounded-lg transition-colors font-mono">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Sign Out
</button>
</form>
</div>
</aside>
<div class="sidebar-backdrop lg:hidden" onclick="document.getElementById('sidebar').classList.remove('open')"></div>
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0 lg:ml-0">
<!-- Top bar -->
<header class="h-14 bg-zinc-900/80 backdrop-blur border-b border-white/[0.06] flex items-center justify-between px-4 lg:px-6 sticky top-0 z-30">
<div class="flex items-center gap-3">
<button onclick="document.getElementById('sidebar').classList.toggle('open')" class="lg:hidden p-2 -ml-2 text-zinc-400 hover:text-zinc-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<h1 class="font-mono text-sm font-semibold text-zinc-200 tracking-tight">{{.Title}}</h1>
<div class="spinner ml-2"></div>
</div>
<div class="flex items-center gap-3">
<a href="/leads/keywords" class="hidden sm:flex items-center gap-2 px-3 py-1.5 text-xs font-mono text-zinc-400 bg-zinc-800/50 border border-white/[0.06] rounded hover:text-amber-400 hover:border-amber-400/20 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
Keywords
</a>
</div>
</header>
<!-- Page content -->
<main class="flex-1 p-4 lg:p-6 overflow-x-hidden">
{{.Content}}
</main>
</div>
</div>
</body>
</html>`
// NavItemRender is used in the layout template.
type NavItemRender struct {
Label string
Href string
Icon string
Active bool
}
// RenderPage writes a complete HTML page with the industrial layout.
func RenderPage(w io.Writer, title, activeNav string, content template.HTML) error {
var items []NavItemRender
for _, ni := range navItems {
items = append(items, NavItemRender{
Label: ni.Label,
Href: ni.Href,
Icon: ni.Icon,
Active: ni.ID == activeNav,
})
}
tmpl, err := template.New("layout").Parse(layoutTemplate)
if err != nil {
return err
}
return tmpl.Execute(w, struct {
Title string
NavItems []NavItemRender
Content template.HTML
ExtraHead template.HTML
}{
Title: title,
NavItems: items,
Content: content,
})
}
// ---------------------------------------------------------------------------
// Page Sections / Helpers
// ---------------------------------------------------------------------------
// PageHeader generates a page header with title and optional action buttons.
func PageHeader(title, subtitle string, actions ...string) template.HTML {
var actionHTML string
if len(actions) > 0 {
actionHTML = `<div class="flex items-center gap-2">` + strings.Join(actions, "") + `</div>`
}
return template.HTML(fmt.Sprintf(`
<div class="mb-6 animate-slide-up">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<h1 class="text-xl font-bold text-zinc-100 tracking-tight">%s</h1>
<p class="text-sm text-zinc-500 mt-1">%s</p>
</div>
%s
</div>
</div>`, template.HTMLEscapeString(title), template.HTMLEscapeString(subtitle), actionHTML))
}
// KPI card for dashboards.
func KPICard(label, value, trend, colorClass, iconSVG string, stagger int) template.HTML {
borderColor := "border-white/[0.06]"
textColor := "text-zinc-300"
switch colorClass {
case "amber":
borderColor = "border-amber-400/15"
textColor = "text-amber-400"
case "emerald":
borderColor = "border-emerald-400/15"
textColor = "text-emerald-400"
case "rose":
borderColor = "border-rose-400/15"
textColor = "text-rose-400"
case "sky":
borderColor = "border-sky-400/15"
textColor = "text-sky-400"
}
trendHTML := ""
if trend != "" {
trendHTML = fmt.Sprintf(`<span class="text-[10px] font-mono text-zinc-500 mt-1">%s</span>`, template.HTMLEscapeString(trend))
}
return template.HTML(fmt.Sprintf(`
<div class="card-industrial p-5 border %s animate-slide-up stagger-%d">
<div class="flex items-start justify-between">
<div>
<div class="text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-2">%s</div>
<div class="text-2xl font-mono font-bold %s">%s</div>
%s
</div>
<div class="p-2 rounded-lg bg-white/[0.03] border border-white/[0.06]">
<svg class="w-5 h-5 %s" fill="none" stroke="currentColor" viewBox="0 0 24 24">%s</svg>
</div>
</div>
</div>`, borderColor, stagger,
template.HTMLEscapeString(label),
textColor,
template.HTMLEscapeString(value),
trendHTML,
textColor,
iconSVG))
}
// TableStart begins a data table.
func TableStart(headers []string) template.HTML {
var ths string
for _, h := range headers {
ths += fmt.Sprintf(`<th>%s</th>`, template.HTMLEscapeString(h))
}
return template.HTML(fmt.Sprintf(`<table class="data-table"><thead><tr>%s</tr></thead><tbody>`, ths))
}
// TableEnd closes a data table.
func TableEnd() template.HTML {
return template.HTML(`</tbody></table>`)
}
// EmptyState shows when no data exists.
func EmptyState(icon, message string) template.HTML {
return template.HTML(fmt.Sprintf(`
<div class="flex flex-col items-center justify-center py-16 text-center animate-slide-up">
<div class="w-16 h-16 rounded-full bg-white/[0.03] border border-white/[0.06] flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">%s</svg>
</div>
<p class="text-zinc-500 text-sm">%s</p>
</div>`, icon, template.HTMLEscapeString(message)))
}
// SectionCard wraps content in a card with title.
func SectionCard(title, subtitle string, content template.HTML) template.HTML {
return template.HTML(fmt.Sprintf(`
<div class="card-industrial animate-slide-up">
<div class="px-5 py-4 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold text-zinc-200">%s</h2>
<p class="text-xs text-zinc-500 mt-0.5">%s</p>
</div>
<div class="p-5">%s</div>
</div>`, template.HTMLEscapeString(title), template.HTMLEscapeString(subtitle), content))
}
// ---------------------------------------------------------------------------
// Render helpers that write to a buffer then to a ResponseWriter
// ---------------------------------------------------------------------------
// BufRender starts a bytes.Buffer for collecting HTML content.
func BufRender() *bytes.Buffer {
return &bytes.Buffer{}
}
// WritePage renders the full page from a buffer to a ResponseWriter.
func WritePage(w io.Writer, buf *bytes.Buffer, title, activeNav string) error {
return RenderPage(w, title, activeNav, template.HTML(buf.String()))
}
// ---------------------------------------------------------------------------
// Convenience: write raw HTML directly to ResponseWriter with layout
// ---------------------------------------------------------------------------
// WriteHTMLPage takes an io.Writer, writes the layout wrapping the given HTML string.
func WriteHTMLPage(w io.Writer, title, activeNav, html string) error {
return RenderPage(w, title, activeNav, template.HTML(html))
}