diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_map.go | 71 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 108 | ||||
| -rw-r--r-- | internal/admin/server.go | 27 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 210 | ||||
| -rw-r--r-- | internal/admin/templates/duplicate-rooms.html | 93 | ||||
| -rw-r--r-- | internal/admin/templates/layout.html | 1 |
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,'&').replace(/</g,'<').replace(/>/g,'>'); } +function escAttr(s) { return String(s || '').replace(/&/g,'&').replace(/"/g,'"'); } +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> |
