From 7a4b91304061777e7b3365b505d1e74878043d35 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 30 Jun 2026 02:17:22 -0400 Subject: feat: web gui mapping and obj/item/mob placement improvements --- internal/admin/api_map.go | 81 +++- internal/admin/api_rooms.go | 192 +++++++-- internal/admin/server.go | 25 +- internal/admin/static/admin.css | 42 +- internal/admin/static/map.js | 853 ++++++++++++++++++++++++++++++++++++-- internal/admin/templates/map.html | 8 + internal/config/config.go | 10 +- 7 files changed, 1103 insertions(+), 108 deletions(-) (limited to 'internal') diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index 1520279..5de41a7 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -162,11 +162,13 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { } writeJSON(w, map[string]any{ - "rooms": rooms, - "links": links, - "upLinks": upLinks, - "downLinks": downLinks, - "dir": getRoomDir(s, seed), + "rooms": rooms, + "links": links, + "upLinks": upLinks, + "downLinks": downLinks, + "dir": getRoomDir(s, seed), + "seed": seed, + "disconnected": findDisconnectedRooms(s, dir, seed, g), "bounds": map[string]int{ "minX": minX, "maxX": maxX, @@ -222,6 +224,75 @@ func findLowestRoom(dir string) (int, bool) { return lowest, found } +func findDisconnectedRooms(s *AdminServer, dir string, seed int, g world.RoomGrid) []map[string]any { + var result []map[string]any + scanDir := dir + if scanDir == "" { + scanDir = getRoomDir(s, seed) + } + allIDs := listRoomIDsInDir(s, scanDir) + for _, id := range allIDs { + if _, inGrid := g.Coord[id]; inGrid { + continue + } + room, err := s.world.LoadRoom(id) + if err != nil { + continue + } + result = append(result, map[string]any{ + "id": id, + "name": room.Name, + }) + } + return result +} + +func listRoomIDsInDir(s *AdminServer, dir string) []int { + base := filepath.Join(s.dataDir, "rooms") + var dirs []string + if dir == "" { + dirs = append(dirs, base) + entries, err := os.ReadDir(base) + if err != nil { + return nil + } + for _, e := range entries { + if e.IsDir() { + dirs = append(dirs, filepath.Join(base, e.Name())) + subE, err := os.ReadDir(filepath.Join(base, e.Name())) + if err != nil { + continue + } + for _, se := range subE { + if se.IsDir() { + dirs = append(dirs, filepath.Join(base, e.Name(), se.Name())) + } + } + } + } + } else { + dirs = append(dirs, filepath.Join(base, dir)) + } + var ids []int + for _, d := range dirs { + entries, err := os.ReadDir(d) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { + continue + } + id, err := strconv.Atoi(e.Name()[:len(e.Name())-5]) + if err != nil { + continue + } + ids = append(ids, id) + } + } + return ids +} + func (s *AdminServer) handleNextRoomID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 985eb49..4075534 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -269,68 +269,164 @@ func (s *AdminServer) handleRoomDirs(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - entries, err := os.ReadDir(filepath.Join(s.dataDir, "rooms")) + base := filepath.Join(s.dataDir, "rooms") + entries, err := os.ReadDir(base) if err != nil { writeJSON(w, []string{""}) return } dirs := []string{""} for _, e := range entries { - if e.IsDir() { + if !e.IsDir() { + continue + } + if hasYAMLFiles(filepath.Join(base, e.Name())) { dirs = append(dirs, e.Name()) } + subEntries, err := os.ReadDir(filepath.Join(base, e.Name())) + if err != nil { + continue + } + for _, se := range subEntries { + if se.IsDir() && hasYAMLFiles(filepath.Join(base, e.Name(), se.Name())) { + dirs = append(dirs, e.Name()+"/"+se.Name()) + } + } } writeJSON(w, dirs) } +func hasYAMLFiles(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { + return true + } + } + return false +} + 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"` + From int `json:"from"` + To int `json:"to"` + Dir string `json:"dir"` } 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"}) + roomA, errA := s.world.LoadRoom(body.From) + roomB, errB := s.world.LoadRoom(body.To) + if errA != nil || errB != nil { + writeJSON(w, map[string]any{"error": "one or both rooms not found"}) 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 body.Dir != "" { + dir = world.ExitDir(strings.ToLower(body.Dir)) + if opp, ok := world.OppositeExit[dir]; ok { + oppDir = opp + } else { + writeJSON(w, map[string]any{"error": "invalid direction: " + body.Dir}) + return + } + } else { + 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] + 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 + } + } + + if roomA.Exits != nil { + for d, exit := range roomA.Exits { + if exit.Room == body.To { + delete(roomA.Exits, d) + break + } + } + } + if roomB.Exits != nil { + for d, exit := range roomB.Exits { + if exit.Room == body.From { + delete(roomB.Exits, d) + break + } } } - if dir == "" { - writeJSON(w, map[string]any{"error": "rooms are not adjacent"}) + + conflicts := 0 + testGrid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { + room, err := s.world.LoadRoom(id) + if err != nil { + return nil, false + } + roomCopy := *room + if room.Exits != nil { + roomCopy.Exits = make(map[world.ExitDir]world.ExitDef, len(room.Exits)) + for k, v := range room.Exits { + if (id == body.From && v.Room == body.To) || (id == body.To && v.Room == body.From) { + continue + } + roomCopy.Exits[k] = v + } + } else { + roomCopy.Exits = make(map[world.ExitDir]world.ExitDef) + } + if id == body.From { + roomCopy.Exits[dir] = world.ExitDef{Room: body.To} + } + if id == body.To { + roomCopy.Exits[oppDir] = world.ExitDef{Room: body.From} + } + return &roomCopy, true + }, nil, func(gc world.GridConflict) { + conflicts++ + }) + _ = testGrid + + if conflicts > 0 { + writeJSONError(w, "link would cause map conflicts (twists or overlaps)", http.StatusConflict) 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) } + roomA.Exits[dir] = world.ExitDef{Room: body.To} + 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) @@ -378,36 +474,42 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { 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"}) + roomA, errA := s.world.LoadRoom(body.From) + roomB, errB := s.world.LoadRoom(body.To) + if errA != nil || errB != nil { + writeJSON(w, map[string]any{"error": "one or both rooms not found"}) return } - dx, dy := cB[0]-cA[0], cB[1]-cA[1] + if roomA == nil || roomB == nil { + writeJSON(w, map[string]any{"error": "one or both rooms not found"}) + return + } + var dir, oppDir world.ExitDir - for d, delta := range world.DirectionDeltas3D { - if delta[0] == dx && delta[1] == dy && delta[2] == 0 { + for _, d := range world.ExitOrder { + if exit, ok := roomA.Exits[d]; ok && exit.Room == body.To { dir = d - oppDir = world.OppositeExit[d] break } } - if dir == "" { - writeJSON(w, map[string]any{"error": "rooms are not adjacent"}) + if dir != "" { + oppDir = world.OppositeExit[dir] + } else { + for _, d := range world.ExitOrder { + if exit, ok := roomB.Exits[d]; ok && exit.Room == body.From { + oppDir = d + dir = world.OppositeExit[d] + break + } + } + } + + if dir == "" && oppDir == "" { + writeJSONError(w, "no exit found between these rooms", http.StatusNotFound) return } - roomA, _ := s.world.LoadRoom(body.From) - roomB, _ := s.world.LoadRoom(body.To) if roomA.Exits != nil { delete(roomA.Exits, dir) } diff --git a/internal/admin/server.go b/internal/admin/server.go index 418a693..8facc4a 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -212,12 +212,11 @@ func (s *AdminServer) checkAuth(r *http.Request) bool { } func (s *AdminServer) isAdminAccount(name string) bool { - for _, a := range s.cfg.AdminHTTPS.AdminAccounts { - if strings.EqualFold(a, name) { - return true - } + acc, err := s.accountStore.LoadAccount(name) + if err != nil { + return false } - return false + return acc.Admin } func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) { @@ -249,17 +248,17 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { 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 } + if !acc.Admin { + s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."}) + return + } + s.setAuthCookie(w, account) http.Redirect(w, r, "/", http.StatusFound) } @@ -355,6 +354,12 @@ func writeJSON(w http.ResponseWriter, data any) { } } +func writeJSONError(w http.ResponseWriter, msg string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]any{"error": msg}) +} + func readJSON(r *http.Request, v any) error { body, err := io.ReadAll(r.Body) if err != nil { diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index c9d0ed5..684a9c6 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -11,7 +11,9 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100 .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{width:580px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0;position:relative} +.panel-resize-handle{position:absolute;left:-3px;top:0;bottom:0;width:6px;cursor:col-resize;z-index:5;background:transparent} +.panel-resize-handle:hover{background:rgba(255,255,255,.06)} .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} @@ -100,3 +102,41 @@ g.ud-hover:hover text{font-weight:bold} .cm-menu button:hover{background:var(--accent)} .cm-menu button.danger{color:#f55} .cm-menu button.danger:hover{background:var(--danger)} +.panel-left{width:200px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column;flex-shrink:0} +.panel-left .panel-header{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;padding:12px 12px 8px;border-bottom:1px solid var(--border);flex-shrink:0} +.panel-left .dc-item{display:block;padding:6px 12px;font-size:11px;cursor:pointer;color:var(--text);border-bottom:1px solid rgba(255,255,255,.03);font-family:monospace} +.panel-left .dc-item:hover{background:rgba(15,52,96,.4)} +.panel-left .dc-item .dc-id{color:#6cf} +.panel-left .dc-item .dc-name{color:#888;font-size:10px;margin-left:6px} +.panel-tabs{display:flex;gap:2px;margin-bottom:10px;border-bottom:1px solid var(--border)} +.panel-tab{padding:4px 12px;font-size:11px;background:transparent;color:#888;border:none;border-bottom:2px solid transparent;cursor:pointer;font-family:monospace} +.panel-tab:hover{color:var(--text)} +.panel-tab.active{color:var(--text);border-bottom-color:var(--accent)} +.panel-tab-content{padding:4px 0} +.cond-clause input,.cond-clause select{padding:4px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:2px;font-family:monospace} +.cond-clause select{min-width:120px}.exit-section{margin-top:12px;border-top:1px solid var(--border);padding-top:8px} +.exit-section h3{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px} +.exit-row{display:flex;gap:4px;align-items:center;margin-bottom:4px;padding:3px 4px;background:rgba(255,255,255,.02);border-radius:3px;font-size:11px} +.exit-row .exit-dir{color:#6cf;font-weight:bold;min-width:28px;text-transform:uppercase} +.exit-row .exit-target{color:var(--text);flex:1;cursor:pointer}.exit-row .exit-target:hover{color:#fff} +.exit-row .exit-meta{font-size:9px;color:#888;display:block} +.exit-row input,.exit-row select{padding:2px 4px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:2px;font-family:monospace} +.exit-row select{min-width:70px} +.exit-add{display:flex;gap:4px;align-items:center;margin-top:6px} +.exit-add select,.exit-add input{width:auto;flex:1;padding:3px 5px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:2px;font-family:monospace} +.exit-add .exit-add-btn{flex-shrink:0;padding:3px 8px;font-size:11px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:2px;cursor:pointer;font-family:monospace} +.exit-add .exit-add-btn:hover{background:var(--hover)}.panel-tab-content .entry-row{display:flex;gap:4px;align-items:flex-start;margin-bottom:6px;padding:4px;background:rgba(255,255,255,.02);border-radius:3px;border:1px solid transparent} +.panel-tab-content .entry-row:hover{border-color:var(--border)} +.panel-tab-content .entry-fields{flex:1;min-width:0} +.panel-tab-content .entry-fields input:not([type=checkbox]),.panel-tab-content .entry-fields textarea{width:100%;padding:3px 5px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:2px;font-family:monospace;margin-bottom:3px} +.panel-tab-content .entry-fields textarea{resize:vertical;min-height:28px} +.panel-tab-content .entry-fields .entry-id{font-size:11px;color:#6cf;display:block;padding:3px 0} +.panel-tab-content .section-search{position:relative;margin-top:4px} +.panel-tab-content .search-input{width:100%;padding:4px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace} +.panel-tab-content .search-results{position:absolute;top:100%;left:0;right:0;max-height:160px;overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.4)} +.panel-tab-content .search-results .sr-item{padding:4px 8px;font-size:11px;cursor:pointer;color:var(--text);border-bottom:1px solid rgba(255,255,255,.03)} +.panel-tab-content .search-results .sr-item:hover{background:var(--accent)} +.panel-tab-content .section-add-btn{display:block;width:100%;margin-top:4px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;padding:4px 8px;font-size:11px;border-radius:3px;cursor:pointer;font-family:monospace} +.panel-tab-content .section-add-btn:hover{background:var(--hover)} +.panel-tab-content .mini-label{font-size:9px;color:#888;display:block;margin-bottom:1px;margin-top:3px} +.panel-tab-content .mini-input{width:60px!important;display:inline-block;margin-right:4px} diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 3443339..ca454a0 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -6,6 +6,8 @@ var dragRoom = false; var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null; var delDrag = false, delFromId = null, delTargetId = null; var roomMap = {}, occupied = {}; +var dirChangedByUser = false; +var currentPanelTab = 0; 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'}, @@ -22,13 +24,29 @@ function changeZ(dz) { function changeDir(dir) { currentDir = dir; + dirChangedByUser = true; + selectedRoom = null; loadMap(); } +function zoomIn() { + scale *= 1.1; + if (scale > 5) scale = 5; + updateView(); +} + +function zoomOut() { + scale *= 0.9; + if (scale < 0.1) scale = 0.1; + updateView(); +} + function loadMap() { $('#zLabel').textContent = 'Z=' + currentZ; var url = '/api/map?z=' + currentZ; if (currentDir) url += '&dir=' + encodeURIComponent(currentDir); + var wasDirChange = dirChangedByUser; + dirChangedByUser = false; API.get(url).then(function(data) { mapData = data; if (!currentDir && data.dir) { @@ -36,11 +54,36 @@ function loadMap() { } updateDirSelect(); renderMap(data); + renderDisconnectedList(data); + if (wasDirChange && data.seed) { + selectRoom(data.seed); + } }).catch(function(e) { notify('Failed to load map: ' + e.message, 'error'); }); } +function blendColors(colorA, colorB) { + var idxA = parseXtermIdx(colorA); + var idxB = parseXtermIdx(colorB); + if (colorA && !colorB) return xtermToCss(idxA); + if (!colorA && colorB) return xtermToCss(idxB); + if (!colorA && !colorB) return '#666666'; + var rgbA = xtermToRGB(idxA); + var rgbB = xtermToRGB(idxB); + var r = Math.round((rgbA[0] + rgbB[0]) / 2); + var g = Math.round((rgbA[1] + rgbB[1]) / 2); + var b = Math.round((rgbA[2] + rgbB[2]) / 2); + return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1).toUpperCase(); +} + +function parseXtermIdx(v) { + if (!v) return 0; + v = String(v).toUpperCase().replace(/[^0-9A-F]/g, ''); + if (v.length >= 2) return parseInt(v.substring(0, 2), 16) || 0; + return 0; +} + function renderMap(data) { var svg = $('#mapSvg'); var container = $('#mapContainer'); @@ -66,9 +109,9 @@ function renderMap(data) { 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'; + var c = blendColors(fr.color, tr.color); c = c.replace('#',''); - g += ''; + g += ''; }); } @@ -84,6 +127,8 @@ function renderMap(data) { var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff'; var sel = selectedRoom === r.id; roomHTML += ''; + var dimColor = hexLuminance(fill) > 0.35 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.4)'; + roomHTML += '#'+r.id+''; roomHTML += wrapText(r.name, x+w/2, y+h/2, w, txtColor); var btnY = y+h-4; @@ -296,29 +341,39 @@ function selectRoom(id) { }); } +var currentRoomData = null; + function renderSidePanel(data) { var panel = $('#panelContent'); if (!data || !data.room) { panel.innerHTML = '

Room not found

'; return; } + currentRoomData = data; var r = data.room; var file = data.file || ''; - var html = '
' + esc(file) + '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - 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 += '
'; - html += '
'; - html += '
'; + var objects = Array.isArray(r.objects) ? r.objects : []; + var localObjs = objects.filter(function(o) { return !o.id; }); + var refObjs = objects.filter(function(o) { return !!o.id; }); + var spawns = Array.isArray(r.item_spawns) ? r.item_spawns : []; + var mobs = Array.isArray(r.mobs) ? r.mobs : []; + + var html = '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + + html += '
'; + if (currentPanelTab === 0) html += renderRoomTab(r, file); + else if (currentPanelTab === 1) html += renderLocalTab(localObjs); + else if (currentPanelTab === 2) html += renderObjectsTab(refObjs); + else if (currentPanelTab === 3) html += renderMobsTab(mobs); + else if (currentPanelTab === 4) html += renderItemsTab(spawns); + html += '
'; panel.innerHTML = html; setTimeout(function() { @@ -331,7 +386,8 @@ function renderSidePanel(data) { loadRoomDirs(function(dirs) { var sel = $('#roomDir'); if (!sel) return; - var current = (file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || ''; + var current = getRoomDirFromFile(file); + sel.innerHTML = ''; dirs.forEach(function(d) { var opt = document.createElement('option'); opt.value = d; @@ -343,6 +399,656 @@ function renderSidePanel(data) { }, 50); } +function renderRoomTab(r, file) { + var html = '
' + esc(file) + '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + 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 += '
'; + html += '
'; + html += '
'; + html += renderExitsSection(r); + return html; +} + +function getRoomDirFromFile(file) { + var rel = (file || '').replace(/^data\/rooms\/?/, ''); + var idx = rel.indexOf('/'); + if (idx >= 0) { + var subIdx = rel.indexOf('/', idx + 1); + if (subIdx >= 0) return rel.substring(0, subIdx); + return rel.substring(0, idx); + } + return ''; +} + +function switchPanelTab(idx) { + currentPanelTab = idx; + if (!currentRoomData) return; + var r = currentRoomData.room; + var file = currentRoomData.file || ''; + var objects = Array.isArray(r.objects) ? r.objects : []; + var localObjs = objects.filter(function(o) { return !o.id; }); + var refObjs = objects.filter(function(o) { return !!o.id; }); + var spawns = Array.isArray(r.item_spawns) ? r.item_spawns : []; + var mobs = Array.isArray(r.mobs) ? r.mobs : []; + var html = ''; + if (idx === 0) html = renderRoomTab(r, file); + else if (idx === 1) html = renderLocalTab(localObjs); + else if (idx === 2) html = renderObjectsTab(refObjs); + else if (idx === 3) html = renderMobsTab(mobs); + else if (idx === 4) html = renderItemsTab(spawns); + var content = document.querySelector('.panel-tab-content'); + if (content) content.innerHTML = html; + var tabs = document.querySelectorAll('.panel-tab'); + tabs.forEach(function(t, i) { t.classList.toggle('active', i === idx); }); + if (idx === 0) { + setTimeout(function() { + var cf = document.querySelector('#panelContent .color-field'); + if (cf) bindColorField(cf); + loadRoomDirs(function(dirs) { + var sel = $('#roomDir'); + if (!sel) return; + sel.innerHTML = ''; + var current = getRoomDirFromFile(file); + 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 renderLocalTab(localObjs) { + var h = ''; + localObjs.forEach(function(obj, i) { + var descText = ''; + if (Array.isArray(obj.description)) { + descText = obj.description.map(function(d) { return d.text || ''; }).join('\n'); + } else if (typeof obj.description === 'string') { + descText = obj.description; + } + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += ''; + h += '
'; + }); + h += ''; + return h; +} + +function renderObjectsTab(refObjs) { + var h = ''; + refObjs.forEach(function(obj, i) { + h += '
'; + h += '
' + esc(obj.id || '') + '
'; + h += ''; + h += ''; + h += '
'; + }); + h += ''; + return h; +} + +function renderMobsTab(mobs) { + var h = ''; + mobs.forEach(function(mob, i) { + h += '
'; + h += '
' + esc(mob.id || '') + '
'; + h += ''; + h += ''; + h += '
'; + }); + h += ''; + return h; +} + +function renderItemsTab(spawns) { + var h = ''; + spawns.forEach(function(spawn, i) { + h += '
'; + h += '
'; + h += '' + esc(spawn.id || '') + ''; + h += ''; + h += ''; + h += '
'; + h += ''; + h += ''; + h += '
'; + }); + h += ''; + return h; +} + +function renderExitsSection(r) { + var exits = r.exits || {}; + var exitDirs = ['north','south','east','west','northeast','northwest','southeast','southwest','up','down']; + var dirAliases = {n:'north',s:'south',e:'east',w:'west',ne:'northeast',nw:'northwest',se:'southeast',sw:'southwest',u:'up',d:'down'}; + var h = '

Exits

'; + exitDirs.forEach(function(d) { + var exit = exits[d]; + if (!exit) return; + var target = typeof exit === 'object' ? exit.room : exit; + var cond = (typeof exit === 'object' && exit.condition) ? exit.condition : null; + var bmsg = (typeof exit === 'object' && exit.blocked_message) ? exit.blocked_message : ''; + var setFlags = (typeof exit === 'object' && exit.set_flags) ? exit.set_flags : null; + var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null; + h += '
'; + h += '' + d + ''; + h += '# ' + target + ''; + if (bmsg) h += 'Blocked: ' + esc(bmsg) + ''; + if (cond) h += 'Conditional'; + h += ''; + h += ''; + h += '
'; + }); + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += '
'; + return h; +} + +var editingExitDir = null; + +function editExitCondition(dir) { + editingExitDir = dir; + var exits = (currentRoomData && currentRoomData.room && currentRoomData.room.exits) || {}; + var exit = exits[dir]; + if (!exit) return; + if (typeof exit !== 'object') { exit = {room: exit}; } + var cond = exit.condition || null; + var bmsg = exit.blocked_message || ''; + var setFlags = exit.set_flags || null; + var setPlayerFlags = exit.set_player_flags || null; + + var flagsStr = ''; + if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', '); + var pflagsStr = ''; + if (setPlayerFlags) pflagsStr = Object.keys(setPlayerFlags).map(function(k) { return k + '=' + setPlayerFlags[k]; }).join(', '); + + var parsed = parseCondition(cond); + + var overlay = document.createElement('div'); + overlay.className = 'cm-overlay exit-overlay'; + var menu = document.createElement('div'); + menu.className = 'cm-menu exit-modal'; + menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:420px;max-height:85vh;overflow-y:auto;padding:14px;z-index:201'; + + var h = '

Exit: ' + esc(dir) + ' → #' + exit.room + '

'; + h += '
'; + h += '
'; + h += '
'; + + h += '
'; + h += ''; + h += '
'; + h += ''; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + + menu.innerHTML = h; + document.body.appendChild(overlay); + document.body.appendChild(menu); + overlay.onclick = closeExitModal; + + menu._condClauses = parsed.clauses; + menu._condMode = parsed.mode; + // Fix: mode is always 'all' for our editor, but store what was parsed + renderConditionClauses(menu); +} + +function parseCondition(cond) { + var clauses = []; + if (!cond || Object.keys(cond).length === 0) return {clauses: clauses, mode: 'all'}; + var arr = cond.all_of; + if (!arr || !Array.isArray(arr) || arr.length === 0) arr = cond.any_of; + if (arr && Array.isArray(arr) && arr.length > 0) { + arr.forEach(function(c) { + var clause = extractClause(c); + if (clause) clauses.push(clause); + }); + return {clauses: clauses, mode: 'all'}; + } + var clause = extractClause(cond); + if (clause) clauses.push(clause); + return {clauses: clauses, mode: 'all'}; +} + +function extractClause(c) { + var not = c.not === true; + if (c.player_flag && c.player_flag !== '') return {type: 'player_flag', value: c.player_flag, not: not}; + if (c.flag && c.flag !== '') return {type: 'flag', value: c.flag, not: not}; + if (c.has_item && c.has_item !== '') return {type: 'has_item', value: c.has_item, not: not}; + if (c.min_credits && c.min_credits !== 0) return {type: 'min_credits', value: String(c.min_credits), not: not}; + if (c.value && typeof c.value === 'object' && c.value.key) return {type: 'value', value: c.value.key + '=' + (c.value.value || ''), not: not}; + return null; +} + +function renderConditionClauses(menu) { + var container = menu.querySelector('#condClauses'); + if (!container) return; + var clauses = menu._condClauses || []; + var h = ''; + clauses.forEach(function(clause, i) { + h += '
'; + h += ''; + if (clause.type === 'value') { + var kv = clause.value ? clause.value.split('=', 2) : ['','']; + var kn = (kv[0] || '').trim(); + var kvv = (kv[1] || '').trim(); + h += ''; + h += '='; + h += ''; + } else { + h += ''; + } + h += ''; + h += ''; + h += '
'; + }); + container.innerHTML = h; +} + +function updateClauseData(el) { + var clauseEl = el.closest('.cond-clause'); + var menu = el.closest('.exit-modal'); + if (!clauseEl || !menu) return; + var idx = parseInt(clauseEl.getAttribute('data-idx')); + var clause = menu._condClauses[idx]; + if (!clause) return; + clause.type = clauseEl.querySelector('.cond-type').value; + clause.not = clauseEl.querySelector('.cond-not').checked; + if (clause.type === 'value') { + var k = clauseEl.querySelector('.cond-val-key'); + var v = clauseEl.querySelector('.cond-val-val'); + clause.value = (k ? k.value.trim() : '') + '=' + (v ? v.value.trim() : ''); + } else { + var val = clauseEl.querySelector('.cond-val'); + clause.value = val ? val.value : ''; + } +} + +function addConditionClause() { + var menu = document.querySelector('.exit-modal'); + if (!menu) return; + if (!menu._condClauses) menu._condClauses = []; + menu._condClauses.push({type: 'player_flag', value: '', not: false}); + renderConditionClauses(menu); +} + +function removeConditionClause(btn) { + var menu = btn.closest('.exit-modal'); + var clauseEl = btn.closest('.cond-clause'); + if (!menu || !clauseEl) return; + var idx = parseInt(clauseEl.getAttribute('data-idx')); + menu._condClauses.splice(idx, 1); + renderConditionClauses(menu); +} + +function closeExitModal() { + editingExitDir = null; + var ov = document.querySelector('.exit-overlay'); + if (ov) ov.remove(); + var m = document.querySelector('.exit-modal'); + if (m) m.remove(); +} + +function saveExitCondition() { + if (!editingExitDir || !currentRoomData || !currentRoomData.room) return; + var menu = document.querySelector('.exit-modal'); + if (!menu) return; + var r = currentRoomData.room; + var exits = r.exits || {}; + var exit = exits[editingExitDir]; + if (!exit) return; + if (typeof exit !== 'object') { + exit = {room: exit}; + exits[editingExitDir] = exit; + r.exits = exits; + } + + var bmsg = document.getElementById('exitBmsg'); + exit.blocked_message = (bmsg && bmsg.value) ? bmsg.value : ''; + var flagsStr = document.getElementById('exitFlags'); + var pflagsStr = document.getElementById('exitPFlags'); + exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : ''); + exit.set_player_flags = parseFlagInput(pflagsStr ? pflagsStr.value : ''); + + // Update clause data from DOM before saving + var clauseEls = menu.querySelectorAll('.cond-clause'); + clauseEls.forEach(function(el) { updateClauseData(el); }); + + var clauses = (menu._condClauses || []).filter(function(c) { return c.value && c.value !== '' && c.value !== '='; }); + var condition = null; + if (clauses.length === 1) { + condition = buildSingleCondition(clauses[0]); + } else if (clauses.length > 1) { + condition = {all_of: clauses.map(function(c) { return buildSingleCondition(c); })}; + } + exit.condition = condition; + + r.exits = exits; + autoSave(); + closeExitModal(); + reRenderPanel(); +} + +function buildSingleCondition(clause) { + var obj = {not: !!clause.not}; + if (clause.type === 'value') { + var parts = (clause.value || '').split('=', 2); + obj.value = {key: (parts[0] || '').trim(), value: (parts[1] || '').trim()}; + } else { + obj[clause.type] = clause.value; + } + return obj; +} + +function clearExitCondition() { + var menu = document.querySelector('.exit-modal'); + if (!menu) return; + menu._condClauses = []; + renderConditionClauses(menu); + var bmsg = document.getElementById('exitBmsg'); + if (bmsg) bmsg.value = ''; + var ef = document.getElementById('exitFlags'); + if (ef) ef.value = ''; + var pf = document.getElementById('exitPFlags'); + if (pf) pf.value = ''; +} + +function parseFlagInput(val) { + if (!val || !val.trim()) return null; + var m = {}; + val.split(',').forEach(function(pair) { + var kv = pair.trim().split('='); + if (kv.length === 2) { + var v = kv[1].trim(); + if (v === 'true') v = true; + else if (v === 'false') v = false; + else if (!isNaN(v)) v = parseFloat(v); + m[kv[0].trim()] = v; + } + }); + return Object.keys(m).length > 0 ? m : null; +} + +function addExit() { + if (!selectedRoom || !currentRoomData) return; + var dir = $('#exitDirSelect').value; + var target = parseInt($('#exitTargetID').value); + if (!dir || !target) { notify('Enter target room ID', 'error'); return; } + API.post('/api/rooms/link', {from: selectedRoom, to: target, dir: dir}).then(function() { + notify('Exit added: ' + dir + ' #' + target, 'success'); + updateUndoBar(); + loadMap(); + selectRoom(selectedRoom); + }).catch(function(e) { notify('Add exit failed: ' + extractError(e), 'error'); }); +} + +function deleteExit(dir, target) { + if (!selectedRoom || !currentRoomData) return; + if (!confirm('Delete exit ' + dir + ' to #' + target + '?')) return; + fetch('/api/rooms/link', { + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({from: selectedRoom, to: target}), + credentials: 'same-origin' + }).then(function(r) { + if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || 'request failed'); }); + return r.json(); + }).then(function() { + notify('Removed exit ' + dir + ' to #' + target, 'success'); + updateUndoBar(); + loadMap(); + selectRoom(selectedRoom); + }).catch(function(e) { notify('Delete exit failed: ' + extractError(e), 'error'); }); +} + +function extractError(e) { + var msg = e.message || 'unknown error'; + try { var j = JSON.parse(msg); if (j.error) msg = j.error; } catch(_) {} + return msg; +} + +function getRoomObjects() { + if (!currentRoomData || !currentRoomData.room) return []; + var objects = currentRoomData.room.objects || []; + if (!Array.isArray(objects)) return []; + return objects; +} + +function setRoomObjects(objs) { + if (!currentRoomData || !currentRoomData.room) return; + currentRoomData.room.objects = objs; + autoSave(); +} + +function getRoomSpawns() { + if (!currentRoomData || !currentRoomData.room) return []; + var spawns = currentRoomData.room.item_spawns || []; + if (!Array.isArray(spawns)) return []; + return spawns; +} + +function setRoomSpawns(spawns) { + if (!currentRoomData || !currentRoomData.room) return; + currentRoomData.room.item_spawns = spawns; + autoSave(); +} + +function getRoomMobs() { + if (!currentRoomData || !currentRoomData.room) return []; + var mobs = currentRoomData.room.mobs || []; + if (!Array.isArray(mobs)) return []; + return mobs; +} + +function setRoomMobs(mobs) { + if (!currentRoomData || !currentRoomData.room) return; + currentRoomData.room.mobs = mobs; + autoSave(); +} + +function addLocalObject() { + var objs = getRoomObjects(); + objs.push({name: '', description: [{text: ''}], aliases: [], hidden: false}); + setRoomObjects(objs); + reRenderPanel(); +} + +function updateLocalObject(i) { + var objs = getRoomObjects(); + var localObjs = objs.filter(function(o) { return !o.id; }); + if (i < 0 || i >= localObjs.length) return; + var row = document.querySelector('.panel-tab-content .entry-row[data-idx="' + i + '"]'); + if (!row) return; + var nameEl = row.querySelector('.local-name'); + var descEl = row.querySelector('.local-desc'); + var aliasesEl = row.querySelector('.local-aliases'); + localObjs[i].name = nameEl ? nameEl.value : ''; + var descVal = descEl ? descEl.value : ''; + if (descVal) { + localObjs[i].description = descVal.split('\n').map(function(line) { return {text: line}; }); + } else { + localObjs[i].description = []; + } + if (aliasesEl && aliasesEl.value) { + localObjs[i].aliases = aliasesEl.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); + } else { + localObjs[i].aliases = []; + } + var refObjs = objs.filter(function(o) { return !!o.id; }); + setRoomObjects(localObjs.concat(refObjs)); +} + +function removeLocalObject(i) { + var objs = getRoomObjects(); + var localObjs = objs.filter(function(o) { return !o.id; }); + if (i < 0 || i >= localObjs.length) return; + localObjs.splice(i, 1); + var refObjs = objs.filter(function(o) { return !!o.id; }); + setRoomObjects(localObjs.concat(refObjs)); + reRenderPanel(); +} + +function removeRefObject(i) { + var objs = getRoomObjects(); + var refObjs = objs.filter(function(o) { return !!o.id; }); + if (i < 0 || i >= refObjs.length) return; + refObjs.splice(i, 1); + var localObjs = objs.filter(function(o) { return !o.id; }); + setRoomObjects(localObjs.concat(refObjs)); + reRenderPanel(); +} + +function addRefObject(id) { + var objs = getRoomObjects(); + objs.push({id: id}); + setRoomObjects(objs); + reRenderPanel(); +} + +function searchRefObjects(input) { + searchAndShow(input, 'objects', function(r) { + addRefObject(r.id); + }); +} + +function removeSpawn(i) { + var spawns = getRoomSpawns(); + spawns.splice(i, 1); + setRoomSpawns(spawns); + reRenderPanel(); +} + +function updateSpawn(i) { + var spawns = getRoomSpawns(); + if (i < 0 || i >= spawns.length) return; + var row = document.querySelector('.panel-tab-content .entry-row[data-idx="' + i + '"]'); + if (!row) return; + var qtyEl = row.querySelector('.spawn-qty'); + var respawnEl = row.querySelector('.spawn-respawn'); + if (qtyEl) spawns[i].quantity = parseInt(qtyEl.value) || 0; + if (respawnEl) spawns[i].respawn_ticks = parseInt(respawnEl.value) || 0; + setRoomSpawns(spawns); +} + +function addSpawn(id) { + var spawns = getRoomSpawns(); + spawns.push({id: id, quantity: 1, respawn_ticks: 0}); + setRoomSpawns(spawns); + reRenderPanel(); +} + +function searchItemSpawns(input) { + searchAndShow(input, 'items', function(r) { + addSpawn(r.id); + }); +} + +function removeMob(i) { + var mobs = getRoomMobs(); + mobs.splice(i, 1); + setRoomMobs(mobs); + reRenderPanel(); +} + +function addMob(id) { + var mobs = getRoomMobs(); + mobs.push({id: id}); + setRoomMobs(mobs); + reRenderPanel(); +} + +function searchMobs(input) { + searchAndShow(input, 'mobs', function(r) { + addMob(r.id); + }); +} + +function searchAndShow(input, type, onSelect) { + var val = input.value.trim(); + var results = input.parentElement.querySelector('.search-results'); + if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; } + API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) { + var items = []; + if (type === 'objects' && data.objects) items = data.objects; + if (type === 'items' && data.items) items = data.items; + if (type === 'mobs' && data.mobs) items = data.mobs; + if (items.length === 0) { + results.style.display = 'block'; + results.innerHTML = '
No results
'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; + }).join(''); + results._items = items; + results._onSelect = onSelect; + }).catch(function() { + results.style.display = 'none'; + }); +} + +function selectSearchResult(el) { + var results = el.parentElement; + var idx = Array.prototype.indexOf.call(results.children, el); + if (results._items && results._items[idx] && results._onSelect) { + results._onSelect(results._items[idx]); + results.style.display = 'none'; + results.innerHTML = ''; + var input = results.parentElement.querySelector('.search-input'); + if (input) input.value = ''; + } +} + +function openEditor(type, id) { + window.open('/editor/' + type + '#' + id, '_blank'); +} + +function reRenderPanel() { + if (!currentRoomData) return; + renderSidePanel(currentRoomData); +} + function autoSave() { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(function() { doSavePanel(); }, 600); @@ -351,6 +1057,12 @@ function autoSave() { function doSavePanel() { var id = selectedRoom; if (!id) return; + var save = function(r) { + API.put('/api/rooms/' + id, r).then(function() { + updateUndoBar(); + }).catch(function(e) { notify('Save failed: '+e.message, 'error'); }); + }; + API.get('/api/rooms/' + id).then(function(data) { var r = data.room || {}; r.id = id; @@ -360,29 +1072,30 @@ function doSavePanel() { el = $('#roomHazard'); if (el) r.hazard = el.value; el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked; + if (currentRoomData && currentRoomData.room) { + r.objects = currentRoomData.room.objects; + r.item_spawns = currentRoomData.room.item_spawns; + r.mobs = currentRoomData.room.mobs; + r.exits = currentRoomData.room.exits; + } + 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(); }); + }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(r); }); + } else { save(r); } + }).catch(function(e) { notify('Move failed: '+e.message, 'error'); save(r); }); } 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(); } + }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(r); }); + } else { save(r); } }); } @@ -699,20 +1412,22 @@ function clearDelHighlight() { } function deleteLink(fromId, toId) { + var orphanedRooms = []; 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; + orphanedRooms.push(roomId); } - return true; }; - if (!checkOrphan(toId, fromId)) return; - if (!checkOrphan(fromId, toId)) return; + checkOrphan(toId, fromId); + checkOrphan(fromId, toId); + if (orphanedRooms.length > 0) { + var names = orphanedRooms.map(function(id) { return '#'+id; }).join(', '); + if (!confirm('Deleting this link will also delete room(s) ' + names + ' because they have no other connections. Continue?')) return; + } } fetch('/api/rooms/link', { @@ -724,19 +1439,75 @@ function deleteLink(fromId, toId) { if (!r.ok) throw new Error('unlink failed'); return r.json(); }).then(function() { - notify('Removed link #'+fromId+' '+toId, 'success'); + return Promise.all(orphanedRooms.map(function(id) { + return API.del('/api/rooms/' + id); + })); + }).then(function() { + var msg = 'Removed link #'+fromId+' '+toId; + if (orphanedRooms.length > 0) { + msg += ' and deleted room(s) ' + orphanedRooms.map(function(id) { return '#'+id; }).join(', '); + } + notify(msg, 'success'); updateUndoBar(); - loadMap(); - if (selectedRoom) { - API.get('/api/rooms/' + selectedRoom).then(function(data) { - renderSidePanel(data); - }); + if (orphanedRooms.indexOf(selectedRoom) !== -1) { + selectedRoom = null; + $('#panelContent').innerHTML = '

Room deleted

'; } + loadMap(); }).catch(function(e) { notify('Delete link failed: ' + e.message, 'error'); }); } +function renderDisconnectedList(data) { + var panel = $('#disconnectedPanel'); + var list = $('#disconnectedList'); + if (!panel || !list) return; + var dc = data.disconnected || []; + if (dc.length === 0) { + panel.style.display = 'none'; + return; + } + panel.style.display = 'flex'; + dc.sort(function(a, b) { return a.id - b.id; }); + list.innerHTML = dc.map(function(r) { + return '
' + + '#' + r.id + '' + + '' + esc(r.name || '') + '' + + '
'; + }).join(''); +} + +function initPanelResize() { + var handle = document.createElement('div'); + handle.className = 'panel-resize-handle'; + var panel = $('#sidePanel'); + if (!panel) return; + panel.appendChild(handle); + var startX, startW; + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + startX = e.clientX; + startW = panel.offsetWidth; + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + function onMove(e) { + var newW = startW + (startX - e.clientX); + if (newW < 300) newW = 300; + if (newW > 800) newW = 800; + panel.style.width = newW + 'px'; + } + function onUp() { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } +} + function esc(s) { if (!s) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); @@ -772,4 +1543,4 @@ function updateDirSelect() { } } -document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); }); +document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); }); diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html index 7f8d5a1..869ee67 100644 --- a/internal/admin/templates/map.html +++ b/internal/admin/templates/map.html @@ -1,5 +1,11 @@ {{define "body-map"}}
+
+
Disconnected Rooms
+
+

None

+
+
@@ -7,6 +13,8 @@ + +
diff --git a/internal/config/config.go b/internal/config/config.go index 15f75d4..40f4891 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,9 +100,8 @@ type HTTPSConfig struct { } type AdminHTTPSConfig struct { - Enabled bool `yaml:"enabled"` - Port int `yaml:"port"` - AdminAccounts []string `yaml:"admin_accounts"` + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` } func Default() *Config { @@ -135,9 +134,8 @@ func Default() *Config { Port: 8443, }, AdminHTTPS: AdminHTTPSConfig{ - Enabled: false, - Port: 9090, - AdminAccounts: []string{}, + Enabled: false, + Port: 9090, }, } } -- cgit v1.2.3