diff options
| author | historia <[not public]> | 2026-07-09 16:09:35 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-09 16:09:35 -0400 |
| commit | b8c90886ef1f3afb8d908aac89028bd177836cae (patch) | |
| tree | 7e566a7e308e7531bbb7d6a3a39239e789f69a3c /internal | |
| parent | c705ae942573984784ef501bf8198f61f5206ddd (diff) | |
| download | thehouseoficarus-b8c90886ef1f3afb8d908aac89028bd177836cae.tar.gz | |
feat(admin): multi-select with shift+drag for common bulk operations
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_map.go | 26 | ||||
| -rw-r--r-- | internal/admin/api_rooms_bulk.go | 214 | ||||
| -rw-r--r-- | internal/admin/server.go | 1 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 4 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 347 | ||||
| -rw-r--r-- | internal/admin/undo.go | 97 |
6 files changed, 651 insertions, 38 deletions
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 += '<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+'"/>'; + 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 += '<rect class="rm'+(multi?' multisel':'')+'" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="5" fill="'+fill+'" stroke="'+stroke+'" stroke-width="'+strokeW+'" data-id="'+r.id+'"/>'; 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+14)+'" text-anchor="middle" font-size="8" fill="'+dimColor+'" font-family="monospace" pointer-events="none">#'+r.id+'</text>'; @@ -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 = '<p style="color:#888;text-align:center;margin-top:40px">Click a room to view details</p>'; +} + +function clearMultiSelectSilent() { + multiSelectedIds.clear(); + multiSubtract = false; + removeSelectionBox(); + var panel = $('#panelContent'); + if (panel) panel.innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Click a room to view details</p>'; +} + +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 = '<h2 style="font-size:15px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)">' + ids.length + ' rooms selected</h2>'; + html += '<div class="panel-tab-content">'; + + var colorVal = colorMixed ? '' : firstColor; + var swatchBg = colorVal ? resolveColor(colorVal) : '#888'; + html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:' + swatchBg + '"></span><input id="bulkRoomColor" value="' + escAttr(colorVal) + '" oninput="bulkColorChanged()" placeholder="' + (colorMixed ? 'Mixed' : 'Color') + '"></div>'; + + html += '<div class="form-group"><label><input type="checkbox" id="bulkBlockTransport" onchange="bulkBlockChanged()"' + (blockAll ? ' checked' : '') + '> Block Transport</label></div>'; + + html += '<div class="form-group"><label>Directory</label><select id="bulkRoomDir" onchange="doBulkSave({dir: this.value})"><option value="">Mixed\u2026</option></select></div>'; + + html += '<div class="section-search">'; + html += '<input type="text" class="search-input" placeholder="Search hazards..." oninput="searchBulkHazards(this)">'; + html += '<div class="search-results" style="display:none"></div>'; + html += '</div>'; + + if (hazardNames.length > 0) { + hazardNames.forEach(function(h) { + html += '<div class="entry-row"><div class="entry-fields"><span class="entry-id">' + esc(h) + '</span></div><button class="btn btn-sm btn-danger" onclick="doBulkSave({hazard: \'\'}, ' + JSON.stringify(hazardsByRoom[h]) + ')">X</button></div>'; + }); + } + + html += '</div>'; + 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{ |
