diff options
| author | historia <[not public]> | 2026-07-15 05:10:40 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-15 05:10:40 -0400 |
| commit | 84dd8c7557d8b9d92cd8d339afc28a0fd9a3a896 (patch) | |
| tree | 60c47059d6776e7b91fb516ebdb04a0932c9aa59 | |
| parent | beb188ce050fb76cdddc0bbeedee7ec608e47995 (diff) | |
| download | thehouseoficarus-84dd8c7557d8b9d92cd8d339afc28a0fd9a3a896.tar.gz | |
feat(admin): color scheme test page added
| -rw-r--r-- | cmd/thoi/main.go | 4 | ||||
| -rw-r--r-- | internal/admin/api_colors.go | 430 | ||||
| -rw-r--r-- | internal/admin/api_colors_test.go | 159 | ||||
| -rw-r--r-- | internal/admin/server.go | 8 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 37 | ||||
| -rw-r--r-- | internal/admin/static/coloreditor.js | 570 | ||||
| -rw-r--r-- | internal/admin/templates/colors.html | 37 | ||||
| -rw-r--r-- | internal/admin/templates/layout.html | 2 |
8 files changed, 1244 insertions, 3 deletions
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go index e5f44f7..4608536 100644 --- a/cmd/thoi/main.go +++ b/cmd/thoi/main.go @@ -77,7 +77,7 @@ func main() { g.SetHub(server.Hub()) if cfg.AdminHTTP.Enabled { - adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs) + adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs, *configPath) if err != nil { log.Fatalf("failed to start admin HTTP server: %v", err) } @@ -90,7 +90,7 @@ func main() { } if cfg.AdminHTTPS.Enabled { - adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs) + adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs, *configPath) if err != nil { log.Fatalf("failed to start admin HTTPS server: %v", err) } diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go new file mode 100644 index 0000000..78e5f98 --- /dev/null +++ b/internal/admin/api_colors.go @@ -0,0 +1,430 @@ +package admin + +import ( + "net/http" + "os" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/config" +) + +// colorCategory describes one editable color category: its config key, a +// human-friendly label, and a UI grouping. +type colorCategory struct { + Name string `json:"name"` + Label string `json:"label"` + Group string `json:"group"` +} + +// colorSegment is one piece of preview text tagged with the category whose +// color spec should paint it. A nil/empty category means plain (uncolored). +type colorSegment struct { + T string `json:"t"` // text + C string `json:"c"` // category name, "" = plain +} + +// colorLine groups segments into one rendered line. +type colorLine struct { + Segments []colorSegment `json:"segments"` +} + +// colorSection is a titled block of example output. +type colorSection struct { + Title string `json:"title"` + Lines []colorLine `json:"lines"` +} + +// colorCategories is the curated, grouped, ordered master list mirroring the +// keys produced by config.DefaultColors(). Editing here is the single source +// of truth for the admin color themer's left-side list. +var colorCategories = []colorCategory{ + // Room / look + {Name: "room_name", Label: "Room Name", Group: "Room"}, + {Name: "room_number", Label: "Room Number", Group: "Room"}, + {Name: "room_desc", Label: "Room Description", Group: "Room"}, + {Name: "exit_direction", Label: "Exit Direction", Group: "Room"}, + {Name: "exit_name", Label: "Exit Name", Group: "Room"}, + {Name: "mob", Label: "Mob", Group: "Room"}, + {Name: "protected_mob", Label: "Protected Mob", Group: "Room"}, + {Name: "player_name", Label: "Player Name", Group: "Room"}, + {Name: "item", Label: "Item", Group: "Room"}, + {Name: "dim", Label: "Dim / Brackets", Group: "Room"}, + + // Movement + {Name: "direction", Label: "Move Direction", Group: "Movement"}, + + // Combat + {Name: "damage_dealt", Label: "Damage Dealt", Group: "Combat"}, + {Name: "damage_taken", Label: "Damage Taken", Group: "Combat"}, + {Name: "damage", Label: "Damage (alt key)", Group: "Combat"}, + {Name: "miss", Label: "Miss", Group: "Combat"}, + {Name: "death", Label: "Death", Group: "Combat"}, + {Name: "victory", Label: "Victory", Group: "Combat"}, + {Name: "warning", Label: "Warning", Group: "Combat"}, + {Name: "error", Label: "Error", Group: "Combat"}, + {Name: "science_mod", Label: "Science Mod", Group: "Combat"}, + + // XP / level + {Name: "xp", Label: "XP Drop", Group: "XP / Level"}, + {Name: "level_up", Label: "Level Up", Group: "XP / Level"}, + + // Pickups / economy + {Name: "credits_pickup", Label: "Credits Pickup", Group: "Pickups / Economy"}, + {Name: "chips_pickup", Label: "Chips Pickup", Group: "Pickups / Economy"}, + + // Chat / dialog + {Name: "say", Label: "Say", Group: "Chat / Dialog"}, + {Name: "global", Label: "Global", Group: "Chat / Dialog"}, + {Name: "broadcast", Label: "Broadcast", Group: "Chat / Dialog"}, + {Name: "dialog", Label: "Dialog", Group: "Chat / Dialog"}, + {Name: "dialog_options", Label: "Dialog Options", Group: "Chat / Dialog"}, + + // Drops / consume / fire + {Name: "drop_message", Label: "Drop Message", Group: "Drops / Consume / Fire"}, + {Name: "eat_food", Label: "Eat Food", Group: "Drops / Consume / Fire"}, + {Name: "fire", Label: "Fire", Group: "Drops / Consume / Fire"}, + + // Tick / status + {Name: "visual_tick", Label: "Visual Tick", Group: "Tick / Status"}, + {Name: "visual_first_tick", Label: "Visual First Tick", Group: "Tick / Status"}, + + // Tech / farm + {Name: "battery", Label: "Battery", Group: "Tech / Farm"}, + {Name: "tech_depleted", Label: "Tech Depleted", Group: "Tech / Farm"}, + {Name: "farm_grow", Label: "Farm Grow", Group: "Tech / Farm"}, + {Name: "farm_disease", Label: "Farm Disease", Group: "Tech / Farm"}, + + // Assassin / map / safespot + {Name: "assassin_task", Label: "Assassin Task", Group: "Assassin / Map / Safespot"}, + {Name: "map_at", Label: "Map At (you)", Group: "Assassin / Map / Safespot"}, + {Name: "map_blocked", Label: "Map Blocked", Group: "Assassin / Map / Safespot"}, + {Name: "map_course", Label: "Map Course", Group: "Assassin / Map / Safespot"}, + {Name: "safespot_alert", Label: "Safespot Alert", Group: "Assassin / Map / Safespot"}, + + // System + {Name: "sequence", Label: "Sequence (trigger)", Group: "System"}, +} + +// knownColor returns true if name is one of the editable categories. +func knownColor(name string) bool { + for _, c := range colorCategories { + if c.Name == name { + return true + } + } + return false +} + +// seg is a small constructor helper for the inline preview builder. +func seg(t, cat string) colorSegment { + return colorSegment{T: t, C: cat} +} + +// ln builds a line from a sequence of segments. +func ln(segs ...colorSegment) colorLine { + return colorLine{Segments: segs} +} + +// txt builds a plain (uncolored) line. +func txt(s string) colorLine { + return colorLine{Segments: []colorSegment{{T: s}}} +} + +// buildPreview constructs the example output covering every color category. +// Text patterns match real in-game output (room looks, combat, pickups, chat, +// etc.) so admins see exactly what each color key paints. +func buildPreview() []colorSection { + var sections []colorSection + + // --- A full room "look" ---------------------------------------------- + sections = append(sections, colorSection{ + Title: "Room Look", + Lines: []colorLine{ + ln(seg("[", "dim"), seg("*", "map_at"), seg("] ", "dim"), + seg("Test Room", "room_name"), + seg(" (", ""), seg("intro", "room_desc"), seg(", ", ""), + seg("#2001", "room_number"), seg(")", "")), + txt(""), + ln(seg("It's a test room", "room_desc")), + txt(""), + ln(seg("A tree is here.", "room_desc")), + txt(""), + ln(seg("A sentry ", "protected_mob"), seg("(level 4) watches the gate.", "")), + ln(seg("A man ", "mob"), seg("(level 5) scribbles something in a small notebook.", "")), + txt(""), + ln(seg("Icarus", "player_name"), seg(" is here.", "")), + txt(""), + txt("On the ground:"), + ln(seg(" bones ", "item"), seg("(reserved for admin 65t)", "dim")), + ln(seg(" bronze axe", "item")), + ln(seg(" bucket of water", "item")), + ln(seg(" 10 x chips ", "item"), seg("(reserved for admin 65t)", "dim")), + ln(seg(" pot of flour", "item")), + txt(""), + txt("Exits:"), + ln(seg(" ", ""), seg("south", "exit_direction"), seg(" - ", ""), seg("Test Room", "exit_name")), + ln(seg(" ", ""), seg("east", "exit_direction"), seg(" - ", ""), seg("Room ", "exit_name"), seg("#1005", "room_number")), + ln(seg(" ", ""), seg("west", "exit_direction"), seg(" - ", ""), seg("Inside Transport Shuttle", "exit_name")), + txt(""), + ln(seg(">", "dim")), + }, + }) + + // --- Movement --------------------------------------------------------- + sections = append(sections, colorSection{ + Title: "Movement", + Lines: []colorLine{ + ln(seg("You walk ", ""), seg("south", "direction"), seg(" to ", ""), + seg("Test Room", "room_name"), seg(" (", ""), seg("#2001", "room_number"), seg(")", "")), + ln(seg("WARNING: that area is exposed to ", ""), seg("radiation", "warning"), seg(".", "")), + }, + }) + + // --- Combat rounds (matches real output) ------------------------------ + sections = append(sections, colorSection{ + Title: "Combat (two encounters)", + Lines: []colorLine{ + txt(""), + ln(seg("> attack man", "dim")), + txt(""), + ln(seg("You attack ", ""), seg("the man", "mob"), seg("! Style: accurate (+3 atk)", "")), + ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""), + seg("\u25B8 6 \u25C2 damage. [\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [ 1/7]", "damage_dealt"), + seg(" (+18xp acc, +6xp hp)", "xp")), + ln(seg("The man", "mob"), seg(" misses you.", "miss")), + ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""), + seg("\u25B8 6 \u25C2 damage. [\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [ 0/7]", "damage_dealt"), + seg(" (+18xp acc, +6xp hp)", "xp")), + ln(seg("You have defeated ", ""), seg("the man", "mob"), seg("!", "victory")), + ln(seg("*** You are now level 12 melee! ***", "level_up")), + txt(""), + ln(seg("A scout", "mob"), seg(" hits you for ", ""), + seg("\u25B8 5 \u25C2 damage. [\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [22/40]", "damage_taken")), + ln(seg("A scout's attack is extra effective! Equip ", ""), + seg("plated vest", "warning"), seg(" for protection.", "")), + ln(seg("You attack ", ""), seg("a scout", "mob"), seg(" with ", ""), seg("laser cutter", "science_mod"), seg("!", "")), + ln(seg("You miss ", ""), seg("a scout", "mob"), seg(".", "miss")), + ln(seg("Oh dear, you are dead!", "death")), + ln(seg("Error: command not recognized.", "error")), + txt(""), + ln(seg("(", ""), seg("damage", "damage"), seg(" is a config-only alt key, not emitted by the engine)", "")), + }, + }) + + // --- Pickups / economy ------------------------------------------------ + sections = append(sections, colorSection{ + Title: "Pickups & Economy", + Lines: []colorLine{ + ln(seg("You pick up ", ""), seg("50 credits. (total: 1234)", "credits_pickup")), + ln(seg("You steal ", ""), seg("25 credits", "credits_pickup"), seg(".", "")), + ln(seg("Score \u2502 ", ""), seg("12 chips", "chips_pickup")), + }, + }) + + // --- Chat / dialog ---------------------------------------------------- + sections = append(sections, colorSection{ + Title: "Chat & Dialog", + Lines: []colorLine{ + ln(seg("You say: ", ""), seg("hello there", "say")), + ln(seg("[Global] ", "global"), seg("Icarus: ", "player_name"), seg("anyone up for a raid?", "global")), + ln(seg("Icarus", "player_name"), seg(" has slain ", ""), + seg("a scout", "broadcast"), seg(" (level 5)!", "broadcast")), + txt(""), + ln(seg("Guard says: ", ""), seg("\u201cHalt. State your business.\u201d", "dialog")), + ln(seg("[enter to continue]", "dialog_options")), + ln(seg(" 1. ", "dialog_options"), seg("I'm looking for work.", "dialog")), + ln(seg(" 2. ", "dialog_options"), seg("Just passing through.", "dialog")), + }, + }) + + // --- Drops / consume / fire ------------------------------------------ + sections = append(sections, colorSection{ + Title: "Drops, Consume & Fire", + Lines: []colorLine{ + ln(seg("The man drops: ", "drop_message"), seg("bones", "item")), + ln(seg("The man drops: ", "drop_message"), + seg("10", "credits_pickup"), seg(" x ", ""), seg("chips", "item")), + ln(seg("You eat the bread. It restores some energy.", "eat_food")), + ln(seg("You manage to get a fire going!", "fire")), + }, + }) + + // --- Tick / tech / farm ---------------------------------------------- + sections = append(sections, colorSection{ + Title: "Tick, Tech & Farm", + Lines: []colorLine{ + ln(seg("tick 1", "visual_first_tick")), + ln(seg("tick 2", "visual_tick")), + ln(seg("tick 3", "visual_tick")), + ln(seg("Your battery is depleted! All tech has been disabled.", "tech_depleted")), + ln(seg("You connect to the charging station. Your battery is fully recharged.", "battery")), + ln(seg("Your marigold has grown to stage 2/4.", "farm_grow")), + ln(seg("Your marigold has become diseased!", "farm_disease")), + }, + }) + + // --- Assassin / safespot --------------------------------------------- + sections = append(sections, colorSection{ + Title: "Assassin & Safespot", + Lines: []colorLine{ + ln(seg("Your target: 15 slugs. Get to work.", "assassin_task")), + ln(seg("*** Assassin task complete! ***", "assassin_task"), + seg(" (+50xp asm)", "xp")), + ln(seg(" Assassin task: 12 of 15 slugs remaining.", "assassin_task")), + ln(seg("Your safespot has been compromised!", "safespot_alert")), + ln(seg("Your divine power prevents death.", "miss")), + }, + }) + + // --- Map (mini map side panel) --------------------------------------- + sections = append(sections, colorSection{ + Title: "Mini Map", + Lines: []colorLine{ + ln(seg("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557", "dim")), + ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")), + ln(seg("\u2551", "dim"), seg("\u2591\u2591", "map_blocked"), seg("@", "map_at"), seg("\u2591\u2591", "map_blocked"), seg("\u2502", "map_course"), seg("\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")), + ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2502", "map_course"), seg("\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")), + ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")), + ln(seg("\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d", "dim")), + ln(seg(" ", ""), seg("Room #2002", "room_number")), + }, + }) + + // --- Trigger sequence / system --------------------------------------- + sections = append(sections, colorSection{ + Title: "Triggers & System", + Lines: []colorLine{ + ln(seg("The ancient console hums to life as you approach.", "sequence")), + ln(seg(" A holographic display flickers. \u201cWelcome, traveler.\u201d", "sequence")), + }, + }) + + return sections +} + +func (s *AdminServer) handleColors(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.getColors(w, r) + case http.MethodPost: + s.saveColors(w, r) + default: + writeJSONError(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) getColors(w http.ResponseWriter, _ *http.Request) { + live := make(map[string]string, len(colorCategories)) + for _, c := range colorCategories { + if v, ok := s.cfg.DefaultColors[c.Name]; ok { + live[c.Name] = v + } else { + live[c.Name] = "" + } + } + defaults := config.DefaultColors() + def := make(map[string]string, len(colorCategories)) + for _, c := range colorCategories { + if v, ok := defaults[c.Name]; ok { + def[c.Name] = v + } else { + def[c.Name] = "" + } + } + writeJSON(w, map[string]any{ + "colors": live, + "default_colors": def, + "categories": colorCategories, + "preview": buildPreview(), + }) +} + +func (s *AdminServer) saveColors(w http.ResponseWriter, r *http.Request) { + var body struct { + Colors map[string]string `json:"colors"` + } + if err := readJSON(r, &body); err != nil { + writeJSONError(w, "invalid request body", http.StatusBadRequest) + return + } + + // Validate every supplied key/spec against the known config grammar. + for name, val := range body.Colors { + if !knownColor(name) { + writeJSONError(w, "unknown color category: "+name, http.StatusBadRequest) + return + } + // "off" or empty is explicitly allowed (NoColor). Anything else must + // parse to a non-empty ColorSpec whose only unknown we tolerate is none. + spec := color.Parse(val) + if strings.TrimSpace(val) != "" && val != "off" && spec.Empty() { + writeJSONError(w, "invalid color spec for "+name+": "+val, http.StatusBadRequest) + return + } + } + + cur := s.cfg.DefaultColors + next := make(map[string]string, len(cur)+len(body.Colors)) + for k, v := range cur { + next[k] = v + } + for k, v := range body.Colors { + next[k] = v + } + s.cfg.DefaultColors = next + + if err := s.writeConfigFile(); err != nil { + writeJSONError(w, "save failed: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +func (s *AdminServer) handleColorsReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.cfg.DefaultColors = config.DefaultColors() + if err := s.writeConfigFile(); err != nil { + writeJSONError(w, "save failed: "+err.Error(), http.StatusInternalServerError) + return + } + // Return the freshly reset live values for the client to reload. + live := make(map[string]string, len(colorCategories)) + for _, c := range colorCategories { + if v, ok := s.cfg.DefaultColors[c.Name]; ok { + live[c.Name] = v + } else { + live[c.Name] = "" + } + } + writeJSON(w, map[string]any{"ok": true, "colors": live}) +} + +// writeConfigFile marshals the entire live *config.Config back to config.yaml, +// overwriting whatever was on disk. A full re-marshal drops the file's manual +// comments but produces a valid, reloadable file with the edited colors. +func (s *AdminServer) writeConfigFile() error { + _ = backupFile(s.configPath) // best-effort snapshot for safety + out, err := yaml.Marshal(s.cfg) + if err != nil { + return err + } + return os.WriteFile(s.configPath, out, 0644) +} + +// sortedKeys is a small helper retained in case future code wants a stable +// iteration order over the category names. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/admin/api_colors_test.go b/internal/admin/api_colors_test.go new file mode 100644 index 0000000..d1e5e9a --- /dev/null +++ b/internal/admin/api_colors_test.go @@ -0,0 +1,159 @@ +package admin + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "thehouseoficarus/internal/config" +) + +func newColorsTestServer(t *testing.T) (*AdminServer, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte("tick_length: 600\ndefault_colors:\n room_name: \"74 bold\"\n"), 0644); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + return &AdminServer{cfg: loaded, configPath: path}, path +} + +func TestColorsGet(t *testing.T) { + s, _ := newColorsTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/api/colors", nil) + w := httptest.NewRecorder() + s.getColors(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var out map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + cats, _ := out["categories"].([]any) + if len(cats) != len(colorCategories) { + t.Fatalf("categories count: got %d want %d", len(cats), len(colorCategories)) + } + preview, _ := out["preview"].([]any) + if len(preview) < 8 { + t.Fatalf("preview sections too few: %d", len(preview)) + } +} + +func TestColorsSaveAndPersist(t *testing.T) { + s, path := newColorsTestServer(t) + before := s.cfg.DefaultColors["room_name"] + + body := map[string]any{"colors": map[string]string{"room_name": "FF bold"}} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.saveColors(w, req) + if w.Code != http.StatusOK { + t.Fatalf("save status %d: %s", w.Code, w.Body.String()) + } + if s.cfg.DefaultColors["room_name"] == before { + t.Fatalf("live map not updated") + } + if s.cfg.DefaultColors["room_name"] != "FF bold" { + t.Fatalf("live value = %q", s.cfg.DefaultColors["room_name"]) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte("FF bold")) { + t.Fatalf("config.yaml not persisted with new value; got:\n%s", string(raw)) + } +} + +func TestColorsSaveRejectsBadSpec(t *testing.T) { + s, _ := newColorsTestServer(t) + body := map[string]any{"colors": map[string]string{"room_name": "zzzzz"}} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.saveColors(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestColorsSaveRejectsUnknownKey(t *testing.T) { + s, _ := newColorsTestServer(t) + body := map[string]any{"colors": map[string]string{"nope": "FF"}} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.saveColors(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestColorsOffAllowed(t *testing.T) { + s, _ := newColorsTestServer(t) + body := map[string]any{"colors": map[string]string{"mob": "off"}} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.saveColors(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for 'off', got %d: %s", w.Code, w.Body.String()) + } +} + +func TestColorsReset(t *testing.T) { + s, _ := newColorsTestServer(t) + s.cfg.DefaultColors["room_name"] = "FF" + req := httptest.NewRequest(http.MethodPost, "/api/colors/reset", nil) + w := httptest.NewRecorder() + s.handleColorsReset(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if s.cfg.DefaultColors["room_name"] != "74 bold" { + t.Fatalf("reset did not restore builtin; got %q", s.cfg.DefaultColors["room_name"]) + } +} + +func TestColorsPreviewExhaustive(t *testing.T) { + s, _ := newColorsTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/api/colors", nil) + w := httptest.NewRecorder() + s.getColors(w, req) + var out map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, sec := range out["preview"].([]any) { + for _, ln := range sec.(map[string]any)["lines"].([]any) { + for _, seg := range ln.(map[string]any)["segments"].([]any) { + c := seg.(map[string]any)["c"].(string) + if c != "" { + seen[c] = true + } + } + } + } + for _, cat := range colorCategories { + if !seen[cat.Name] { + t.Errorf("preview missing category: %s", cat.Name) + } + } + _ = io.Discard +}
\ No newline at end of file diff --git a/internal/admin/server.go b/internal/admin/server.go index 9983a0b..842f640 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -55,9 +55,12 @@ type AdminServer struct { // admin web GUI can trigger the same migration as the swapid command // without the admin package depending on game. rewriteRoomIDs func(idMap map[int]int) + // configPath is the on-disk config.yaml path, used by the color themer + // to persist edited default_colors back to the file the game loaded. + configPath string } -func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int)) (*AdminServer, error) { +func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), configPath string) (*AdminServer, error) { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("cookie secret: %w", err) @@ -102,6 +105,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor cookieSecret: secret, reloadCourses: reloadCourses, rewriteRoomIDs: rewriteRoomIDs, + configPath: configPath, } mux := http.NewServeMux() @@ -185,6 +189,8 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor apiMux.HandleFunc("/api/rooms/resolve-duplicate", s.handleResolveDuplicate) apiMux.HandleFunc("/api/triggers", s.handleTriggers) apiMux.HandleFunc("/api/triggers/", s.handleTriggerByID) + apiMux.HandleFunc("/api/colors", s.handleColors) + apiMux.HandleFunc("/api/colors/reset", s.handleColorsReset) mux.Handle("/api/", s.authMiddleware(apiMux)) diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index aeec09d..06a640c 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -491,3 +491,40 @@ details.obj-card summary.obj-card-header::-webkit-details-marker{display:none} .obj-card[data-card="trigger_action"] .ie-inter-header .ie-trig-dn{display:none} input:focus-visible,select:focus-visible,textarea:focus-visible,button:focus-visible{outline:2px solid rgba(100,160,255,.5);outline-offset:1px} .sr-no-results{padding:6px 8px;font-size:11px;color:#666;text-align:center;cursor:default;pointer-events:none} + +/* ── Color themer ── */ +.color-themer{display:flex;flex:1;min-height:0} +.color-list{width:340px;display:flex;flex-direction:column;padding-bottom:0} +.color-list .search-bar{flex-shrink:0} +#colorRows{flex:1;overflow-y:auto;padding-right:4px} +.color-group{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#777;margin:10px 0 4px 0;padding-bottom:2px;border-bottom:1px solid var(--border)} +.color-row{display:flex;align-items:center;gap:6px;padding:4px 4px;border-radius:3px;margin-bottom:1px} +.color-row:hover{background:rgba(15,52,96,.3)} +.color-row.dirty{background:rgba(158,99,7,.12)} +.color-row .color-swatch{width:22px;height:22px;flex-shrink:0;border-radius:3px;border:1px solid #555;cursor:pointer} +.color-row .color-swatch.nocolor{background:repeating-linear-gradient(45deg,#333,#333 3px,#222 3px,#222 6px)!important;border-color:#444} +.color-row .color-label{font-size:12px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer} +.color-row .color-flags{display:flex;flex-direction:column;gap:1px;font-size:9px;color:#aaa} +.color-row .color-flags label{display:flex;align-items:center;gap:2px;cursor:pointer} +.color-row .color-flags input[type=checkbox]{margin:0} +.color-row .color-adv{width:78px;padding:2px 4px;font-size:10px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace} +.color-row .color-revert{flex-shrink:0;padding:0 4px;font-size:11px;line-height:1;background:none;border:none;color:#888;cursor:pointer} +.color-row .color-revert:hover{color:#fff} +.color-row .color-revert:disabled{opacity:.3;cursor:default} +.color-list-actions{flex-shrink:0;display:flex;gap:4px;padding:8px 0 4px 0;border-top:1px solid var(--border);margin-top:4px} +.color-save-bar{flex-shrink:0;display:flex;align-items:center;gap:8px;padding:8px 0 12px 0} +.color-save-status{font-size:11px;color:#888} +.color-save-status.ok{color:var(--success)} +.color-save-status.err{color:var(--danger)} + +.color-preview-main{display:flex;flex-direction:column;padding-bottom:8px} +.color-preview-toolbar{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-bottom:8px} +.color-preview-toolbar select{padding:3px 6px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace} +.color-preview-hint{font-size:11px;color:#888} +.color-preview-scroll{flex:1;overflow-y:auto;overflow-x:auto;border:1px solid var(--border);border-radius:4px} +.color-preview-pre{margin:0;padding:8px;font-family:ui-monospace,"Cascadia Mono","Cascadia Code","JetBrains Mono","Fira Code",Menlo,Consolas,"DejaVu Sans Mono","Courier New",monospace;font-size:12px;line-height:1;white-space:pre-wrap;color:#e0e0e0;background:#000} +.cp-title{color:#9ab;display:block;margin:8px 0 3px 0;font-size:11px;text-transform:uppercase;letter-spacing:.5px;font-weight:bold} +.cp-title:first-child{margin-top:0} +/* Picker popup tweaks for the themer (richer than bindColorField's) */ +#__cp_popover .cp-grid .cp-swatch{width:14px;height:14px} +#__cp_popover .cp-swatch.selected{outline:2px solid #fff;outline-offset:1px} diff --git a/internal/admin/static/coloreditor.js b/internal/admin/static/coloreditor.js new file mode 100644 index 0000000..dc219f6 --- /dev/null +++ b/internal/admin/static/coloreditor.js @@ -0,0 +1,570 @@ +// Color themer: left-side category editor + right-side live example preview. +// Self-contained picker that does NOT clobber bold/dim/bg tokens (unlike the +// generic bindColorField), so a row's full color spec stays editable in parts. + +(function () { + 'use strict'; + + var STATE = { + categories: [], // [{name,label,group}] + categoriesById: {}, // name -> cat + saved: {}, // name -> spec string (last persisted) + staged: {}, // name -> spec string (unsaved edits) + defaults: {}, // name -> spec string (builtins, for reset) + preview: [], // [{title, lines:[{segments:[{t,c}]}]}] + dirty: {} // name -> true + }; + + function $i(id) { return document.getElementById(id); } + function el(tag, cls, txt) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (txt != null) e.textContent = txt; + return e; + } + + // ── Spec parse/serialize (JS port of internal/color.Parse) ────────────── + function parseSpec(v) { + v = (v || '').trim(); + var s = { off: false, fg: -1, bg: -1, bold: false, dim: false, underline: false, gradient: null }; + if (v === '' || v === 'off') { s.off = true; return s; } + var toks = v.split(/\s+/); + for (var i = 0; i < toks.length; i++) { + var t = toks[i]; + if (!t) continue; + if (t === 'bold') { s.bold = true; continue; } + if (t === 'dim') { s.dim = true; continue; } + if (t === 'underline') { s.underline = true; continue; } + if (t.indexOf('bg:') === 0) { + var h = t.slice(3).replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); + if (h.length === 2) { var n = parseInt(h, 16); if (n >= 0 && n <= 255) s.bg = n; } + continue; + } + if (t.indexOf('g:') === 0) { + var parts = t.slice(2).split(',').map(function (p) { return p.trim(); }); + var stops = []; + for (var j = 0; j < parts.length; j++) { + var gh = parts[j].replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); + if (gh.length === 2) stops.push(parseInt(gh, 16)); + } + if (stops.length >= 2) s.gradient = stops; + continue; + } + var hx = t.replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); + if (hx.length === 2) { + var fi = parseInt(hx, 16); + if (fi >= 0 && fi <= 255) s.fg = fi; + } + } + return s; + } + + function serializeSpec(s) { + if (!s || s.off) return s && s.off ? 'off' : ''; + var parts = []; + if (s.fg >= 0) parts.push(('0' + s.fg.toString(16)).slice(-2).toUpperCase()); + if (s.bold) parts.push('bold'); + if (s.dim) parts.push('dim'); + if (s.underline) parts.push('underline'); + if (s.bg >= 0) parts.push('bg:' + ('0' + s.bg.toString(16)).slice(-2).toUpperCase()); + if (s.gradient && s.gradient.length >= 2) { + parts.push('g:' + s.gradient.map(function (g) { return ('0' + g.toString(16)).slice(-2).toUpperCase(); }).join(',')); + } + return parts.join(' '); + } + + // Current effective spec for a category (staged overrides saved). + function effectiveSpec(name) { + if (STATE.dirty[name]) return STATE.staged[name]; + return STATE.saved[name] || STATE.defaults[name] || ''; + } + + // ── 16-color ANSI projection (mirrors nearestANSI in color.go) ────────── + var ANSI16 = [ + [0, 0, 0], [170, 0, 0], [0, 170, 0], [170, 170, 0], + [0, 0, 170], [170, 0, 170], [0, 170, 170], [170, 170, 170], + [85, 85, 85], [255, 85, 85], [85, 255, 85], [255, 255, 85], + [85, 85, 255], [255, 85, 255], [85, 255, 255], [255, 255, 255] + ]; + function rgbForCss(hex) { + if (!hex) return [0, 0, 0]; + hex = hex.replace('#', ''); + return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)]; + } + function nearestAnsi16(idx) { + var rgb = (window.xtermToRGB ? xtermToRGB(idx) : [0, 0, 0]); + var best = 0, bestD = Infinity; + for (var i = 0; i < 16; i++) { + var dr = rgb[0] - ANSI16[i][0], dg = rgb[1] - ANSI16[i][1], db = rgb[2] - ANSI16[i][2]; + var d = dr * dr + dg * dg + db * db; + if (d < bestD) { bestD = d; best = i; } + } + var c = ANSI16[best]; + return '#' + ('0' + c[0].toString(16)).slice(-2) + ('0' + c[1].toString(16)).slice(-2) + ('0' + c[2].toString(16)).slice(-2); + } + function nearest256OfRgb(r, g, b) { + if (window.cssToXtermIdx) { + var hex = ('0' + (r & 255).toString(16)).slice(-2) + ('0' + (g & 255).toString(16)).slice(-2) + ('0' + (b & 255).toString(16)).slice(-2); + return cssToXtermIdx(hex); + } + return 0; + } + + // ── Left-side row builder ────────────────────────────────────────────── + function buildRows() { + var host = $i('colorRows'); + host.innerHTML = ''; + var byGroup = {}; + var order = []; + STATE.categories.forEach(function (c) { + if (!byGroup[c.group]) { byGroup[c.group] = []; order.push(c.group); } + byGroup[c.group].push(c); + }); + order.forEach(function (g) { + host.appendChild(el('div', 'color-group', g)); + byGroup[g].forEach(function (c) { host.appendChild(buildRow(c)); }); + }); + } + + function rowSwatchColor(spec) { + if (spec.off || spec.fg < 0) return null; // no color → striped swatch + return window.xtermToCss ? xtermToCss(spec.fg) : '#808080'; + } + + function buildRow(cat) { + var row = el('div', 'color-row'); + row.dataset.name = cat.name; + + var savedSpec = parseSpec(effectiveSpec(cat.name)); + row.dataset.fg = savedSpec.fg >= 0 ? savedSpec.fg : '-1'; + + var sw = el('div', 'color-swatch'); + var sc = rowSwatchColor(savedSpec); + if (sc) sw.style.background = sc; else sw.classList.add('nocolor'); + sw.title = 'Click to pick foreground color'; + + var lbl = el('div', 'color-label', cat.label); + + var flags = el('div', 'color-flags'); + function mkFlag(name, label) { + var lab = el('label'); + var cb = el('input'); cb.type = 'checkbox'; cb.dataset.flag = name; + if (savedSpec[name]) cb.checked = true; + lab.appendChild(cb); lab.appendChild(el('span', null, label)); + return lab; + } + flags.appendChild(mkFlag('bold', 'B')); + flags.appendChild(mkFlag('dim', 'D')); + flags.appendChild(mkFlag('underline', 'U')); + + var adv = el('input', 'color-adv'); + adv.type = 'text'; adv.placeholder = 'bg:/g:'; + adv.value = extractAdvanced(savedSpec); + adv.title = 'Advanced tokens: bg:HH or g:HH,HH,...'; + adv.addEventListener('input', function () { commitRow(row, cat); }); + + var rbtn = el('button', 'color-revert', '\u21BA'); + rbtn.title = 'Revert this row to the saved value'; + + row.appendChild(sw); + row.appendChild(lbl); + row.appendChild(flags); + row.appendChild(adv); + row.appendChild(rbtn); + + // wire events + flags.addEventListener('change', function () { commitRow(row, cat); }); + sw.addEventListener('click', function (e) { e.stopPropagation(); openPicker(row, cat, sw); }); + lbl.addEventListener('click', function (e) { e.stopPropagation(); openPicker(row, cat, sw); }); + rbtn.addEventListener('click', function (e) { + e.stopPropagation(); + revertRow(row, cat); + }); + + refreshRow(row); + return row; + } + + // Read the row's current UI into a spec string, mark dirty, refresh. + function commitRow(row, cat) { + var s = { off: false, fg: -1, bg: -1, bold: false, dim: false, underline: false, gradient: null }; + s.fg = parseInt(row.dataset.fg != null ? row.dataset.fg : '-1', 10); + if (isNaN(s.fg)) s.fg = -1; + var flags = row.querySelectorAll('.color-flags input'); + for (var i = 0; i < flags.length; i++) { + var f = flags[i].dataset.flag; + if (f === 'bold') s.bold = flags[i].checked; + if (f === 'dim') s.dim = flags[i].checked; + if (f === 'underline') s.underline = flags[i].checked; + } + var adv = row.querySelector('.color-adv'); + var advStr = adv.value.trim(); + if (advStr) { + var toks = advStr.split(/\s+/); + for (var j = 0; j < toks.length; j++) { + var t = toks[j]; + if (t.indexOf('bg:') === 0) { + var h = t.slice(3).replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); + if (h.length === 2) s.bg = parseInt(h, 16); + } else if (t.indexOf('g:') === 0) { + var parts = t.slice(2).split(',').map(function (p) { return p.trim(); }); + var stops = []; + for (var k = 0; k < parts.length; k++) { + var gh = parts[k].replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); + if (gh.length === 2) stops.push(parseInt(gh, 16)); + } + if (stops.length >= 2) s.gradient = stops; + } else if (t === 'off') { + s.off = true; + } + } + } + var specStr = serializeSpec(s); + STATE.staged[cat.name] = specStr; + STATE.dirty[cat.name] = (specStr !== (STATE.saved[cat.name] || '')); + refreshRow(row); + refreshRevertButton(row, cat); + renderColorPreview(); + } + + function extractAdvanced(spec) { + if (!spec || spec.off) return ''; + var parts = []; + if (spec.bg >= 0) parts.push('bg:' + ('0' + spec.bg.toString(16)).slice(-2).toUpperCase()); + if (spec.gradient && spec.gradient.length >= 2) { + parts.push('g:' + spec.gradient.map(function (g) { return ('0' + g.toString(16)).slice(-2).toUpperCase(); }).join(',')); + } + return parts.join(' '); + } + + function refreshRow(row) { + row.classList.toggle('dirty', !!STATE.dirty[row.dataset.name]); + var spec = parseSpec(effectiveSpec(row.dataset.name)); + var sw = row.querySelector('.color-swatch'); + var sc = rowSwatchColor(spec); + sw.classList.toggle('nocolor', !sc); + sw.style.background = sc || ''; + } + + function refreshRevertButton(row, cat) { + var btn = row.querySelector('.color-revert'); + btn.disabled = !STATE.dirty[cat.name]; + } + + function revertRow(row, cat) { + delete STATE.staged[cat.name]; + delete STATE.dirty[cat.name]; + var spec = parseSpec(STATE.saved[cat.name] || STATE.defaults[cat.name] || ''); + row.dataset.fg = spec.fg >= 0 ? spec.fg : '-1'; + row.querySelectorAll('.color-flags input').forEach(function (cb) { + var f = cb.dataset.flag; cb.checked = !!spec[f]; + }); + row.querySelector('.color-adv').value = extractAdvanced(spec); + refreshRow(row); + refreshRevertButton(row, cat); + renderColorPreview(); + } + + // ── Picker popover (per-row, does not touch bold/dim/bg) ──────────────── + var popover = null, boundRow = null, boundCat = null, boundSwatch = null; + + function ensurePicker() { + if (popover) return; + popover = el('div'); + popover.id = '__ct_popover'; + popover.style.cssText = 'display:none;position:fixed;z-index:300;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,.6);max-width:340px'; + var head = el('div'); head.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px'; + head.appendChild(el('span', 'color-swatch')); + var hexIn = el('input'); hexIn.type = 'text'; hexIn.maxLength = 2; + hexIn.style.cssText = 'width:50px;padding:3px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;text-transform:uppercase'; + var offB = el('button', 'btn btn-sm', 'Off'); + var xB = el('button', 'btn btn-sm', '\u00d7'); xB.title = 'close'; + head.appendChild(hexIn); head.appendChild(offB); head.appendChild(xB); + popover.appendChild(head); + + var grid = el('div', 'cp-grid'); + for (var i = 0; i < 256; i++) { + var sw = el('div', 'cp-swatch'); + sw.className = 'cp-swatch'; + (function (idx, ee) { + ee.style.background = window.xtermToCss ? xtermToCss(idx) : '#000'; + ee.dataset.idx = idx; + ee.title = ('0' + idx.toString(16)).slice(-2).toUpperCase() + ' (' + idx + ')'; + })(i, sw); + grid.appendChild(sw); + } + popover.appendChild(grid); + + document.body.appendChild(popover); + + grid.addEventListener('click', function (e) { + var s = e.target.closest('.cp-swatch'); if (!s) return; + var idx = parseInt(s.dataset.idx, 10); + setRowFg(boundRow, boundCat, idx); + hexIn.value = ('0' + idx.toString(16)).slice(-2).toUpperCase(); + highlightGrid(idx); + popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(idx) : '#000'; + }); + hexIn.addEventListener('input', function () { + var v = this.value.toUpperCase().replace(/[^0-9A-F]/g, '').substring(0, 2); + this.value = v; + if (v.length === 2) { + var idx = parseInt(v, 16); + setRowFg(boundRow, boundCat, idx); + highlightGrid(idx); + popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(idx) : '#000'; + } + }); + offB.addEventListener('click', function () { + setRowFg(boundRow, boundCat, -1); + boundRow.querySelector('.color-swatch').classList.add('nocolor'); + boundRow.querySelector('.color-swatch').style.background = ''; + hidePicker(); + }); + xB.addEventListener('click', hidePicker); + + document.addEventListener('click', function (e) { + if (!popover || popover.style.display === 'none') return; + if (!popover.contains(e.target) && e.target !== boundSwatch) hidePicker(); + }); + } + + function highlightGrid(idx) { + var all = popover.querySelectorAll('.cp-swatch'); + for (var i = 0; i < all.length; i++) all[i].classList.remove('selected'); + var sel = popover.querySelector('.cp-swatch[data-idx="' + idx + '"]'); + if (sel) sel.classList.add('selected'); + } + + function setRowFg(row, cat, idx) { + row.dataset.fg = idx; + commitRow(row, cat); + var sw = row.querySelector('.color-swatch'); + if (idx < 0) { sw.classList.add('nocolor'); sw.style.background = ''; } + else { sw.classList.remove('nocolor'); sw.style.background = window.xtermToCss ? xtermToCss(idx) : '#808080'; } + } + + function openPicker(row, cat, swatch) { + ensurePicker(); + boundRow = row; boundCat = cat; boundSwatch = swatch; + var spec = parseSpec(effectiveSpec(cat.name)); + var fg = spec.fg >= 0 ? spec.fg : 0; + var hexIn = popover.querySelector('input[type=text]'); + hexIn.value = ('0' + fg.toString(16)).slice(-2).toUpperCase(); + popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(fg) : '#000'; + highlightGrid(fg); + var rect = swatch.getBoundingClientRect(); + popover.style.display = 'block'; + var pw = popover.offsetWidth; + var left = rect.left; + if (left + pw > window.innerWidth - 10) left = Math.max(10, window.innerWidth - pw - 10); + popover.style.left = left + 'px'; + popover.style.top = (rect.bottom + 4) + 'px'; + hexIn.focus(); hexIn.select(); + } + + function hidePicker() { if (popover) popover.style.display = 'none'; } + + // ── Preview rendering ────────────────────────────────────────────────── + function renderColorPreview() { + var pre = $i('colorPreview'); + if (!pre) return; + var mode = ($i('colorMode') || {}).value || 'xterm256'; + pre.innerHTML = ''; + STATE.preview.forEach(function (sec, idx) { + var title = el('span', 'cp-title', sec.title); + pre.appendChild(title); + sec.lines.forEach(function (line) { + var lineEl = el('div'); + lineEl.style.minHeight = '1em'; + line.segments.forEach(function (seg) { + lineEl.appendChild(renderSegment(seg, mode)); + }); + pre.appendChild(lineEl); + }); + }); + } + + function renderSegment(seg, mode) { + var span = el('span'); + span.textContent = seg.t; + if (!seg.c || mode === 'none') return span; + var spec = parseSpec(effectiveSpec(seg.c)); + if (spec.off) return span; + + if (spec.bg >= 0) { + var bgCss = mode === 'ansi16' ? nearestAnsi16(spec.bg) : (window.xtermToCss ? xtermToCss(spec.bg) : '#808080'); + span.style.backgroundColor = bgCss; + // pick a readable fg when the spec has none + var rgb = rgbForCss(bgCss); + span.style.color = (rgb[0] + rgb[1] + rgb[2] > 384) ? '#000' : '#fff'; + } + + if (spec.gradient && spec.gradient.length >= 2 && mode === 'xterm256') { + // per-character gradient ramp + var stops = spec.gradient; + var chars = Array.from(seg.t); + var txt = document.createDocumentFragment(); + chars.forEach(function (ch, i) { + var t = chars.length > 1 ? i / (chars.length - 1) : 0; + var idx = interpolateGradient(stops, t); + var s = el('span'); + s.textContent = ch; + if (spec.bold) s.style.fontWeight = 'bold'; + if (spec.dim) s.style.opacity = '0.55'; + if (spec.underline) s.style.textDecoration = 'underline'; + s.style.color = window.xtermToCss ? xtermToCss(idx) : '#808080'; + if (spec.bg >= 0) { s.style.backgroundColor = span.style.backgroundColor; } + txt.appendChild(s); + }); + return txt; + } + + if (spec.fg >= 0) { + var fgCss; + if (mode === 'ansi16') fgCss = nearestAnsi16(spec.fg); + else fgCss = window.xtermToCss ? xtermToCss(spec.fg) : '#808080'; + span.style.color = fgCss; + } + if (spec.bold) span.style.fontWeight = 'bold'; + if (spec.dim) span.style.opacity = '0.55'; + if (spec.underline) span.style.textDecoration = 'underline'; + return span; + } + + function interpolateGradient(stops, t) { + if (t <= 0) return stops[0]; + if (t >= 1) return stops[stops.length - 1]; + var seg2 = t * (stops.length - 1); + var i = Math.floor(seg2); + if (i >= stops.length - 1) i = stops.length - 2; + var frac = seg2 - i; + var a = window.xtermToRGB ? xtermToRGB(stops[i]) : [0, 0, 0]; + var b = window.xtermToRGB ? xtermToRGB(stops[i + 1]) : [0, 0, 0]; + var r = Math.round(a[0] + frac * (b[0] - a[0])); + var g = Math.round(a[1] + frac * (b[1] - a[1])); + var bb = Math.round(a[2] + frac * (b[2] - a[2])); + return nearest256OfRgb(r, g, bb); + } + + // ── Save / reset / export / import ───────────────────────────────────── + function colorSave() { + var status = $i('colorSaveStatus'); + status.textContent = 'Saving...'; status.className = 'color-save-status'; + var payload = {}; + Object.keys(STATE.dirty).forEach(function (k) { + if (STATE.dirty[k]) payload[k] = effectiveSpec(k); + }); + fetch('/api/colors', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ colors: payload }) + }).then(function (r) { return r.json(); }).then(function (j) { + if (j.ok) { + // committed values become the new saved baseline + Object.keys(STATE.dirty).forEach(function (k) { if (STATE.dirty[k]) STATE.saved[k] = effectiveSpec(k); }); + STATE.dirty = {}; + document.querySelectorAll('.color-row.dirty').forEach(function (row) { + row.classList.remove('dirty'); + var btn = row.querySelector('.color-revert'); if (btn) btn.disabled = true; + }); + status.textContent = 'Saved'; status.className = 'color-save-status ok'; + } else { + status.textContent = 'Error: ' + (j.error || 'unknown'); status.className = 'color-save-status err'; + } + }).catch(function (e) { + status.textContent = 'Error: ' + e; status.className = 'color-save-status err'; + }); + } + + function colorResetAll() { + if (!confirm('Reset ALL colors to the built-in defaults? This saves immediately.')) return; + fetch('/api/colors/reset', { method: 'POST' }).then(function (r) { return r.json(); }).then(function (j) { + if (!j.ok) { alert('Reset failed: ' + (j.error || 'unknown')); return; } + Object.keys(j.colors || {}).forEach(function (k) { + STATE.saved[k] = j.colors[k]; + STATE.staged[k] = j.colors[k]; + delete STATE.dirty[k]; + }); + buildRows(); + renderColorPreview(); + }).catch(function (e) { alert('Reset failed: ' + e); }); + } + + function colorExport() { + var out = {}; + STATE.categories.forEach(function (c) { out[c.name] = effectiveSpec(c.name); }); + var json = JSON.stringify(out, null, 2); + var ta = el('textarea'); ta.value = json; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + var status = $i('colorSaveStatus'); + status.textContent = 'Theme JSON copied to clipboard'; status.className = 'color-save-status ok'; + setTimeout(function () { if (status.textContent === 'Theme JSON copied to clipboard') status.textContent = ''; }, 2500); + } + + function colorImportPrompt() { + var txt = prompt('Paste exported theme JSON (a {category: spec} object):'); + if (!txt) return; + var obj; + try { obj = JSON.parse(txt); } catch (e) { alert('Invalid JSON: ' + e); return; } + var applied = 0; + Object.keys(obj).forEach(function (k) { + if (!STATE.categoriesById[k]) return; + var spec = parseSpec(obj[k]); + STATE.staged[k] = serializeSpec(spec); + STATE.dirty[k] = (STATE.staged[k] !== (STATE.saved[k] || '')); + applied++; + }); + if (applied === 0) { alert('No known categories found in the JSON.'); return; } + buildRows(); + renderColorPreview(); + var status = $i('colorSaveStatus'); + status.textContent = 'Imported ' + applied + ' colors (press Save to persist).'; + status.className = 'color-save-status'; + } + + function filterColorRows(q) { + q = (q || '').toLowerCase(); + document.querySelectorAll('#colorRows .color-row').forEach(function (row) { + var name = row.dataset.name; + var label = (STATE.categoriesById[name] || {}).label || ''; + row.style.display = (!q || name.indexOf(q) >= 0 || label.toLowerCase().indexOf(q) >= 0) ? '' : 'none'; + }); + document.querySelectorAll('#colorRows .color-group').forEach(function (g) { + // hide group header if all its rows are hidden — naive: check sibling visibility + var any = false, n = g.nextElementSibling; + while (n && !n.classList.contains('color-group')) { + if (n.classList.contains('color-row') && n.style.display !== 'none') { any = true; break; } + n = n.nextElementSibling; + } + g.style.display = any || !q ? '' : 'none'; + }); + } + + // ── Init ─────────────────────────────────────────────────────────────── + window.initColorEditor = function () { + fetch('/api/colors').then(function (r) { return r.json(); }).then(function (data) { + STATE.categories = data.categories || []; + STATE.categoriesById = {}; + STATE.categories.forEach(function (c) { STATE.categoriesById[c.name] = c; }); + STATE.saved = data.colors || {}; + STATE.defaults = data.default_colors || {}; + STATE.preview = data.preview || []; + STATE.staged = {}; STATE.dirty = {}; + Object.keys(STATE.saved).forEach(function (k) { STATE.staged[k] = STATE.saved[k]; }); + buildRows(); + renderColorPreview(); + }).catch(function (e) { + $i('colorRows').textContent = 'Failed to load colors: ' + e; + }); + }; + + // expose for inline onclick handlers + window.colorSave = colorSave; + window.colorResetAll = colorResetAll; + window.colorExport = colorExport; + window.colorImportPrompt = colorImportPrompt; + window.filterColorRows = filterColorRows; + window.renderColorPreview = renderColorPreview; +})();
\ No newline at end of file diff --git a/internal/admin/templates/colors.html b/internal/admin/templates/colors.html new file mode 100644 index 0000000..a559dc3 --- /dev/null +++ b/internal/admin/templates/colors.html @@ -0,0 +1,37 @@ +{{define "body-colors"}} +<div class="color-themer"> + <div class="editor-list color-list" id="colorList"> + <div class="search-bar"><input type="search" id="colorFilter" placeholder="Filter categories..." oninput="filterColorRows(this.value)"></div> + <div id="colorRows"></div> + <div class="color-list-actions"> + <button class="btn btn-sm" id="colorExportBtn" onclick="colorExport()">Export JSON</button> + <button class="btn btn-sm" id="colorImportBtn" onclick="colorImportPrompt()">Import JSON</button> + <button class="btn btn-sm" id="colorResetBtn" onclick="colorResetAll()">Reset all to defaults</button> + </div> + <div class="color-save-bar"> + <button class="btn btn-primary" id="colorSaveBtn" onclick="colorSave()">Save Colors</button> + <span id="colorSaveStatus" class="color-save-status"></span> + </div> + </div> + <div class="editor-main color-preview-main"> + <div class="color-preview-toolbar"> + <label for="colorMode">Color mode:</label> + <select id="colorMode" onchange="renderColorPreview()"> + <option value="xterm256">xterm256 (full color)</option> + <option value="ansi16">ansi16 (16-color nearest)</option> + <option value="none">none (uncolored)</option> + </select> + <span class="color-preview-hint">Right side updates live as you edit colors.</span> + </div> + <div class="color-preview-scroll"> + <pre id="colorPreview" class="color-preview-pre"></pre> + </div> + </div> +</div> +<input type="hidden" id="colorImportInput"> +<script src="/static/coloreditor.js"></script> +<script> +initColorEditor(); +</script> +{{end}} +</content>
\ No newline at end of file diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html index 02f8a01..f3bf9fd 100644 --- a/internal/admin/templates/layout.html +++ b/internal/admin/templates/layout.html @@ -20,6 +20,7 @@ <a href="/editor/techs" class="{{if eq .Page "techs"}}active{{end}}">Techs</a> <a href="/editor/modules" class="{{if eq .Page "modules"}}active{{end}}">Modules</a> <a href="/editor/triggers" class="{{if eq .Page "triggers"}}active{{end}}">Triggers</a> + <a href="/editor/colors" class="{{if eq .Page "colors"}}active{{end}}">Color</a> <a href="/editor/characters" class="{{if eq .Page "characters"}}active{{end}}">Characters</a> <a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a> <a href="/editor/files" class="{{if eq .Page "files"}}active{{end}}">Files</a> @@ -40,6 +41,7 @@ {{else if eq .Page "techs"}}{{template "body-techs" .}} {{else if eq .Page "modules"}}{{template "body-modules" .}} {{else if eq .Page "triggers"}}{{template "body-triggers" .}} +{{else if eq .Page "colors"}}{{template "body-colors" .}} {{else if eq .Page "characters"}}{{template "body-characters" .}} {{else if eq .Page "dashboard"}}{{template "body-dashboard" .}} {{else if eq .Page "files"}}{{template "body-files" .}} |
