feat: bootstrap design kit and vaporwave demo baseline
This commit is contained in:
340
demo/internal/web/server.go
Normal file
340
demo/internal/web/server.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
appweb "git.mchus.pro/mchus/ui-design-code-demo/web"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
type PatternCard struct {
|
||||
ID string
|
||||
Name string
|
||||
Bundle string
|
||||
Link string
|
||||
Status string
|
||||
Summary string
|
||||
}
|
||||
|
||||
type IndexViewData struct {
|
||||
Title string
|
||||
CurrentPath string
|
||||
Bundles []PatternCard
|
||||
Patterns []PatternCard
|
||||
}
|
||||
|
||||
type tableDemoRow struct {
|
||||
ID int
|
||||
Name string
|
||||
Category string
|
||||
Status string
|
||||
Owner string
|
||||
Updated string
|
||||
}
|
||||
|
||||
type tableDemoFilters struct {
|
||||
Query string
|
||||
Category string
|
||||
Status string
|
||||
}
|
||||
|
||||
type tableDemoPager struct {
|
||||
Page int
|
||||
PerPage int
|
||||
TotalItems int
|
||||
TotalPages int
|
||||
From int
|
||||
To int
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Links []tableDemoPageLink
|
||||
}
|
||||
|
||||
type tableDemoPageLink struct {
|
||||
Label string
|
||||
URL string
|
||||
Current bool
|
||||
Ellipsis bool
|
||||
}
|
||||
|
||||
type tableDemoPageData struct {
|
||||
Title string
|
||||
CurrentPath string
|
||||
Rows []tableDemoRow
|
||||
Filters tableDemoFilters
|
||||
Pager tableDemoPager
|
||||
PerPage int
|
||||
PerPageOpts []int
|
||||
Categories []string
|
||||
Statuses []string
|
||||
}
|
||||
|
||||
func NewServer() (*Server, error) {
|
||||
tmpl, err := template.New("demo").Funcs(template.FuncMap{
|
||||
"withQuery": withQuery,
|
||||
"dict": dict,
|
||||
}).ParseFS(appweb.Assets, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{mux: http.NewServeMux(), tmpl: tmpl}
|
||||
s.registerRoutes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler { return s.mux }
|
||||
|
||||
func (s *Server) registerRoutes() {
|
||||
s.mux.HandleFunc("/", s.handleIndex)
|
||||
s.mux.HandleFunc("/patterns/table", s.handleTablePattern)
|
||||
s.mux.HandleFunc("/patterns/controls", s.handleControlsPattern)
|
||||
s.mux.HandleFunc("/patterns/modals", s.handleModalPattern)
|
||||
s.mux.HandleFunc("/patterns/io", s.handleIOPattern)
|
||||
s.mux.HandleFunc("/patterns/io/export.csv", s.handleIOExportCSV)
|
||||
s.mux.HandleFunc("/patterns/forms", s.handleFormsPattern)
|
||||
s.mux.HandleFunc("/patterns/style-playground", s.handleStylePlaygroundPattern)
|
||||
s.mux.HandleFunc("/patterns/operator-tools", s.handleOperatorToolsPattern)
|
||||
s.mux.HandleFunc("/patterns/timeline", s.handleTimelinePattern)
|
||||
s.mux.HandleFunc("/healthz", s.handleHealthz)
|
||||
staticFS, err := fs.Sub(appweb.Assets, "static")
|
||||
if err == nil {
|
||||
s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := IndexViewData{
|
||||
Title: "UI Design Code",
|
||||
CurrentPath: "/",
|
||||
Bundles: []PatternCard{
|
||||
{ID: "ai-rules", Name: "AI Rules", Bundle: "ai-rules", Status: "ready", Summary: "CLAUDE/AGENTS templates + shared architecture doc policy."},
|
||||
{ID: "bible-core", Name: "Bible Core", Bundle: "bible-core", Status: "ready", Summary: "Canonical Bible skeleton for Go web projects using AI coding agents."},
|
||||
{ID: "go-web-skeleton", Name: "Go Web Skeleton", Bundle: "go-web-skeleton", Status: "ready", Summary: "Minimal net/http + templates scaffold for host repos."},
|
||||
},
|
||||
Patterns: []PatternCard{
|
||||
{ID: "table-pagination", Name: "Table + Pagination", Bundle: "ui-pattern-table", Link: "/patterns/table", Status: "ready", Summary: "Server-side filters + pagination contract for canonical list pages."},
|
||||
{ID: "controls-selection", Name: "Controls + Selection", Bundle: "ui-pattern-controls", Link: "/patterns/controls", Status: "ready", Summary: "Buttons, checkboxes, bulk-selection, segmented actions, status chips."},
|
||||
{ID: "modal-workflows", Name: "Modal Workflows", Bundle: "ui-pattern-modal", Link: "/patterns/modals", Status: "ready", Summary: "Create/edit/remove + confirm modal workflow contracts for admin and detail pages."},
|
||||
{ID: "import-export", Name: "Import / Export", Bundle: "ui-pattern-io", Link: "/patterns/io", Status: "ready", Summary: "File import forms, preview/confirm UX, CSV export controls and download endpoint."},
|
||||
{ID: "forms-validation", Name: "Forms + Validation", Bundle: "ui-pattern-forms", Link: "/patterns/forms", Status: "ready", Summary: "Inline validation, datalist suggestions, tabs/steps, confirm-before-submit forms."},
|
||||
{ID: "operator-tools", Name: "Operator Tools", Bundle: "ui-pattern-operator-tools", Link: "/patterns/operator-tools", Status: "ready", Summary: "Complex operator/admin dashboards: queue tables, batch actions, safety checks, and run states."},
|
||||
{ID: "timeline-cards", Name: "Timeline Cards", Bundle: "ui-pattern-timeline", Link: "/patterns/timeline", Status: "ready", Summary: "Grouped timeline cards with drilldown modal and in-card filtering."},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (s *Server) handleTablePattern(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/patterns/table" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
filters := tableDemoFilters{
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Category: strings.TrimSpace(r.URL.Query().Get("category")),
|
||||
Status: strings.TrimSpace(r.URL.Query().Get("status")),
|
||||
}
|
||||
allRows := demoTableRows()
|
||||
filtered := make([]tableDemoRow, 0, len(allRows))
|
||||
for _, row := range allRows {
|
||||
if !matchesTableFilters(row, filters) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
page := parsePositiveInt(r.URL.Query().Get("page"), 1)
|
||||
perPage := parseAllowedInt(r.URL.Query().Get("per_page"), 10, []int{5, 10, 20})
|
||||
rowsPage, pager := paginateTableRows(r.URL, filtered, page, perPage)
|
||||
data := tableDemoPageData{
|
||||
Title: "Table + Pagination Pattern",
|
||||
CurrentPath: "/patterns/table",
|
||||
Rows: rowsPage,
|
||||
Filters: filters,
|
||||
Pager: pager,
|
||||
PerPage: perPage,
|
||||
PerPageOpts: []int{5, 10, 20},
|
||||
Categories: []string{"Compute", "Networking", "Power", "Storage"},
|
||||
Statuses: []string{"ready", "warning", "review"},
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "table_pattern.html", data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func matchesTableFilters(row tableDemoRow, f tableDemoFilters) bool {
|
||||
if f.Query != "" {
|
||||
q := strings.ToLower(f.Query)
|
||||
haystack := strings.ToLower(strings.Join([]string{row.Name, row.Category, row.Status, row.Owner}, " "))
|
||||
if !strings.Contains(haystack, q) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Category != "" && !strings.EqualFold(row.Category, f.Category) {
|
||||
return false
|
||||
}
|
||||
if f.Status != "" && !strings.EqualFold(row.Status, f.Status) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func paginateTableRows(u *url.URL, rows []tableDemoRow, page, perPage int) ([]tableDemoRow, tableDemoPager) {
|
||||
totalItems := len(rows)
|
||||
totalPages := 1
|
||||
if totalItems > 0 {
|
||||
totalPages = (totalItems + perPage - 1) / perPage
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = 1
|
||||
}
|
||||
start := 0
|
||||
end := 0
|
||||
from := 0
|
||||
to := 0
|
||||
pageRows := []tableDemoRow{}
|
||||
if totalItems > 0 {
|
||||
start = (page - 1) * perPage
|
||||
end = start + perPage
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
from = start + 1
|
||||
to = end
|
||||
pageRows = rows[start:end]
|
||||
}
|
||||
pager := tableDemoPager{
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalItems: totalItems,
|
||||
TotalPages: totalPages,
|
||||
From: from,
|
||||
To: to,
|
||||
Links: buildPageLinks(u, page, totalPages),
|
||||
}
|
||||
return pageRows, pager
|
||||
}
|
||||
|
||||
func pageURL(u *url.URL, page, totalPages int) string {
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 || page > totalPages {
|
||||
return ""
|
||||
}
|
||||
clone := *u
|
||||
q := clone.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
clone.RawQuery = q.Encode()
|
||||
return clone.String()
|
||||
}
|
||||
|
||||
func buildPageLinks(u *url.URL, current, totalPages int) []tableDemoPageLink {
|
||||
if totalPages <= 1 {
|
||||
return nil
|
||||
}
|
||||
if totalPages <= 4 {
|
||||
out := make([]tableDemoPageLink, 0, totalPages)
|
||||
for p := 1; p <= totalPages; p++ {
|
||||
out = append(out, tableDemoPageLink{
|
||||
Label: strconv.Itoa(p),
|
||||
URL: pageURL(u, p, totalPages),
|
||||
Current: p == current,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
pages := map[int]bool{
|
||||
1: true, 2: true,
|
||||
totalPages: true, totalPages - 1: true,
|
||||
current: true, current - 1: true, current + 1: true,
|
||||
}
|
||||
keys := make([]int, 0, len(pages))
|
||||
for p := range pages {
|
||||
if p < 1 || p > totalPages {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, p)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
out := make([]tableDemoPageLink, 0, len(keys)+2)
|
||||
prev := 0
|
||||
for _, p := range keys {
|
||||
if prev != 0 && p-prev > 1 {
|
||||
out = append(out, tableDemoPageLink{Label: "...", Ellipsis: true})
|
||||
}
|
||||
out = append(out, tableDemoPageLink{
|
||||
Label: strconv.Itoa(p),
|
||||
URL: pageURL(u, p, totalPages),
|
||||
Current: p == current,
|
||||
})
|
||||
prev = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parsePositiveInt(v string, fallback int) int {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseAllowedInt(v string, fallback int, allowed []int) int {
|
||||
n := parsePositiveInt(v, fallback)
|
||||
for _, candidate := range allowed {
|
||||
if n == candidate {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func demoTableRows() []tableDemoRow {
|
||||
rows := make([]tableDemoRow, 0, 36)
|
||||
categories := []string{"Compute", "Networking", "Storage", "Power"}
|
||||
statuses := []string{"ready", "warning", "review"}
|
||||
owners := []string{"Ops", "Infra", "QA", "Procurement"}
|
||||
for i := 1; i <= 36; i++ {
|
||||
rows = append(rows, tableDemoRow{
|
||||
ID: i,
|
||||
Name: fmt.Sprintf("Component Spec %02d", i),
|
||||
Category: categories[(i-1)%len(categories)],
|
||||
Status: statuses[(i-1)%len(statuses)],
|
||||
Owner: owners[(i-1)%len(owners)],
|
||||
Updated: fmt.Sprintf("2026-02-%02d", (i%23)+1),
|
||||
})
|
||||
}
|
||||
// Seed a few targeted names to make filter behavior testable and demonstrative.
|
||||
rows[4].Name = "Rack Controller Alpha"
|
||||
rows[11].Name = "Rack Controller Beta"
|
||||
rows[20].Name = "Rack Controller Gamma"
|
||||
return rows
|
||||
}
|
||||
Reference in New Issue
Block a user