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 }