diff options
| author | historia <[not public]> | 2026-06-29 22:31:11 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-29 22:31:11 -0400 |
| commit | 069d3c1c81d71042706372df153254487a765dfc (patch) | |
| tree | d83a4a3954d2b8304f49d83e365f4c2b075b91eb /internal/admin | |
| parent | 6c5271fb085b4571a10f106391974c249cd84317 (diff) | |
| download | thehouseoficarus-069d3c1c81d71042706372df153254487a765dfc.tar.gz | |
feat: wip web admin for mapping and crud
Diffstat (limited to 'internal/admin')
41 files changed, 5114 insertions, 0 deletions
diff --git a/internal/admin/api_courses.go b/internal/admin/api_courses.go new file mode 100644 index 0000000..63b356c --- /dev/null +++ b/internal/admin/api_courses.go @@ -0,0 +1,138 @@ +package admin + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "courses") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + case http.MethodPost: + var m map[string]any + if err := readJSON(r, &m); err != nil { + writeJSON(w, map[string]any{"error": "invalid json"}) + return + } + id, _ := m["id"].(string) + if strings.TrimSpace(id) == "" { + writeJSON(w, map[string]any{"error": "missing id"}) + return + } + delete(m, "id") + path := filepath.Join(s.dataDir, "courses", id+".yaml") + if _, err := os.Stat(path); err == nil { + writeJSON(w, map[string]any{"error": "course already exists"}) + return + } + newContent, err := writeMapAsYAML(path, m) + if err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Created course " + id, + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/courses/") + if id == "" { + http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + data, path, err := findYAMLFileInSubdirs(s.dataDir, "courses", id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + raw, err := yamlToMap(data) + if err != nil { + writeJSON(w, map[string]any{"error": "parse error: " + err.Error()}) + return + } + raw["id"] = id + raw["_path"] = path + raw["_raw"] = string(data) + writeJSON(w, raw) + + case http.MethodPost, http.MethodPut: + var body map[string]any + if err := readJSON(r, &body); err != nil { + writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()}) + return + } + delete(body, "id") + delete(body, "_path") + delete(body, "_raw") + + path := filepath.Join(s.dataDir, "courses", id+".yaml") + var oldContent []byte + if existing, err := snapshotFile(path); err == nil { + oldContent = existing + } + + newContent, err := writeMapAsYAML(path, body) + if err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + + isCreate := oldContent == nil + desc := "Updated course " + id + if isCreate { + desc = "Created course " + id + } + s.undoStack.Push(ChangeDesc{ + Description: desc, + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + IsCreate: isCreate, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + + case http.MethodDelete: + _, path, err := findYAMLFileInSubdirs(s.dataDir, "courses", id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + oldContent, _ := snapshotFile(path) + if err := os.Remove(path); err != nil { + writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Deleted course " + id, + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/admin/api_dashboard.go b/internal/admin/api_dashboard.go new file mode 100644 index 0000000..f7e7a6f --- /dev/null +++ b/internal/admin/api_dashboard.go @@ -0,0 +1,39 @@ +package admin + +import ( + "net/http" + "os" + "path/filepath" +) + +func (s *AdminServer) handleDashboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + countYAML := func(subdir string) int { + dir := filepath.Join(s.dataDir, subdir) + n := 0 + filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if filepath.Ext(d.Name()) == ".yaml" { + n++ + } + return nil + }) + return n + } + + dashboard := map[string]any{ + "roomCount": countYAML("rooms"), + "itemCount": countYAML("items"), + "objectCount": countYAML("objects"), + "mobCount": countYAML("mobs"), + "playerCount": countYAML("players/characters"), + "accountCount": countYAML("players/accounts"), + } + writeJSON(w, dashboard) +} diff --git a/internal/admin/api_drops.go b/internal/admin/api_drops.go new file mode 100644 index 0000000..7645d39 --- /dev/null +++ b/internal/admin/api_drops.go @@ -0,0 +1,168 @@ +package admin + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "thehouseoficarus/internal/behavior" +) + +func (s *AdminServer) handleDrops(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "drops") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + + case http.MethodPost: + s.createDrop(w, r) + + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleDropByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/drops/") + if id == "" { + http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + s.getDrop(w, r, id) + case http.MethodPut: + s.updateDrop(w, r, id) + case http.MethodDelete: + s.deleteDrop(w, r, id) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) getDrop(w http.ResponseWriter, r *http.Request, id string) { + data, path, err := readYAMLFile(s.dataDir, "drops", id) + if err != nil { + http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound) + return + } + m, err := yamlToMap(data) + if err != nil { + http.Error(w, `{"error":"failed to parse drop yaml"}`, http.StatusInternalServerError) + return + } + m["_path"] = path + m["_raw"] = string(data) + writeJSON(w, m) +} + +func (s *AdminServer) updateDrop(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := readYAMLFile(s.dataDir, "drops", id) + if err != nil { + http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound) + return + } + + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + m["id"] = id + + oldContent, err := snapshotFile(path) + if err != nil { + http.Error(w, `{"error":"failed to read existing drop"}`, http.StatusInternalServerError) + return + } + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write drop"}`, http.StatusInternalServerError) + return + } + + behavior.ClearDropIndex() + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Update drop %s", id), + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + }) + + writeJSON(w, m) +} + +func (s *AdminServer) deleteDrop(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := readYAMLFile(s.dataDir, "drops", id) + if err != nil { + http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound) + return + } + + oldContent := backupFile(path) + + if err := os.Remove(path); err != nil { + http.Error(w, `{"error":"failed to delete drop"}`, http.StatusInternalServerError) + return + } + + behavior.ClearDropIndex() + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Delete drop %s", id), + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + + writeJSON(w, map[string]any{"deleted": id}) +} + +func (s *AdminServer) createDrop(w http.ResponseWriter, r *http.Request) { + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { + http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest) + return + } + + path := filepath.Join(s.dataDir, "drops", id+".yaml") + if _, err := os.Stat(path); err == nil { + http.Error(w, `{"error":"drop already exists"}`, http.StatusConflict) + return + } + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write drop"}`, http.StatusInternalServerError) + return + } + + behavior.ClearDropIndex() + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Create drop %s", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + + writeJSON(w, m) +} diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go new file mode 100644 index 0000000..2a87dd3 --- /dev/null +++ b/internal/admin/api_files.go @@ -0,0 +1,135 @@ +package admin + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { + relPath := r.URL.Query().Get("path") + relPath = strings.TrimPrefix(relPath, "/") + relPath = strings.TrimPrefix(relPath, "data/") + + switch r.Method { + case http.MethodGet: + if relPath == "" { + entries, err := os.ReadDir(s.dataDir) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + var files []map[string]any + for _, e := range entries { + files = append(files, map[string]any{ + "name": e.Name(), + "dir": e.IsDir(), + }) + } + writeJSON(w, files) + return + } + + fullPath := filepath.Join(s.dataDir, relPath) + info, err := os.Stat(fullPath) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + relPath}) + return + } + + if info.IsDir() { + entries, err := os.ReadDir(fullPath) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + var files []map[string]any + for _, e := range entries { + files = append(files, map[string]any{ + "name": e.Name(), + "dir": e.IsDir(), + }) + } + writeJSON(w, files) + return + } + + data, err := os.ReadFile(fullPath) + if err != nil { + writeJSON(w, map[string]any{"error": "read error: " + err.Error()}) + return + } + writeJSON(w, map[string]any{ + "name": info.Name(), + "content": string(data), + "path": relPath, + }) + + case http.MethodPut, http.MethodPost: + if relPath == "" { + writeJSON(w, map[string]any{"error": "missing path"}) + return + } + fullPath := filepath.Join(s.dataDir, relPath) + + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, map[string]any{"error": "read body: " + err.Error()}) + return + } + + var oldContent []byte + if existing, err := snapshotFile(fullPath); err == nil { + oldContent = existing + } + + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + writeJSON(w, map[string]any{"error": "mkdir: " + err.Error()}) + return + } + + if err := os.WriteFile(fullPath, body, 0644); err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + + isCreate := oldContent == nil + desc := "Updated file " + relPath + if isCreate { + desc = "Created file " + relPath + } + s.undoStack.Push(ChangeDesc{ + Description: desc, + FilePath: fullPath, + OldContent: oldContent, + NewContent: body, + IsCreate: isCreate, + }) + writeJSON(w, map[string]any{"ok": true, "path": relPath}) + + case http.MethodDelete: + if relPath == "" { + writeJSON(w, map[string]any{"error": "missing path"}) + return + } + fullPath := filepath.Join(s.dataDir, relPath) + oldContent, _ := snapshotFile(fullPath) + if err := os.Remove(fullPath); err != nil { + writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Deleted file " + relPath, + FilePath: fullPath, + OldContent: oldContent, + IsDelete: true, + }) + writeJSON(w, map[string]any{"ok": true, "path": relPath}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/admin/api_flags.go b/internal/admin/api_flags.go new file mode 100644 index 0000000..e2594f3 --- /dev/null +++ b/internal/admin/api_flags.go @@ -0,0 +1,14 @@ +package admin + +import "net/http" + +func (s *AdminServer) handleFlags(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, map[string]any{ + "flags": []any{}, + "note": "world flags are in-memory only", + }) +} diff --git a/internal/admin/api_hazards.go b/internal/admin/api_hazards.go new file mode 100644 index 0000000..c8c0282 --- /dev/null +++ b/internal/admin/api_hazards.go @@ -0,0 +1,166 @@ +package admin + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleHazards(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "hazards") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + + case http.MethodPost: + s.createHazard(w, r) + + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleHazardByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/hazards/") + if id == "" { + http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + s.getHazard(w, r, id) + case http.MethodPut: + s.updateHazard(w, r, id) + case http.MethodDelete: + s.deleteHazard(w, r, id) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) getHazard(w http.ResponseWriter, r *http.Request, id string) { + data, path, err := readYAMLFile(s.dataDir, "hazards", id) + if err != nil { + http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound) + return + } + m, err := yamlToMap(data) + if err != nil { + http.Error(w, `{"error":"failed to parse hazard yaml"}`, http.StatusInternalServerError) + return + } + m["_path"] = path + m["_raw"] = string(data) + writeJSON(w, m) +} + +func (s *AdminServer) updateHazard(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := readYAMLFile(s.dataDir, "hazards", id) + if err != nil { + http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound) + return + } + + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + m["id"] = id + + oldContent, err := snapshotFile(path) + if err != nil { + http.Error(w, `{"error":"failed to read existing hazard"}`, http.StatusInternalServerError) + return + } + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write hazard"}`, http.StatusInternalServerError) + return + } + + s.world.ClearHazardCache() + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Update hazard %s", id), + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + }) + + writeJSON(w, m) +} + +func (s *AdminServer) deleteHazard(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := readYAMLFile(s.dataDir, "hazards", id) + if err != nil { + http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound) + return + } + + oldContent := backupFile(path) + + if err := os.Remove(path); err != nil { + http.Error(w, `{"error":"failed to delete hazard"}`, http.StatusInternalServerError) + return + } + + s.world.ClearHazardCache() + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Delete hazard %s", id), + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + + writeJSON(w, map[string]any{"deleted": id}) +} + +func (s *AdminServer) createHazard(w http.ResponseWriter, r *http.Request) { + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { + http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest) + return + } + + path := filepath.Join(s.dataDir, "hazards", id+".yaml") + if _, err := os.Stat(path); err == nil { + http.Error(w, `{"error":"hazard already exists"}`, http.StatusConflict) + return + } + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write hazard"}`, http.StatusInternalServerError) + return + } + + s.world.ClearHazardCache() + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Create hazard %s", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + + writeJSON(w, m) +} diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go new file mode 100644 index 0000000..78e59d4 --- /dev/null +++ b/internal/admin/api_helpers.go @@ -0,0 +1,31 @@ +package admin + +import ( + "bytes" + "os" + + "gopkg.in/yaml.v3" +) + +func yamlToMap(data []byte) (map[string]any, error) { + var m map[string]any + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func writeMapAsYAML(path string, m map[string]any) ([]byte, error) { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(m); err != nil { + return nil, err + } + enc.Close() + content := buf.Bytes() + if err := os.WriteFile(path, content, 0644); err != nil { + return nil, err + } + return content, nil +} diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go new file mode 100644 index 0000000..3ab20ae --- /dev/null +++ b/internal/admin/api_items.go @@ -0,0 +1,166 @@ +package admin + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleItems(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "items") + if err != nil { + http.Error(w, `{"error":"failed to list items"}`, http.StatusInternalServerError) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + case http.MethodPost: + s.createItem(w, r) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleItemByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/items/") + if id == "" { + http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + s.getItem(w, r, id) + case http.MethodPut: + s.updateItem(w, r, id) + case http.MethodDelete: + s.deleteItem(w, r, id) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) findItemFile(id string) ([]byte, string, error) { + data, path, err := readYAMLFile(s.dataDir, "items", id) + if err == nil { + return data, path, nil + } + return findYAMLFileInSubdirs(s.dataDir, "items", id) +} + +func (s *AdminServer) getItem(w http.ResponseWriter, r *http.Request, id string) { + data, path, err := s.findItemFile(id) + if err != nil { + http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) + return + } + m, err := yamlToMap(data) + if err != nil { + http.Error(w, `{"error":"failed to parse item yaml"}`, http.StatusInternalServerError) + return + } + m["_path"] = path + m["_raw"] = string(data) + writeJSON(w, m) +} + +func (s *AdminServer) updateItem(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := s.findItemFile(id) + if err != nil { + http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) + return + } + + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + m["id"] = id + + oldContent, err := snapshotFile(path) + if err != nil { + http.Error(w, `{"error":"failed to read existing item"}`, http.StatusInternalServerError) + return + } + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write item"}`, http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Update item %s", id), + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + }) + + writeJSON(w, m) +} + +func (s *AdminServer) deleteItem(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := s.findItemFile(id) + if err != nil { + http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) + return + } + + oldContent := backupFile(path) + + if err := os.Remove(path); err != nil { + http.Error(w, `{"error":"failed to delete item"}`, http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Delete item %s", id), + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + + writeJSON(w, map[string]any{"deleted": id}) +} + +func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) { + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + id, _ := m["id"].(string) + if strings.TrimSpace(id) == "" { + http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest) + return + } + + if _, _, err := s.findItemFile(id); err == nil { + http.Error(w, `{"error":"item already exists"}`, http.StatusConflict) + return + } + + path := filepath.Join(s.dataDir, "items", id+".yaml") + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write item"}`, http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Create item %s", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + + writeJSON(w, m) +} diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go new file mode 100644 index 0000000..1520279 --- /dev/null +++ b/internal/admin/api_map.go @@ -0,0 +1,241 @@ +package admin + +import ( + "net/http" + "os" + "path/filepath" + "strconv" + + "thehouseoficarus/internal/world" +) + +func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + zStr := r.URL.Query().Get("z") + z, err := strconv.Atoi(zStr) + if err != nil { + z = 0 + } + + dir := r.URL.Query().Get("dir") + seed := s.cfg.StartingRoom + if dir != "" { + base := filepath.Join(s.dataDir, "rooms", dir) + if low, ok := findLowestRoom(base); ok { + seed = low + } + } + + g := world.BuildGrid(seed, func(id int) (*world.Room, bool) { + room, err := s.world.LoadRoom(id) + if err != nil { + return nil, false + } + return room, true + }, nil, nil) + + type RoomEntry struct { + ID int `json:"id"` + X int `json:"x"` + Y int `json:"y"` + Name string `json:"name"` + Color string `json:"color"` + Symbol string `json:"symbol"` + } + + type LinkEntry struct { + From int `json:"from"` + To int `json:"to"` + Dir string `json:"dir"` + Bidirectional bool `json:"bidirectional"` + } + + type UDLink struct { + From int `json:"from"` + To int `json:"to"` + } + + roomData := make(map[int]*world.Room) + var rooms []RoomEntry + + minX, maxX := 0, 0 + minY, maxY := 0, 0 + first := true + + for rid, coord := range g.Coord { + if coord[2] != z { + continue + } + + r, err := s.world.LoadRoom(rid) + if err != nil { + continue + } + roomData[rid] = r + + rooms = append(rooms, RoomEntry{ + ID: rid, + X: coord[0], + Y: coord[1], + Name: r.Name, + Color: r.Color, + }) + + if first { + minX, maxX = coord[0], coord[0] + minY, maxY = coord[1], coord[1] + first = false + } else { + if coord[0] < minX { + minX = coord[0] + } + if coord[0] > maxX { + maxX = coord[0] + } + if coord[1] < minY { + minY = coord[1] + } + if coord[1] > maxY { + maxY = coord[1] + } + } + } + + var links []LinkEntry + var upLinks []UDLink + var downLinks []UDLink + + seenLinks := make(map[string]bool) + for rid, room := range roomData { + c := g.Coord[rid] + for dir, exit := range room.Exits { + target := exit.Room + if target <= 0 { + continue + } + targetCoord, ok := g.Coord[target] + if !ok { + continue + } + + if targetCoord[2] > c[2] { + upLinks = append(upLinks, UDLink{From: rid, To: target}) + continue + } + if targetCoord[2] < c[2] { + downLinks = append(downLinks, UDLink{From: rid, To: target}) + continue + } + + if dir == world.Up || dir == world.Down { + continue + } + + if _, onZ := roomData[target]; !onZ { + continue + } + + bidirectional := false + if targetRoom, ok := roomData[target]; ok { + if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid { + bidirectional = true + } + } + + key := linkKey(rid, target) + if bidirectional && seenLinks[key] { + continue + } + seenLinks[key] = true + + links = append(links, LinkEntry{ + From: rid, + To: target, + Dir: string(dir), + Bidirectional: bidirectional, + }) + } + } + + writeJSON(w, map[string]any{ + "rooms": rooms, + "links": links, + "upLinks": upLinks, + "downLinks": downLinks, + "dir": getRoomDir(s, seed), + "bounds": map[string]int{ + "minX": minX, + "maxX": maxX, + "minY": minY, + "maxY": maxY, + }, + }) +} + +func getRoomDir(s *AdminServer, roomID int) string { + path, ok := s.world.GetRoomPath(roomID) + if !ok { + return "" + } + rel, err := filepath.Rel(filepath.Join(s.dataDir, "rooms"), filepath.Dir(path)) + if err != nil || rel == "." { + return "" + } + return rel +} + +func linkKey(a, b int) string { + if a < b { + return strconv.Itoa(a) + "-" + strconv.Itoa(b) + } + return strconv.Itoa(b) + "-" + strconv.Itoa(a) +} + +func findLowestRoom(dir string) (int, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, false + } + lowest := 0 + found := false + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if filepath.Ext(name) != ".yaml" { + continue + } + id, err := strconv.Atoi(name[:len(name)-5]) + if err != nil || id <= 0 { + continue + } + if !found || id < lowest { + lowest = id + found = true + } + } + return lowest, found +} + +func (s *AdminServer) handleNextRoomID(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + fromStr := r.URL.Query().Get("from") + fromID := 0 + if fromStr != "" { + fromID, _ = strconv.Atoi(fromStr) + } + id, _, err := nextRoomIDInDir(s.dataDir, fromID) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, map[string]any{"id": id}) +} diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go new file mode 100644 index 0000000..5fdfbc8 --- /dev/null +++ b/internal/admin/api_mobs.go @@ -0,0 +1,163 @@ +package admin + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleMobs(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "mobs") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + + case http.MethodPost: + s.createMob(w, r) + + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleMobByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/mobs/") + if id == "" { + http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + s.getMob(w, r, id) + case http.MethodPut: + s.updateMob(w, r, id) + case http.MethodDelete: + s.deleteMob(w, r, id) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) findMobFile(id string) ([]byte, string, error) { + data, path, err := readYAMLFile(s.dataDir, "mobs", id) + if err == nil { + return data, path, nil + } + return findYAMLFileInSubdirs(s.dataDir, "mobs", id) +} + +func (s *AdminServer) getMob(w http.ResponseWriter, r *http.Request, id string) { + data, path, err := s.findMobFile(id) + if err != nil { + http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound) + return + } + m, err := yamlToMap(data) + if err != nil { + http.Error(w, `{"error":"failed to parse mob yaml"}`, http.StatusInternalServerError) + return + } + m["_path"] = path + m["_raw"] = string(data) + writeJSON(w, m) +} + +func (s *AdminServer) updateMob(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := s.findMobFile(id) + if err != nil { + http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound) + return + } + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + m["id"] = id + + oldContent, err := snapshotFile(path) + if err != nil { + http.Error(w, `{"error":"failed to read existing mob"}`, http.StatusInternalServerError) + return + } + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write mob"}`, http.StatusInternalServerError) + return + } + s.mobStore.ReloadDefs(s.dataDir) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Update mob %s", id), + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + }) + writeJSON(w, m) +} + +func (s *AdminServer) deleteMob(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := s.findMobFile(id) + if err != nil { + http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound) + return + } + + oldContent := backupFile(path) + + if err := os.Remove(path); err != nil { + http.Error(w, `{"error":"failed to delete mob"}`, http.StatusInternalServerError) + return + } + + s.mobStore.ReloadDefs(s.dataDir) + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Delete mob %s", id), + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + + writeJSON(w, map[string]any{"deleted": id}) +} + +func (s *AdminServer) createMob(w http.ResponseWriter, r *http.Request) { + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + id, _ := m["id"].(string) + if strings.TrimSpace(id) == "" { + http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest) + return + } + if _, _, err := s.findMobFile(id); err == nil { + http.Error(w, `{"error":"mob already exists"}`, http.StatusConflict) + return + } + path := filepath.Join(s.dataDir, "mobs", id+".yaml") + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write mob"}`, http.StatusInternalServerError) + return + } + s.mobStore.ReloadDefs(s.dataDir) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Create mob %s", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + writeJSON(w, m) +} diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go new file mode 100644 index 0000000..e676cd7 --- /dev/null +++ b/internal/admin/api_modules.go @@ -0,0 +1,138 @@ +package admin + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleModules(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "modules") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + case http.MethodPost: + var m map[string]any + if err := readJSON(r, &m); err != nil { + writeJSON(w, map[string]any{"error": "invalid json"}) + return + } + id, _ := m["id"].(string) + if strings.TrimSpace(id) == "" { + writeJSON(w, map[string]any{"error": "missing id"}) + return + } + delete(m, "id") + path := filepath.Join(s.dataDir, "modules", id+".yaml") + if _, err := os.Stat(path); err == nil { + writeJSON(w, map[string]any{"error": "module already exists"}) + return + } + newContent, err := writeMapAsYAML(path, m) + if err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Created module " + id, + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/modules/") + if id == "" { + http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + data, path, err := findYAMLFileInSubdirs(s.dataDir, "modules", id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + raw, err := yamlToMap(data) + if err != nil { + writeJSON(w, map[string]any{"error": "parse error: " + err.Error()}) + return + } + raw["id"] = id + raw["_path"] = path + raw["_raw"] = string(data) + writeJSON(w, raw) + + case http.MethodPost, http.MethodPut: + var body map[string]any + if err := readJSON(r, &body); err != nil { + writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()}) + return + } + delete(body, "id") + delete(body, "_path") + delete(body, "_raw") + + path := filepath.Join(s.dataDir, "modules", id+".yaml") + var oldContent []byte + if existing, err := snapshotFile(path); err == nil { + oldContent = existing + } + + newContent, err := writeMapAsYAML(path, body) + if err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + + isCreate := oldContent == nil + desc := "Updated module " + id + if isCreate { + desc = "Created module " + id + } + s.undoStack.Push(ChangeDesc{ + Description: desc, + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + IsCreate: isCreate, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + + case http.MethodDelete: + _, path, err := findYAMLFileInSubdirs(s.dataDir, "modules", id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + oldContent, _ := snapshotFile(path) + if err := os.Remove(path); err != nil { + writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Deleted module " + id, + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go new file mode 100644 index 0000000..e4a2c60 --- /dev/null +++ b/internal/admin/api_objects.go @@ -0,0 +1,166 @@ +package admin + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleObjects(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "objects") + if err != nil { + http.Error(w, `{"error":"failed to list objects"}`, http.StatusInternalServerError) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + case http.MethodPost: + s.createObject(w, r) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleObjectByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/objects/") + if id == "" { + http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + s.getObject(w, r, id) + case http.MethodPut: + s.updateObject(w, r, id) + case http.MethodDelete: + s.deleteObject(w, r, id) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) findObjectFile(id string) ([]byte, string, error) { + data, path, err := readYAMLFile(s.dataDir, "objects", id) + if err == nil { + return data, path, nil + } + return findYAMLFileInSubdirs(s.dataDir, "objects", id) +} + +func (s *AdminServer) getObject(w http.ResponseWriter, r *http.Request, id string) { + data, path, err := s.findObjectFile(id) + if err != nil { + http.Error(w, `{"error":"object not found"}`, http.StatusNotFound) + return + } + m, err := yamlToMap(data) + if err != nil { + http.Error(w, `{"error":"failed to parse object yaml"}`, http.StatusInternalServerError) + return + } + m["_path"] = path + m["_raw"] = string(data) + writeJSON(w, m) +} + +func (s *AdminServer) updateObject(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := s.findObjectFile(id) + if err != nil { + http.Error(w, `{"error":"object not found"}`, http.StatusNotFound) + return + } + + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + m["id"] = id + + oldContent, err := snapshotFile(path) + if err != nil { + http.Error(w, `{"error":"failed to read existing object"}`, http.StatusInternalServerError) + return + } + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write object"}`, http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Update object %s", id), + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + }) + + writeJSON(w, m) +} + +func (s *AdminServer) deleteObject(w http.ResponseWriter, r *http.Request, id string) { + _, path, err := s.findObjectFile(id) + if err != nil { + http.Error(w, `{"error":"object not found"}`, http.StatusNotFound) + return + } + + oldContent := backupFile(path) + + if err := os.Remove(path); err != nil { + http.Error(w, `{"error":"failed to delete object"}`, http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Delete object %s", id), + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + + writeJSON(w, map[string]any{"deleted": id}) +} + +func (s *AdminServer) createObject(w http.ResponseWriter, r *http.Request) { + var m map[string]any + if err := readJSON(r, &m); err != nil { + http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) + return + } + id, _ := m["id"].(string) + if strings.TrimSpace(id) == "" { + http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest) + return + } + + if _, _, err := s.findObjectFile(id); err == nil { + http.Error(w, `{"error":"object already exists"}`, http.StatusConflict) + return + } + + path := filepath.Join(s.dataDir, "objects", id+".yaml") + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + http.Error(w, `{"error":"failed to write object"}`, http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("Create object %s", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + + writeJSON(w, m) +} diff --git a/internal/admin/api_players.go b/internal/admin/api_players.go new file mode 100644 index 0000000..2d0c09b --- /dev/null +++ b/internal/admin/api_players.go @@ -0,0 +1,77 @@ +package admin + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "thehouseoficarus/internal/player" +) + +func (s *AdminServer) handlePlayers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + nameFilter := r.URL.Query().Get("name") + + if nameFilter != "" { + name, err := s.accountStore.FindCharacter(nameFilter) + if err != nil { + writeJSON(w, map[string]any{"error": "character not found: " + nameFilter}) + return + } + charPath := s.accountStore.CharPath(name) + data, err := os.ReadFile(charPath) + if err != nil { + writeJSON(w, map[string]any{"error": "read error: " + err.Error()}) + return + } + var p player.Player + if err := yaml.Unmarshal(data, &p); err != nil { + writeJSON(w, map[string]any{"error": "parse error: " + err.Error()}) + return + } + writeJSON(w, p) + return + } + + charsDir := filepath.Join(s.dataDir, "players", "characters") + entries, err := os.ReadDir(charsDir) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + + type playerSummary struct { + Name string `json:"name"` + Level int `json:"level"` + Room int `json:"room"` + } + + var players []playerSummary + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { + continue + } + rawName := strings.TrimSuffix(e.Name(), ".yaml") + data, err := os.ReadFile(filepath.Join(charsDir, e.Name())) + if err != nil { + continue + } + var p player.Player + if err := yaml.Unmarshal(data, &p); err != nil { + continue + } + players = append(players, playerSummary{ + Name: rawName, + Level: p.CombatLevel(), + Room: p.RoomID, + }) + } + writeJSON(w, players) +} diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go new file mode 100644 index 0000000..985eb49 --- /dev/null +++ b/internal/admin/api_rooms.go @@ -0,0 +1,561 @@ +package admin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "thehouseoficarus/internal/world" +) + +func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listRoomIDs(s.dataDir) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if ids == nil { + ids = []int{} + } + writeJSON(w, ids) + + case http.MethodPost: + body, err := io.ReadAll(r.Body) + r.Body.Close() + if err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", err)}) + return + } + + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)}) + return + } + if m == nil { + m = map[string]any{} + } + + var linkFrom *int + var linkDir *string + if v, ok := m["link_from"]; ok { + if f, ok := v.(float64); ok { + lf := int(f) + linkFrom = &lf + } + delete(m, "link_from") + } + if v, ok := m["link_dir"]; ok { + if d, ok := v.(string); ok && d != "" { + ld := d + linkDir = &ld + } + delete(m, "link_dir") + } + + fromID := 0 + if linkFrom != nil { + fromID = *linkFrom + } + id, subdir, allocErr := nextRoomIDInDir(s.dataDir, fromID) + if allocErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("alloc id: %v", allocErr)}) + return + } + m["id"] = id + + exits, _ := m["exits"].(map[string]any) + if exits == nil { + exits = make(map[string]any) + m["exits"] = exits + } + + if linkFrom != nil && linkDir != nil && *linkDir != "" { + dir := world.ExitDir(strings.ToLower(*linkDir)) + if opp, ok := world.OppositeExit[dir]; ok { + srcRoom, loadErr := s.world.LoadRoom(*linkFrom) + if loadErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("load link_from room %d: %v", *linkFrom, loadErr)}) + return + } + + if srcRoom.Exits == nil { + srcRoom.Exits = make(map[world.ExitDir]world.ExitDef) + } + srcRoom.Exits[dir] = world.ExitDef{Room: id} + srcPath, srcOk := s.world.GetRoomPath(*linkFrom) + if srcOk { + oldContent, _ := snapshotFile(srcPath) + newContent, writeErr := writeYAMLFile(srcPath, srcRoom) + if writeErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("write source room: %v", writeErr)}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("update room %d (add exit %s to %d)", *linkFrom, *linkDir, id), + FilePath: srcPath, + OldContent: oldContent, + NewContent: newContent, + }) + } + + exits[string(opp)] = float64(*linkFrom) + } + } + + path := filepath.Join(subdir, strconv.Itoa(id)+".yaml") + newContent, writeErr := writeMapAsYAML(path, m) + if writeErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("write room: %v", writeErr)}) + return + } + + s.world.AddRoomPath(id, path) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("create room %d", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + + writeJSON(w, map[string]any{"room": m}) + + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/api/rooms/") + if idx := strings.IndexByte(idStr, '/'); idx >= 0 { + idStr = idStr[:idx] + } + id, err := strconv.Atoi(idStr) + if err != nil { + writeJSON(w, map[string]any{"error": "invalid room id"}) + return + } + + switch r.Method { + case http.MethodGet: + path, ok := s.world.GetRoomPath(id) + if !ok { + writeJSON(w, map[string]any{"error": "room not found"}) + return + } + data, err := os.ReadFile(path) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + m, err := yamlToMap(data) + if err != nil { + writeJSON(w, map[string]any{"error": "yaml parse error"}) + return + } + m["id"] = id + writeJSON(w, map[string]any{"room": m, "path": path, "file": path, "raw": string(data)}) + + case http.MethodPut: + path, ok := s.world.GetRoomPath(id) + if !ok { + writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) + return + } + + oldContent, snapErr := snapshotFile(path) + if snapErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room: %v", snapErr)}) + return + } + + var m map[string]any + body, readErr := io.ReadAll(r.Body) + r.Body.Close() + if readErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", readErr)}) + return + } + if err := json.Unmarshal(body, &m); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)}) + return + } + if m == nil { + m = map[string]any{} + } + m["id"] = id + + newContent, writeErr := writeMapAsYAML(path, m) + if writeErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("write room: %v", writeErr)}) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("update room %d", id), + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + }) + + writeJSON(w, map[string]any{"room": m}) + + case http.MethodDelete: + path, ok := s.world.GetRoomPath(id) + if !ok { + writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) + return + } + + oldContent, backupErr := snapshotFile(path) + if backupErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("backup room: %v", backupErr)}) + return + } + + if err := os.Remove(path); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("delete room: %v", err)}) + return + } + + s.world.RebuildRoomIndex(s.dataDir) + s.world.ClearRoomState(id) + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("delete room %d", id), + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + + writeJSON(w, map[string]any{"ok": true}) + + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func listRoomIDs(dataDir string) ([]int, error) { + var ids []int + base := filepath.Join(dataDir, "rooms") + err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Ext(d.Name()) == ".yaml" { + idStr := d.Name()[:len(d.Name())-5] + id, convErr := strconv.Atoi(idStr) + if convErr == nil { + ids = append(ids, id) + } + } + return nil + }) + if os.IsNotExist(err) { + return []int{}, nil + } + return ids, err +} + +func (s *AdminServer) handleRoomDirs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + entries, err := os.ReadDir(filepath.Join(s.dataDir, "rooms")) + if err != nil { + writeJSON(w, []string{""}) + return + } + dirs := []string{""} + for _, e := range entries { + if e.IsDir() { + dirs = append(dirs, e.Name()) + } + } + writeJSON(w, dirs) +} + +func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var body struct { + From int `json:"from"` + To int `json:"to"` + } + if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { + writeJSON(w, map[string]any{"error": "invalid body, need from and to"}) + return + } + grid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { + room, err := s.world.LoadRoom(id) + if err != nil { + return nil, false + } + return room, true + }, nil, nil) + + cA, okA := grid.Coord[body.From] + cB, okB := grid.Coord[body.To] + if !okA || !okB || cA[2] != cB[2] { + writeJSON(w, map[string]any{"error": "rooms must be on same z-level"}) + return + } + dx, dy := cB[0]-cA[0], cB[1]-cA[1] + var dir, oppDir world.ExitDir + for d, delta := range world.DirectionDeltas3D { + if delta[0] == dx && delta[1] == dy && delta[2] == 0 { + dir = d + oppDir = world.OppositeExit[d] + break + } + } + if dir == "" { + writeJSON(w, map[string]any{"error": "rooms are not adjacent"}) + return + } + + roomA, _ := s.world.LoadRoom(body.From) + roomB, _ := s.world.LoadRoom(body.To) + if roomA.Exits == nil { + roomA.Exits = make(map[world.ExitDir]world.ExitDef) + } + if roomB.Exits == nil { + roomB.Exits = make(map[world.ExitDir]world.ExitDef) + } + roomA.Exits[dir] = world.ExitDef{Room: body.To} + roomB.Exits[oppDir] = world.ExitDef{Room: body.From} + + pathA, okA2 := s.world.GetRoomPath(body.From) + pathB, okB2 := s.world.GetRoomPath(body.To) + if !okA2 || !okB2 { + writeJSON(w, map[string]any{"error": "room path not found"}) + return + } + + oldA, _ := snapshotFile(pathA) + oldB, _ := snapshotFile(pathB) + newA, err := writeYAMLFile(pathA, roomA) + if err != nil { + writeJSON(w, map[string]any{"error": "write A: " + err.Error()}) + return + } + newB, err := writeYAMLFile(pathB, roomB) + if err != nil { + writeJSON(w, map[string]any{"error": "write B: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To), + FilePath: pathA, + OldContent: oldA, + NewContent: newA, + }) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("link %d %s %d (reverse)", body.To, oppDir, body.From), + FilePath: pathB, + OldContent: oldB, + NewContent: newB, + }) + writeJSON(w, map[string]any{"ok": true}) + + case http.MethodDelete: + var body struct { + From int `json:"from"` + To int `json:"to"` + } + if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { + if r.Body != nil { + r.Body.Close() + } + writeJSON(w, map[string]any{"error": "invalid body"}) + return + } + grid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { + room, err := s.world.LoadRoom(id) + if err != nil { + return nil, false + } + return room, true + }, nil, nil) + + cA, okA := grid.Coord[body.From] + cB, okB := grid.Coord[body.To] + if !okA || !okB || cA[2] != cB[2] { + writeJSON(w, map[string]any{"error": "rooms must be on same z-level"}) + return + } + dx, dy := cB[0]-cA[0], cB[1]-cA[1] + var dir, oppDir world.ExitDir + for d, delta := range world.DirectionDeltas3D { + if delta[0] == dx && delta[1] == dy && delta[2] == 0 { + dir = d + oppDir = world.OppositeExit[d] + break + } + } + if dir == "" { + writeJSON(w, map[string]any{"error": "rooms are not adjacent"}) + return + } + + roomA, _ := s.world.LoadRoom(body.From) + roomB, _ := s.world.LoadRoom(body.To) + if roomA.Exits != nil { + delete(roomA.Exits, dir) + } + if roomB.Exits != nil { + delete(roomB.Exits, oppDir) + } + + pathA, okA2 := s.world.GetRoomPath(body.From) + pathB, okB2 := s.world.GetRoomPath(body.To) + if !okA2 || !okB2 { + writeJSON(w, map[string]any{"error": "room path not found"}) + return + } + + oldA, _ := snapshotFile(pathA) + oldB, _ := snapshotFile(pathB) + newA, err := writeYAMLFile(pathA, roomA) + if err != nil { + writeJSON(w, map[string]any{"error": "write A: " + err.Error()}) + return + } + newB, err := writeYAMLFile(pathB, roomB) + if err != nil { + writeJSON(w, map[string]any{"error": "write B: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("unlink %d %s %d", body.From, dir, body.To), + FilePath: pathA, + OldContent: oldA, + NewContent: newA, + }) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, body.From), + FilePath: pathB, + OldContent: oldB, + NewContent: newB, + }) + writeJSON(w, map[string]any{"ok": true}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID int `json:"id"` + Dir string `json:"dir"` + } + if err := readJSON(r, &body); err != nil || body.ID <= 0 { + writeJSON(w, map[string]any{"error": "invalid"}) + return + } + oldPath, ok := s.world.GetRoomPath(body.ID) + if !ok { + writeJSON(w, map[string]any{"error": "room not found"}) + return + } + dir := strings.TrimSpace(body.Dir) + destDir := filepath.Join(s.dataDir, "rooms", dir) + if _, err := os.Stat(destDir); os.IsNotExist(err) { + writeJSON(w, map[string]any{"error": "directory does not exist: " + dir}) + return + } + fileName := strconv.Itoa(body.ID) + ".yaml" + newPath := filepath.Join(destDir, fileName) + if _, err := os.Stat(newPath); err == nil { + writeJSON(w, map[string]any{"error": "room already exists in target directory"}) + return + } + data, err := os.ReadFile(oldPath) + if err != nil { + writeJSON(w, map[string]any{"error": "read: " + err.Error()}) + return + } + if err := os.WriteFile(newPath, data, 0644); err != nil { + writeJSON(w, map[string]any{"error": "write: " + err.Error()}) + return + } + if err := os.Remove(oldPath); err != nil { + writeJSON(w, map[string]any{"error": "remove old: " + err.Error()}) + return + } + s.world.RebuildRoomIndex(s.dataDir) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("move room %d to %s/", body.ID, dir), + FilePath: oldPath, + NewFilePath: newPath, + OldContent: data, + NewContent: data, + }) + writeJSON(w, map[string]any{"ok": true, "path": newPath}) +} + +func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID int `json:"id"` + NewID int `json:"new_id"` + } + if err := readJSON(r, &body); err != nil || body.ID <= 0 || body.NewID <= 0 { + writeJSON(w, map[string]any{"error": "invalid"}) + return + } + if body.ID == body.NewID { + writeJSON(w, map[string]any{"ok": true}) + return + } + oldPath, ok := s.world.GetRoomPath(body.ID) + if !ok { + writeJSON(w, map[string]any{"error": "room not found"}) + return + } + dir := filepath.Dir(oldPath) + newPath := filepath.Join(dir, strconv.Itoa(body.NewID)+".yaml") + if _, err := os.Stat(newPath); err == nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)}) + return + } + data, err := os.ReadFile(oldPath) + if err != nil { + writeJSON(w, map[string]any{"error": "read: " + err.Error()}) + return + } + if err := os.WriteFile(newPath, data, 0644); err != nil { + writeJSON(w, map[string]any{"error": "write: " + err.Error()}) + return + } + if err := os.Remove(oldPath); err != nil { + writeJSON(w, map[string]any{"error": "remove old: " + err.Error()}) + return + } + s.world.RebuildRoomIndex(s.dataDir) + s.world.ClearRoomState(body.ID) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("rename room %d to %d", body.ID, body.NewID), + FilePath: oldPath, + NewFilePath: newPath, + OldContent: data, + NewContent: data, + }) + writeJSON(w, map[string]any{"ok": true}) +} diff --git a/internal/admin/api_search.go b/internal/admin/api_search.go new file mode 100644 index 0000000..d47dc0e --- /dev/null +++ b/internal/admin/api_search.go @@ -0,0 +1,82 @@ +package admin + +import ( + "net/http" + "strconv" + "strings" +) + +func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) + if q == "" { + writeJSON(w, map[string]any{"error": "missing query parameter 'q'"}) + return + } + + type searchResult struct { + ID string `json:"id"` + Name string `json:"name"` + } + + type searchResponse struct { + Rooms []searchResult `json:"rooms"` + Items []searchResult `json:"items"` + Mobs []searchResult `json:"mobs"` + Objects []searchResult `json:"objects"` + } + + var resp searchResponse + + roomIDs, _ := listYAMLFiles(s.dataDir, "rooms") + for _, idStr := range roomIDs { + roomID, err := strconv.Atoi(idStr) + if err != nil { + continue + } + room, err := s.world.LoadRoom(roomID) + if err != nil { + continue + } + if strings.Contains(strings.ToLower(room.Name), q) || strings.Contains(strings.ToLower(idStr), q) { + resp.Rooms = append(resp.Rooms, searchResult{ID: idStr, Name: room.Name}) + } + } + + for id := range s.itemStore.PathIndex() { + itemDef, err := s.itemStore.Load(id) + if err != nil { + continue + } + if strings.Contains(strings.ToLower(itemDef.Name), q) || strings.Contains(strings.ToLower(itemDef.ID), q) { + resp.Items = append(resp.Items, searchResult{ID: itemDef.ID, Name: itemDef.Name}) + } + } + + mobIDs, _ := listYAMLFiles(s.dataDir, "mobs") + for _, id := range mobIDs { + mobDef, err := s.mobStore.LoadDef(id) + if err != nil { + continue + } + if strings.Contains(strings.ToLower(mobDef.Name), q) || strings.Contains(strings.ToLower(mobDef.ID), q) { + resp.Mobs = append(resp.Mobs, searchResult{ID: mobDef.ID, Name: mobDef.Name}) + } + } + + for id := range s.objectStore.PathIndex() { + objDef, err := s.objectStore.Load(id) + if err != nil { + continue + } + if strings.Contains(strings.ToLower(objDef.Name), q) || strings.Contains(strings.ToLower(objDef.ID), q) { + resp.Objects = append(resp.Objects, searchResult{ID: objDef.ID, Name: objDef.Name}) + } + } + + writeJSON(w, resp) +} diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go new file mode 100644 index 0000000..9b29647 --- /dev/null +++ b/internal/admin/api_techs.go @@ -0,0 +1,138 @@ +package admin + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := listYAMLFiles(s.dataDir, "techs") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + writeJSON(w, ids) + case http.MethodPost: + var m map[string]any + if err := readJSON(r, &m); err != nil { + writeJSON(w, map[string]any{"error": "invalid json"}) + return + } + id, _ := m["id"].(string) + if strings.TrimSpace(id) == "" { + writeJSON(w, map[string]any{"error": "missing id"}) + return + } + delete(m, "id") + path := filepath.Join(s.dataDir, "techs", id+".yaml") + if _, err := os.Stat(path); err == nil { + writeJSON(w, map[string]any{"error": "tech already exists"}) + return + } + newContent, err := writeMapAsYAML(path, m) + if err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Created tech " + id, + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/techs/") + if id == "" { + http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + data, path, err := findYAMLFileInSubdirs(s.dataDir, "techs", id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + raw, err := yamlToMap(data) + if err != nil { + writeJSON(w, map[string]any{"error": "parse error: " + err.Error()}) + return + } + raw["id"] = id + raw["_path"] = path + raw["_raw"] = string(data) + writeJSON(w, raw) + + case http.MethodPost, http.MethodPut: + var body map[string]any + if err := readJSON(r, &body); err != nil { + writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()}) + return + } + delete(body, "id") + delete(body, "_path") + delete(body, "_raw") + + path := filepath.Join(s.dataDir, "techs", id+".yaml") + var oldContent []byte + if existing, err := snapshotFile(path); err == nil { + oldContent = existing + } + + newContent, err := writeMapAsYAML(path, body) + if err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + + isCreate := oldContent == nil + desc := "Updated tech " + id + if isCreate { + desc = "Created tech " + id + } + s.undoStack.Push(ChangeDesc{ + Description: desc, + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + IsCreate: isCreate, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + + case http.MethodDelete: + _, path, err := findYAMLFileInSubdirs(s.dataDir, "techs", id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + oldContent, _ := snapshotFile(path) + if err := os.Remove(path); err != nil { + writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Deleted tech " + id, + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + writeJSON(w, map[string]any{"ok": true, "id": id}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/admin/id_alloc.go b/internal/admin/id_alloc.go new file mode 100644 index 0000000..d79718b --- /dev/null +++ b/internal/admin/id_alloc.go @@ -0,0 +1,64 @@ +package admin + +import ( + "os" + "path/filepath" + "strconv" +) + +func nextRoomIDInDir(dataDir string, fromRoomID int) (int, string, error) { + base := filepath.Join(dataDir, "rooms") + subdir, err := findRoomSubdir(base, fromRoomID) + if err != nil { + subdir = base + } + used := map[int]bool{} + scanDir(subdir, used) + if len(used) == 0 { + return 1, subdir, nil + } + minID := 1<<31 - 1 + for id := range used { + if id < minID { + minID = id + } + } + id := minID + for used[id] { + id++ + } + return id, subdir, nil +} + +func findRoomSubdir(base string, roomID int) (string, error) { + fileName := strconv.Itoa(roomID) + ".yaml" + var found string + filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil || found != "" { + return nil + } + if !d.IsDir() && d.Name() == fileName { + found = filepath.Dir(path) + } + return nil + }) + if found == "" { + return "", os.ErrNotExist + } + return found, nil +} + +func scanDir(dir string, used map[int]bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { + idStr := e.Name()[:len(e.Name())-5] + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + used[id] = true + } + } + } +} diff --git a/internal/admin/server.go b/internal/admin/server.go new file mode 100644 index 0000000..418a693 --- /dev/null +++ b/internal/admin/server.go @@ -0,0 +1,395 @@ +package admin + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "io" + "io/fs" + "log" + "math/big" + "net" + "net/http" + "strings" + "time" + + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +//go:embed templates static +var embedded embed.FS + +type AdminServer struct { + cfg *config.Config + accountStore *player.AccountStore + world *world.World + itemStore *item.ItemStore + objectStore *object.ObjectStore + mobStore *world.MobStore + dataDir string + httpServer *http.Server + undoStack *UndoStack + tmpl *template.Template + cookieSecret []byte +} + +func NewServer(cfg *config.Config, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string) (*AdminServer, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("cookie secret: %w", err) + } + + tmpl, err := template.New("").Funcs(template.FuncMap{ + "json": func(v any) string { + b, _ := json.Marshal(v) + return string(b) + }, + }).ParseFS(embedded, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + + cert, err := loadOrGenerateCert(cfg) + if err != nil { + return nil, fmt.Errorf("admin cert: %w", err) + } + + s := &AdminServer{ + cfg: cfg, + accountStore: accountStore, + world: w, + itemStore: is, + objectStore: os, + mobStore: ms, + dataDir: dataDir, + undoStack: NewUndoStack(dataDir), + tmpl: tmpl, + cookieSecret: secret, + } + + mux := http.NewServeMux() + + staticFS, err := fs.Sub(embedded, "static") + if err != nil { + return nil, fmt.Errorf("static subfs: %w", err) + } + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + mux.HandleFunc("/login", s.handleLogin) + mux.HandleFunc("/logout", s.handleLogout) + + apiMux := http.NewServeMux() + apiMux.HandleFunc("/api/map", s.handleMap) + apiMux.HandleFunc("/api/room-dirs", s.handleRoomDirs) + apiMux.HandleFunc("/api/rooms/link", s.handleRoomLink) + apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove) + apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename) + apiMux.HandleFunc("/api/rooms", s.handleRooms) + apiMux.HandleFunc("/api/rooms/", s.handleRoomByID) + apiMux.HandleFunc("/api/objects", s.handleObjects) + apiMux.HandleFunc("/api/objects/", s.handleObjectByID) + apiMux.HandleFunc("/api/items", s.handleItems) + apiMux.HandleFunc("/api/items/", s.handleItemByID) + apiMux.HandleFunc("/api/mobs", s.handleMobs) + apiMux.HandleFunc("/api/mobs/", s.handleMobByID) + apiMux.HandleFunc("/api/drops", s.handleDrops) + apiMux.HandleFunc("/api/drops/", s.handleDropByID) + apiMux.HandleFunc("/api/hazards", s.handleHazards) + apiMux.HandleFunc("/api/hazards/", s.handleHazardByID) + apiMux.HandleFunc("/api/techs", s.handleTechs) + apiMux.HandleFunc("/api/techs/", s.handleTechByID) + apiMux.HandleFunc("/api/courses", s.handleCourses) + apiMux.HandleFunc("/api/courses/", s.handleCourseByID) + apiMux.HandleFunc("/api/modules", s.handleModules) + apiMux.HandleFunc("/api/modules/", s.handleModuleByID) + apiMux.HandleFunc("/api/players", s.handlePlayers) + apiMux.HandleFunc("/api/dashboard", s.handleDashboard) + apiMux.HandleFunc("/api/flags", s.handleFlags) + apiMux.HandleFunc("/api/search", s.handleSearch) + apiMux.HandleFunc("/api/undo/state", s.handleUndoState) + apiMux.HandleFunc("/api/undo/undo", s.doUndo) + apiMux.HandleFunc("/api/undo/redo", s.doRedo) + apiMux.HandleFunc("/api/next-room-id", s.handleNextRoomID) + apiMux.HandleFunc("/api/files", s.handleFiles) + + mux.Handle("/api/", s.authMiddleware(apiMux)) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + page := strings.TrimPrefix(r.URL.Path, "/") + if page == "" { + page = "map" + } + data := map[string]any{ + "Account": s.accountFromCookie(r), + "UndoStack": s.undoStack.Info(), + "Page": page, + } + s.renderPage(w, r, "layout", data) + }) + + mux.HandleFunc("/editor/", func(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + page := strings.TrimPrefix(r.URL.Path, "/editor/") + data := map[string]any{ + "Account": s.accountFromCookie(r), + "UndoStack": s.undoStack.Info(), + "Page": page, + } + s.renderPage(w, r, "layout", data) + }) + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.AdminHTTPS.Port), + Handler: mux, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }, + } + + return s, nil +} + +func (s *AdminServer) ListenAndServe() error { + ln, err := net.Listen("tcp", s.httpServer.Addr) + if err != nil { + return fmt.Errorf("admin listen: %w", err) + } + tlsLn := tls.NewListener(ln, s.httpServer.TLSConfig) + return s.httpServer.Serve(tlsLn) +} + +func (s *AdminServer) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *AdminServer) checkAuth(r *http.Request) bool { + cookie, err := r.Cookie("admin_session") + if err != nil { + return false + } + parts := strings.SplitN(cookie.Value, ":", 2) + if len(parts) != 2 { + return false + } + account := parts[0] + sig := parts[1] + + if !s.isAdminAccount(account) { + return false + } + + mac := hmac.New(sha256.New, s.cookieSecret) + mac.Write([]byte(account)) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(sig), []byte(expected)) +} + +func (s *AdminServer) isAdminAccount(name string) bool { + for _, a := range s.cfg.AdminHTTPS.AdminAccounts { + if strings.EqualFold(a, name) { + return true + } + } + return false +} + +func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) { + mac := hmac.New(sha256.New, s.cookieSecret) + mac.Write([]byte(account)) + sig := hex.EncodeToString(mac.Sum(nil)) + http.SetCookie(w, &http.Cookie{ + Name: "admin_session", + Value: account + ":" + sig, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 86400, + }) +} + +func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + data := map[string]any{} + s.renderPage(w, r, "login.html", data) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + account := strings.TrimSpace(r.FormValue("account")) + password := r.FormValue("password") + + if !s.isAdminAccount(account) { + s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."}) + return + } + + acc, err := s.accountStore.LoadAccount(account) + if err != nil || !player.CheckPassword(password, acc.PasswordHash) { + s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."}) + return + } + + s.setAuthCookie(w, account) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "admin_session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + }) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *AdminServer) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { + if data == nil { + data = map[string]any{} + } + if _, ok := data["Account"]; !ok { + if acc := s.accountFromCookie(r); acc != "" { + data["Account"] = acc + } + } + if _, ok := data["UndoStack"]; !ok { + data["UndoStack"] = s.undoStack.Info() + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { + log.Printf("template error (%s): %v", name, err) + } +} + +func (s *AdminServer) accountFromCookie(r *http.Request) string { + cookie, err := r.Cookie("admin_session") + if err != nil { + return "" + } + parts := strings.SplitN(cookie.Value, ":", 2) + if len(parts) != 2 { + return "" + } + return parts[0] +} + +func loadOrGenerateCert(cfg *config.Config) (tls.Certificate, error) { + if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { + return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) + } + log.Printf("admin_https: using self-signed certificate (no shared tls cert configured)") + return generateSelfSignedCert() +} + +func generateSelfSignedCert() (tls.Certificate, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("serial number: %w", err) + } + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "THOI Admin Self-Signed Certificate", + }, + NotBefore: now.Add(-1 * time.Hour), + NotAfter: now.Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err) + } + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + }, nil +} + +func writeJSON(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + log.Printf("json encode error: %v", err) + } +} + +func readJSON(r *http.Request, v any) error { + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + defer r.Body.Close() + return json.Unmarshal(body, v) +} + +func (s *AdminServer) handleUndoState(w http.ResponseWriter, r *http.Request) { + writeJSON(w, s.undoStack.Info()) +} + +func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + change := s.undoStack.Undo() + if change == nil { + writeJSON(w, map[string]any{"message": "Nothing to undo"}) + return + } + writeJSON(w, map[string]any{"message": "Undid: " + change.Description}) +} + +func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + change := s.undoStack.Redo() + if change == nil { + writeJSON(w, map[string]any{"message": "Nothing to redo"}) + return + } + writeJSON(w, map[string]any{"message": "Redid: " + change.Description}) +} diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css new file mode 100644 index 0000000..c9d0ed5 --- /dev/null +++ b/internal/admin/static/admin.css @@ -0,0 +1,102 @@ +:root{--bg:#1a1a2e;--panel:#16213e;--text:#e0e0e0;--accent:#0f3460;--border:#2a2a4a;--hover:#1a3a6e;--danger:#c0392b;--success:#27ae60;--warn:#f39c12;--input-bg:#0d1b36;--input-border:#3a3a6a} +*{box-sizing:border-box;margin:0;padding:0} +body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100vh} +.nav{background:var(--panel);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.nav a{color:var(--text);text-decoration:none;padding:4px 10px;border-radius:3px;font-size:13px} +.nav a:hover,.nav a.active{background:var(--accent)} +.nav .undo-bar{display:flex;gap:6px;margin-left:auto;align-items:center;font-size:12px} +.nav .undo-bar button{padding:3px 8px;font-size:12px;border:1px solid var(--border);border-radius:3px;background:var(--input-bg);color:var(--text);cursor:pointer} +.nav .undo-bar button:disabled{opacity:.4;cursor:default} +.nav .undo-bar button:not(:disabled):hover{background:var(--accent)} +.nav .undo-bar span{color:#888} +.layout{display:flex;height:calc(100vh - 38px)} +.main{flex:1;overflow:auto;position:relative} +.panel{width:380px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0} +.panel h2{font-size:15px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)} +.form-group{margin-bottom:10px} +.form-group label{display:block;font-size:11px;color:#aaa;margin-bottom:3px;text-transform:uppercase;letter-spacing:.5px} +.form-group input,.form-group textarea,.form-group select{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace} +.form-group textarea{resize:vertical;min-height:60px} +.form-group input[type=checkbox]{width:auto;margin-right:6px} +.btn{padding:6px 14px;border:none;border-radius:3px;cursor:pointer;font-size:12px;font-weight:bold;font-family:monospace} +.btn-primary{background:var(--accent);color:var(--text);border:1px solid #1a5a8e} +.btn-primary:hover{background:var(--hover)} +.btn-danger{background:var(--danger);color:#fff} +.btn-danger:hover{opacity:.9} +.btn-success{background:var(--success);color:#fff} +.btn-sm{padding:3px 8px;font-size:11px} +.btn-row{display:flex;gap:6px;margin-top:12px} +.table{width:100%;border-collapse:collapse;font-size:12px} +.table th,.table td{padding:4px 8px;text-align:left;border-bottom:1px solid var(--border)} +.table th{color:#aaa;font-weight:bold;font-size:10px;text-transform:uppercase} +.table tr:hover{background:rgba(15,52,96,.3)} +.table a{color:var(--text);text-decoration:none} +.table a:hover{color:#fff} +.search-bar{margin-bottom:12px} +.search-bar input{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px} +.login{max-width:360px;margin:80px auto;background:var(--panel);padding:30px;border:1px solid var(--border);border-radius:6px} +.login h1{font-size:18px;margin-bottom:20px;text-align:center} +.z-controls{position:absolute;top:12px;left:12px;display:flex;gap:6px;align-items:center;background:var(--panel);padding:6px 10px;border-radius:4px;border:1px solid var(--border);z-index:10} +.z-controls button{width:28px;height:28px;font-size:16px;line-height:1;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer} +.z-controls button:hover{background:var(--accent)} +.z-controls .z-label{font-size:13px;min-width:40px;text-align:center;font-weight:bold} +.room-tooltip{position:absolute;background:var(--panel);border:1px solid var(--border);padding:6px 10px;border-radius:3px;font-size:11px;pointer-events:none;z-index:20;white-space:nowrap} +.notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s} +.notification.error{border-color:var(--danger)} +.notification.success{border-color:var(--success)} +@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}} +.talk-tree .node{margin-left:16px;margin-bottom:6px;border-left:2px solid var(--accent);padding-left:10px} +.talk-tree .node-header{display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer;font-size:12px} +.talk-tree .node-header .key{color:#6cf;font-weight:bold} +.talk-tree .node-header .preview{color:#aaa;font-size:11px} +.talk-tree .node-children{display:none;padding:4px 0} +.talk-tree .node.open>.node-children,.talk-tree .node.root>.node-children{display:block} +.talk-tree .option{margin-left:10px;padding:3px 6px;font-size:11px;color:#8cf;border-left:1px solid #444} +.talk-tree .add-btn{font-size:10px;padding:2px 6px;background:var(--accent);color:var(--text);border:none;border-radius:2px;cursor:pointer;margin:4px 0} +.talk-tree .add-btn:hover{opacity:.8} +.tabs{display:flex;gap:2px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0} +.tab{padding:6px 14px;font-size:12px;background:transparent;color:#aaa;border:none;border-bottom:2px solid transparent;cursor:pointer;font-family:monospace} +.tab:hover{color:var(--text)} +.tab.active{color:var(--text);border-bottom-color:var(--accent)} +.kv-row{display:flex;gap:6px;align-items:center;margin-bottom:6px} +.kv-row input{flex:1;min-width:0} +.kv-row button{flex-shrink:0} +.color-swatch{display:inline-block;width:16px;height:16px;border-radius:2px;border:1px solid #555;vertical-align:middle;margin-right:4px} +.color-picker{display:flex;flex-wrap:wrap;gap:2px;margin-top:4px;max-height:200px;overflow-y:auto} +.color-picker .swatch{width:18px;height:18px;border:1px solid transparent;cursor:pointer;border-radius:1px} +.color-picker .swatch:hover,.color-picker .swatch.selected{border-color:#fff;transform:scale(1.2);z-index:1} +.editor-container{display:flex;height:calc(100vh - 38px)} +.editor-list{width:260px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;padding:10px;flex-shrink:0} +.editor-main{flex:1;overflow-y:auto;padding:16px} +.editor-list .item{padding:5px 8px;font-size:12px;cursor:pointer;border-radius:3px;margin-bottom:2px} +.editor-list .item:hover{background:rgba(15,52,96,.3)} +.editor-list .item.active{background:var(--accent)} +.editor-list h3{font-size:13px;margin-bottom:8px;color:#aaa} +.editor-main pre{background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;border:1px solid var(--border)} +.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 10px} +.form-grid .form-group{margin-bottom:4px} +.form-grid .form-group input,.form-grid .form-group select{font-size:11px;padding:4px 6px} +.form-grid .form-group label{font-size:9px;margin-bottom:1px} +.form-grid .form-group textarea{font-size:11px;padding:4px 6px;min-height:40px} +.form-grid .form-group.full{grid-column:1/-1} +.form-grid .form-group.full textarea{min-height:60px} +.cp-grid{display:grid;grid-template-columns:repeat(16,1fr);gap:2px} +.cp-swatch{aspect-ratio:1;border:1px solid rgba(255,255,255,.15);cursor:pointer;border-radius:2px;min-width:14px} +.cp-swatch:hover{transform:scale(1.3);z-index:1;border-color:#fff} +.cp-swatch.selected{outline:2px solid #fff;outline-offset:1px;z-index:1} +.color-field{display:flex;gap:6px;align-items:center} +.color-field input{flex:1;min-width:0;font-family:monospace;text-transform:uppercase} +.color-field .color-swatch{width:24px;height:24px;flex-shrink:0;border-radius:3px;border:1px solid #555;cursor:pointer} +.ghost-hover{cursor:pointer;transition:opacity .1s} +.ghost-hover:hover{opacity:1!important} +rect.ghost:hover{fill:rgba(100,200,255,0.28)!important;stroke:rgba(100,200,255,0.55)!important} +g.ud-hover{cursor:pointer} +g.ud-hover:hover rect{opacity:0.35!important} +g.ud-hover text{font-size:11px} +g.ud-hover:hover text{font-weight:bold} +.cm-overlay{position:fixed;inset:0;z-index:199} +.cm-menu{position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 0;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.4)} +.cm-menu button{display:block;width:100%;padding:6px 12px;text-align:left;font-size:12px;font-family:monospace;background:none;border:none;color:var(--text);cursor:pointer} +.cm-menu button:hover{background:var(--accent)} +.cm-menu button.danger{color:#f55} +.cm-menu button.danger:hover{background:var(--danger)} diff --git a/internal/admin/static/admin.js b/internal/admin/static/admin.js new file mode 100644 index 0000000..cce3bc9 --- /dev/null +++ b/internal/admin/static/admin.js @@ -0,0 +1,55 @@ +window.API = { + async get(url) { const r = await fetch(url,{credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); }, + async post(url,data) { const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); }, + async put(url,data) { const r = await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); }, + async del(url) { const r = await fetch(url,{method:'DELETE',credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); } +}; + +window.$ = (sel) => document.querySelector(sel); +window.$$ = (sel) => document.querySelectorAll(sel); + +window.notify = function(msg, type) { + var el = document.createElement('div'); + el.className = 'notification ' + (type || ''); + el.textContent = msg; + document.body.appendChild(el); + setTimeout(function() { el.remove(); }, 3000); +}; + +window.undoState = null; + +window.updateUndoBar = function() { + API.get('/api/undo/state').then(function(s) { + undoState = s; + var uBtn = $('.undo-btn'); + var rBtn = $('.redo-btn'); + var uLabel = $('.undo-label'); + if (uBtn) { uBtn.disabled = !s.can_undo; uBtn.title = s.undo_desc || ''; } + if (rBtn) { rBtn.disabled = !s.can_redo; rBtn.title = s.redo_desc || ''; } + if (uLabel) { uLabel.textContent = s.undo_desc || ''; } + }).catch(function() {}); +}; + +window.doUndo = function() { + API.post('/api/undo/undo').then(function(r) { notify(r.message || 'Undo complete', 'success'); updateUndoBar(); if(window.refreshPage)refreshPage(); }).catch(function(e) { notify('Undo failed: '+e.message, 'error'); }); +}; + +window.doRedo = function() { + API.post('/api/undo/redo').then(function(r) { notify(r.message || 'Redo complete', 'success'); updateUndoBar(); if(window.refreshPage)refreshPage(); }).catch(function(e) { notify('Redo failed: '+e.message, 'error'); }); +}; + +window.addEventListener('keydown', function(e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'z') { + e.preventDefault(); + if (e.shiftKey) { doRedo(); } else { doUndo(); } + } +}); + +window.saveForm = function(url, data, msg) { + API.put(url, data).then(function() { + updateUndoBar(); + notify(msg || 'Saved', 'success'); + }).catch(function(e) { notify('Save failed: '+e.message, 'error'); }); +}; + +document.addEventListener('DOMContentLoaded', function() { updateUndoBar(); }); diff --git a/internal/admin/static/colorpicker.js b/internal/admin/static/colorpicker.js new file mode 100644 index 0000000..9431e83 --- /dev/null +++ b/internal/admin/static/colorpicker.js @@ -0,0 +1,197 @@ +window.xtermToRGB = function(idx) { + idx = parseInt(idx); + if (idx < 0 || idx > 255) return [0,0,0]; + if (idx < 16) { + var a = [[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]]; + return a[idx]; + } + if (idx < 232) { + var n = idx - 16; + var cube = [0,95,135,175,215,255]; + return [cube[Math.floor(n/36)], cube[Math.floor((n%36)/6)], cube[n%6]]; + } + var g = 8 + (idx-232)*10; + return [g,g,g]; +}; + +window.xtermIdxToHex = function(idx) { + idx = parseInt(idx) || 0; + return idx.toString(16).toUpperCase().padStart(2,'0'); +}; + +window.hexToXtermIdx = function(hex) { + return parseInt(hex, 16); +}; + +window.xtermToCss = function(idx) { + var rgb = xtermToRGB(idx); + return '#' + ((rgb[0]<<16)|(rgb[1]<<8)|rgb[2]).toString(16).padStart(6,'0').toUpperCase(); +}; + +window.resolveColor = function(v) { + if (!v) return '#3a3a6a'; + v = v.toUpperCase().replace(/[^0-9A-F]/g,''); + if (v.length === 2) return xtermToCss(parseInt(v,16)); + return '#' + v.substring(0,6).padEnd(6,'0'); +}; + +window.hexLuminance = function(hex) { + hex = hex.replace('#','').toUpperCase(); + var r = parseInt(hex.substring(0,2),16)/255; + var g = parseInt(hex.substring(2,4),16)/255; + var b = parseInt(hex.substring(4,6),16)/255; + return 0.299*r + 0.587*g + 0.114*b; +}; + +window.cssToXtermIdx = function(css) { + css = css.replace('#','').toUpperCase(); + if (css.length < 6) return 0; + var tr = parseInt(css.substring(0,2),16); + var tg = parseInt(css.substring(2,4),16); + var tb = parseInt(css.substring(4,6),16); + var best = 0, bestDist = Infinity; + for (var i = 0; i < 256; i++) { + var srgb = xtermToRGB(i); + var dr = tr-srgb[0], dg = tg-srgb[1], db = tb-srgb[2]; + var d = dr*dr + dg*dg + db*db; + if (d < bestDist) { bestDist = d; best = i; } + } + return best; +}; + +(function() { + var pickerEl = null, boundInput = null, boundSwatch = null; + + function ensurePicker() { + if (pickerEl) return; + pickerEl = document.createElement('div'); + pickerEl.id = '__cp_popover'; + pickerEl.style.cssText = 'display:none;position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,.5);min-width:280px'; + pickerEl.innerHTML = buildPickerHTML(); + document.body.appendChild(pickerEl); + + pickerEl.addEventListener('click', function(e) { + var sw = e.target.closest('.cp-swatch'); + if (sw) { + var idx = parseInt(sw.getAttribute('data-idx')); + var hex = xtermIdxToHex(idx); + if (boundInput) boundInput.value = hex; + if (boundSwatch) boundSwatch.style.background = xtermToCss(idx); + if (boundInput) boundInput.dispatchEvent(new Event('input', {bubbles:true})); + hidePicker(); + } + }); + } + + function buildPickerHTML() { + var h = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">'; + h += '<span id="__cp_preview" class="color-swatch" style="width:24px;height:24px;flex-shrink:0"></span>'; + h += '<input id="__cp_hex" style="flex:1;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" maxlength="2" placeholder="00">'; + h += '<button id="__cp_close" style="background:none;border:none;color:#aaa;cursor:pointer;font-size:14px;padding:0 4px">×</button>'; + h += '</div>'; + h += '<div class="cp-grid">'; + for (var i = 0; i < 256; i++) { + var css = xtermToCss(i); + h += '<div class="cp-swatch" data-idx="'+i+'" style="background:'+css+'" title="'+xtermIdxToHex(i)+' ('+i+')"></div>'; + } + h += '</div>'; + return h; + } + + window.showColorPicker = function(inputEl, swatchEl) { + ensurePicker(); + boundInput = inputEl; + boundSwatch = swatchEl; + + var raw = (inputEl.value || '').toUpperCase().replace(/[^0-9A-F]/g,''); + var idx; + if (raw.length === 2) { + idx = parseInt(raw, 16); + } else if (raw.length >= 6) { + idx = cssToXtermIdx(raw); + } else { + idx = parseInt(raw, 16) || 0; + } + var hex = xtermIdxToHex(idx); + inputEl.value = hex; + if (swatchEl) swatchEl.style.background = xtermToCss(idx); + + $('#__cp_hex').value = hex; + $('#__cp_preview').style.background = xtermToCss(idx); + updatePickerSelection(idx); + + var rect = inputEl.getBoundingClientRect(); + pickerEl.style.display = 'block'; + var pw = pickerEl.offsetWidth; + var left = rect.left; + if (left + pw > window.innerWidth - 10) left = Math.max(10, window.innerWidth - pw - 10); + pickerEl.style.left = left + 'px'; + pickerEl.style.top = (rect.bottom + 4) + 'px'; + + $('#__cp_hex').oninput = function() { + var v = this.value.toUpperCase().replace(/[^0-9A-F]/g,'').substring(0,2); + this.value = v; + if (v.length === 2) { + var i = parseInt(v, 16); + inputEl.value = v; + if (swatchEl) swatchEl.style.background = xtermToCss(i); + updatePickerSelection(i); + } + }; + + $('#__cp_close').onclick = hidePicker; + }; + + function updatePickerSelection(idx) { + var swatches = document.querySelectorAll('.cp-swatch'); + for (var i = 0; i < swatches.length; i++) swatches[i].classList.remove('selected'); + var s = document.querySelector('.cp-swatch[data-idx="'+idx+'"]'); + if (s) s.classList.add('selected'); + } + + window.hideColorPicker = hidePicker; + function hidePicker() { + if (pickerEl) pickerEl.style.display = 'none'; + boundInput = null; boundSwatch = null; + } + + document.addEventListener('click', function(e) { + if (!pickerEl || pickerEl.style.display === 'none') return; + if (!pickerEl.contains(e.target) && e.target !== boundInput && e.target !== (boundSwatch || {}).parentElement) { + hidePicker(); + } + }); + + window.bindColorField = function(fieldEl) { + var input = fieldEl.querySelector('input[type=text]') || fieldEl.querySelector('input:not([type])'); + var swatch = fieldEl.querySelector('.color-swatch'); + if (!input) return; + + fieldEl.addEventListener('click', function(e) { + if (e.target === input || e.target === swatch) { + showColorPicker(input, swatch); + } + }); + + var raw = (input.value || '').toUpperCase().replace(/[^0-9A-F]/g,''); + if (raw.length === 2) { + if (swatch) swatch.style.background = xtermToCss(parseInt(raw, 16)); + } else if (raw.length >= 6) { + var idx = cssToXtermIdx(raw); + input.value = xtermIdxToHex(idx); + if (swatch) swatch.style.background = xtermToCss(idx); + } + + input.addEventListener('input', function() { + var v = this.value.toUpperCase().replace(/[^0-9A-F]/g,'').substring(0,2); + this.value = v; + if (v.length === 2 && swatch) { + swatch.style.background = xtermToCss(parseInt(v, 16)); + } + }); + input.setAttribute('maxlength', '2'); + input.setAttribute('placeholder', '00'); + input.style.textTransform = 'uppercase'; + }; +})(); diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js new file mode 100644 index 0000000..d9ec36f --- /dev/null +++ b/internal/admin/static/editor.js @@ -0,0 +1,174 @@ +var editorType = ''; +var editorFields = []; +var currentID = null; +var allIDs = []; + +function initEditor(type, fields) { + editorType = type; + editorFields = fields; + loadList(); + var hash = window.location.hash.substring(1); + if (hash) { loadItem(hash); } +} + +function loadList() { + API.get('/api/' + editorType).then(function(data) { + allIDs = data.ids || data || []; + renderList(''); + }).catch(function(e) { + notify('Failed to load list: ' + e.message, 'error'); + }); +} + +function renderList(filter) { + var el = $('#listEntries'); + var f = filter.toLowerCase(); + var filtered = allIDs.filter(function(id) { return !f || String(id).toLowerCase().indexOf(f) >= 0; }); + el.innerHTML = filtered.map(function(id) { + return '<div class="item' + (id === currentID ? ' active' : '') + '" onclick="loadItem(\'' + esc(id) + '\')">' + esc(id) + '</div>'; + }).join(''); +} + +function filterList(val) { + renderList(val); +} + +function loadItem(id) { + currentID = id; + renderList(''); + window.location.hash = id; + API.get('/api/' + editorType + '/' + encodeURIComponent(id)).then(function(data) { + renderEditor(id, data); + }).catch(function(e) { + $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>'; + }); +} + +function renderEditor(id, data) { + var obj = data; + var html = '<h2>' + esc(editorType.charAt(0).toUpperCase() + editorType.slice(1)) + ': ' + esc(id) + '</h2>'; + + html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>'; + html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>'; + + html += '<div class="tab-content" id="tabFields">'; + html += '<div class="form-grid">'; + editorFields.forEach(function(f) { + var val = getNested(obj, f.key); + val = val === undefined || val === null ? '' : val; + var cls = f.type === 'textarea' ? 'form-group full' : 'form-group'; + if (f.type === 'textarea') { + var displayVal; + if (f.encode) { + displayVal = f.encode(val); + } else if (typeof val === 'object') { + displayVal = JSON.stringify(val, null, 2); + } else { + displayVal = String(val); + } + html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><textarea id="f_' + esc(f.key) + '" rows="4">' + esc(displayVal) + '</textarea></div>'; + } else if (f.type === 'checkbox') { + html += '<div class="'+cls+'"><label><input type="checkbox" id="f_' + esc(f.key) + '"' + (val ? ' checked' : '') + '> ' + esc(f.label) + '</label></div>'; + } else if (f.type === 'number') { + html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><input type="number" id="f_' + esc(f.key) + '" value="' + escAttr(String(val)) + '"></div>'; + } else if (f.type === 'color') { + html += '<div class="'+cls+' color-field"><label>' + esc(f.label) + '</label><span class="color-swatch" style="background:#' + escAttr(String(val) || '808080') + '"></span><input id="f_' + esc(f.key) + '" value="' + escAttr(String(val) || '') + '"></div>'; + } else { + html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><input id="f_' + esc(f.key) + '" value="' + escAttr(String(val)) + '"></div>'; + } + }); + html += '</div>'; + html += '</div>'; + + html += '<div class="tab-content" id="tabYaml" style="display:none">'; + html += '<pre>' + esc(obj._raw || JSON.stringify(obj, null, 2)) + '</pre>'; + html += '</div>'; + + html += '<div class="btn-row">'; + html += '<button class="btn btn-primary" onclick="saveItem(\'' + esc(id) + '\')">Save</button>'; + html += '<button class="btn btn-danger" onclick="deleteItem(\'' + esc(id) + '\')">Delete</button>'; + html += '</div>'; + + $('#editorMain').innerHTML = html; + setTimeout(function() { + document.querySelectorAll('.color-field').forEach(function(el) { bindColorField(el); }); + }, 50); +} + +function saveItem(id) { + var data = {}; + editorFields.forEach(function(f) { + var el = $('#f_' + f.key); + if (!el) return; + var val; + if (f.type === 'checkbox') { + val = el.checked; + } else if (f.type === 'number') { + val = parseFloat(el.value) || 0; + } else if (f.type === 'textarea') { + val = el.value; + } else { + val = el.value; + } + if (f.parser) { + val = f.parser(val); + } + setNested(data, f.key, val); + }); + data.id = id; + + API.put('/api/' + editorType + '/' + encodeURIComponent(id), data).then(function() { + notify(editorType + ' ' + id + ' saved', 'success'); + updateUndoBar(); + }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); }); +} + +function deleteItem(id) { + if (!confirm('Delete ' + editorType + ' "' + id + '"?')) return; + API.del('/api/' + editorType + '/' + encodeURIComponent(id)).then(function() { + notify(editorType + ' ' + id + ' deleted', 'success'); + updateUndoBar(); + currentID = null; + $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>'; + loadList(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +function createNew() { + var name = prompt(editorType + ' ID:'); + if (!name) return; + API.post('/api/' + editorType, {id: name}).then(function() { + notify(editorType + ' ' + name + ' created', 'success'); + updateUndoBar(); + loadList(); + loadItem(name); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); +} + +function switchTab(e, tab) { + e.target.parentElement.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); }); + e.target.classList.add('active'); + var tabs = $('#editorMain').querySelectorAll('.tab-content'); + tabs.forEach(function(t) { t.style.display = 'none'; }); + var target = document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)); + if (target) target.style.display = 'block'; +} + +function getNested(obj, path) { + return path.split('.').reduce(function(o, k) { return o && o[k] !== undefined ? o[k] : undefined; }, obj); +} + +function setNested(obj, path, val) { + var keys = path.split('.'); + var last = keys.pop(); + var target = keys.reduce(function(o, k) { + if (!o[k]) o[k] = {}; + return o[k]; + }, obj); + target[last] = val; +} + +function esc(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } +function escAttr(s) { return String(s || '').replace(/&/g,'&').replace(/"/g,'"'); } + +window.refreshPage = function() { loadList(); if (currentID) loadItem(currentID); }; diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js new file mode 100644 index 0000000..3443339 --- /dev/null +++ b/internal/admin/static/map.js @@ -0,0 +1,775 @@ +var CELL = 100; +var currentZ = 0, currentDir = '', selectedRoom = null, mapData = null; +var panX = 0, panY = 0, scale = 1; +var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0; +var dragRoom = false; +var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null; +var delDrag = false, delFromId = null, delTargetId = null; +var roomMap = {}, occupied = {}; +var gridDirs = [ + {dx:0,dy:-1,dir:'north'},{dx:0,dy:1,dir:'south'}, + {dx:1,dy:0,dir:'east'},{dx:-1,dy:0,dir:'west'}, + {dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'}, + {dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'} +]; +var saveTimer = null; +var upTarget = {}, downTarget = {}; + +function changeZ(dz) { + if (dz === 0) currentZ = 0; else currentZ += dz; + loadMap(); +} + +function changeDir(dir) { + currentDir = dir; + loadMap(); +} + +function loadMap() { + $('#zLabel').textContent = 'Z=' + currentZ; + var url = '/api/map?z=' + currentZ; + if (currentDir) url += '&dir=' + encodeURIComponent(currentDir); + API.get(url).then(function(data) { + mapData = data; + if (!currentDir && data.dir) { + currentDir = data.dir; + } + updateDirSelect(); + renderMap(data); + }).catch(function(e) { + notify('Failed to load map: ' + e.message, 'error'); + }); +} + +function renderMap(data) { + var svg = $('#mapSvg'); + var container = $('#mapContainer'); + var W = container.clientWidth; + var H = container.clientHeight; + + roomMap = {}; + if (data.rooms) data.rooms.forEach(function(r) { roomMap[r.id] = r; }); + + occupied = {}; + if (data.rooms) data.rooms.forEach(function(r) { occupied[r.x+','+r.y] = r.id; }); + + upTarget = {}; downTarget = {}; + if (data.upLinks) data.upLinks.forEach(function(l) { upTarget[l.from] = l.to; }); + if (data.downLinks) data.downLinks.forEach(function(l) { downTarget[l.from] = l.to; }); + + var svgNS = 'http://www.w3.org/2000/svg'; + var g = ''; + + if (data.links) { + data.links.forEach(function(l) { + var fr = roomMap[l.from], tr = roomMap[l.to]; + if (!fr || !tr) return; + var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2; + var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2; + var c = fr.color ? resolveColor(fr.color) : '#666'; + c = c.replace('#',''); + g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.35"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>'; + }); + } + + var hasUp = {}, hasDown = {}; + if (data.upLinks) data.upLinks.forEach(function(l) { hasUp[l.from] = true; }); + if (data.downLinks) data.downLinks.forEach(function(l) { hasDown[l.from] = true; }); + + var roomHTML = ''; + if (data.rooms) { + data.rooms.forEach(function(r) { + var x = r.x * CELL + 8, y = r.y * CELL + 8, w = CELL-16, h = CELL-16; + var fill = resolveColor(r.color); + var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff'; + var sel = selectedRoom === r.id; + roomHTML += '<rect class="rm" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="5" fill="'+fill+'" stroke="'+(sel?'#fff':fill)+'" stroke-width="'+(sel?3:1.5)+'" data-id="'+r.id+'" data-name="'+esc(r.name)+'"/>'; + roomHTML += wrapText(r.name, x+w/2, y+h/2, w, txtColor); + + var btnY = y+h-4; + var topY = y+4; + if (hasUp[r.id] && upTarget[r.id]) { + roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+3)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="1" data-target="'+upTarget[r.id]+'" title="Go up to #'+upTarget[r.id]+'">\u2191</text></g>'; + } + if (hasDown[r.id] && downTarget[r.id]) { + roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+w-17)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+w-10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="-1" data-target="'+downTarget[r.id]+'" title="Go down to #'+downTarget[r.id]+'">\u2193</text></g>'; + } + + if (sel) { + var btnX = x + 6, btnBW = 18; + roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(btnX-2)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(btnX+btnBW/2-2)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="up" title="Create room up">+\u2191</text></g>'; + roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(x+w-20)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(x+w-11)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="down" title="Create room down">+\u2193</text></g>'; + } + }); + } + + var ghostHTML = ''; + if (selectedRoom && roomMap[selectedRoom]) { + var sr = roomMap[selectedRoom]; + gridDirs.forEach(function(d) { + var nx = sr.x + d.dx, ny = sr.y + d.dy; + var key = nx+','+ny; + if (!occupied[key]) { + ghostHTML += '<rect class="ghost ghost-hover" x="'+(nx*CELL+8)+'" y="'+(ny*CELL+8)+'" width="'+(CELL-16)+'" height="'+(CELL-16)+'" rx="5" fill="rgba(100,200,255,0.12)" stroke="rgba(100,200,255,0.25)" stroke-width="1.5" stroke-dasharray="4,4" data-dir="'+d.dir+'" data-gx="'+nx+'" data-gy="'+ny+'"/>'; + ghostHTML += '<text x="'+(nx*CELL+CELL/2)+'" y="'+(ny*CELL+CELL/2+4)+'" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.35)" pointer-events="none" font-family="monospace">+</text>'; + } + }); + } + + var innerHTML = g + roomHTML + ghostHTML; + var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale]; + var svgHTML = '<svg width="'+W+'" height="'+H+'" viewBox="'+vb.join(' ')+'" xmlns="'+svgNS+'"><g>'+innerHTML+'</g></svg>'; + svg.innerHTML = svgHTML; + + svg.addEventListener('contextmenu', function(e) { e.preventDefault(); }); + + svg.onmousedown = function(e) { + var onRoom = e.target.classList.contains('rm'); + var onGhost = e.target.classList.contains('ghost'); + var onUdBtn = e.target.classList.contains('ud-btn'); + var onNavBtn = e.target.classList.contains('nav-btn'); + + if (onNavBtn) { + e.stopPropagation(); + var dz = parseInt(e.target.getAttribute('data-dz')); + var tid = parseInt(e.target.getAttribute('data-target')); + currentZ += dz; + selectedRoom = tid; + loadMap(); + selectRoom(tid); + return; + } + + if (e.button === 2 && onRoom) { + delFromId = parseInt(e.target.getAttribute('data-id')); + delDrag = false; delTargetId = null; + dragging = true; + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; + clearLinkHighlight(); + return; + } + + if (e.button !== 0) return; + + if (onUdBtn) { + e.stopPropagation(); + var dir = e.target.getAttribute('data-dir'); + createRoom(selectedRoom, dir); + return; + } + + dragRoom = onRoom || onGhost; + dragging = true; + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + linkFromId = onRoom ? parseInt(e.target.getAttribute('data-id')) : null; + linkDrag = false; + linkTargetId = null; + linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + clearLinkHighlight(); + }; + + svg.onmousemove = function(e) { + if (!dragging) return; + var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; + + if (delFromId && moved && !delDrag) { + delDrag = true; + } + if (delDrag && moved) { + updateDelDragTarget(e); + return; + } + + if (!dragRoom && !delDrag && !delFromId) { + panX += e.clientX - prevX; + panY += e.clientY - prevY; + prevX = e.clientX; prevY = e.clientY; + updateView(); + } + + if (linkFromId && moved) { + linkDrag = true; + updateLinkDragTarget(e); + } + }; + + svg.onmouseup = function(e) { + if (!dragging) return; + + var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; + + if (delFromId && !delDrag && !moved) { + showRoomContextMenu(startX, startY, delFromId); + clearDelHighlight(); + dragging = false; dragRoom = false; delFromId = null; delTargetId = null; delDrag = false; + return; + } + + if (delDrag && delFromId && delTargetId) { + deleteLink(delFromId, delTargetId); + clearDelHighlight(); + dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null; + return; + } + + if (linkDrag && linkFromId) { + if (linkTargetId) { + createLink(linkFromId, linkTargetId); + } else if (linkGhostDir) { + createRoom(linkFromId, linkGhostDir); + } + } else if (!moved && !delDrag) { + if (e.target.classList.contains('rm')) { + selectRoom(parseInt(e.target.getAttribute('data-id'))); + } else if (e.target.classList.contains('ghost')) { + var dir = e.target.getAttribute('data-dir'); + createRoom(selectedRoom, dir); + } + } + + clearLinkHighlight(); + clearDelHighlight(); + dragging = false; dragRoom = false; + linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + }; + + svg.onwheel = function(e) { + e.preventDefault(); + var zf = e.deltaY < 0 ? 1.1 : 0.9; + scale *= zf; + if (scale < 0.1) scale = 0.1; + if (scale > 5) scale = 5; + updateView(); + }; + + svg.querySelectorAll('rect.rm').forEach(function(r) { + r.addEventListener('mousemove', function(ev) { + var tip = $('#tooltip'); + tip.style.display = 'block'; + tip.style.left = (ev.clientX+12)+'px'; + tip.style.top = (ev.clientY+12)+'px'; + tip.textContent = '#'+r.getAttribute('data-id')+' '+r.getAttribute('data-name'); + }); + r.addEventListener('mouseleave', function() { + $('#tooltip').style.display = 'none'; + }); + }); +} + +function wrapText(name, cx, cy, maxWidth, color) { + if (!name) name = ''; + color = color || '#fff'; + var charW = 6; + var maxLen = Math.max(4, Math.floor((maxWidth - 4) / charW)); + var lines = []; + for (var i = 0; i < name.length; i += maxLen) { + lines.push(name.substring(i, i + maxLen)); + } + if (lines.length > 3) lines = lines.slice(0, 3); + var dy = -(lines.length - 1) * 6; + return lines.map(function(l, i) { + return '<text x="'+cx+'" y="'+(cy+dy+i*12)+'" text-anchor="middle" font-size="10" fill="'+color+'" pointer-events="none" font-family="monospace">'+esc(l)+'</text>'; + }).join(''); +} + +function updateView() { + var svg = document.querySelector('#mapSvg svg'); + if (!svg) return; + var W = svg.parentElement.clientWidth; + var H = svg.parentElement.clientHeight; + var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale]; + svg.setAttribute('viewBox', vb.join(' ')); +} + +function selectRoom(id) { + selectedRoom = id; + loadMap(); + API.get('/api/rooms/' + id).then(function(data) { + renderSidePanel(data); + }).catch(function(e) { + notify('Failed to load room: '+e.message, 'error'); + }); +} + +function renderSidePanel(data) { + var panel = $('#panelContent'); + if (!data || !data.room) { + panel.innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room not found</p>'; + return; + } + var r = data.room; + var file = data.file || ''; + var html = '<div class="form-group"><label>File</label><span style="font-size:11px;color:#888">' + esc(file) + '</span></div>'; + + html += '<div class="form-group"><label>ID</label><input id="roomID" type="number" value="' + r.id + '" onchange="autoSave()"></div>'; + html += '<div class="form-group"><label>Name</label><input id="roomName" value="' + escAttr(r.name || '') + '" oninput="autoSave()"></div>'; + html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:'+resolveColor(r.color)+'"></span><input id="roomColor" value="' + escAttr(r.color || '') + '" oninput="autoSave()"></div>'; + html += '<div class="form-group"><label>Directory</label><select id="roomDir" onchange="autoSave()"></select></div>'; + var descText = ''; + if (Array.isArray(r.description)) { + descText = r.description.map(function(d) { return d.text || ''; }).join('\n\n'); + } else if (typeof r.description === 'string') { + descText = r.description; + } + html += '<div class="form-group"><label>Description</label><textarea id="roomDesc" rows="4" oninput="autoSave()">' + esc(descText) + '</textarea></div>'; + html += '<div class="form-group"><label>Hazard</label><input id="roomHazard" value="' + escAttr(r.hazard || '') + '" oninput="autoSave()"></div>'; + html += '<div class="form-group"><label><input type="checkbox" id="roomBlockTransport" onchange="autoSave()"' + (r.block_transport ? ' checked' : '') + '> Block Transport</label></div>'; + + panel.innerHTML = html; + setTimeout(function() { + var cf = document.querySelector('#panelContent .color-field'); + if (cf) { + bindColorField(cf); + var ci = cf.querySelector('input'); + if (ci) ci.addEventListener('input', function() { autoSave(); }); + } + loadRoomDirs(function(dirs) { + var sel = $('#roomDir'); + if (!sel) return; + var current = (file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || ''; + dirs.forEach(function(d) { + var opt = document.createElement('option'); + opt.value = d; + opt.textContent = d || '(root)'; + if (d === current) opt.selected = true; + sel.appendChild(opt); + }); + }); + }, 50); +} + +function autoSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function() { doSavePanel(); }, 600); +} + +function doSavePanel() { + var id = selectedRoom; + if (!id) return; + API.get('/api/rooms/' + id).then(function(data) { + var r = data.room || {}; + r.id = id; + var el = $('#roomName'); if (el) r.name = el.value; + el = $('#roomColor'); if (el) r.color = el.value; + el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}]; + el = $('#roomHazard'); if (el) r.hazard = el.value; + el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked; + + var newID = parseInt($('#roomID').value) || id; + var newDir = $('#roomDir').value; + var currentDir = (data.file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || ''; + + var save = function() { + API.put('/api/rooms/' + id, r).then(function() { + updateUndoBar(); + }).catch(function(e) { notify('Save failed: '+e.message, 'error'); }); + }; + + if (newDir !== currentDir && newDir !== undefined) { + API.post('/api/rooms/move', {id: id, dir: newDir}).then(function() { + if (newID !== id) { + API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() { + selectRoom(newID); + }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); }); + } else { save(); } + }).catch(function(e) { notify('Move failed: '+e.message, 'error'); save(); }); + } else if (newID !== id) { + API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() { + selectRoom(newID); + }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); }); + } else { save(); } + }); +} + +function deleteRoom(id) { + if (!confirm('Delete room #' + id + '?')) return; + if (mapData && mapData.rooms) { + var neighbors = []; + if (mapData.links) { + mapData.links.forEach(function(l) { + if (l.from === id && l.to !== id) neighbors.push(l.to); + if (l.to === id && l.from !== id) neighbors.push(l.from); + }); + } + if (neighbors.length > 1) { + var remaining = mapData.rooms.filter(function(r) { return r.id !== id; }); + var adj = {}; + remaining.forEach(function(r) { adj[r.id] = []; }); + if (mapData.links) { + mapData.links.forEach(function(l) { + if (l.from === id || l.to === id) return; + if (adj[l.from]) adj[l.from].push(l.to); + if (adj[l.to]) adj[l.to].push(l.from); + }); + } + if (remaining.length > 0) { + var visited = {}, queue = [remaining[0].id]; + visited[remaining[0].id] = true; + while (queue.length > 0) { + var cur = queue.shift(); + (adj[cur] || []).forEach(function(n) { if (!visited[n]) { visited[n] = true; queue.push(n); } }); + } + if (Object.keys(visited).length < remaining.length) { + if (!confirm('Deleting room #'+id+' will split the map into disconnected areas. Continue?')) return; + } + } + } + } + API.del('/api/rooms/' + id).then(function() { + notify('Room #' + id + ' deleted', 'success'); + updateUndoBar(); + selectedRoom = null; + $('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>'; + loadMap(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +function createRoom(fromID, dir) { + API.get('/api/next-room-id?from=' + fromID).then(function(r) { + var newID = r.id; + var name = 'Room #' + newID; + var body = { name: name, link_from: fromID, link_dir: dir }; + var srcRoom = roomMap[fromID]; + if (srcRoom && srcRoom.color) body.color = srcRoom.color; + API.post('/api/rooms', body).then(function(resp) { + var createdId = (resp && resp.room && resp.room.id) || newID; + notify('Room #' + createdId + ' created', 'success'); + updateUndoBar(); + selectRoom(createdId); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); + }); +} + +function showRoomContextMenu(x, y, id) { + var overlay = document.createElement('div'); + overlay.className = 'cm-overlay'; + overlay.onclick = function() { overlay.remove(); menu.remove(); }; + + var menu = document.createElement('div'); + menu.className = 'cm-menu'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + var delBtn = document.createElement('button'); + delBtn.textContent = 'Delete Room'; + delBtn.className = 'danger'; + delBtn.onclick = function() { + overlay.remove(); menu.remove(); + deleteRoom(id); + }; + menu.appendChild(delBtn); + + var selBtn = document.createElement('button'); + selBtn.textContent = 'Room Details'; + selBtn.onclick = function() { + overlay.remove(); menu.remove(); + selectRoom(id); + }; + menu.appendChild(selBtn); + + document.body.appendChild(overlay); + document.body.appendChild(menu); +} + +function mouseToGrid(e) { + var svgEl = document.querySelector('#mapSvg svg'); + if (!svgEl) return null; + var rect = svgEl.getBoundingClientRect(); + var mx = e.clientX - rect.left; + var my = e.clientY - rect.top; + if (mx < 0 || my < 0 || mx > rect.width || my > rect.height) return null; + var vb = svgEl.getAttribute('viewBox'); + if (!vb) return null; + var parts = vb.split(' '); + var vbX = parseFloat(parts[0]), vbY = parseFloat(parts[1]); + var vbW = parseFloat(parts[2]), vbH = parseFloat(parts[3]); + var wx = vbX + mx * vbW / rect.width; + var wy = vbY + my * vbH / rect.height; + return { + x: Math.floor(wx / CELL), + y: Math.floor(wy / CELL) + }; +} + +function gridDeltaToDir(dx, dy) { + for (var i = 0; i < gridDirs.length; i++) { + if (gridDirs[i].dx === dx && gridDirs[i].dy === dy) return gridDirs[i].dir; + } + return null; +} + +function updateLinkDragTarget(e) { + clearLinkHighlight(); + linkTargetId = null; + linkGhostDir = null; + + var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === linkFromId;}) : null; + if (!fromRoom) return; + + var gp = mouseToGrid(e); + if (!gp) return; + + var neighbor = occupied[gp.x+','+gp.y]; + var gdx = gp.x - fromRoom.x; + var gdy = gp.y - fromRoom.y; + if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return; + if (gdx === 0 && gdy === 0) return; + var dir = gridDeltaToDir(gdx, gdy); + if (!dir) return; + + if (neighbor && neighbor !== linkFromId) { + linkTargetId = neighbor; + linkGhostDir = null; + applyLinkHighlight(linkFromId, linkTargetId); + } else if (!neighbor) { + linkGhostDir = dir; + linkTargetId = null; + applyGhostLinkHighlight(fromRoom, gp.x, gp.y); + } else { + linkGhostDir = null; + } +} + +function applyGhostLinkHighlight(fromRoom, gx, gy) { + clearLinkHighlight(); + linkTargetId = null; + + var fromEl = document.querySelector('.rm[data-id="'+linkFromId+'"]'); + if (!fromEl) return; + fromEl.setAttribute('stroke', '#0ff'); + fromEl.setAttribute('stroke-width', '3'); + + var g = document.querySelector('#mapSvg svg g'); + if (!g) return; + var svgNS = 'http://www.w3.org/2000/svg'; + var line = document.createElementNS(svgNS, 'line'); + line.id = '__hl_line'; + line.setAttribute('x1', fromRoom.x * CELL + CELL/2); + line.setAttribute('y1', fromRoom.y * CELL + CELL/2); + line.setAttribute('x2', gx * CELL + CELL/2); + line.setAttribute('y2', gy * CELL + CELL/2); + line.setAttribute('stroke', '#0ff'); + line.setAttribute('stroke-width', '3'); + line.setAttribute('stroke-dasharray', '6,3'); + line.setAttribute('pointer-events', 'none'); + g.appendChild(line); +} + +function applyLinkHighlight(fromId, toId) { + clearLinkHighlight(); + + var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]'); + var toEl = document.querySelector('.rm[data-id="'+toId+'"]'); + var fromRoom = roomMap[fromId]; + var toRoom = roomMap[toId]; + if (!fromEl || !toEl || !fromRoom || !toRoom) return; + + fromEl.setAttribute('stroke', '#0ff'); + fromEl.setAttribute('stroke-width', '3'); + toEl.setAttribute('stroke', '#f0f'); + toEl.setAttribute('stroke-width', '3'); + + var g = document.querySelector('#mapSvg svg g'); + if (!g) return; + var svgNS = 'http://www.w3.org/2000/svg'; + var line = document.createElementNS(svgNS, 'line'); + line.id = '__hl_line'; + line.setAttribute('x1', fromRoom.x * CELL + CELL/2); + line.setAttribute('y1', fromRoom.y * CELL + CELL/2); + line.setAttribute('x2', toRoom.x * CELL + CELL/2); + line.setAttribute('y2', toRoom.y * CELL + CELL/2); + line.setAttribute('stroke', '#f0f'); + line.setAttribute('stroke-width', '3'); + line.setAttribute('stroke-dasharray', '6,3'); + line.setAttribute('pointer-events', 'none'); + g.appendChild(line); +} + +function clearLinkHighlight() { + var line = document.getElementById('__hl_line'); + if (line) line.remove(); + + if (linkFromId) { + var el = document.querySelector('.rm[data-id="'+linkFromId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === linkFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === linkFromId ? '3' : '1.5'); + } + } + if (linkTargetId) { + var el = document.querySelector('.rm[data-id="'+linkTargetId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === linkTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === linkTargetId ? '3' : '1.5'); + } + } +} + +function createLink(fromId, toId) { + API.post('/api/rooms/link', {from: fromId, to: toId}).then(function() { + notify('Linked rooms #'+fromId+' '+toId, 'success'); + updateUndoBar(); + loadMap(); + if (selectedRoom) { + API.get('/api/rooms/' + selectedRoom).then(function(data) { + renderSidePanel(data); + }); + } + }).catch(function(e) { + notify('Link failed: ' + e.message, 'error'); + }); +} + +function updateDelDragTarget(e) { + clearDelHighlight(); + delTargetId = null; + + var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === delFromId;}) : null; + if (!fromRoom) return; + + var gp = mouseToGrid(e); + if (!gp) return; + + var neighbor = occupied[gp.x+','+gp.y]; + if (!neighbor || neighbor === delFromId) return; + + var gdx = gp.x - fromRoom.x; + var gdy = gp.y - fromRoom.y; + if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return; + if (gdx === 0 && gdy === 0) return; + if (!gridDeltaToDir(gdx, gdy)) return; + + delTargetId = neighbor; + applyDelHighlight(delFromId, delTargetId); +} + +function applyDelHighlight(fromId, toId) { + clearDelHighlight(); + + var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]'); + var toEl = document.querySelector('.rm[data-id="'+toId+'"]'); + var fromRoom = roomMap[fromId]; + var toRoom = roomMap[toId]; + if (!fromEl || !toEl || !fromRoom || !toRoom) return; + + fromEl.setAttribute('stroke', '#f55'); + fromEl.setAttribute('stroke-width', '3'); + toEl.setAttribute('stroke', '#f55'); + toEl.setAttribute('stroke-width', '3'); + + var g = document.querySelector('#mapSvg svg g'); + if (!g) return; + var svgNS = 'http://www.w3.org/2000/svg'; + var line = document.createElementNS(svgNS, 'line'); + line.id = '__dl_line'; + line.setAttribute('x1', fromRoom.x * CELL + CELL/2); + line.setAttribute('y1', fromRoom.y * CELL + CELL/2); + line.setAttribute('x2', toRoom.x * CELL + CELL/2); + line.setAttribute('y2', toRoom.y * CELL + CELL/2); + line.setAttribute('stroke', '#f55'); + line.setAttribute('stroke-width', '3'); + line.setAttribute('stroke-dasharray', '6,3'); + line.setAttribute('pointer-events', 'none'); + g.appendChild(line); +} + +function clearDelHighlight() { + var line = document.getElementById('__dl_line'); + if (line) line.remove(); + + if (delFromId) { + var el = document.querySelector('.rm[data-id="'+delFromId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === delFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === delFromId ? '3' : '1.5'); + } + } + if (delTargetId) { + var el = document.querySelector('.rm[data-id="'+delTargetId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === delTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === delTargetId ? '3' : '1.5'); + } + } +} + +function deleteLink(fromId, toId) { + if (mapData && mapData.links) { + var checkOrphan = function(roomId, otherId) { + var room = roomMap[roomId]; + if (!room) return false; + var otherLinks = mapData.links.filter(function(l) { + return (l.from === roomId || l.to === roomId) && !((l.from === roomId && l.to === otherId) || (l.from === otherId && l.to === roomId)); + }); + if (otherLinks.length === 0) { + if (!confirm('Removing this link will orphan room #'+roomId+' and it will disappear from the map. Continue?')) return false; + } + return true; + }; + if (!checkOrphan(toId, fromId)) return; + if (!checkOrphan(fromId, toId)) return; + } + + fetch('/api/rooms/link', { + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({from: fromId, to: toId}), + credentials: 'same-origin' + }).then(function(r) { + if (!r.ok) throw new Error('unlink failed'); + return r.json(); + }).then(function() { + notify('Removed link #'+fromId+' '+toId, 'success'); + updateUndoBar(); + loadMap(); + if (selectedRoom) { + API.get('/api/rooms/' + selectedRoom).then(function(data) { + renderSidePanel(data); + }); + } + }).catch(function(e) { + notify('Delete link failed: ' + e.message, 'error'); + }); +} + +function esc(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); +} + +function escAttr(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(/"/g,'"'); +} + +window.refreshPage = function() { loadMap(); if (selectedRoom) selectRoom(selectedRoom); }; + +function loadRoomDirs(cb) { + API.get('/api/room-dirs').then(function(dirs) { + var sel = $('#dirSelect'); + if (sel && sel.options.length === 0) { + dirs.forEach(function(d) { + var opt = document.createElement('option'); + opt.value = d; + opt.textContent = d || '(root)'; + sel.appendChild(opt); + }); + } + updateDirSelect(); + if (cb) cb(dirs); + }); +} + +function updateDirSelect() { + var sel = $('#dirSelect'); + if (sel && currentDir !== undefined) { + sel.value = currentDir; + } +} + +document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); }); diff --git a/internal/admin/static/talktree.js b/internal/admin/static/talktree.js new file mode 100644 index 0000000..f254c21 --- /dev/null +++ b/internal/admin/static/talktree.js @@ -0,0 +1,92 @@ +function renderTalkTree(data) { + var container = document.getElementById('talktree'); + if (!container) return; + if (!data || !data.talk || !data.talk.nodes) { + container.innerHTML = '<p style="color:#888">No conversation tree defined.</p>'; + return; + } + window._talkData = data; + var nodes = data.talk.nodes; + var html = '<div class="talk-tree">'; + html += renderTreeNodes(nodes, 'start', {}); + html += '</div>'; + container.innerHTML = html; +} + +function renderTreeNodes(nodes, rootKey, seen) { + var node = nodes[rootKey]; + if (!node) return ''; + if (seen[rootKey]) { + return '<div class="node ref"><span class="key">' + esc(rootKey) + '</span> <span class="preview">(already shown)</span></div>'; + } + seen[rootKey] = true; + + var html = ''; + html += '<div class="node root' + (rootKey === 'start' ? ' open' : '') + '">'; + html += '<div class="node-header" onclick="this.parentElement.classList.toggle(\'open\')">'; + html += '<span class="arrow"></span>'; + html += '<span class="key">' + esc(rootKey) + '</span>'; + html += '<span class="preview">' + esc((node.message || '').substring(0, 60)) + '</span>'; + html += '</div>'; + html += '<div class="node-children">'; + + html += '<div style="margin-left:8px;font-size:11px">'; + if (node.message) html += '<div><b>Message:</b> ' + esc(node.message) + '</div>'; + if (node.condition) html += '<div><b>Condition:</b> ' + esc(JSON.stringify(node.condition)) + '</div>'; + if (node.goto) html += '<div><b>Goto:</b> ' + esc(node.goto) + '</div>'; + html += '</div>'; + + html += '<div style="margin-left:10px"><b style="font-size:11px;color:#aaa">Options:</b>'; + if (node.options && node.options.length > 0) { + node.options.forEach(function(opt, idx) { + html += '<div class="option">'; + html += '<span>' + esc(opt.text || '') + '</span> \u2192 <span style="color:#8cf">' + esc(opt.goto || '') + '</span>'; + if (opt.condition) html += ' <span style="color:#888;font-size:10px">[cond]</span>'; + if (opt.action) html += ' <span style="color:#888;font-size:10px">[action]</span>'; + html += ' <button class="btn btn-danger btn-sm" onclick="deleteOption(event,\'' + escAttr(rootKey) + '\',' + idx + ')">Delete Option</button>'; + html += '</div>'; + }); + } else { + html += '<div class="option" style="color:#888">No options</div>'; + } + html += '<div style="margin-top:4px"><button class="btn btn-primary btn-sm" onclick="addOption(event,\'' + escAttr(rootKey) + '\')">+ Add Option</button></div>'; + html += '</div>'; + + html += '</div></div>'; + + if (node.options) { + node.options.forEach(function(opt) { + if (opt.goto && nodes[opt.goto]) { + html += renderTreeNodes(nodes, opt.goto, seen); + } + }); + } + + return html; +} + +function addOption(e, nodeKey) { + if (e) e.stopPropagation(); + var data = window._talkData; + if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return; + var text = prompt('Option text:'); + if (text === null) return; + var dest = prompt('Goto node key:') || ''; + var node = data.talk.nodes[nodeKey]; + if (!node.options) node.options = []; + node.options.push({ text: text, goto: dest }); + renderTalkTree(data); + if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data); +} + +function deleteOption(e, nodeKey, idx) { + if (e) e.stopPropagation(); + var data = window._talkData; + if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return; + var node = data.talk.nodes[nodeKey]; + if (!node.options || idx < 0 || idx >= node.options.length) return; + if (!confirm('Delete this option?')) return; + node.options.splice(idx, 1); + renderTalkTree(data); + if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data); +} diff --git a/internal/admin/templates/courses.html b/internal/admin/templates/courses.html new file mode 100644 index 0000000..60e07f8 --- /dev/null +++ b/internal/admin/templates/courses.html @@ -0,0 +1,14 @@ +{{define "body-courses"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search courses..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Course</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select a course to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script>initEditor('courses', [{key:'name',label:'Name',type:'text'},{key:'skill',label:'Skill',type:'text'},{key:'level',label:'Level',type:'number'},{key:'cost',label:'Cost',type:'number'},{key:'description',label:'Description',type:'textarea'}]);</script> +{{end}} diff --git a/internal/admin/templates/dashboard.html b/internal/admin/templates/dashboard.html new file mode 100644 index 0000000..198746d --- /dev/null +++ b/internal/admin/templates/dashboard.html @@ -0,0 +1,22 @@ +{{define "body-dashboard"}} +<div style="padding:20px"> + <h2>Dashboard</h2> + <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:16px" id="dashGrid"></div> +</div> +<script> +API.get('/api/dashboard').then(function(d){ + var grid = $('#dashGrid'); + var items = [ + {label:'Rooms',value:d.roomCount}, + {label:'Items',value:d.itemCount}, + {label:'Objects',value:d.objectCount}, + {label:'Mobs',value:d.mobCount}, + {label:'Players',value:d.playerCount}, + {label:'Accounts',value:d.accountCount} + ]; + grid.innerHTML = items.map(function(i){ + return '<div style="background:var(--panel);border:1px solid var(--border);padding:16px;border-radius:4px;text-align:center"><div style="font-size:28px;font-weight:bold">'+i.value+'</div><div style="font-size:11px;color:#888;margin-top:4px">'+i.label+'</div></div>'; + }).join(''); +}).catch(function(e){notify('Failed: '+e.message,'error')}); +</script> +{{end}} diff --git a/internal/admin/templates/drops.html b/internal/admin/templates/drops.html new file mode 100644 index 0000000..c040ef2 --- /dev/null +++ b/internal/admin/templates/drops.html @@ -0,0 +1,18 @@ +{{define "body-drops"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search drop tables..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Drop Table</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select a drop table to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script> +initEditor('drops', [ + {key:'drops',label:'Drops (JSON array)',type:'textarea',parser:function(v){try{return JSON.parse(v)}catch(e){return []}}} +]); +</script> +{{end}} diff --git a/internal/admin/templates/files.html b/internal/admin/templates/files.html new file mode 100644 index 0000000..b89a142 --- /dev/null +++ b/internal/admin/templates/files.html @@ -0,0 +1,81 @@ +{{define "body-files"}} +<div class="editor-container"> + <div class="editor-list" id="fileList"> + <div class="search-bar"><input placeholder="Filter files..." oninput="filterFiles(this.value)"></div> + <div id="listEntries"></div> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select a file to edit</p> + </div> +</div> +<script> +var currentPath = ''; +window._allFiles = []; + +function loadFiles(path) { + currentPath = path || ''; + API.get('/api/files' + (path ? '?path=' + encodeURIComponent(path) : '')).then(function(r){ + var files = r.files || r || []; + window._allFiles = files; + renderFileList(''); + }).catch(function(e){notify('Failed: '+e.message,'error')}); +} + +function renderFileList(filter){ + var el = $('#listEntries'); + var f = filter.toLowerCase(); + var items = window._allFiles.filter(function(x){ return !f || x.name.toLowerCase().indexOf(f) >= 0; }); + var html = '<div class="item" onclick="loadFiles(\'\')" style="color:#6cf">[root]</div>'; + if (currentPath) { + var parent = currentPath.split('/').slice(0,-1).join('/'); + html += '<div class="item" onclick="loadFiles(\''+escJs(parent)+'\')" style="color:#6cf">[..]</div>'; + } + items.forEach(function(x){ + if (x.dir) { + var sub = currentPath ? currentPath + '/' + x.name : x.name; + html += '<div class="item" onclick="loadFiles(\''+escJs(sub)+'\')" style="color:#fc6">'+escHtml(x.name)+'/</div>'; + } else { + var fp = currentPath ? currentPath + '/' + x.name : x.name; + html += '<div class="item" onclick="loadFileContent(\''+escJs(fp)+'\',\''+escJs(x.name)+'\')">'+escHtml(x.name)+'</div>'; + } + }); + el.innerHTML = html; +} + +function filterFiles(val) { renderFileList(val); } + +function loadFileContent(path, name) { + currentPath = path; + API.get('/api/files?path=' + encodeURIComponent(path)).then(function(r){ + var content = r.content || r.raw || JSON.stringify(r,null,2); + var html = '<h2>'+escHtml(name)+'</h2>'; + html += '<div class="form-group"><label>Path</label><span style="font-size:11px;color:#888">'+escHtml(path)+'</span></div>'; + html += '<textarea id="fileContent" rows="20" style="width:100%;font-family:monospace;font-size:12px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);padding:8px">'+escHtml(content)+'</textarea>'; + html += '<div class="btn-row"><button class="btn btn-primary" onclick="saveFile(\''+escJs(path)+'\')">Save</button></div>'; + $('#editorMain').innerHTML = html; + }).catch(function(e){notify('Failed: '+e.message,'error')}); +} + +function saveFile(path) { + var content = $('#fileContent').value; + fetch('/api/files?path=' + encodeURIComponent(path), { + method: 'PUT', + headers: {'Content-Type': 'text/plain'}, + body: content, + credentials: 'same-origin' + }).then(function(r) { + if (!r.ok) throw new Error('Save failed'); + return r.json(); + }).then(function() { + notify('File saved', 'success'); + updateUndoBar(); + }).catch(function(e) { notify('Save failed: '+e.message, 'error'); }); +} + +window.refreshPage = function() { loadFiles(currentPath); }; +function escHtml(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } +function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'\\"'); } + +loadFiles(''); +</script> +{{end}} diff --git a/internal/admin/templates/hazards.html b/internal/admin/templates/hazards.html new file mode 100644 index 0000000..0d60df6 --- /dev/null +++ b/internal/admin/templates/hazards.html @@ -0,0 +1,14 @@ +{{define "body-hazards"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search hazards..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Hazard</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select a hazard to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script>initEditor('hazards', [{key:'name',label:'Name',type:'text'},{key:'damage',label:'Damage',type:'number'},{key:'speed',label:'Speed',type:'number'},{key:'required_item',label:'Required Item',type:'text'},{key:'message',label:'Message',type:'text'},{key:'lethal',label:'Lethal',type:'checkbox'}]);</script> +{{end}} diff --git a/internal/admin/templates/items.html b/internal/admin/templates/items.html new file mode 100644 index 0000000..f8f3b66 --- /dev/null +++ b/internal/admin/templates/items.html @@ -0,0 +1,45 @@ +{{define "body-items"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search items..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Item</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select an item to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script> +initEditor('items', [ + {key:'name',label:'Name',type:'text'}, + {key:'color',label:'Color',type:'color'}, + {key:'description',label:'Description',type:'textarea'}, + {key:'value',label:'Value',type:'number'}, + {key:'stackable',label:'Stackable',type:'checkbox'}, + {key:'equip_slot',label:'Equip Slot',type:'text'}, + {key:'weapon_type',label:'Weapon Type',type:'text'}, + {key:'attack_type',label:'Attack Type',type:'text'}, + {key:'speed',label:'Speed',type:'number'}, + {key:'tool_type',label:'Tool Type',type:'text'}, + {key:'tool_speed',label:'Tool Speed',type:'number'}, + {key:'burn_ticks',label:'Burn Ticks',type:'number'}, + {key:'fire_level',label:'Fire Level',type:'number'}, + {key:'fire_xp',label:'Fire XP',type:'number'}, + {key:'quality',label:'Quality',type:'number'}, + {key:'max_quality',label:'Max Quality',type:'number'}, + {key:'search_table',label:'Search Table',type:'text'}, + {key:'search_ticks',label:'Search Ticks',type:'number'}, + {key:'heal_value',label:'Heal Value',type:'number'}, + {key:'eat_message',label:'Eat Message',type:'text'}, + {key:'farm_patch_type',label:'Farm Patch',type:'text'}, + {key:'farm_level',label:'Farm Level',type:'number'}, + {key:'farm_stages',label:'Farm Stages',type:'number'}, + {key:'farm_product',label:'Farm Product',type:'text'}, + {key:'potion_effect',label:'Potion Effect',type:'text'}, + {key:'potion_bonus',label:'Potion Bonus',type:'number'}, + {key:'potion_duration',label:'Potion Duration',type:'number'}, + {key:'recoverable',label:'Recoverable',type:'checkbox'} +]); +</script> +{{end}} diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html new file mode 100644 index 0000000..99c1f0d --- /dev/null +++ b/internal/admin/templates/layout.html @@ -0,0 +1,48 @@ +{{define "layout"}}<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>THOI Admin{{if .Page}} — {{.Page}}{{end}}</title> +<link rel="stylesheet" href="/static/admin.css"> +<script src="/static/admin.js"></script> +<script src="/static/colorpicker.js"></script> +</head> +<body> +<nav class="nav"> + <a href="/" class="{{if eq .Page "map"}}active{{end}}">Map</a> + <a href="/editor/objects" class="{{if eq .Page "objects"}}active{{end}}">Objects</a> + <a href="/editor/items" class="{{if eq .Page "items"}}active{{end}}">Items</a> + <a href="/editor/mobs" class="{{if eq .Page "mobs"}}active{{end}}">Mobs</a> + <a href="/editor/drops" class="{{if eq .Page "drops"}}active{{end}}">Drops</a> + <a href="/editor/hazards" class="{{if eq .Page "hazards"}}active{{end}}">Hazards</a> + <a href="/editor/techs" class="{{if eq .Page "techs"}}active{{end}}">Techs</a> + <a href="/editor/courses" class="{{if eq .Page "courses"}}active{{end}}">Courses</a> + <a href="/editor/modules" class="{{if eq .Page "modules"}}active{{end}}">Modules</a> + <a href="/editor/players" class="{{if eq .Page "players"}}active{{end}}">Players</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> + <div class="undo-bar"> + <button class="undo-btn" onclick="doUndo()" title="">⟲ Undo</button> + <button class="redo-btn" onclick="doRedo()" title="">⟳ Redo</button> + <span class="undo-label"></span> + </div> + <span style="font-size:11px;color:#888">| {{.Account}}</span> + <a href="/logout" style="font-size:11px">Logout</a> +</nav> +{{if eq .Page "map"}}{{template "body-map" .}} +{{else if eq .Page "objects"}}{{template "body-objects" .}} +{{else if eq .Page "items"}}{{template "body-items" .}} +{{else if eq .Page "mobs"}}{{template "body-mobs" .}} +{{else if eq .Page "drops"}}{{template "body-drops" .}} +{{else if eq .Page "hazards"}}{{template "body-hazards" .}} +{{else if eq .Page "techs"}}{{template "body-techs" .}} +{{else if eq .Page "courses"}}{{template "body-courses" .}} +{{else if eq .Page "modules"}}{{template "body-modules" .}} +{{else if eq .Page "players"}}{{template "body-players" .}} +{{else if eq .Page "dashboard"}}{{template "body-dashboard" .}} +{{else if eq .Page "files"}}{{template "body-files" .}} +{{else}}<p style="color:#888;text-align:center;margin-top:60px">Unknown page: {{.Page}}</p>{{end}} +</body> +</html> +{{end}} diff --git a/internal/admin/templates/login.html b/internal/admin/templates/login.html new file mode 100644 index 0000000..ffc59c6 --- /dev/null +++ b/internal/admin/templates/login.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>THOI Admin — Login</title> +<link rel="stylesheet" href="/static/admin.css"> +</head> +<body> +<div class="login"> + <h1>THOI Admin Portal</h1> + {{if .Error}}<p style="color:var(--danger);text-align:center;margin-bottom:12px;font-size:13px">{{.Error}}</p>{{end}} + <form method="post" action="/login"> + <div class="form-group"> + <label>Account Name</label> + <input type="text" name="account" required autofocus autocomplete="username"> + </div> + <div class="form-group"> + <label>Password</label> + <input type="password" name="password" required autocomplete="current-password"> + </div> + <div class="btn-row"> + <button type="submit" class="btn btn-primary" style="width:100%">Login</button> + </div> + </form> +</div> +</body> +</html> diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html new file mode 100644 index 0000000..7f8d5a1 --- /dev/null +++ b/internal/admin/templates/map.html @@ -0,0 +1,21 @@ +{{define "body-map"}} +<div class="layout"> + <div class="main" id="mapContainer"> + <div class="z-controls"> + <button onclick="changeZ(-1)">-</button> + <span class="z-label" id="zLabel">Z=0</span> + <button onclick="changeZ(1)">+</button> + <button onclick="changeZ(0)" style="width:auto;font-size:11px">Reset</button> + <select id="dirSelect" onchange="changeDir(this.value)" style="margin-left:8px;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"></select> + </div> + <svg id="mapSvg" style="width:100%;height:100%"></svg> + <div class="room-tooltip" id="tooltip" style="display:none"></div> + </div> + <div class="panel" id="sidePanel"> + <div id="panelContent"> + <p style="color:#888;text-align:center;margin-top:40px">Click a room to view details</p> + </div> + </div> +</div> +<script src="/static/map.js"></script> +{{end}} diff --git a/internal/admin/templates/mobs.html b/internal/admin/templates/mobs.html new file mode 100644 index 0000000..d6191fa --- /dev/null +++ b/internal/admin/templates/mobs.html @@ -0,0 +1,52 @@ +{{define "body-mobs"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search mobs..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Mob</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select a mob to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script src="/static/talktree.js"></script> +<script> +initEditor('mobs', [ + {key:'name',label:'Name',type:'text'}, + {key:'description',label:'Description',type:'textarea'}, + {key:'attack',label:'Attack',type:'number'}, + {key:'strength',label:'Strength',type:'number'}, + {key:'defense',label:'Defense',type:'number'}, + {key:'hp',label:'HP',type:'number'}, + {key:'ranged',label:'Ranged',type:'number'}, + {key:'science',label:'Science',type:'number'}, + {key:'speed',label:'Speed',type:'number'}, + {key:'aggressive',label:'Aggressive',type:'checkbox'}, + {key:'protected',label:'Protected',type:'checkbox'}, + {key:'unique',label:'Unique',type:'checkbox'}, + {key:'respawn_ticks',label:'Respawn Ticks',type:'number'}, + {key:'attack_bonus',label:'Attack Bonus',type:'number'}, + {key:'strength_bonus',label:'Strength Bonus',type:'number'}, + {key:'attack_type',label:'Attack Type',type:'text'}, + {key:'stab_defense',label:'Stab Defense',type:'number'}, + {key:'slash_defense',label:'Slash Defense',type:'number'}, + {key:'crush_defense',label:'Crush Defense',type:'number'}, + {key:'science_defense',label:'Science Defense',type:'number'}, + {key:'ranged_defense',label:'Ranged Defense',type:'number'}, + {key:'weakness',label:'Weakness',type:'text'}, + {key:'steal_table',label:'Steal Table',type:'text'}, + {key:'steal_level',label:'Steal Level',type:'number'}, + {key:'steal_xp',label:'Steal XP',type:'number'}, + {key:'steal_speed',label:'Steal Speed',type:'number'}, + {key:'assassin_level',label:'Assassin Level',type:'number'}, + {key:'finishing_blow',label:'Finishing Blow',type:'text'}, + {key:'damage_without',label:'Damage Without',type:'text'}, + {key:'size',label:'Size',type:'text'}, + {key:'kind',label:'Kind',type:'text'}, + {key:'verb',label:'Verb',type:'text'}, + {key:'progress_noun',label:'Progress Noun',type:'text'}, + {key:'complete_message',label:'Complete Message',type:'text'} +]); +</script> +{{end}} diff --git a/internal/admin/templates/modules.html b/internal/admin/templates/modules.html new file mode 100644 index 0000000..0f8ca7c --- /dev/null +++ b/internal/admin/templates/modules.html @@ -0,0 +1,14 @@ +{{define "body-modules"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search modules..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Module</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select a module to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script>initEditor('modules', [{key:'name',label:'Name',type:'text'},{key:'type',label:'Type',type:'text'},{key:'effect',label:'Effect',type:'text'},{key:'description',label:'Description',type:'textarea'}]);</script> +{{end}} diff --git a/internal/admin/templates/objects.html b/internal/admin/templates/objects.html new file mode 100644 index 0000000..91aca09 --- /dev/null +++ b/internal/admin/templates/objects.html @@ -0,0 +1,29 @@ +{{define "body-objects"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search objects..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Object</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select an object to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script> +initEditor('objects', [ + {key:'name',label:'Name',type:'text'}, + {key:'color',label:'Color',type:'color'}, + {key:'aliases',label:'Aliases (comma sep)',type:'text'}, + {key:'hidden',label:'Hidden',type:'checkbox'}, + {key:'inroom_description',label:'In-Room Description',type:'text'}, + {key:'description',label:'Description',type:'textarea'}, + {key:'removal_item',label:'Removal Item ID',type:'text'}, + {key:'steal_table',label:'Steal Table ID',type:'text'}, + {key:'steal_level',label:'Steal Level',type:'number'}, + {key:'steal_xp',label:'Steal XP',type:'number'}, + {key:'steal_speed',label:'Steal Speed',type:'number'}, + {key:'guard_mob',label:'Guard Mob ID',type:'text'} +]); +</script> +{{end}} diff --git a/internal/admin/templates/players.html b/internal/admin/templates/players.html new file mode 100644 index 0000000..43627d8 --- /dev/null +++ b/internal/admin/templates/players.html @@ -0,0 +1,33 @@ +{{define "body-players"}} +<div style="padding:20px"> + <h2>Players</h2> + <div class="search-bar" style="max-width:400px"><input placeholder="Search players..." id="playerSearch" oninput="searchPlayers()"></div> + <table class="table" style="margin-top:12px"> + <thead><tr><th>Name</th><th>Level</th><th>Room</th><th>Account</th></tr></thead> + <tbody id="playerTableBody"></tbody> + </table> +</div> +<script> +API.get('/api/players').then(function(r){ + var tbody = $('#playerTableBody'); + var players = r.players || r || []; + window._players = players; + renderPlayerTable(''); +}).catch(function(e){notify('Failed: '+e.message,'error')}); +function searchPlayers(){ + var q = ($('#playerSearch').value||'').toLowerCase(); + renderPlayerTable(q); +} +function renderPlayerTable(q){ + var tbody = $('#playerTableBody'); + var rows = (window._players||[]).filter(function(p){ + var name = (p.name||p.Name||'').toLowerCase(); + return !q || name.indexOf(q) >= 0; + }).map(function(p){ + return '<tr><td>'+esc(p.name||p.Name)+'</td><td>'+esc(p.level||p.Level)+'</td><td>'+esc(p.room||p.Room)+'</td><td>'+esc(p.account||p.Account)+'</td></tr>'; + }); + tbody.innerHTML = rows.join('') || '<tr><td colspan="4" style="color:#888">No players found</td></tr>'; +} +function esc(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } +</script> +{{end}} diff --git a/internal/admin/templates/techs.html b/internal/admin/templates/techs.html new file mode 100644 index 0000000..084d564 --- /dev/null +++ b/internal/admin/templates/techs.html @@ -0,0 +1,14 @@ +{{define "body-techs"}} +<div class="editor-container"> + <div class="editor-list" id="itemList"> + <div class="search-bar"><input placeholder="Search techs..." oninput="filterList(this.value)"></div> + <div id="listEntries"></div> + <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Tech</button> + </div> + <div class="editor-main" id="editorMain"> + <p style="color:#888;text-align:center;margin-top:60px">Select a tech to edit</p> + </div> +</div> +<script src="/static/editor.js"></script> +<script>initEditor('techs', [{key:'name',label:'Name',type:'text'},{key:'category',label:'Category',type:'text'},{key:'tier',label:'Tier',type:'number'},{key:'drain',label:'Drain Rate',type:'number'},{key:'bonus',label:'Bonus',type:'number'},{key:'description',label:'Description',type:'textarea'}]);</script> +{{end}} diff --git a/internal/admin/undo.go b/internal/admin/undo.go new file mode 100644 index 0000000..39e8c4e --- /dev/null +++ b/internal/admin/undo.go @@ -0,0 +1,247 @@ +package admin + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +type ChangeDesc struct { + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent []byte `json:"old_content"` + NewContent []byte `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` +} + +type UndoInfo struct { + CanUndo bool `json:"can_undo"` + CanRedo bool `json:"can_redo"` + UndoDesc string `json:"undo_desc"` + RedoDesc string `json:"redo_desc"` + StackSize int `json:"stack_size"` + RedoSize int `json:"redo_size"` +} + +type UndoStack struct { + mu sync.Mutex + dataDir string + history []ChangeDesc + redo []ChangeDesc + maxSize int + filePath string +} + +func NewUndoStack(dataDir string) *UndoStack { + us := &UndoStack{ + dataDir: dataDir, + maxSize: 100, + filePath: filepath.Join(dataDir, ".admin_history.yaml"), + } + us.load() + return us +} + +func (us *UndoStack) Push(desc ChangeDesc) { + us.mu.Lock() + defer us.mu.Unlock() + desc.Time = time.Now().Format(time.RFC3339) + us.history = append(us.history, desc) + us.redo = nil + if len(us.history) > us.maxSize { + us.history = us.history[len(us.history)-us.maxSize:] + } + us.save() +} + +func (us *UndoStack) Undo() *ChangeDesc { + us.mu.Lock() + defer us.mu.Unlock() + if len(us.history) == 0 { + return nil + } + last := us.history[len(us.history)-1] + us.history = us.history[:len(us.history)-1] + + if last.NewFilePath != "" { + os.Remove(last.NewFilePath) + } + if last.IsDelete { + if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil { + log.Printf("undo: failed to restore deleted file %s: %v", last.FilePath, err) + return nil + } + } else if last.IsCreate { + os.Remove(last.FilePath) + } else { + if last.NewFilePath != "" { + if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil { + log.Printf("undo: failed to revert move %s: %v", last.FilePath, err) + return nil + } + } else { + if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil { + log.Printf("undo: failed to revert file %s: %v", last.FilePath, err) + return nil + } + } + } + + us.redo = append(us.redo, last) + us.save() + return &last +} + +func (us *UndoStack) Redo() *ChangeDesc { + us.mu.Lock() + defer us.mu.Unlock() + if len(us.redo) == 0 { + return nil + } + last := us.redo[len(us.redo)-1] + us.redo = us.redo[:len(us.redo)-1] + + if last.NewFilePath != "" { + os.Remove(last.FilePath) + } + if last.IsCreate { + if last.NewFilePath != "" { + if err := os.WriteFile(last.NewFilePath, last.NewContent, 0644); err != nil { + log.Printf("redo: failed to recreate file %s: %v", last.NewFilePath, err) + return nil + } + } else { + if err := os.WriteFile(last.FilePath, last.NewContent, 0644); err != nil { + log.Printf("redo: failed to recreate file %s: %v", last.FilePath, err) + return nil + } + } + } else if last.IsDelete { + os.Remove(last.FilePath) + } else { + if last.NewFilePath != "" { + if err := os.WriteFile(last.NewFilePath, last.NewContent, 0644); err != nil { + log.Printf("redo: failed to reapply move %s: %v", last.NewFilePath, err) + return nil + } + } else { + if err := os.WriteFile(last.FilePath, last.NewContent, 0644); err != nil { + log.Printf("redo: failed to reapply file %s: %v", last.FilePath, err) + return nil + } + } + } + + us.history = append(us.history, last) + us.save() + return &last +} + +func (us *UndoStack) Info() UndoInfo { + us.mu.Lock() + defer us.mu.Unlock() + info := UndoInfo{ + StackSize: len(us.history), + RedoSize: len(us.redo), + } + if len(us.history) > 0 { + info.CanUndo = true + info.UndoDesc = us.history[len(us.history)-1].Description + } + if len(us.redo) > 0 { + info.CanRedo = true + info.RedoDesc = us.redo[len(us.redo)-1].Description + } + return info +} + +func (us *UndoStack) save() { + type entry struct { + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + } + type saveData struct { + History []entry `json:"history"` + Redo []entry `json:"redo"` + } + toEntries := func(changes []ChangeDesc) []entry { + var entries []entry + for _, c := range changes { + entries = append(entries, entry{ + Time: c.Time, + Description: c.Description, + FilePath: c.FilePath, + NewFilePath: c.NewFilePath, + OldContent: string(c.OldContent), + NewContent: string(c.NewContent), + IsDelete: c.IsDelete, + IsCreate: c.IsCreate, + }) + } + return entries + } + data := saveData{ + History: toEntries(us.history), + Redo: toEntries(us.redo), + } + b, err := json.Marshal(data) + if err != nil { + log.Printf("undo: failed to marshal history: %v", err) + return + } + os.WriteFile(us.filePath, b, 0644) +} + +func (us *UndoStack) load() { + data, err := os.ReadFile(us.filePath) + if err != nil { + return + } + type entry struct { + Time string `json:"time"` + Description string `json:"description"` + FilePath string `json:"file_path"` + NewFilePath string `json:"new_file_path,omitempty"` + OldContent string `json:"old_content"` + NewContent string `json:"new_content"` + IsDelete bool `json:"is_delete"` + IsCreate bool `json:"is_create"` + } + var saveData struct { + History []entry `json:"history"` + Redo []entry `json:"redo"` + } + if err := json.Unmarshal(data, &saveData); err != nil { + return + } + fromEntries := func(entries []entry) []ChangeDesc { + var changes []ChangeDesc + for _, e := range entries { + changes = append(changes, ChangeDesc{ + Time: e.Time, + Description: e.Description, + FilePath: e.FilePath, + NewFilePath: e.NewFilePath, + OldContent: []byte(e.OldContent), + NewContent: []byte(e.NewContent), + IsDelete: e.IsDelete, + IsCreate: e.IsCreate, + }) + } + return changes + } + us.history = fromEntries(saveData.History) + us.redo = fromEntries(saveData.Redo) +} diff --git a/internal/admin/xterm_to_css.go b/internal/admin/xterm_to_css.go new file mode 100644 index 0000000..098ce62 --- /dev/null +++ b/internal/admin/xterm_to_css.go @@ -0,0 +1,40 @@ +package admin + +import ( + "fmt" + "strconv" + "strings" + + "thehouseoficarus/internal/color" +) + +func XtermToCSS(xtermHex string) string { + xtermHex = strings.TrimPrefix(xtermHex, "#") + idx, err := strconv.ParseUint(xtermHex, 16, 8) + if err != nil || idx > 255 { + return "#808080" + } + r, g, b := color.Xterm256ToRGB(int(idx)) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +func XtermColorForCSS(idx int) string { + if idx < 0 || idx > 255 { + return "#808080" + } + r, g, b := color.Xterm256ToRGB(idx) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +func xtermColorList() []map[string]any { + var list []map[string]any + for i := 0; i < 256; i++ { + r, g, b := color.Xterm256ToRGB(i) + list = append(list, map[string]any{ + "index": i, + "hex": fmt.Sprintf("%02X%02X%02X", r, g, b), + "color": fmt.Sprintf("#%02x%02x%02x", r, g, b), + }) + } + return list +} diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go new file mode 100644 index 0000000..b97a4f2 --- /dev/null +++ b/internal/admin/yaml_util.go @@ -0,0 +1,117 @@ +package admin + +import ( + "bytes" + "log" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +func readYAMLFile(dataDir, subdir, id string) ([]byte, string, error) { + path := filepath.Join(dataDir, subdir, id+".yaml") + data, err := os.ReadFile(path) + return data, path, err +} + +func writeYAMLFile(path string, data any) ([]byte, error) { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(data); err != nil { + return nil, err + } + enc.Close() + content := buf.Bytes() + if err := os.WriteFile(path, content, 0644); err != nil { + return nil, err + } + return content, nil +} + +func findYAMLFileInSubdirs(dataDir, subdir, id string) ([]byte, string, error) { + base := filepath.Join(dataDir, subdir) + entries, err := os.ReadDir(base) + if err != nil { + return nil, "", err + } + for _, e := range entries { + fullPath := filepath.Join(base, e.Name(), id+".yaml") + if data, err := os.ReadFile(fullPath); err == nil { + return data, fullPath, nil + } + subPath := filepath.Join(base, e.Name()) + subEntries, err := os.ReadDir(subPath) + if err != nil { + continue + } + for _, se := range subEntries { + fullPath := filepath.Join(subPath, se.Name(), id+".yaml") + if data, err := os.ReadFile(fullPath); err == nil { + return data, fullPath, nil + } + } + } + return nil, "", os.ErrNotExist +} + +func listYAMLFiles(dataDir, subdir string) ([]string, error) { + var ids []string + base := filepath.Join(dataDir, subdir) + + entries, err := os.ReadDir(base) + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { + ids = append(ids, e.Name()[:len(e.Name())-5]) + } + } + for _, e := range entries { + if e.IsDir() { + subPath := filepath.Join(base, e.Name()) + subEntries, err := os.ReadDir(subPath) + if err != nil { + continue + } + for _, se := range subEntries { + if !se.IsDir() && filepath.Ext(se.Name()) == ".yaml" { + ids = append(ids, se.Name()[:len(se.Name())-5]) + } + } + } + } + return ids, nil +} + +func listYAMLFilesDeep(dataDir, subdir string) ([]string, error) { + var ids []string + base := filepath.Join(dataDir, subdir) + err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Ext(d.Name()) == ".yaml" { + rel, _ := filepath.Rel(base, path) + id := rel[:len(rel)-5] + ids = append(ids, id) + } + return nil + }) + return ids, err +} + +func backupFile(path string) []byte { + data, err := os.ReadFile(path) + if err != nil { + log.Printf("backup: failed to read %s: %v", path, err) + return nil + } + return data +} + +func snapshotFile(path string) ([]byte, error) { + return os.ReadFile(path) +} |
