From a5d4ead1795882d25da7eda53680d2a84590693d Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 3 Jul 2026 20:53:00 -0400 Subject: feat: admin map can add new and rename areas. works with empty directories now. --- internal/admin/api_map.go | 12 +++ internal/admin/api_rooms.go | 145 ++++++++++++++++++++++++-- internal/admin/server.go | 18 +++- internal/admin/static/admin.css | 7 +- internal/admin/static/admin.js | 4 +- internal/admin/static/map.js | 119 +++++++++++++++++++++ internal/admin/templates/duplicate-rooms.html | 5 +- internal/admin/templates/map.html | 46 +++++--- internal/admin/undo.go | 9 ++ 9 files changed, 333 insertions(+), 32 deletions(-) (limited to 'internal/admin') diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index e0476b3..7759029 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -28,6 +28,18 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { if dir != "" { if low, ok := findLowestLoadableRoom(s, dir); ok { seed = low + } else { + writeJSON(w, map[string]any{ + "rooms": []any{}, + "links": []any{}, + "upLinks": []any{}, + "downLinks": []any{}, + "dir": dir, + "seed": 0, + "disconnected": []any{}, + "bounds": map[string]int{"minX": 0, "maxX": 0, "minY": 0, "maxY": 0}, + }) + return } } diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index d1193f0..b7f7d0e 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -318,22 +318,85 @@ func (s *AdminServer) handleRoomDirs(w http.ResponseWriter, r *http.Request) { if err != nil || !d.IsDir() || path == base { return nil } - entries, rdErr := os.ReadDir(path) - if rdErr != nil { - return nil - } - for _, e := range entries { - if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { - rel, _ := filepath.Rel(base, path) - dirs = append(dirs, rel) - return nil - } - } + rel, _ := filepath.Rel(base, path) + dirs = append(dirs, rel) return nil }) writeJSON(w, dirs) } +func (s *AdminServer) handleRenameRoomDir(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Dir string `json:"dir"` + NewName string `json:"new_name"` + } + if err := readJSON(r, &body); err != nil || body.NewName == "" { + writeJSON(w, map[string]any{"error": "invalid"}) + return + } + if body.Dir == "" { + writeJSON(w, map[string]any{"error": "cannot rename root"}) + return + } + newName := strings.TrimSpace(body.NewName) + if newName == "" || strings.ContainsAny(newName, "/\\") || newName == "." || newName == ".." { + writeJSON(w, map[string]any{"error": "invalid directory name"}) + return + } + base := filepath.Join(s.dataDir, "rooms") + parent := filepath.Dir(body.Dir) + oldPath := filepath.Join(base, body.Dir) + newRel := filepath.Join(parent, newName) + newPath := filepath.Join(base, newRel) + if _, err := os.Stat(oldPath); os.IsNotExist(err) { + writeJSON(w, map[string]any{"error": "directory not found"}) + return + } + if _, err := os.Stat(newPath); err == nil { + writeJSON(w, map[string]any{"error": "directory already exists: " + newRel}) + return + } + if err := os.Rename(oldPath, newPath); err != nil { + writeJSON(w, map[string]any{"error": "rename failed: " + err.Error()}) + return + } + s.world.RebuildRoomIndex(s.dataDir) + writeJSON(w, map[string]any{"ok": true, "new_dir": newRel}) +} + +func (s *AdminServer) handleCreateRoomDir(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Name string `json:"name"` + } + if err := readJSON(r, &body); err != nil { + writeJSON(w, map[string]any{"error": "invalid"}) + return + } + name := strings.TrimSpace(body.Name) + if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { + writeJSON(w, map[string]any{"error": "invalid directory name"}) + return + } + newPath := filepath.Join(s.dataDir, "rooms", name) + if _, err := os.Stat(newPath); err == nil { + writeJSON(w, map[string]any{"error": "directory already exists"}) + return + } + if err := os.Mkdir(newPath, 0755); err != nil { + writeJSON(w, map[string]any{"error": "create failed: " + err.Error()}) + return + } + writeJSON(w, map[string]any{"ok": true, "name": name}) +} + func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: @@ -844,3 +907,63 @@ func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Requ writeJSON(w, map[string]any{"error": "unknown action: " + body.Action}) } } + +func (s *AdminServer) handleCreateEmptyRoom(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + var body struct { + Dir string `json:"dir"` + } + if err := readJSON(r, &body); err != nil { + writeJSON(w, map[string]any{"error": "invalid"}) + return + } + base := filepath.Join(s.dataDir, "rooms") + targetDir := base + if body.Dir != "" { + targetDir = filepath.Join(base, body.Dir) + if _, err := os.Stat(targetDir); os.IsNotExist(err) { + writeJSON(w, map[string]any{"error": "directory not found"}) + return + } + } + + used := map[int]bool{} + filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || filepath.Ext(d.Name()) != ".yaml" { + return nil + } + name := d.Name() + id, convErr := strconv.Atoi(name[:len(name)-5]) + if convErr == nil && id > 0 { + used[id] = true + } + return nil + }) + + id := 1 + for used[id] { + id++ + } + + path := filepath.Join(targetDir, strconv.Itoa(id)+".yaml") + m := map[string]any{ + "name": "New Room", + "description": "An empty room.", + } + newContent, writeErr := writeMapAsYAML(path, m) + if writeErr != nil { + writeJSON(w, map[string]any{"error": "write room: " + writeErr.Error()}) + return + } + s.world.RebuildRoomIndex(s.dataDir) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("create room %d", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + writeJSON(w, map[string]any{"room": map[string]any{"id": id, "name": "New Room"}}) +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 227201b..ef95e73 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -109,10 +109,13 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor apiMux := http.NewServeMux() apiMux.HandleFunc("/api/map", s.handleMap) apiMux.HandleFunc("/api/room-dirs", s.handleRoomDirs) + apiMux.HandleFunc("/api/room-dirs/rename", s.handleRenameRoomDir) + apiMux.HandleFunc("/api/room-dirs/create", s.handleCreateRoomDir) 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/new-empty", s.handleCreateEmptyRoom) apiMux.HandleFunc("/api/rooms/", s.handleRoomByID) apiMux.HandleFunc("/api/objects", s.handleObjects) apiMux.HandleFunc("/api/objects/", s.handleObjectByID) @@ -306,6 +309,7 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { } s.setAuthCookie(w, account) + s.undoStack.Clear() http.Redirect(w, r, "/", http.StatusFound) } @@ -430,7 +434,12 @@ func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) { return } s.rebuildAfterUndo(change) - writeJSON(w, map[string]any{"message": "Undid: " + change.ShortDesc()}) + resp := map[string]any{"message": "Undid: " + change.ShortDesc()} + dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) + if len(dups) > 0 { + resp["has_duplicates"] = true + } + writeJSON(w, resp) } func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { @@ -444,7 +453,12 @@ func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { return } s.rebuildAfterUndo(change) - writeJSON(w, map[string]any{"message": "Redid: " + change.ShortDesc()}) + resp := map[string]any{"message": "Redid: " + change.ShortDesc()} + dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) + if len(dups) > 0 { + resp["has_duplicates"] = true + } + writeJSON(w, resp) } func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 8c3ee8e..f068207 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -41,10 +41,15 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100 .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-wrapper{position:absolute;top:12px;left:12px;z-index:10} +.z-controls{position:relative;display:flex;gap:6px;align-items:center;background:var(--panel);padding:6px 10px;border-radius:4px;border:1px solid var(--border)} .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} +.z-controls .z-text-btn{width:auto} +.z-controls .z-text-btn.active{background:var(--accent);border-color:#1a5a8e} +.z-controls .z-sep{width:1px;height:20px;background:var(--border);flex-shrink:0} +.z-dir-form{display:none;position:absolute;left:0;top:calc(100% + 4px);background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px 10px;z-index:11;gap:6px;align-items:center;white-space:nowrap} .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)} diff --git a/internal/admin/static/admin.js b/internal/admin/static/admin.js index cce3bc9..f8e9d13 100644 --- a/internal/admin/static/admin.js +++ b/internal/admin/static/admin.js @@ -31,11 +31,11 @@ window.updateUndoBar = 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'); }); + API.post('/api/undo/undo').then(function(r) { notify(r.message || 'Undo complete', 'success'); updateUndoBar(); if(r.has_duplicates) { window.location = '/'; return; } 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'); }); + API.post('/api/undo/redo').then(function(r) { notify(r.message || 'Redo complete', 'success'); updateUndoBar(); if(r.has_duplicates) { window.location = '/'; return; } if(window.refreshPage)refreshPage(); }).catch(function(e) { notify('Redo failed: '+e.message, 'error'); }); }; window.addEventListener('keydown', function(e) { diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 6acec47..740edf3 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -135,6 +135,13 @@ function loadMap() { } renderMap(data); renderDisconnectedList(data); + var newRoomBtn = $('#newRoomBtn'); + var newRoomSep = $('#newRoomSep'); + if (newRoomBtn && newRoomSep) { + var empty = !data.rooms || data.rooms.length === 0; + newRoomBtn.style.display = empty ? '' : 'none'; + newRoomSep.style.display = empty ? '' : 'none'; + } if (wasDirChange && data.seed) { selectRoom(data.seed); } @@ -1716,4 +1723,116 @@ function updateDirSelect() { } } +function showRenameDir() { + if (currentDir === '') { notify('Cannot rename root', 'error'); return; } + hideNewDir(); + $('#renameDirBtn').classList.add('active'); + var form = $('#renameDirForm'); + form.style.display = 'flex'; + var base = currentDir.includes('/') ? currentDir.split('/').pop() : currentDir; + var input = $('#renameDirInput'); + input.placeholder = base; + input.value = ''; + input.focus(); +} + +function cancelRenameDir() { + $('#renameDirForm').style.display = 'none'; + $('#renameDirBtn').classList.remove('active'); +} + +function confirmRenameDir() { + var newName = $('#renameDirInput').value.trim(); + if (!newName) { notify('Enter a directory name', 'error'); return; } + if (newName.indexOf('/') !== -1 || newName.indexOf('\\') !== -1 || newName === '.' || newName === '..') { notify('Invalid directory name', 'error'); return; } + API.post('/api/room-dirs/rename', {dir: currentDir, new_name: newName}).then(function(r) { + if (r.error) { notify(r.error, 'error'); return; } + notify('Renamed to ' + newName, 'success'); + cancelRenameDir(); + currentDir = r.new_dir; + reloadRoomDirs(function() { + changeDir(r.new_dir); + }); + }).catch(function(e) { notify('Rename failed: ' + e.message, 'error'); }); +} + +function showNewDir() { + hideRenameDir(); + $('#newDirBtn').classList.add('active'); + var form = $('#newDirForm'); + form.style.display = 'flex'; + var input = $('#newDirInput'); + input.value = ''; + input.focus(); +} + +function cancelNewDir() { + $('#newDirForm').style.display = 'none'; + $('#newDirBtn').classList.remove('active'); +} + +function confirmNewDir() { + var name = $('#newDirInput').value.trim(); + if (!name) { notify('Enter a directory name', 'error'); return; } + if (name.indexOf('/') !== -1 || name.indexOf('\\') !== -1 || name === '.' || name === '..') { notify('Invalid directory name', 'error'); return; } + API.post('/api/room-dirs/create', {name: name}).then(function(r) { + if (r.error) { notify(r.error, 'error'); return; } + notify('Created ' + name, 'success'); + cancelNewDir(); + currentDir = name; + reloadRoomDirs(function() { + changeDir(name); + }); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); +} + +function onDirInputKey(event, type) { + if (event.key === 'Enter') { + event.preventDefault(); + if (type === 'rename') confirmRenameDir(); + else if (type === 'new') confirmNewDir(); + } else if (event.key === 'Escape') { + event.preventDefault(); + if (type === 'rename') cancelRenameDir(); + else if (type === 'new') cancelNewDir(); + } +} + +function createEmptyRoom() { + API.post('/api/rooms/new-empty', {dir: currentDir}).then(function(r) { + if (r.error) { notify(r.error, 'error'); return; } + notify('Created room ' + r.room.id, 'success'); + loadMap().then(function() { + if (r.room && r.room.id) selectRoom(r.room.id); + }); + }).catch(function(e) { notify('Create room failed: ' + e.message, 'error'); }); +} + +function hideRenameDir() { + $('#renameDirForm').style.display = 'none'; + $('#renameDirBtn').classList.remove('active'); +} + +function hideNewDir() { + $('#newDirForm').style.display = 'none'; + $('#newDirBtn').classList.remove('active'); +} + +function reloadRoomDirs(cb) { + API.get('/api/room-dirs').then(function(dirs) { + var sel = $('#dirSelect'); + if (sel) { + sel.innerHTML = ''; + dirs.forEach(function(d) { + var opt = document.createElement('option'); + opt.value = d; + opt.textContent = d || '(root)'; + sel.appendChild(opt); + }); + } + updateDirSelect(); + if (cb) cb(dirs); + }); +} + document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); }); diff --git a/internal/admin/templates/duplicate-rooms.html b/internal/admin/templates/duplicate-rooms.html index 4e603bb..9533460 100644 --- a/internal/admin/templates/duplicate-rooms.html +++ b/internal/admin/templates/duplicate-rooms.html @@ -64,9 +64,9 @@ function checkDups() { } function deleteDupFile(path) { - if (!confirm('Delete this file?\n\n' + path)) return; API.post('/api/rooms/resolve-duplicate', {path: path, action: 'delete'}).then(function() { notify('Deleted', 'success'); + updateUndoBar(); checkDups(); }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); @@ -77,9 +77,9 @@ function renameDupFile(path, inputId) { var el = document.getElementById(inputId); var newID = parseInt(el.value, 10); if (!newID || newID <= 0) { notify('Enter a valid new room ID', 'error'); return; } - if (!confirm('Rename this file to ID ' + newID + '?\n\n' + path)) return; API.post('/api/rooms/resolve-duplicate', {path: path, action: 'rename', new_id: newID}).then(function() { notify('Renamed to ' + newID, 'success'); + updateUndoBar(); checkDups(); }).catch(function(e) { notify('Rename failed: ' + e.message, 'error'); @@ -89,5 +89,6 @@ function renameDupFile(path, inputId) { function esc(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>'); } function escAttr(s) { return String(s || '').replace(/&/g,'&').replace(/"/g,'"'); } checkDups(); +window.refreshPage = checkDups; {{end}} diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html index 3bcbb30..1e45f8e 100644 --- a/internal/admin/templates/map.html +++ b/internal/admin/templates/map.html @@ -7,20 +7,38 @@