Files
PriceForge/bible-local/demo/internal/web/server.go
2026-03-01 22:26:50 +03:00

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
}