tracker/mockups.go
package main
import (
"bytes"
"encoding/json"
"errors"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
func ucNumber(id string) int { n, _ := strconv.Atoi(strings.TrimPrefix(id, "W-")); return n }
type MockupView struct {
ModuleNames map[string]string
FlowGroups []UCModuleGroup
Tab string
Screens []MockScreen
Selected *MockScreen
Catalog MockCatalog
UseCases []UseCase
UseCase *UseCase
Query, Module, Channel, Status string
Modules, Channels []string
Total int
Key string
Components []struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
}
type mockGraphResponse struct {
Graph MockGraph `json:"graph"`
CurrentSourceHash string `json:"current_source_hash"`
SourceChanged bool `json:"source_changed"`
}
func mockDecode(w http.ResponseWriter, r *http.Request, v any) error {
r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(v); err != nil {
return err
}
var extra any
if d.Decode(&extra) != io.EOF {
return errors.New("one JSON value required")
}
return nil
}
func mockJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(v)
}
func registerMockups(mux *http.ServeMux, store *Store) {
tpl := template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS, "templates/layout.html", "templates/partials.html", "templates/mockups.html"))
ms := &mockStore{dir: store.dir}
page := func(w http.ResponseWriter, r *http.Request) {
c, err := loadMockCatalog(repoRoot)
if err != nil {
http.Error(w, "อ่าน artifacts ไม่สำเร็จ", 500)
return
}
uc, err := loadUseCases(repoRoot)
if err != nil {
http.Error(w, "อ่าน Use cases ไม่สำเร็จ", 500)
return
}
v := MockupView{Tab: "sitemap", Catalog: c, Total: len(c.Screens), Query: strings.TrimSpace(r.URL.Query().Get("q")), Module: r.URL.Query().Get("module"), Channel: r.URL.Query().Get("channel"), Status: r.URL.Query().Get("status"), Key: "sitemap"}
v.ModuleNames = c.ModuleNames
if strings.HasPrefix(r.URL.Path, "/mockup/screens") {
v.Tab = "screens"
}
if strings.HasPrefix(r.URL.Path, "/mockup/page-flow") {
v.Tab = "flow"
v.Key = ""
}
modules, channels := map[string]bool{}, map[string]bool{}
for _, s := range c.Screens {
for _, m := range s.Modules {
modules[m] = true
}
channels[s.Channel] = true
search := strings.ToLower(s.ID + " " + s.Title + " " + strings.Join(s.UseCases, " "))
if v.Query != "" && !strings.Contains(search, strings.ToLower(v.Query)) {
continue
}
if v.Module != "" && !containsUC(s.Modules, v.Module) {
continue
}
if v.Channel != "" && s.Channel != v.Channel {
continue
}
if v.Status != "" && s.Status != v.Status {
continue
}
v.Screens = append(v.Screens, s)
}
for m := range modules {
v.Modules = append(v.Modules, m)
}
for ch := range channels {
v.Channels = append(v.Channels, ch)
}
sort.Strings(v.Modules)
sort.Strings(v.Channels)
for _, u := range uc.UC {
v.UseCases = append(v.UseCases, u)
}
sort.Slice(v.UseCases, func(i, j int) bool { return ucNumber(v.UseCases[i].ID) < ucNumber(v.UseCases[j].ID) })
codes := []string{}
for code := range uc.Modules {
codes = append(codes, code)
}
sort.Strings(codes)
for _, code := range codes {
group := UCModuleGroup{Code: code, Name: moduleLabel(v.ModuleNames, code), Group: uc.Modules[code].Group}
for _, u := range v.UseCases {
if len(u.Modules) > 0 && u.Modules[0] == code {
group.Items = append(group.Items, u)
}
}
if len(group.Items) > 0 {
v.FlowGroups = append(v.FlowGroups, group)
}
}
if sid := r.PathValue("sid"); sid != "" {
s, ok := c.ByID[sid]
if !ok {
http.NotFound(w, r)
return
}
v.Selected = &s
}
if id := r.PathValue("uid"); id != "" {
u, ok := uc.UC[id]
if !ok {
http.NotFound(w, r)
return
}
v.UseCase = &u
v.Key = id
}
if b, err := mockFile(repoRoot, "mockups/design-system/components.json"); err == nil {
var defs struct {
Components []struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
} `json:"components"`
}
if json.Unmarshal(b, &defs) == nil {
v.Components = defs.Components
}
}
p := buildPage(store, "mockup", "Mockup")
p.Mockups = &v
var b bytes.Buffer
if err = tpl.ExecuteTemplate(&b, "layout", p); err != nil {
http.Error(w, "render mockup error: "+err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
b.WriteTo(w)
}
for _, path := range []string{"/mockup", "/mockup/sitemap", "/mockup/page-flow", "/mockup/page-flow/{uid}", "/mockup/screens", "/mockup/screens/{sid}"} {
mux.HandleFunc("GET "+path, page)
}
mux.HandleFunc("GET /api/mockups/catalog", func(w http.ResponseWriter, r *http.Request) {
c, err := loadMockCatalog(repoRoot)
if err != nil {
http.Error(w, "อ่าน catalog ไม่สำเร็จ", 500)
return
}
mockJSON(w, c)
})
mux.HandleFunc("GET /mockup-assets/{path...}", func(w http.ResponseWriter, r *http.Request) {
c, err := loadMockCatalog(repoRoot)
path := r.PathValue("path")
if err != nil || !c.Assets[path] {
http.NotFound(w, r)
return
}
b, err := mockFile(repoRoot, path)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
if filepath.Ext(path) == ".svg" {
w.Header().Set("Content-Type", "image/svg+xml")
} else {
w.Header().Set("Content-Type", http.DetectContentType(b))
}
w.Write(b)
})
source := func(w http.ResponseWriter, r *http.Request) (string, MockCatalog, MockGraph, bool) {
key := r.PathValue("key")
c, err := loadMockCatalog(repoRoot)
if err != nil {
http.Error(w, "อ่าน catalog ไม่สำเร็จ", 500)
return key, c, MockGraph{}, false
}
var draft MockGraph
if key == "sitemap" {
draft, err = draftMockSitemap(repoRoot, c)
} else {
uc, e := loadUseCases(repoRoot)
u, ok := uc.UC[key]
if e != nil || !ok {
http.NotFound(w, r)
return key, c, draft, false
}
draft, err = draftMockFlow(repoRoot, u, c)
}
if err != nil {
http.Error(w, "อ่านต้นฉบับ graph ไม่สำเร็จ", 500)
return key, c, draft, false
}
return key, c, draft, true
}
mux.HandleFunc("GET /api/mockups/graphs/{key}", func(w http.ResponseWriter, r *http.Request) {
key, c, draft, ok := source(w, r)
if !ok {
return
}
g, err := ms.load(key, draft)
if err != nil {
http.Error(w, "อ่าน diagram ที่บันทึกไม่สำเร็จ ไม่มีการรีเซ็ตข้อมูล", 500)
return
}
if r.URL.Query().Get("draft") == "1" {
rev := g.Revision
g = draft
g.Revision = rev
}
if err = validateMockGraph(g, c); err != nil {
http.Error(w, "diagram มี screen/variant ที่ไม่อยู่ใน catalog กรุณาตรวจ artifacts ก่อน", 422)
return
}
mockJSON(w, mockGraphResponse{Graph: g, CurrentSourceHash: draft.SourceHash, SourceChanged: g.SourceHash != draft.SourceHash})
})
mux.HandleFunc("PUT /api/mockups/graphs/{key}", func(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "ต้องบันทึกจาก tracker นี้", 403)
return
}
key, c, draft, ok := source(w, r)
if !ok {
return
}
var g MockGraph
if mockDecode(w, r, &g) != nil || g.Direction != draft.Direction {
http.Error(w, "ข้อมูล graph ไม่ถูกต้อง", 400)
return
}
saved, err := ms.save(key, g, c)
if err != nil {
code := 400
if errors.Is(err, errMockConflict) {
code = 409
}
http.Error(w, err.Error(), code)
return
}
mockJSON(w, mockGraphResponse{Graph: saved, CurrentSourceHash: draft.SourceHash, SourceChanged: saved.SourceHash != draft.SourceHash})
})
mux.HandleFunc("GET /api/mockups/graphs/{key}/revisions", func(w http.ResponseWriter, r *http.Request) {
key, _, _, ok := source(w, r)
if !ok {
return
}
history, err := ms.history(key)
if err != nil {
http.Error(w, "อ่านประวัติไม่สำเร็จ", 500)
return
}
mockJSON(w, history)
})
mux.HandleFunc("POST /api/mockups/graphs/{key}/restore", func(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "ต้องคืน revision จาก tracker นี้", 403)
return
}
key, c, draft, ok := source(w, r)
if !ok {
return
}
var sub struct {
Revision int `json:"revision"`
Target int `json:"target_revision"`
}
if mockDecode(w, r, &sub) != nil {
http.Error(w, "ข้อมูล restore ไม่ถูกต้อง", 400)
return
}
g, err := ms.restore(key, sub.Revision, sub.Target, c)
if err != nil {
code := 400
if errors.Is(err, errMockConflict) {
code = 409
}
if errors.Is(err, os.ErrNotExist) {
code = 404
}
http.Error(w, err.Error(), code)
return
}
mockJSON(w, mockGraphResponse{Graph: g, CurrentSourceHash: draft.SourceHash, SourceChanged: g.SourceHash != draft.SourceHash})
})
}