From b8c90886ef1f3afb8d908aac89028bd177836cae Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Thu, 9 Jul 2026 16:09:35 -0400
Subject: feat(admin): multi-select with shift+drag for common bulk operations
---
internal/admin/api_map.go | 26 +--
internal/admin/api_rooms_bulk.go | 214 ++++++++++++++++++++++++
internal/admin/server.go | 1 +
internal/admin/static/admin.css | 4 +
internal/admin/static/map.js | 347 ++++++++++++++++++++++++++++++++++++++-
internal/admin/undo.go | 97 ++++++++---
6 files changed, 651 insertions(+), 38 deletions(-)
create mode 100644 internal/admin/api_rooms_bulk.go
(limited to 'internal')
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index 3705cde..f5fd9ee 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -158,11 +158,14 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
}
type RoomEntry struct {
- ID int `json:"id"`
- X int `json:"x"`
- Y int `json:"y"`
- Name string `json:"name"`
- Color string `json:"color"`
+ ID int `json:"id"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ Dir string `json:"dir"`
+ BlockTransport bool `json:"block_transport"`
+ Hazard string `json:"hazard"`
}
type LinkEntry struct {
@@ -198,11 +201,14 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
roomData[rid] = r
rooms = append(rooms, RoomEntry{
- ID: rid,
- X: coord[0],
- Y: coord[1],
- Name: r.Name,
- Color: r.Color,
+ ID: rid,
+ X: coord[0],
+ Y: coord[1],
+ Name: r.Name,
+ Color: r.Color,
+ Dir: getRoomDir(s, rid),
+ BlockTransport: r.BlockTransport,
+ Hazard: r.Hazard,
})
if first {
diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go
new file mode 100644
index 0000000..34ca209
--- /dev/null
+++ b/internal/admin/api_rooms_bulk.go
@@ -0,0 +1,214 @@
+package admin
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+)
+
+type bulkEditPatch struct {
+ Color *string `json:"color"`
+ BlockTransport *bool `json:"block_transport"`
+ Dir *string `json:"dir"`
+ Hazard *string `json:"hazard"`
+}
+
+type bulkEditRequest struct {
+ Patch bulkEditPatch `json:"patch"`
+ IDs []int `json:"ids"`
+}
+
+func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ body, readErr := io.ReadAll(r.Body)
+ r.Body.Close()
+ if readErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", readErr)})
+ return
+ }
+
+ var req bulkEditRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)})
+ return
+ }
+
+ if len(req.IDs) == 0 {
+ writeJSON(w, map[string]any{"error": "no room ids"})
+ return
+ }
+
+ if isEmptyPatch(req.Patch) {
+ writeJSON(w, map[string]any{"error": "no patch fields"})
+ return
+ }
+
+ patch := req.Patch
+
+ roomPaths := make([]string, 0, len(req.IDs))
+ for _, id := range req.IDs {
+ path, ok := s.world.GetRoomPath(id)
+ if !ok {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)})
+ return
+ }
+ roomPaths = append(roomPaths, path)
+ }
+
+ type roomWrite struct {
+ oldContent []byte
+ newContent []byte
+ oldPath string
+ newPath string
+ wasMoved bool
+ }
+
+ writes := make([]roomWrite, 0, len(req.IDs))
+ dirMovePaths := make(map[string]struct{})
+
+ for i, id := range req.IDs {
+ path := roomPaths[i]
+
+ oldContent, snapErr := snapshotFile(path)
+ if snapErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room %d: %v", id, snapErr)})
+ return
+ }
+
+ data, readErr := os.ReadFile(path)
+ if readErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, readErr)})
+ return
+ }
+
+ m, yamlErr := yamlToMap(data)
+ if yamlErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("parse room %d: %v", id, yamlErr)})
+ return
+ }
+
+ if patch.Color != nil {
+ m["color"] = *patch.Color
+ }
+ if patch.BlockTransport != nil {
+ m["block_transport"] = *patch.BlockTransport
+ }
+ if patch.Hazard != nil {
+ if *patch.Hazard == "" {
+ delete(m, "hazard")
+ } else {
+ m["hazard"] = *patch.Hazard
+ }
+ }
+
+ if patch.Dir != nil && *patch.Dir != "" {
+ destDir := filepath.Join(s.dataDir, "rooms", *patch.Dir)
+ if _, err := os.Stat(destDir); os.IsNotExist(err) {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("directory does not exist: %s", *patch.Dir)})
+ return
+ }
+
+ fileName := strconv.Itoa(id) + ".yaml"
+ newPath := filepath.Join(destDir, fileName)
+ if newPath != path {
+ if _, err := os.Stat(newPath); err == nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists in target directory", id)})
+ return
+ }
+ }
+
+ newContent, writeErr := writeMapAsYAML(newPath, m)
+ if writeErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)})
+ return
+ }
+
+ writes = append(writes, roomWrite{
+ oldContent: oldContent,
+ newContent: newContent,
+ oldPath: path,
+ newPath: newPath,
+ wasMoved: true,
+ })
+ dirMovePaths[path] = struct{}{}
+ } else {
+ newContent, writeErr := writeMapAsYAML(path, m)
+ if writeErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)})
+ return
+ }
+
+ writes = append(writes, roomWrite{
+ oldContent: oldContent,
+ newContent: newContent,
+ oldPath: path,
+ newPath: path,
+ wasMoved: false,
+ })
+ }
+ }
+
+ for _, rw := range writes {
+ if rw.wasMoved {
+ if _, alreadyRemoved := dirMovePaths[rw.oldPath]; alreadyRemoved {
+ delete(dirMovePaths, rw.oldPath)
+ if err := os.Remove(rw.oldPath); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("remove old room: %v", err)})
+ return
+ }
+ }
+ }
+ }
+
+ s.world.RebuildRoomIndex(s.dataDir)
+
+ var desc string
+ if patch.Dir != nil && *patch.Dir != "" {
+ desc = fmt.Sprintf("bulk edit %d rooms (move to %s)", len(req.IDs), *patch.Dir)
+ } else {
+ desc = fmt.Sprintf("bulk edit %d rooms", len(req.IDs))
+ }
+
+ var extraFiles []ExtraFile
+ var firstDesc ChangeDesc
+ for i, rw := range writes {
+ ef := ExtraFile{
+ FilePath: rw.oldPath,
+ OldContent: rw.oldContent,
+ NewContent: rw.newContent,
+ }
+ if rw.wasMoved {
+ ef.NewFilePath = rw.newPath
+ }
+ if i == 0 {
+ firstDesc = ChangeDesc{
+ Description: desc,
+ FilePath: rw.oldPath,
+ OldContent: rw.oldContent,
+ NewContent: rw.newContent,
+ }
+ if rw.wasMoved {
+ firstDesc.NewFilePath = rw.newPath
+ }
+ } else {
+ extraFiles = append(extraFiles, ef)
+ }
+ }
+
+ firstDesc.ExtraFiles = extraFiles
+ s.undoStack.Push(firstDesc)
+
+ writeJSON(w, map[string]any{"ok": true, "count": len(req.IDs)})
+}
+
+func isEmptyPatch(p bulkEditPatch) bool {
+ return p.Color == nil && p.BlockTransport == nil && p.Dir == nil && p.Hazard == nil
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index f06b7cf..c33bd21 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -125,6 +125,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/rooms/remove", s.handleRoomRemove)
apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove)
apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename)
+ apiMux.HandleFunc("/api/rooms/bulk-edit", s.handleBulkEdit)
apiMux.HandleFunc("/api/rooms", s.handleRooms)
apiMux.HandleFunc("/api/rooms/new-empty", s.handleCreateEmptyRoom)
apiMux.HandleFunc("/api/rooms/", s.handleRoomByID)
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index aa80488..393fafd 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -55,6 +55,10 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f
.link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64}
#mapSvg:focus{outline:none}
#mapSvg,#mapSvg *{user-select:none;-webkit-user-select:none;-moz-user-select:none}
+.selection-box{fill:rgba(100,200,255,0.1);stroke:#5cf;stroke-width:1.5;stroke-dasharray:5,5;pointer-events:none}
+.selection-box.subtract{fill:rgba(255,80,80,0.1);stroke:#f55}
+.rm.multisel{cursor:pointer}
+.bulk-swatch{background:#888!important}
.notification{position:fixed;bottom:16px;left: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)}
.notification.success{border-color:var(--success)}
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index aeb2d57..1f67275 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -9,6 +9,8 @@ var ctrlHeld = false;
var delOneWay = false;
var delDrag = false, delFromId = null, delTargetId = null;
var roomMap = {}, occupied = {};
+var multiSelectedIds = new Set();
+var multiDrag = false, multiSubtract = false, multiStartSVGX = 0, multiStartSVGY = 0;
var dirChangedByUser = false;
var currentPanelTab = 0;
var gridDirs = [
@@ -33,6 +35,10 @@ function isCtrlKey(e) {
}
function onMapMouseMove(e) {
+ if (multiDrag) {
+ updateSelectionBox(e);
+ return;
+ }
if (!dragging) return;
var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
@@ -65,6 +71,13 @@ function onMapMouseUp(e) {
document.removeEventListener('mouseup', onMapMouseUp);
_mapMouseCleanup = false;
+ if (multiDrag) {
+ finalizeMultiSelect(e);
+ multiDrag = false; multiSubtract = false; dragging = false; dragRoom = false;
+ if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
+ return;
+ }
+
var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
if (delFromId && !delDrag && !moved) {
@@ -113,7 +126,9 @@ function cancelMapDrag() {
clearLinkHighlight();
clearDelHighlight();
hideDragBadge();
+ removeSelectionBox();
dragging = false; dragRoom = false;
+ multiDrag = false; multiSubtract = false;
linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false;
delDrag = false; delFromId = null; delTargetId = null; delOneWay = false;
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
@@ -153,6 +168,7 @@ function refreshDragMode(ctrlDown) {
function changeZ(dz) {
if (dz === 0) currentZ = 0; else currentZ += dz;
panX = 0; panY = 0; scale = 1;
+ clearMultiSelectSilent();
loadMap();
}
@@ -161,6 +177,7 @@ function changeDir(dir) {
dirChangedByUser = true;
selectedRoom = null;
panX = 0; panY = 0; scale = 1;
+ clearMultiSelectSilent();
loadMap();
}
@@ -279,7 +296,12 @@ function renderMap(data) {
var fill = resolveColor(r.color);
var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff';
var sel = selectedRoom === r.id;
- roomHTML += '';
+ var multi = multiSelectedIds.has(r.id);
+ var stroke = '#fff', strokeW = 3;
+ if (sel && !multi) { stroke = '#fff'; strokeW = 3; }
+ else if (multi) { stroke = '#5cf'; strokeW = 3.5; }
+ else { stroke = fill; strokeW = 1.5; }
+ roomHTML += '';
var dimColor = hexLuminance(fill) > 0.35 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.4)';
roomHTML += '#'+r.id+'';
@@ -331,6 +353,7 @@ function renderMap(data) {
svg.setAttribute('tabindex', '0');
svg.addEventListener('keydown', function(e) {
if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); }
+ if (e.key === 'Escape' && multiSelectedIds.size > 0) { clearMultiSelect(); e.preventDefault(); }
});
svg.addEventListener('keyup', function(e) {
if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); }
@@ -345,6 +368,10 @@ function renderMap(data) {
var onUdBtn = e.target.classList.contains('ud-btn');
var onNavBtn = e.target.classList.contains('nav-btn');
+ if (!e.shiftKey && multiSelectedIds.size > 0) {
+ clearMultiSelect();
+ }
+
if (onNavBtn) {
e.stopPropagation();
var dz = parseInt(e.target.getAttribute('data-dz'));
@@ -354,6 +381,26 @@ function renderMap(data) {
return;
}
+ if (e.shiftKey && e.button === 2) {
+ e.preventDefault();
+ multiDrag = true;
+ multiSubtract = true;
+ dragging = true;
+ if (svg.focus) svg.focus();
+ startX = e.clientX; startY = e.clientY;
+ prevX = e.clientX; prevY = e.clientY;
+ var coords = screenToSVGCoords(e);
+ multiStartSVGX = coords.svgX; multiStartSVGY = coords.svgY;
+ dragRoom = true;
+ linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+ clearLinkHighlight(); clearDelHighlight();
+ document.addEventListener('mousemove', onMapMouseMove);
+ document.addEventListener('mouseup', onMapMouseUp);
+ _mapMouseCleanup = true;
+ return;
+ }
+
if (e.button === 2 && onRoom) {
delFromId = parseInt(e.target.getAttribute('data-id'));
delDrag = false; delTargetId = null; delOneWay = false;
@@ -378,6 +425,24 @@ function renderMap(data) {
return;
}
+ if (e.shiftKey) {
+ multiDrag = true;
+ dragging = true;
+ if (svg.focus) svg.focus();
+ startX = e.clientX; startY = e.clientY;
+ prevX = e.clientX; prevY = e.clientY;
+ var coords = screenToSVGCoords(e);
+ multiStartSVGX = coords.svgX; multiStartSVGY = coords.svgY;
+ dragRoom = true;
+ linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+ clearLinkHighlight(); clearDelHighlight();
+ document.addEventListener('mousemove', onMapMouseMove);
+ document.addEventListener('mouseup', onMapMouseUp);
+ _mapMouseCleanup = true;
+ return;
+ }
+
dragRoom = onRoom || onGhost;
dragging = true;
if (svg.focus) svg.focus();
@@ -450,6 +515,8 @@ function selectRoom(id) {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
var prev = selectedRoom;
selectedRoom = id;
+ multiSelectedIds.clear();
+ removeSelectionBox();
if (mapData && prev !== id) {
renderMap(mapData);
@@ -2885,6 +2952,284 @@ function reloadRoomDirs(cb) {
});
}
+function screenToSVGCoords(e) {
+ var svgEl = $('#mapSvg');
+ if (!svgEl) return null;
+ var rect = svgEl.getBoundingClientRect();
+ var mx = e.clientX - rect.left;
+ var my = e.clientY - rect.top;
+ var vb = svgEl.getAttribute('viewBox');
+ if (!vb) return null;
+ var parts = vb.split(' ');
+ var vbX = parseFloat(parts[0]), vbY = parseFloat(parts[1]);
+ var vbW = parseFloat(parts[2]), vbH = parseFloat(parts[3]);
+ var svgX = vbX + mx * vbW / rect.width;
+ var svgY = vbY + my * vbH / rect.height;
+ return {
+ svgX: svgX, svgY: svgY,
+ gridX: Math.floor(svgX / CELL),
+ gridY: Math.floor(svgY / CELL)
+ };
+}
+
+function updateSelectionBox(e) {
+ var coords = screenToSVGCoords(e);
+ if (!coords) return;
+
+ var g = document.querySelector('#mapSvg g');
+ if (!g) return;
+
+ var x1 = Math.min(multiStartSVGX, coords.svgX);
+ var y1 = Math.min(multiStartSVGY, coords.svgY);
+ var x2 = Math.max(multiStartSVGX, coords.svgX);
+ var y2 = Math.max(multiStartSVGY, coords.svgY);
+
+ var box = document.getElementById('__multi_box');
+ if (!box) {
+ var ns = 'http://www.w3.org/2000/svg';
+ box = document.createElementNS(ns, 'rect');
+ box.id = '__multi_box';
+ box.classList.add('selection-box');
+ g.appendChild(box);
+ }
+ box.setAttribute('x', x1);
+ box.setAttribute('y', y1);
+ box.setAttribute('width', x2 - x1);
+ box.setAttribute('height', y2 - y1);
+ if (multiSubtract) {
+ box.classList.add('subtract');
+ } else {
+ box.classList.remove('subtract');
+ }
+
+ var startCoords = {gridX: Math.floor(multiStartSVGX/CELL), gridY: Math.floor(multiStartSVGY/CELL)};
+ var gx1 = Math.min(startCoords.gridX, coords.gridX);
+ var gy1 = Math.min(startCoords.gridY, coords.gridY);
+ var gx2 = Math.max(startCoords.gridX, coords.gridX);
+ var gy2 = Math.max(startCoords.gridY, coords.gridY);
+
+ var allRms = document.querySelectorAll('.rm');
+ for (var r = 0; r < allRms.length; r++) {
+ var rm = allRms[r];
+ var rid = parseInt(rm.getAttribute('data-id'));
+ if (multiSelectedIds.has(rid)) {
+ rm.setAttribute('stroke', '#5cf');
+ rm.setAttribute('stroke-width', '3.5');
+ } else {
+ var fill = rm.getAttribute('fill');
+ rm.setAttribute('stroke', fill);
+ rm.setAttribute('stroke-width', '1.5');
+ }
+ }
+
+ for (var gx = gx1; gx <= gx2; gx++) {
+ for (var gy = gy1; gy <= gy2; gy++) {
+ var rId = occupied[gx+','+gy];
+ if (rId === undefined) continue;
+ var rm = document.querySelector('.rm[data-id="'+rId+'"]');
+ if (!rm) continue;
+ if (multiSubtract) {
+ if (multiSelectedIds.has(rId)) {
+ rm.setAttribute('stroke', '#f55');
+ rm.setAttribute('stroke-width', '3.5');
+ }
+ } else {
+ if (!multiSelectedIds.has(rId)) {
+ rm.setAttribute('stroke', '#5cf');
+ rm.setAttribute('stroke-width', '3.5');
+ }
+ }
+ }
+ }
+}
+
+function removeSelectionBox() {
+ var box = document.getElementById('__multi_box');
+ if (box) box.remove();
+}
+
+function finalizeMultiSelect(e) {
+ var coords = screenToSVGCoords(e);
+ removeSelectionBox();
+
+ if (!coords) return;
+
+ var startCoords = {gridX: Math.floor(multiStartSVGX/CELL), gridY: Math.floor(multiStartSVGY/CELL)};
+ var gx1 = Math.min(startCoords.gridX, coords.gridX);
+ var gy1 = Math.min(startCoords.gridY, coords.gridY);
+ var gx2 = Math.max(startCoords.gridX, coords.gridX);
+ var gy2 = Math.max(startCoords.gridY, coords.gridY);
+
+ var changed = false;
+ for (var gx = gx1; gx <= gx2; gx++) {
+ for (var gy = gy1; gy <= gy2; gy++) {
+ var key = gx+','+gy;
+ if (occupied[key] !== undefined) {
+ if (multiSubtract) {
+ if (multiSelectedIds.delete(occupied[key])) changed = true;
+ } else {
+ if (!multiSelectedIds.has(occupied[key])) {
+ multiSelectedIds.add(occupied[key]);
+ changed = true;
+ }
+ }
+ }
+ }
+ }
+
+ if (multiSelectedIds.size === 0) {
+ clearMultiSelect();
+ return;
+ }
+
+ selectedRoom = null;
+ if (mapData) renderMap(mapData);
+ renderBulkPanel();
+}
+
+function clearMultiSelect() {
+ multiSelectedIds.clear();
+ multiSubtract = false;
+ removeSelectionBox();
+ if (mapData) renderMap(mapData);
+ var panel = $('#panelContent');
+ if (panel) panel.innerHTML = '
Click a room to view details
';
+}
+
+function clearMultiSelectSilent() {
+ multiSelectedIds.clear();
+ multiSubtract = false;
+ removeSelectionBox();
+ var panel = $('#panelContent');
+ if (panel) panel.innerHTML = 'Click a room to view details
';
+}
+
+function renderBulkPanel() {
+ if (multiSelectedIds.size === 0) return;
+ var panel = $('#panelContent');
+ if (!panel) return;
+
+ var ids = Array.from(multiSelectedIds);
+ var rooms = ids.map(function(id) { return roomMap[id]; }).filter(Boolean);
+
+ var colorMixed = false, firstColor = rooms.length > 0 ? (rooms[0].color || '') : '';
+ var blockNone = true, blockAll = true;
+ var dirMixedSet = new Set();
+ var hazardsByRoom = {};
+
+ rooms.forEach(function(r) {
+ if ((r.color || '') !== firstColor) colorMixed = true;
+ if (r.block_transport) blockNone = false; else blockAll = false;
+ if (r.dir) dirMixedSet.add(r.dir);
+ var h = r.hazard || '';
+ if (h) {
+ if (!hazardsByRoom[h]) hazardsByRoom[h] = [];
+ hazardsByRoom[h].push(r.id);
+ }
+ });
+
+ var hazardNames = Object.keys(hazardsByRoom);
+
+ var html = '' + ids.length + ' rooms selected
';
+ html += '';
+
+ var colorVal = colorMixed ? '' : firstColor;
+ var swatchBg = colorVal ? resolveColor(colorVal) : '#888';
+ html += '
';
+
+ html += '
';
+
+ html += '
';
+
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+
+ if (hazardNames.length > 0) {
+ hazardNames.forEach(function(h) {
+ html += '
';
+ });
+ }
+
+ html += '
';
+ panel.innerHTML = html;
+
+ var preloadDirs = dirMixedSet.size === 1 ? dirMixedSet.values().next().value : null;
+
+ setTimeout(function() {
+ var cf = document.querySelector('#panelContent .color-field');
+ if (cf) {
+ bindColorField(cf);
+ var ci = cf.querySelector('input');
+ if (ci) ci.addEventListener('blur', function() { bulkColorChanged(); });
+ }
+
+ var blockCB = document.getElementById('bulkBlockTransport');
+ if (blockCB) {
+ if (blockNone) { blockCB.checked = false; blockCB.indeterminate = false; }
+ else if (blockAll) { blockCB.checked = true; blockCB.indeterminate = false; }
+ else { blockCB.indeterminate = true; }
+ }
+
+ loadRoomDirs(function(dirs) {
+ var sel = $('#bulkRoomDir');
+ if (!sel) return;
+ sel.innerHTML = '';
+ var mixedOpt = document.createElement('option');
+ mixedOpt.value = '';
+ mixedOpt.textContent = 'Mixed\u2026';
+ if (!preloadDirs) mixedOpt.selected = true;
+ sel.appendChild(mixedOpt);
+ dirs.forEach(function(d) {
+ var opt = document.createElement('option');
+ opt.value = d;
+ opt.textContent = d || '(root)';
+ if (preloadDirs && d === preloadDirs) opt.selected = true;
+ sel.appendChild(opt);
+ });
+ });
+ }, 50);
+}
+
+function bulkColorChanged() {
+ if (window._colorPickerActive) return;
+ var el = $('#bulkRoomColor');
+ if (!el) return;
+ var v = el.value;
+ if (v.length === 2 || v.length === 0) {
+ doBulkSave({color: v});
+ }
+}
+
+function bulkBlockChanged() {
+ var el = document.getElementById('bulkBlockTransport');
+ if (!el) return;
+ doBulkSave({block_transport: el.checked});
+}
+
+function searchBulkHazards(input) {
+ searchAndShow(input, 'hazards', function(r) { doBulkSave({hazard: r.id}); });
+}
+
+function doBulkSave(patch, customIds) {
+ var ids = customIds || Array.from(multiSelectedIds);
+ if (ids.length === 0) return;
+
+ if (patch.hasOwnProperty('hazard') && patch.hazard === null) delete patch.hazard;
+ if (patch.hasOwnProperty('dir') && (patch.dir === '' || patch.dir === null)) delete patch.dir;
+ if (!patch.hasOwnProperty('hazard') && !patch.hasOwnProperty('block_transport') && !patch.hasOwnProperty('color') && !patch.hasOwnProperty('dir')) return;
+
+ API.post('/api/rooms/bulk-edit', {patch: patch, ids: ids}).then(function(res) {
+ if (res.error) { notify(res.error, 'error'); return; }
+ notify('Updated ' + res.count + ' rooms', 'success');
+ updateUndoBar();
+ loadMap().then(function() { renderBulkPanel(); });
+ }).catch(function(e) {
+ notify('Bulk update failed: ' + extractError(e), 'error');
+ });
+}
+
document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize();
window.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } });
window.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } });
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 4c39ec6..53af942 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -11,9 +11,12 @@ import (
)
type ExtraFile struct {
- FilePath string `json:"file_path"`
- OldContent []byte `json:"-"`
- NewContent []byte `json:"-"`
+ FilePath string `json:"file_path"`
+ OldContent []byte `json:"-"`
+ NewContent []byte `json:"-"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ IsDelete bool `json:"is_delete,omitempty"`
+ IsCreate bool `json:"is_create,omitempty"`
}
type ChangeDesc struct {
@@ -140,14 +143,12 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
}
}
for _, ef := range c.ExtraFiles {
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ applyUndoExtraFile(ef)
}
} else if c.IsCreate {
os.Remove(c.FilePath)
for _, ef := range c.ExtraFiles {
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ applyUndoExtraFile(ef)
}
} else {
target := c.FilePath
@@ -160,8 +161,7 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
return fmt.Errorf("revert %s: %w", target, err)
}
for _, ef := range c.ExtraFiles {
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ applyUndoExtraFile(ef)
}
}
return nil
@@ -181,14 +181,12 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
return fmt.Errorf("recreate %s: %w", target, err)
}
for _, ef := range c.ExtraFiles {
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.NewContent, 0644)
+ applyRedoExtraFile(ef)
}
} else if c.IsDelete {
os.Remove(c.FilePath)
for _, ef := range c.ExtraFiles {
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.NewContent, 0644)
+ applyRedoExtraFile(ef)
}
} else {
target := c.FilePath
@@ -201,13 +199,46 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
return fmt.Errorf("reapply %s: %w", target, err)
}
for _, ef := range c.ExtraFiles {
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.NewContent, 0644)
+ applyRedoExtraFile(ef)
}
}
return nil
}
+func applyUndoExtraFile(ef ExtraFile) {
+ switch {
+ case ef.IsCreate:
+ os.Remove(ef.FilePath)
+ case ef.IsDelete:
+ os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
+ os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ case ef.NewFilePath != "":
+ os.Remove(ef.NewFilePath)
+ os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
+ os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ default:
+ os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
+ os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ }
+}
+
+func applyRedoExtraFile(ef ExtraFile) {
+ switch {
+ case ef.IsDelete:
+ os.Remove(ef.FilePath)
+ case ef.IsCreate:
+ os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
+ os.WriteFile(ef.FilePath, ef.NewContent, 0644)
+ case ef.NewFilePath != "":
+ os.Remove(ef.FilePath)
+ os.MkdirAll(filepath.Dir(ef.NewFilePath), 0755)
+ os.WriteFile(ef.NewFilePath, ef.NewContent, 0644)
+ default:
+ os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
+ os.WriteFile(ef.FilePath, ef.NewContent, 0644)
+ }
+}
+
func (us *UndoStack) Info() UndoInfo {
us.mu.Lock()
defer us.mu.Unlock()
@@ -228,9 +259,12 @@ func (us *UndoStack) Info() UndoInfo {
func (us *UndoStack) save() {
type extraEntry struct {
- FilePath string `json:"file_path"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete,omitempty"`
+ IsCreate bool `json:"is_create,omitempty"`
}
type entry struct {
Time string `json:"time"`
@@ -253,9 +287,12 @@ func (us *UndoStack) save() {
var extra []extraEntry
for _, ef := range c.ExtraFiles {
extra = append(extra, extraEntry{
- FilePath: ef.FilePath,
- OldContent: string(ef.OldContent),
- NewContent: string(ef.NewContent),
+ FilePath: ef.FilePath,
+ NewFilePath: ef.NewFilePath,
+ OldContent: string(ef.OldContent),
+ NewContent: string(ef.NewContent),
+ IsDelete: ef.IsDelete,
+ IsCreate: ef.IsCreate,
})
}
entries = append(entries, entry{
@@ -293,9 +330,12 @@ func (us *UndoStack) load() {
}
log.Printf("undo: loaded history from %s", us.filePath)
type extraEntry struct {
- FilePath string `json:"file_path"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete,omitempty"`
+ IsCreate bool `json:"is_create,omitempty"`
}
type entry struct {
Time string `json:"time"`
@@ -322,9 +362,12 @@ func (us *UndoStack) load() {
var extra []ExtraFile
for _, ef := range e.ExtraFiles {
extra = append(extra, ExtraFile{
- FilePath: ef.FilePath,
- OldContent: []byte(ef.OldContent),
- NewContent: []byte(ef.NewContent),
+ FilePath: ef.FilePath,
+ NewFilePath: ef.NewFilePath,
+ OldContent: []byte(ef.OldContent),
+ NewContent: []byte(ef.NewContent),
+ IsDelete: ef.IsDelete,
+ IsCreate: ef.IsCreate,
})
}
changes = append(changes, ChangeDesc{
--
cgit v1.2.3