aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_rooms.go177
-rw-r--r--internal/admin/api_search.go12
-rw-r--r--internal/admin/static/admin.css10
-rw-r--r--internal/admin/static/map.js263
-rw-r--r--internal/admin/templates/layout.html2
-rw-r--r--internal/admin/templates/map.html10
6 files changed, 320 insertions, 154 deletions
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 4075534..b60aefc 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -228,8 +228,36 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
s.world.RebuildRoomIndex(s.dataDir)
s.world.ClearRoomState(id)
+ allIDs, _ := listRoomIDs(s.dataDir)
+ var exitCleaned int
+ for _, otherID := range allIDs {
+ if otherID == id {
+ continue
+ }
+ otherRoom, loadErr := s.world.LoadRoom(otherID)
+ if loadErr != nil {
+ continue
+ }
+ changed := false
+ for _, dir := range world.ExitOrder {
+ exit, exists := otherRoom.Exits[dir]
+ if !exists || exit.Room != id {
+ continue
+ }
+ delete(otherRoom.Exits, dir)
+ changed = true
+ }
+ if changed {
+ otherPath, pathOk := s.world.GetRoomPath(otherID)
+ if pathOk {
+ writeYAMLFile(otherPath, otherRoom)
+ exitCleaned++
+ }
+ }
+ }
+
s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("delete room %d", id),
+ Description: fmt.Sprintf("delete room %d (cleaned %d exits)", id, exitCleaned),
FilePath: path,
OldContent: oldContent,
IsDelete: true,
@@ -477,25 +505,29 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
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"})
+
+ if errA != nil && errB != nil {
+ writeJSON(w, map[string]any{"error": "both rooms not found"})
return
}
- if roomA == nil || roomB == nil {
- writeJSON(w, map[string]any{"error": "one or both rooms not found"})
- return
+ if (errA != nil && roomA == nil) {
+ roomA = nil
+ }
+ if (errB != nil && roomB == nil) {
+ roomB = nil
}
var dir, oppDir world.ExitDir
- for _, d := range world.ExitOrder {
- if exit, ok := roomA.Exits[d]; ok && exit.Room == body.To {
- dir = d
- break
+ if roomA != nil {
+ 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 != "" {
- oppDir = world.OppositeExit[dir]
- } else {
+ if dir == "" && roomB != nil {
for _, d := range world.ExitOrder {
if exit, ok := roomB.Exits[d]; ok && exit.Room == body.From {
oppDir = d
@@ -505,49 +537,58 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
}
}
- if dir == "" && oppDir == "" {
- writeJSONError(w, "no exit found between these rooms", http.StatusNotFound)
- return
- }
-
- if roomA.Exits != nil {
+ if roomA != nil && roomA.Exits != nil {
delete(roomA.Exits, dir)
}
- if roomB.Exits != nil {
+ if roomB != nil && roomB.Exits != nil {
delete(roomB.Exits, oppDir)
}
- pathA, okA2 := s.world.GetRoomPath(body.From)
- pathB, okB2 := s.world.GetRoomPath(body.To)
- if !okA2 || !okB2 {
- writeJSON(w, map[string]any{"error": "room path not found"})
- return
- }
+ var pushEntries []ChangeDesc
- oldA, _ := snapshotFile(pathA)
- oldB, _ := snapshotFile(pathB)
- newA, err := writeYAMLFile(pathA, roomA)
- if err != nil {
- writeJSON(w, map[string]any{"error": "write A: " + err.Error()})
- return
+ if roomA != nil {
+ pathA, okA2 := s.world.GetRoomPath(body.From)
+ if !okA2 {
+ writeJSON(w, map[string]any{"error": "room A path not found"})
+ return
+ }
+ oldA, _ := snapshotFile(pathA)
+ newA, err := writeYAMLFile(pathA, roomA)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write A: " + err.Error()})
+ return
+ }
+ pushEntries = append(pushEntries, ChangeDesc{
+ Description: fmt.Sprintf("unlink %d %s %d", body.From, dir, body.To),
+ FilePath: pathA,
+ OldContent: oldA,
+ NewContent: newA,
+ })
+ }
+
+ if roomB != nil {
+ pathB, okB2 := s.world.GetRoomPath(body.To)
+ if !okB2 {
+ writeJSON(w, map[string]any{"error": "room B path not found"})
+ return
+ }
+ oldB, _ := snapshotFile(pathB)
+ newB, err := writeYAMLFile(pathB, roomB)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write B: " + err.Error()})
+ return
+ }
+ pushEntries = append(pushEntries, ChangeDesc{
+ Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, body.From),
+ FilePath: pathB,
+ OldContent: oldB,
+ NewContent: newB,
+ })
}
- newB, err := writeYAMLFile(pathB, roomB)
- if err != nil {
- writeJSON(w, map[string]any{"error": "write B: " + err.Error()})
- return
+
+ for _, entry := range pushEntries {
+ s.undoStack.Push(entry)
}
- s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("unlink %d %s %d", body.From, dir, body.To),
- FilePath: pathA,
- OldContent: oldA,
- NewContent: newA,
- })
- s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, body.From),
- FilePath: pathB,
- OldContent: oldB,
- NewContent: newB,
- })
writeJSON(w, map[string]any{"ok": true})
default:
@@ -631,12 +672,19 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "room not found"})
return
}
+
+ if _, ok := s.world.GetRoomPath(body.NewID); ok {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)})
+ return
+ }
+
dir := filepath.Dir(oldPath)
newPath := filepath.Join(dir, strconv.Itoa(body.NewID)+".yaml")
if _, err := os.Stat(newPath); err == nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)})
return
}
+
data, err := os.ReadFile(oldPath)
if err != nil {
writeJSON(w, map[string]any{"error": "read: " + err.Error()})
@@ -650,10 +698,41 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "remove old: " + err.Error()})
return
}
+
s.world.RebuildRoomIndex(s.dataDir)
s.world.ClearRoomState(body.ID)
+
+ allIDs, _ := listRoomIDs(s.dataDir)
+ var exitUpdates int
+ for _, otherID := range allIDs {
+ if otherID == body.NewID {
+ continue
+ }
+ otherRoom, loadErr := s.world.LoadRoom(otherID)
+ if loadErr != nil {
+ continue
+ }
+ changed := false
+ for _, dir := range world.ExitOrder {
+ exit, exists := otherRoom.Exits[dir]
+ if !exists || exit.Room != body.ID {
+ continue
+ }
+ exit.Room = body.NewID
+ otherRoom.Exits[dir] = exit
+ changed = true
+ }
+ if changed {
+ otherPath, pathOk := s.world.GetRoomPath(otherID)
+ if pathOk {
+ writeYAMLFile(otherPath, otherRoom)
+ exitUpdates++
+ }
+ }
+ }
+
s.undoStack.Push(ChangeDesc{
- Description: fmt.Sprintf("rename room %d to %d", body.ID, body.NewID),
+ Description: fmt.Sprintf("rename room %d to %d (updated %d exits)", body.ID, body.NewID, exitUpdates),
FilePath: oldPath,
NewFilePath: newPath,
OldContent: data,
diff --git a/internal/admin/api_search.go b/internal/admin/api_search.go
index d47dc0e..3367a52 100644
--- a/internal/admin/api_search.go
+++ b/internal/admin/api_search.go
@@ -28,6 +28,7 @@ func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) {
Items []searchResult `json:"items"`
Mobs []searchResult `json:"mobs"`
Objects []searchResult `json:"objects"`
+ Hazards []searchResult `json:"hazards"`
}
var resp searchResponse
@@ -78,5 +79,16 @@ func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
+ hazardIDs, _ := listYAMLFiles(s.dataDir, "hazards")
+ for _, id := range hazardIDs {
+ h, err := s.world.LoadHazard(id)
+ if err != nil {
+ continue
+ }
+ if strings.Contains(strings.ToLower(h.Name), q) || strings.Contains(strings.ToLower(id), q) {
+ resp.Hazards = append(resp.Hazards, searchResult{ID: id, Name: h.Name})
+ }
+ }
+
writeJSON(w, resp)
}
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 684a9c6..6a83664 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -8,16 +8,18 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.nav .undo-bar button{padding:3px 8px;font-size:12px;border:1px solid var(--border);border-radius:3px;background:var(--input-bg);color:var(--text);cursor:pointer}
.nav .undo-bar button:disabled{opacity:.4;cursor:default}
.nav .undo-bar button:not(:disabled):hover{background:var(--accent)}
-.nav .undo-bar span{color:#888}
+.nav .undo-bar span{color:#888;flex:1;text-align:right;margin-right:8px}
.layout{display:flex;height:calc(100vh - 38px)}
-.main{flex:1;overflow:auto;position:relative}
-.panel{width:580px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0;position:relative}
+.main{flex:1;overflow:auto;position:relative;user-select:none;-webkit-user-select:none}
+.panel{width:800px;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}
.form-group input,.form-group textarea,.form-group select{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.form-group input[type=number]{-moz-appearance:textfield}
+.form-group input[type=number]::-webkit-inner-spin-button,.form-group input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.form-group textarea{resize:vertical;min-height:60px}
.form-group input[type=checkbox]{width:auto;margin-right:6px}
.btn{padding:6px 14px;border:none;border-radius:3px;cursor:pointer;font-size:12px;font-weight:bold;font-family:monospace}
@@ -124,6 +126,8 @@ g.ud-hover:hover text{font-weight:bold}
.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 input[type=number]{-moz-appearance:textfield}
+.exit-add input[type=number]::-webkit-inner-spin-button,.exit-add input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.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)}
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index ca454a0..cd53bed 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -127,23 +127,22 @@ function renderMap(data) {
var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff';
var sel = selectedRoom === r.id;
roomHTML += '<rect class="rm" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="5" fill="'+fill+'" stroke="'+(sel?'#fff':fill)+'" stroke-width="'+(sel?3:1.5)+'" data-id="'+r.id+'" data-name="'+esc(r.name)+'"/>';
+
var dimColor = hexLuminance(fill) > 0.35 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.4)';
- roomHTML += '<text x="'+(x+w/2)+'" y="'+(y+h/2-10)+'" text-anchor="middle" font-size="8" fill="'+dimColor+'" font-family="monospace" pointer-events="none">#'+r.id+'</text>';
- roomHTML += wrapText(r.name, x+w/2, y+h/2, w, txtColor);
+ roomHTML += '<text x="'+(x+w/2)+'" y="'+(y+14)+'" text-anchor="middle" font-size="8" fill="'+dimColor+'" font-family="monospace" pointer-events="none">#'+r.id+'</text>';
+ roomHTML += wrapText(r.name, x+w/2, y+h/2+2, w, txtColor);
- var btnY = y+h-4;
- var topY = y+4;
if (hasUp[r.id] && upTarget[r.id]) {
- roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+3)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="1" data-target="'+upTarget[r.id]+'" title="Go up to #'+upTarget[r.id]+'">\u2191</text></g>';
+ roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+2)+'" y="'+(y+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+9)+'" y="'+(y+12)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="1" data-target="'+upTarget[r.id]+'" title="Go up to #'+upTarget[r.id]+'">\u2191</text></g>';
}
if (hasDown[r.id] && downTarget[r.id]) {
- roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+w-17)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+w-10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="-1" data-target="'+downTarget[r.id]+'" title="Go down to #'+downTarget[r.id]+'">\u2193</text></g>';
+ roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+w-16)+'" y="'+(y+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+w-9)+'" y="'+(y+12)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="-1" data-target="'+downTarget[r.id]+'" title="Go down to #'+downTarget[r.id]+'">\u2193</text></g>';
}
if (sel) {
- var btnX = x + 6, btnBW = 18;
- roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(btnX-2)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(btnX+btnBW/2-2)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="up" title="Create room up">+\u2191</text></g>';
- roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(x+w-20)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(x+w-11)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="down" title="Create room down">+\u2193</text></g>';
+ var btnBW = 18;
+ roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(x+2)+'" y="'+(y+h-16)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(x+2+btnBW/2)+'" y="'+(y+h-5)+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="up" title="Create room up">+\u2191</text></g>';
+ roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(x+w-btnBW-2)+'" y="'+(y+h-16)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(x+w-btnBW/2-2)+'" y="'+(y+h-5)+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="down" title="Create room down">+\u2193</text></g>';
}
});
}
@@ -163,8 +162,10 @@ function renderMap(data) {
var innerHTML = g + roomHTML + ghostHTML;
var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale];
- var svgHTML = '<svg width="'+W+'" height="'+H+'" viewBox="'+vb.join(' ')+'" xmlns="'+svgNS+'"><g>'+innerHTML+'</g></svg>';
- svg.innerHTML = svgHTML;
+ svg.setAttribute('viewBox', vb.join(' '));
+ svg.setAttribute('width', W);
+ svg.setAttribute('height', H);
+ svg.innerHTML = '<g>' + innerHTML + '</g>';
svg.addEventListener('contextmenu', function(e) { e.preventDefault(); });
@@ -292,18 +293,6 @@ function renderMap(data) {
updateView();
};
- svg.querySelectorAll('rect.rm').forEach(function(r) {
- r.addEventListener('mousemove', function(ev) {
- var tip = $('#tooltip');
- tip.style.display = 'block';
- tip.style.left = (ev.clientX+12)+'px';
- tip.style.top = (ev.clientY+12)+'px';
- tip.textContent = '#'+r.getAttribute('data-id')+' '+r.getAttribute('data-name');
- });
- r.addEventListener('mouseleave', function() {
- $('#tooltip').style.display = 'none';
- });
- });
}
function wrapText(name, cx, cy, maxWidth, color) {
@@ -311,10 +300,26 @@ function wrapText(name, cx, cy, maxWidth, color) {
color = color || '#fff';
var charW = 6;
var maxLen = Math.max(4, Math.floor((maxWidth - 4) / charW));
+ var words = name.split(/\s+/);
var lines = [];
- for (var i = 0; i < name.length; i += maxLen) {
- lines.push(name.substring(i, i + maxLen));
- }
+ var cur = '';
+ words.forEach(function(w) {
+ if (w.length > maxLen) {
+ if (cur) { lines.push(cur); cur = ''; }
+ for (var j = 0; j < w.length; j += maxLen) {
+ lines.push(w.substring(j, j + maxLen));
+ }
+ } else if (!cur) {
+ cur = w;
+ } else if ((cur + ' ' + w).length <= maxLen) {
+ cur += ' ' + w;
+ } else {
+ lines.push(cur);
+ cur = w;
+ }
+ });
+ if (cur) lines.push(cur);
+ if (lines.length === 0) lines.push('');
if (lines.length > 3) lines = lines.slice(0, 3);
var dy = -(lines.length - 1) * 6;
return lines.map(function(l, i) {
@@ -323,7 +328,7 @@ function wrapText(name, cx, cy, maxWidth, color) {
}
function updateView() {
- var svg = document.querySelector('#mapSvg svg');
+ var svg = $('#mapSvg');
if (!svg) return;
var W = svg.parentElement.clientWidth;
var H = svg.parentElement.clientHeight;
@@ -332,6 +337,7 @@ function updateView() {
}
function selectRoom(id) {
+ if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
selectedRoom = id;
loadMap();
API.get('/api/rooms/' + id).then(function(data) {
@@ -361,18 +367,22 @@ function renderSidePanel(data) {
var html = '<div class="panel-tabs">';
html += '<button class="panel-tab' + (currentPanelTab === 0 ? ' active' : '') + '" onclick="switchPanelTab(0)">Room</button>';
- html += '<button class="panel-tab' + (currentPanelTab === 1 ? ' active' : '') + '" onclick="switchPanelTab(1)">Local</button>';
- html += '<button class="panel-tab' + (currentPanelTab === 2 ? ' active' : '') + '" onclick="switchPanelTab(2)">Objects</button>';
- html += '<button class="panel-tab' + (currentPanelTab === 3 ? ' active' : '') + '" onclick="switchPanelTab(3)">Mobs</button>';
- html += '<button class="panel-tab' + (currentPanelTab === 4 ? ' active' : '') + '" onclick="switchPanelTab(4)">Items</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 1 ? ' active' : '') + '" onclick="switchPanelTab(1)">Exits</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 2 ? ' active' : '') + '" onclick="switchPanelTab(2)">Local</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 3 ? ' active' : '') + '" onclick="switchPanelTab(3)">Objects</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 4 ? ' active' : '') + '" onclick="switchPanelTab(4)">Mobs</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 5 ? ' active' : '') + '" onclick="switchPanelTab(5)">Items</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 6 ? ' active' : '') + '" onclick="switchPanelTab(6)">Hazard</button>';
html += '</div>';
html += '<div class="panel-tab-content">';
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);
+ else if (currentPanelTab === 1) html += renderExitsTab(r);
+ else if (currentPanelTab === 2) html += renderLocalTab(localObjs);
+ else if (currentPanelTab === 3) html += renderObjectsTab(refObjs);
+ else if (currentPanelTab === 4) html += renderMobsTab(mobs);
+ else if (currentPanelTab === 5) html += renderItemsTab(spawns);
+ else if (currentPanelTab === 6) html += renderHazardTab(r);
html += '</div>';
panel.innerHTML = html;
@@ -401,23 +411,61 @@ function renderSidePanel(data) {
function renderRoomTab(r, file) {
var html = '<div class="form-group"><label>File</label><span style="font-size:11px;color:#888">' + esc(file) + '</span></div>';
- html += '<div class="form-group"><label>ID</label><input id="roomID" type="number" value="' + r.id + '" onchange="autoSave()"></div>';
- html += '<div class="form-group"><label>Name</label><input id="roomName" value="' + escAttr(r.name || '') + '" oninput="autoSave()"></div>';
- html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:'+resolveColor(r.color)+'"></span><input id="roomColor" value="' + escAttr(r.color || '') + '" oninput="autoSave()"></div>';
- html += '<div class="form-group"><label>Directory</label><select id="roomDir" onchange="autoSave()"></select></div>';
+ html += '<div class="form-group"><label>ID</label><input id="roomID" type="number" value="' + r.id + '" onchange="autoSave()" onblur="saveNow()"></div>';
+ html += '<div class="form-group"><label>Name</label><input id="roomName" value="' + escAttr(r.name || '') + '" oninput="autoSave()" onblur="saveNow()"></div>';
+ html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:'+resolveColor(r.color)+'"></span><input id="roomColor" value="' + escAttr(r.color || '') + '" oninput="autoSave()" onblur="saveNow()"></div>';
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 += '<div class="form-group"><label>Description</label><textarea id="roomDesc" rows="4" oninput="autoSave()">' + esc(descText) + '</textarea></div>';
- html += '<div class="form-group"><label>Hazard</label><input id="roomHazard" value="' + escAttr(r.hazard || '') + '" oninput="autoSave()"></div>';
- html += '<div class="form-group"><label><input type="checkbox" id="roomBlockTransport" onchange="autoSave()"' + (r.block_transport ? ' checked' : '') + '> Block Transport</label></div>';
- html += renderExitsSection(r);
+ html += '<div class="form-group"><label>Description</label><textarea id="roomDesc" rows="12" oninput="autoSave()" onblur="saveNow()">' + esc(descText) + '</textarea></div>';
+ html += '<div class="form-group"><label><input type="checkbox" id="roomBlockTransport" onchange="updateRoomBlockTransport()"' + (r.block_transport ? ' checked' : '') + '> Block Transport</label></div>';
+ html += '<div class="form-group"><label>Directory</label><select id="roomDir" onchange="autoSave()" onblur="saveNow()"></select></div>';
+ return html;
+}
+
+function renderExitsTab(r) {
+ return renderExitsSection(r);
+}
+
+function renderHazardTab(r) {
+ var html = '<div class="section-search">';
+ html += '<input type="text" class="search-input" placeholder="Search hazards..." oninput="searchHazards(this)">';
+ html += '<div class="search-results" style="display:none"></div>';
+ html += '</div>';
+ var current = r.hazard || '';
+ if (current) {
+ html += '<div class="entry-row"><div class="entry-fields"><span class="entry-id">' + esc(current) + '</span></div><button class="btn btn-sm btn-danger" onclick="clearHazard()">X</button></div>';
+ }
return html;
}
+function searchHazards(input) {
+ searchAndShow(input, 'hazards', function(r) {
+ addHazard(r.id);
+ });
+}
+function addHazard(id) {
+ if (!currentRoomData || !currentRoomData.room) return;
+ currentRoomData.room.hazard = id;
+ saveNow();
+ reRenderPanel();
+}
+function clearHazard() {
+ if (!currentRoomData || !currentRoomData.room) return;
+ currentRoomData.room.hazard = '';
+ saveNow();
+ reRenderPanel();
+}
+
+function updateRoomBlockTransport() {
+ if (!currentRoomData || !currentRoomData.room) return;
+ currentRoomData.room.block_transport = $('#roomBlockTransport').checked;
+ saveNow();
+}
+
function getRoomDirFromFile(file) {
var rel = (file || '').replace(/^data\/rooms\/?/, '');
var idx = rel.indexOf('/');
@@ -441,10 +489,12 @@ function switchPanelTab(idx) {
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);
+ else if (idx === 1) html = renderExitsTab(r);
+ else if (idx === 2) html = renderLocalTab(localObjs);
+ else if (idx === 3) html = renderObjectsTab(refObjs);
+ else if (idx === 4) html = renderMobsTab(mobs);
+ else if (idx === 5) html = renderItemsTab(spawns);
+ else if (idx === 6) html = renderHazardTab(r);
var content = document.querySelector('.panel-tab-content');
if (content) content.innerHTML = html;
var tabs = document.querySelectorAll('.panel-tab');
@@ -482,7 +532,7 @@ function renderLocalTab(localObjs) {
h += '<div class="entry-row" data-idx="' + i + '">';
h += '<div class="entry-fields">';
h += '<input class="local-name" value="' + escAttr(obj.name || '') + '" placeholder="name" onchange="updateLocalObject(' + i + ')" style="font-weight:bold">';
- h += '<textarea class="local-desc" rows="2" placeholder="description (one per line)" onchange="updateLocalObject(' + i + ')">' + esc(descText) + '</textarea>';
+ h += '<textarea class="local-desc" rows="4" placeholder="description (one per line)" onchange="updateLocalObject(' + i + ')">' + esc(descText) + '</textarea>';
h += '<input class="local-aliases" value="' + escAttr((obj.aliases || []).join(', ')) + '" placeholder="aliases (comma separated)" onchange="updateLocalObject(' + i + ')">';
h += '</div>';
h += '<button class="btn btn-sm btn-danger" onclick="removeLocalObject(' + i + ')">X</button>';
@@ -557,8 +607,9 @@ function renderExitsSection(r) {
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;
+ var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags);
h += '<div class="exit-row" data-dir="' + d + '">';
- h += '<span class="exit-dir">' + d + '</span>';
+ h += '<span class="exit-dir"' + (isConditional ? ' style="color:#f55"' : '') + '>' + d + '</span>';
h += '<span class="exit-target" onclick="selectRoom(' + target + ')"># ' + target + '</span>';
if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>';
if (cond) h += '<span class="exit-meta">Conditional</span>';
@@ -601,12 +652,12 @@ function editExitCondition(dir) {
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';
+ menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:420px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201';
var h = '<h3 style="margin-bottom:8px;font-size:13px">Exit: ' + esc(dir) + ' &rarr; #' + exit.room + '</h3>';
- h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>';
h += '<div class="form-group"><label>Set Flags (key=value, comma separated)</label><input id="exitFlags" value="' + escAttr(flagsStr) + '"></div>';
h += '<div class="form-group"><label>Set Player Flags (key=value, comma separated)</label><input id="exitPFlags" value="' + escAttr(pflagsStr) + '"></div>';
+ h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>';
h += '<div style="margin-top:10px;border-top:1px solid var(--border);padding-top:8px">';
h += '<label style="font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px">Conditions</label>';
@@ -857,7 +908,7 @@ function getRoomObjects() {
function setRoomObjects(objs) {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.objects = objs;
- autoSave();
+ saveNow();
}
function getRoomSpawns() {
@@ -870,7 +921,7 @@ function getRoomSpawns() {
function setRoomSpawns(spawns) {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.item_spawns = spawns;
- autoSave();
+ saveNow();
}
function getRoomMobs() {
@@ -883,7 +934,7 @@ function getRoomMobs() {
function setRoomMobs(mobs) {
if (!currentRoomData || !currentRoomData.room) return;
currentRoomData.room.mobs = mobs;
- autoSave();
+ saveNow();
}
function addLocalObject() {
@@ -1012,6 +1063,7 @@ function searchAndShow(input, type, onSelect) {
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 (type === 'hazards' && data.hazards) items = data.hazards;
if (items.length === 0) {
results.style.display = 'block';
results.innerHTML = '<div class="sr-item" style="color:#888">No results</div>';
@@ -1054,12 +1106,20 @@ function autoSave() {
saveTimer = setTimeout(function() { doSavePanel(); }, 600);
}
+function saveNow() {
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = null;
+ doSavePanel();
+}
+
function doSavePanel() {
var id = selectedRoom;
if (!id) return;
var save = function(r) {
API.put('/api/rooms/' + id, r).then(function() {
+ notify('Room #' + id + ' saved', 'success');
updateUndoBar();
+ loadMap();
}).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
};
@@ -1069,7 +1129,6 @@ function doSavePanel() {
var el = $('#roomName'); if (el) r.name = el.value;
el = $('#roomColor'); if (el) r.color = el.value;
el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}];
- el = $('#roomHazard'); if (el) r.hazard = el.value;
el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked;
if (currentRoomData && currentRoomData.room) {
@@ -1077,13 +1136,16 @@ function doSavePanel() {
r.item_spawns = currentRoomData.room.item_spawns;
r.mobs = currentRoomData.room.mobs;
r.exits = currentRoomData.room.exits;
+ r.hazard = currentRoomData.room.hazard || '';
+ if (r.block_transport === undefined) r.block_transport = currentRoomData.room.block_transport;
}
- var newID = parseInt($('#roomID').value) || id;
- var newDir = $('#roomDir').value;
+ var el = $('#roomID'); var newID = el ? parseInt(el.value) || id : id;
+ var dirEl = $('#roomDir');
+ var newDir = dirEl ? dirEl.value : null;
var currentDir = (data.file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || '';
- if (newDir !== currentDir && newDir !== undefined) {
+ if (dirEl && newDir !== currentDir) {
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() {
@@ -1094,9 +1156,13 @@ function doSavePanel() {
} 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(r); });
- } else { save(r); }
- });
+ }).catch(function(e) {
+ notify('Rename failed: '+e.message, 'error');
+ var rid = $('#roomID'); if (rid) rid.value = id;
+ save(r);
+ });
+ } else { save(r); }
+ }).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
}
function deleteRoom(id) {
@@ -1190,7 +1256,7 @@ function showRoomContextMenu(x, y, id) {
}
function mouseToGrid(e) {
- var svgEl = document.querySelector('#mapSvg svg');
+ var svgEl = $('#mapSvg');
if (!svgEl) return null;
var rect = svgEl.getBoundingClientRect();
var mx = e.clientX - rect.left;
@@ -1257,7 +1323,7 @@ function applyGhostLinkHighlight(fromRoom, gx, gy) {
fromEl.setAttribute('stroke', '#0ff');
fromEl.setAttribute('stroke-width', '3');
- var g = document.querySelector('#mapSvg svg g');
+ var g = document.querySelector('#mapSvg g');
if (!g) return;
var svgNS = 'http://www.w3.org/2000/svg';
var line = document.createElementNS(svgNS, 'line');
@@ -1287,7 +1353,7 @@ function applyLinkHighlight(fromId, toId) {
toEl.setAttribute('stroke', '#f0f');
toEl.setAttribute('stroke-width', '3');
- var g = document.querySelector('#mapSvg svg g');
+ var g = document.querySelector('#mapSvg g');
if (!g) return;
var svgNS = 'http://www.w3.org/2000/svg';
var line = document.createElementNS(svgNS, 'line');
@@ -1375,7 +1441,7 @@ function applyDelHighlight(fromId, toId) {
toEl.setAttribute('stroke', '#f55');
toEl.setAttribute('stroke-width', '3');
- var g = document.querySelector('#mapSvg svg g');
+ var g = document.querySelector('#mapSvg g');
if (!g) return;
var svgNS = 'http://www.w3.org/2000/svg';
var line = document.createElementNS(svgNS, 'line');
@@ -1412,24 +1478,6 @@ function clearDelHighlight() {
}
function deleteLink(fromId, toId) {
- var orphanedRooms = [];
- if (mapData && mapData.links) {
- var checkOrphan = function(roomId, otherId) {
- 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) {
- orphanedRooms.push(roomId);
- }
- };
- 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', {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
@@ -1439,20 +1487,8 @@ function deleteLink(fromId, toId) {
if (!r.ok) throw new Error('unlink failed');
return r.json();
}).then(function() {
- 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');
+ notify('Removed link #'+fromId+' '+toId, 'success');
updateUndoBar();
- if (orphanedRooms.indexOf(selectedRoom) !== -1) {
- selectedRoom = null;
- $('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
- }
loadMap();
}).catch(function(e) {
notify('Delete link failed: ' + e.message, 'error');
@@ -1471,13 +1507,44 @@ function renderDisconnectedList(data) {
panel.style.display = 'flex';
dc.sort(function(a, b) { return a.id - b.id; });
list.innerHTML = dc.map(function(r) {
- return '<div class="dc-item" onclick="selectRoom(' + r.id + ')" title="' + esc(r.name || '') + '">' +
+ return '<div class="dc-item" onclick="selectRoom(' + r.id + ')" oncontextmenu="event.preventDefault(); showDcContextMenu(event,' + r.id + ')" title="' + esc(r.name || '') + '">' +
'<span class="dc-id">#' + r.id + '</span>' +
'<span class="dc-name">' + esc(r.name || '') + '</span>' +
'</div>';
}).join('');
}
+function showDcContextMenu(e, id) {
+ var overlay = document.createElement('div');
+ overlay.className = 'cm-overlay';
+ overlay.onclick = function() { overlay.remove(); menu.remove(); };
+
+ var menu = document.createElement('div');
+ menu.className = 'cm-menu';
+ menu.style.left = e.clientX + 'px';
+ menu.style.top = e.clientY + 'px';
+
+ var delBtn = document.createElement('button');
+ delBtn.textContent = 'Delete Room';
+ delBtn.className = 'danger';
+ delBtn.onclick = function() {
+ overlay.remove(); menu.remove();
+ deleteRoom(id);
+ };
+ menu.appendChild(delBtn);
+
+ var selBtn = document.createElement('button');
+ selBtn.textContent = 'Room Details';
+ selBtn.onclick = function() {
+ overlay.remove(); menu.remove();
+ selectRoom(id);
+ };
+ menu.appendChild(selBtn);
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(menu);
+}
+
function initPanelResize() {
var handle = document.createElement('div');
handle.className = 'panel-resize-handle';
@@ -1499,12 +1566,14 @@ function initPanelResize() {
if (newW < 300) newW = 300;
if (newW > 800) newW = 800;
panel.style.width = newW + 'px';
+ updateView();
}
function onUp() {
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
+ updateView();
}
}
diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html
index 99c1f0d..ed939bd 100644
--- a/internal/admin/templates/layout.html
+++ b/internal/admin/templates/layout.html
@@ -23,9 +23,9 @@
<a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
<a href="/editor/files" class="{{if eq .Page "files"}}active{{end}}">Files</a>
<div class="undo-bar">
+ <span class="undo-label"></span>
<button class="undo-btn" onclick="doUndo()" title="">⟲ Undo</button>
<button class="redo-btn" onclick="doRedo()" title="">⟳ Redo</button>
- <span class="undo-label"></span>
</div>
<span style="font-size:11px;color:#888">| {{.Account}}</span>
<a href="/logout" style="font-size:11px">Logout</a>
diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html
index 869ee67..6fd6261 100644
--- a/internal/admin/templates/map.html
+++ b/internal/admin/templates/map.html
@@ -11,10 +11,12 @@
<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">Reset</button>
- <select id="dirSelect" onchange="changeDir(this.value)" style="margin-left:8px;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 onclick="zoomIn()" title="Zoom in" style="margin-left:8px;width:auto;font-size:14px">&#x2795;</button>
- <button onclick="zoomOut()" title="Zoom out" style="width:auto;font-size:14px">&#x2796;</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>
</div>
<svg id="mapSvg" style="width:100%;height:100%"></svg>
<div class="room-tooltip" id="tooltip" style="display:none"></div>