341 lines
9.9 KiB
Go
341 lines
9.9 KiB
Go
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
|
|
}
|