aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_map.go12
-rw-r--r--internal/admin/api_rooms.go145
-rw-r--r--internal/admin/server.go18
-rw-r--r--internal/admin/static/admin.css7
-rw-r--r--internal/admin/static/admin.js4
-rw-r--r--internal/admin/static/map.js119
-rw-r--r--internal/admin/templates/duplicate-rooms.html5
-rw-r--r--internal/admin/templates/map.html46
-rw-r--r--internal/admin/undo.go9
-rw-r--r--internal/validate/checks.go33
10 files changed, 366 insertions, 32 deletions
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s) { return String(s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;'); }
checkDups();
+window.refreshPage = checkDups;
</script>
{{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 @@
</div>
</div>
<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;padding:0 10px;min-width:44px">Reset</button>
- <span style="font-size:11px;color:#aaa;margin-left:8px">Area:</span>
- <select id="dirSelect" onchange="changeDir(this.value)" style="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>
- <span style="font-size:11px;color:#aaa;margin-left:8px">Zoom:</span>
- <button onclick="zoomIn()" title="Zoom in" style="width:auto;font-size:14px;padding:0 6px;min-width:32px">&#x2795;</button>
- <button onclick="zoomOut()" title="Zoom out" style="width:auto;font-size:14px;padding:0 6px;min-width:32px">&#x2796;</button>
- <label style="font-size:11px;color:#ccc;cursor:pointer;display:flex;align-items:center;gap:4px;margin-left:8px" title="When unchecked, skips the confirm dialog when deleting rooms">
- <input type="checkbox" id="confirmDelete" style="cursor:pointer">
- Confirm before delete
- </label>
+ <div class="z-controls-wrapper">
+ <div class="z-controls">
+ <span style="font-size:11px;color:#aaa">Area:</span>
+ <select id="dirSelect" onchange="changeDir(this.value)" style="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>
+ <button id="renameDirBtn" class="z-text-btn" onclick="showRenameDir()" style="font-size:11px;padding:0 10px;min-width:44px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer;font-family:monospace">Rename</button>
+ <button id="newDirBtn" class="z-text-btn" onclick="showNewDir()" style="font-size:11px;padding:0 10px;min-width:44px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer;font-family:monospace">New</button>
+ <span class="z-sep"></span>
+ <button id="newRoomBtn" class="z-text-btn" onclick="createEmptyRoom()" style="display:none;font-size:11px;padding:0 10px;min-width:44px;background:var(--danger);color:#fff;border:1px solid var(--danger);border-radius:3px;cursor:pointer;font-family:monospace">New Room</button>
+ <span class="z-sep" id="newRoomSep" style="display:none"></span>
+ <button onclick="changeZ(-1)">-</button>
+ <span class="z-label" id="zLabel">Z=0</span>
+ <button onclick="changeZ(1)">+</button>
+ <button class="z-text-btn" onclick="changeZ(0)" style="width:auto;font-size:11px;padding:0 10px;min-width:44px">Reset</button>
+ <span style="font-size:11px;color:#aaa;margin-left:8px">Zoom:</span>
+ <button onclick="zoomIn()" title="Zoom in" style="width:auto;font-size:14px;padding:0 6px;min-width:32px">&#x2795;</button>
+ <button onclick="zoomOut()" title="Zoom out" style="width:auto;font-size:14px;padding:0 6px;min-width:32px">&#x2796;</button>
+ <span class="z-sep"></span>
+ <label style="font-size:11px;color:#ccc;cursor:pointer;display:flex;align-items:center;gap:4px;margin-left:8px" title="When unchecked, skips the confirm dialog when deleting rooms">
+ <input type="checkbox" id="confirmDelete" style="cursor:pointer">
+ Confirm before delete
+ </label>
+ <div id="renameDirForm" class="z-dir-form">
+ <input id="renameDirInput" type="text" onkeydown="onDirInputKey(event,'rename')" style="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;width:160px">
+ <button class="z-text-btn" onclick="confirmRenameDir()" style="font-size:11px;padding:0 8px;min-width:44px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:3px;cursor:pointer;font-family:monospace">Confirm</button>
+ <button class="z-text-btn" onclick="cancelRenameDir()" style="font-size:11px;padding:0 8px;min-width:44px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer;font-family:monospace">Cancel</button>
+ </div>
+ <div id="newDirForm" class="z-dir-form">
+ <input id="newDirInput" type="text" placeholder="area name" onkeydown="onDirInputKey(event,'new')" style="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;width:160px">
+ <button class="z-text-btn" onclick="confirmNewDir()" style="font-size:11px;padding:0 8px;min-width:44px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:3px;cursor:pointer;font-family:monospace">Confirm</button>
+ <button class="z-text-btn" onclick="cancelNewDir()" style="font-size:11px;padding:0 8px;min-width:44px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer;font-family:monospace">Cancel</button>
+ </div>
+ </div>
</div>
<svg id="mapSvg" style="width:100%;height:100%"></svg>
<div class="room-tooltip" id="tooltip" style="display:none"></div>
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index fb91a15..f69fd9f 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -330,3 +330,12 @@ func (us *UndoStack) load() {
us.history = fromEntries(saveData.History)
log.Printf("undo: loaded %d history entries", len(us.history))
}
+
+func (us *UndoStack) Clear() {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ us.history = nil
+ us.redo = nil
+ os.Remove(us.filePath)
+ log.Printf("undo: cleared history")
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index ccf996c..974998a 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -1024,6 +1024,39 @@ func validateRoomGrid(s Source) []Issue {
}
}
+ onConf := func(origin int) func(c world.GridConflict) {
+ return func(c world.GridConflict) {
+ switch c.Kind {
+ case "twist":
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid twist: room %d (via %d %s) maps to (%d,%d,%d) but was already placed at (%d,%d,%d) [origin %d]",
+ c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Existing[0], c.Existing[1], c.Existing[2], origin),
+ })
+ case "overlap":
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid overlap: room %d (via %d %s) wants (%d,%d,%d), already used by room %d [origin %d]",
+ c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Occupier, origin),
+ })
+ }
+ }
+ }
+
+ for id := range roomIndex {
+ if placed[id] {
+ continue
+ }
+ grid := world.BuildGrid(id, load, include, onConf(id))
+ for rid := range grid.Coord {
+ placed[rid] = true
+ }
+ }
+
return issues
}