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
This commit is contained in:
490
apps/go-crm/internal/templates/ui.go
Normal file
490
apps/go-crm/internal/templates/ui.go
Normal file
@@ -0,0 +1,490 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user