aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_map.go71
-rw-r--r--internal/admin/api_rooms.go108
-rw-r--r--internal/admin/server.go27
-rw-r--r--internal/admin/static/map.js210
-rw-r--r--internal/admin/templates/duplicate-rooms.html93
-rw-r--r--internal/admin/templates/layout.html1
6 files changed, 423 insertions, 87 deletions
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index e546a39..e0476b3 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -38,7 +38,76 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
return nil, false
}
return room, true
- }, nil, nil)
+ }, nil, func(gc world.GridConflict) {
+ if gc.Kind == "overlap" {
+ log.Printf("map: grid overlap: room %d wants cell %v already held by %d (via %d->%s)",
+ gc.Target, gc.Want, gc.Occupier, gc.From, gc.Dir)
+ }
+ })
+
+ // Discover all rooms reachable from the seed (including ones dropped by grid overlaps).
+ reachable := map[int]bool{seed: true}
+ queue := []int{seed}
+ for len(queue) > 0 {
+ id := queue[0]
+ queue = queue[1:]
+ room, err := s.world.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ for _, exit := range room.Exits {
+ if exit.Room > 0 && !reachable[exit.Room] {
+ reachable[exit.Room] = true
+ queue = append(queue, exit.Room)
+ }
+ }
+ }
+
+ // Place any reachable room missing from the grid at a nearby free cell.
+ for id := range reachable {
+ if _, ok := g.Coord[id]; ok {
+ continue
+ }
+ room, err := s.world.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ // Try adjacent to a connected neighbour already on grid.
+ for _, exit := range room.Exits {
+ if exit.Room <= 0 {
+ continue
+ }
+ nc, ok := g.Coord[exit.Room]
+ if !ok {
+ continue
+ }
+ for dx := -1; dx <= 1; dx++ {
+ for dy := -1; dy <= 1; dy++ {
+ if dx == 0 && dy == 0 {
+ continue
+ }
+ want := [3]int{nc[0] + dx, nc[1] + dy, nc[2]}
+ if _, used := g.RoomAt[want]; !used {
+ g.Coord[id] = want
+ g.RoomAt[want] = id
+ goto placed
+ }
+ }
+ }
+ }
+ // Fallback: scan a generous rectangle for a free cell.
+ for sx := -10; sx <= 10; sx++ {
+ for sy := -10; sy <= 10; sy++ {
+ want := [3]int{sx, sy, z}
+ if _, used := g.RoomAt[want]; !used {
+ g.Coord[id] = want
+ g.RoomAt[want] = id
+ goto placed
+ }
+ }
+ }
+ placed:
+ }
type RoomEntry struct {
ID int `json:"id"`
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index cf23761..d1193f0 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/world"
)
@@ -736,3 +737,110 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
})
writeJSON(w, map[string]any{"ok": true})
}
+
+func (s *AdminServer) handleDuplicateRooms(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeJSON(w, map[string]any{"error": "method not allowed"})
+ return
+ }
+ dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms"))
+ type DupEntry struct {
+ ID string `json:"id"`
+ Paths []string `json:"paths"`
+ }
+ result := make([]DupEntry, len(dups))
+ for i, d := range dups {
+ result[i] = DupEntry{ID: d.ID, Paths: d.Paths}
+ }
+ writeJSON(w, map[string]any{"duplicates": result})
+}
+
+func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeJSON(w, map[string]any{"error": "method not allowed"})
+ return
+ }
+ var body struct {
+ Path string `json:"path"`
+ Action string `json:"action"`
+ NewID int `json:"new_id"`
+ }
+ if err := readJSON(r, &body); err != nil || body.Path == "" || body.Action == "" {
+ writeJSON(w, map[string]any{"error": "invalid body"})
+ return
+ }
+
+ roomsDir := filepath.Join(s.dataDir, "rooms")
+ absRooms, _ := filepath.Abs(roomsDir)
+ absPath, err := filepath.Abs(body.Path)
+ if err != nil || !strings.HasPrefix(absPath, absRooms+string(filepath.Separator)) {
+ writeJSON(w, map[string]any{"error": "invalid path"})
+ return
+ }
+
+ switch body.Action {
+ case "delete":
+ old, _ := snapshotFile(absPath)
+ if err := os.Remove(absPath); err != nil {
+ writeJSON(w, map[string]any{"error": "delete failed: " + err.Error()})
+ return
+ }
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("delete duplicate room file %s", body.Path),
+ FilePath: absPath,
+ OldContent: old,
+ IsDelete: true,
+ })
+ writeJSON(w, map[string]any{"ok": true})
+
+ case "rename":
+ if body.NewID <= 0 {
+ writeJSON(w, map[string]any{"error": "new_id must be positive"})
+ return
+ }
+ if _, ok := s.world.GetRoomPath(body.NewID); ok {
+ writeJSON(w, map[string]any{"error": "room ID already exists"})
+ return
+ }
+ newRel := filepath.Join(filepath.Dir(absPath), strconv.Itoa(body.NewID)+".yaml")
+ if _, err := os.Stat(newRel); err == nil {
+ writeJSON(w, map[string]any{"error": "target file already exists"})
+ return
+ }
+
+ oldContent, err := os.ReadFile(absPath)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "read failed: " + err.Error()})
+ return
+ }
+ m, err := yamlToMap(oldContent)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "yaml parse failed: " + err.Error()})
+ return
+ }
+ m["id"] = body.NewID
+
+ newContent, err := writeMapAsYAML(newRel, m)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write failed: " + err.Error()})
+ return
+ }
+ if err := os.Remove(absPath); err != nil {
+ writeJSON(w, map[string]any{"error": "remove old failed: " + err.Error()})
+ return
+ }
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("rename duplicate room %s to ID %d", body.Path, body.NewID),
+ FilePath: absPath,
+ NewFilePath: newRel,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+ writeJSON(w, map[string]any{"ok": true})
+
+ default:
+ writeJSON(w, map[string]any{"error": "unknown action: " + body.Action})
+ }
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index ff2bebd..227201b 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -24,6 +24,7 @@ import (
"strings"
"time"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/config"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/object"
@@ -138,6 +139,8 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/undo/redo", s.doRedo)
apiMux.HandleFunc("/api/next-room-id", s.handleNextRoomID)
apiMux.HandleFunc("/api/files", s.handleFiles)
+ apiMux.HandleFunc("/api/duplicate-rooms", s.handleDuplicateRooms)
+ apiMux.HandleFunc("/api/rooms/resolve-duplicate", s.handleResolveDuplicate)
mux.Handle("/api/", s.authMiddleware(apiMux))
@@ -150,6 +153,18 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
if page == "" {
page = "map"
}
+ if page == "map" {
+ dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms"))
+ if len(dups) > 0 {
+ data := map[string]any{
+ "Account": s.accountFromCookie(r),
+ "UndoStack": s.undoStack.Info(),
+ "Page": "duplicate-rooms",
+ }
+ s.renderPage(w, r, "layout", data)
+ return
+ }
+ }
data := map[string]any{
"Account": s.accountFromCookie(r),
"UndoStack": s.undoStack.Info(),
@@ -164,6 +179,18 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
return
}
page := strings.TrimPrefix(r.URL.Path, "/editor/")
+ if page == "map" {
+ dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms"))
+ if len(dups) > 0 {
+ data := map[string]any{
+ "Account": s.accountFromCookie(r),
+ "UndoStack": s.undoStack.Info(),
+ "Page": "duplicate-rooms",
+ }
+ s.renderPage(w, r, "layout", data)
+ return
+ }
+ }
data := map[string]any{
"Account": s.accountFromCookie(r),
"UndoStack": s.undoStack.Info(),
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 95bde7e..6acec47 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -17,8 +17,83 @@ var gridDirs = [
var saveTimer = null;
var upTarget = {}, downTarget = {};
+var _mapMouseCleanup = false;
+
+function onMapMouseMove(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);
+ }
+}
+
+function onMapMouseUp(e) {
+ if (!dragging) return;
+ document.removeEventListener('mousemove', onMapMouseMove);
+ document.removeEventListener('mouseup', onMapMouseUp);
+ _mapMouseCleanup = false;
+
+ 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')) {
+ createRoom(selectedRoom, e.target.getAttribute('data-dir'));
+ }
+ }
+
+ clearLinkHighlight();
+ clearDelHighlight();
+ dragging = false; dragRoom = false;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+}
+
+function cancelMapDrag() {
+ if (!dragging) return;
+ document.removeEventListener('mousemove', onMapMouseMove);
+ document.removeEventListener('mouseup', onMapMouseUp);
+ _mapMouseCleanup = false;
+ clearLinkHighlight();
+ clearDelHighlight();
+ dragging = false; dragRoom = false;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+}
+
function changeZ(dz) {
if (dz === 0) currentZ = 0; else currentZ += dz;
+ panX = 0; panY = 0; scale = 1;
loadMap();
}
@@ -26,6 +101,7 @@ function changeDir(dir) {
currentDir = dir;
dirChangedByUser = true;
selectedRoom = null;
+ panX = 0; panY = 0; scale = 1;
loadMap();
}
@@ -49,10 +125,14 @@ function loadMap() {
dirChangedByUser = false;
return API.get(url).then(function(data) {
mapData = data;
+ var dirUpdated = false;
if (!currentDir && data.dir) {
currentDir = data.dir;
+ dirUpdated = true;
+ }
+ if (dirUpdated || wasDirChange) {
+ updateDirSelect();
}
- updateDirSelect();
renderMap(data);
renderDisconnectedList(data);
if (wasDirChange && data.seed) {
@@ -191,6 +271,9 @@ function renderMap(data) {
prevX = e.clientX; prevY = e.clientY;
dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null;
clearLinkHighlight();
+ document.addEventListener('mousemove', onMapMouseMove);
+ document.addEventListener('mouseup', onMapMouseUp);
+ _mapMouseCleanup = true;
return;
}
@@ -213,72 +296,9 @@ function renderMap(data) {
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;
+ document.addEventListener('mousemove', onMapMouseMove);
+ document.addEventListener('mouseup', onMapMouseUp);
+ _mapMouseCleanup = true;
};
svg.onwheel = function(e) {
@@ -337,19 +357,8 @@ function selectRoom(id) {
var prev = selectedRoom;
selectedRoom = id;
- if (mapData && roomMap) {
- if (prev && prev !== id) {
- var prevEl = document.querySelector('.rm[data-id="'+prev+'"]');
- if (prevEl) {
- prevEl.setAttribute('stroke', prevEl.getAttribute('fill') || '#3a3a6a');
- prevEl.setAttribute('stroke-width', '1.5');
- }
- }
- var el = document.querySelector('.rm[data-id="'+id+'"]');
- if (el) {
- el.setAttribute('stroke', '#fff');
- el.setAttribute('stroke-width', '3');
- }
+ if (mapData && prev !== id) {
+ renderMap(mapData);
}
API.get('/api/rooms/' + id).then(function(data) {
@@ -360,6 +369,7 @@ function selectRoom(id) {
}
var currentRoomData = null;
+var lastDcDir = null;
function renderSidePanel(data) {
var panel = $('#panelContent');
@@ -406,9 +416,15 @@ function renderSidePanel(data) {
if (ci) ci.addEventListener('input', function() { autoSave(); });
}
loadRoomDirs(function(dirs) {
+ var current = getRoomDirFromFile(file);
+ var areaSel = $('#dirSelect');
+ if (areaSel && current) {
+ areaSel.value = current;
+ loadDisconnectedRooms(current);
+ }
+
var sel = $('#roomDir');
if (!sel) return;
- var current = getRoomDirFromFile(file);
sel.innerHTML = '';
dirs.forEach(function(d) {
var opt = document.createElement('option');
@@ -1329,7 +1345,8 @@ function mouseToGrid(e) {
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;
+ mx = Math.max(0, Math.min(mx, rect.width - 1));
+ my = Math.max(0, Math.min(my, rect.height - 1));
var vb = svgEl.getAttribute('viewBox');
if (!vb) return null;
var parts = vb.split(' ');
@@ -1461,9 +1478,15 @@ function createLink(fromId, toId) {
API.post('/api/rooms/link', {from: fromId, to: toId}).then(function() {
notify('Linked rooms #'+fromId+' '+toId, 'success');
updateUndoBar();
- loadMap().then(function() {
- if (selectedRoom) selectRoom(selectedRoom);
- });
+ if (mapData && mapData.rooms) {
+ var fr = mapData.rooms.find(function(r) { return r.id === fromId; });
+ var tr = mapData.rooms.find(function(r) { return r.id === toId; });
+ if (fr && tr) {
+ mapData.links = mapData.links || [];
+ mapData.links.push({from: fromId, to: toId, dir: gridDeltaToDir(tr.x - fr.x, tr.y - fr.y) || '', bidirectional: true});
+ renderMap(mapData);
+ }
+ }
}).catch(function(e) {
notify('Link failed: ' + e.message, 'error');
});
@@ -1554,7 +1577,12 @@ function deleteLink(fromId, toId) {
}).then(function() {
notify('Removed link #'+fromId+' '+toId, 'success');
updateUndoBar();
- loadMap();
+ if (mapData && mapData.links) {
+ mapData.links = mapData.links.filter(function(l) {
+ return !((l.from === fromId && l.to === toId) || (l.from === toId && l.to === fromId));
+ });
+ renderMap(mapData);
+ }
}).catch(function(e) {
notify('Delete link failed: ' + e.message, 'error');
});
@@ -1579,6 +1607,16 @@ function renderDisconnectedList(data) {
}).join('');
}
+function loadDisconnectedRooms(dir) {
+ if (dir === lastDcDir) return;
+ lastDcDir = dir;
+ var url = '/api/map?z=' + currentZ;
+ if (dir) url += '&dir=' + encodeURIComponent(dir);
+ API.get(url).then(function(data) {
+ renderDisconnectedList(data);
+ });
+}
+
function showDcContextMenu(e, id) {
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
diff --git a/internal/admin/templates/duplicate-rooms.html b/internal/admin/templates/duplicate-rooms.html
new file mode 100644
index 0000000..4e603bb
--- /dev/null
+++ b/internal/admin/templates/duplicate-rooms.html
@@ -0,0 +1,93 @@
+{{define "body-duplicate-rooms"}}
+<style>
+.dup-body{max-width:800px;margin:40px auto;font-family:monospace}
+.dup-body h1{color:var(--danger)}
+.dup-body p.hint{color:#aaa;margin-bottom:20px;line-height:1.6}
+.dup-group{border:1px solid var(--border);border-radius:5px;margin-bottom:12px;overflow:hidden}
+.dup-group-header{background:rgba(192,57,43,.15);padding:8px 12px;font-size:13px;font-weight:bold;color:var(--danger)}
+.dup-row{display:flex;align-items:center;gap:8px;padding:6px 12px;border-top:1px solid rgba(255,255,255,.03);font-size:11px}
+.dup-row .dup-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
+.dup-row .dup-path code{font-size:10px;color:#6cf}
+.dup-row input{width:80px;padding:3px 5px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.dup-row button{padding:3px 8px;font-size:11px;white-space:nowrap}
+.dup-actions{margin:20px 0;display:flex;gap:8px;align-items:center}
+.dup-actions .dup-count{color:#888;font-size:11px;flex:1}
+.dup-none{color:var(--success);font-size:14px;text-align:center;margin:30px 0}
+</style>
+<div class="dup-body">
+ <h1>Duplicate Room IDs Detected</h1>
+ <p class="hint">
+ The following room IDs appear in multiple files. This corrupts the map and makes
+ the editor unreliable. Delete extra copies or rename them to new unique IDs.
+ </p>
+ <div id="dupList"><p style="color:#888;text-align:center">Loading…</p></div>
+ <div class="dup-actions">
+ <button class="btn btn-primary" onclick="checkDups()">Re-check</button>
+ <span class="dup-count" id="dupCount"></span>
+ <button class="btn btn-success" id="continueBtn" style="display:none" onclick="window.location='/'">Continue to Map Editor</button>
+ </div>
+ <div id="dupMsg" style="margin-top:12px"></div>
+</div>
+<script>
+function checkDups() {
+ var listEl = document.getElementById('dupList');
+ listEl.innerHTML = '<p style="color:#888;text-align:center">Loading…</p>';
+ API.get('/api/duplicate-rooms').then(function(data) {
+ var dups = data.duplicates || [];
+ document.getElementById('dupCount').textContent = dups.length + ' duplicate group' + (dups.length !== 1 ? 's' : '');
+ var continueBtn = document.getElementById('continueBtn');
+ continueBtn.style.display = dups.length === 0 ? 'inline-block' : 'none';
+
+ if (dups.length === 0) {
+ document.getElementById('dupMsg').innerHTML = '';
+ listEl.innerHTML = '<div class="dup-none">No duplicate room IDs found.</div>';
+ return;
+ }
+ var html = '';
+ dups.forEach(function(d) {
+ html += '<div class="dup-group"><div class="dup-group-header">Room ID: ' + esc(d.id) + ' (' + d.paths.length + ' copies)</div>';
+ d.paths.forEach(function(p, idx) {
+ var short = p.replace(/^.*\/data\/rooms\//, '');
+ html += '<div class="dup-row">';
+ html += '<span class="dup-path"><code>' + esc(short) + '</code></span>';
+ html += '<button class="btn btn-sm btn-danger" onclick="deleteDupFile(\'' + escAttr(p) + '\')">Delete</button>';
+ html += '<input type="number" id="rename-' + escAttr(d.id) + '-' + idx + '" placeholder="new ID" style="width:80px">';
+ html += '<button class="btn btn-sm btn-primary" onclick="renameDupFile(\'' + escAttr(p) + '\',\'rename-' + escAttr(d.id) + '-' + idx + '\')">Rename</button>';
+ html += '</div>';
+ });
+ html += '</div>';
+ });
+ listEl.innerHTML = html;
+ }).catch(function(e) {
+ listEl.innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+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');
+ checkDups();
+ }).catch(function(e) {
+ notify('Delete failed: ' + e.message, 'error');
+ });
+}
+
+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');
+ checkDups();
+ }).catch(function(e) {
+ notify('Rename failed: ' + e.message, 'error');
+ });
+}
+
+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();
+</script>
+{{end}}
diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html
index ed939bd..410c8df 100644
--- a/internal/admin/templates/layout.html
+++ b/internal/admin/templates/layout.html
@@ -42,6 +42,7 @@
{{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 if eq .Page "duplicate-rooms"}}{{template "body-duplicate-rooms" .}}
{{else}}<p style="color:#888;text-align:center;margin-top:60px">Unknown page: {{.Page}}</p>{{end}}
</body>
</html>