aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/.admin_history.json1
-rw-r--r--internal/admin/server.go45
-rw-r--r--internal/admin/static/admin.css9
-rw-r--r--internal/admin/static/dropeditor.js3
-rw-r--r--internal/admin/static/editor.js82
-rw-r--r--internal/admin/static/hazardeditor.js3
-rw-r--r--internal/admin/static/itemeditor.js3
-rw-r--r--internal/admin/static/mobeditor.js3
-rw-r--r--internal/admin/static/moduleeditor.js3
-rw-r--r--internal/admin/static/objecteditor.js3
-rw-r--r--internal/admin/static/techeditor.js3
-rw-r--r--internal/admin/undo.go21
-rw-r--r--internal/admin/yaml_util.go64
13 files changed, 219 insertions, 24 deletions
diff --git a/data/.admin_history.json b/data/.admin_history.json
new file mode 100644
index 0000000..4639022
--- /dev/null
+++ b/data/.admin_history.json
@@ -0,0 +1 @@
+{"history":[{"time":"2026-07-04T23:03:14-04:00","description":"move objects teak_tree to test/","file_path":"data/objects/teak_tree.yaml","new_file_path":"data/objects/test/teak_tree.yaml","old_content":"color: \"B3\"\ngather:\n deplete_timer: 50\n drops:\n - depletes: false\n item_id: teak_logs\n level: 35\n success_message: You get some {B3}teak logs{/}.\n weight: 100\n xp: 85\n fail_message: You swing but get no logs.\n gather_message: You swing your axe at the teak tree...\n nest_chance: 256\n respawn_broadcast: A {name} grows back.\n respawn_timer: 15\n skill: woodcutting\n success:\n base: 0.32\n cap: 0.82\n per_level: 0.01\n tools:\n - axe\nname: teak tree\n","new_content":"color: \"B3\"\ngather:\n deplete_timer: 50\n drops:\n - depletes: false\n item_id: teak_logs\n level: 35\n success_message: You get some {B3}teak logs{/}.\n weight: 100\n xp: 85\n fail_message: You swing but get no logs.\n gather_message: You swing your axe at the teak tree...\n nest_chance: 256\n respawn_broadcast: A {name} grows back.\n respawn_timer: 15\n skill: woodcutting\n success:\n base: 0.32\n cap: 0.82\n per_level: 0.01\n tools:\n - axe\nname: teak tree\n","is_delete":false,"is_create":false},{"time":"2026-07-04T23:03:19-04:00","description":"move objects teak_tree to root","file_path":"data/objects/test/teak_tree.yaml","new_file_path":"data/objects/teak_tree.yaml","old_content":"color: \"B3\"\ngather:\n deplete_timer: 50\n drops:\n - depletes: false\n item_id: teak_logs\n level: 35\n success_message: You get some {B3}teak logs{/}.\n weight: 100\n xp: 85\n fail_message: You swing but get no logs.\n gather_message: You swing your axe at the teak tree...\n nest_chance: 256\n respawn_broadcast: A {name} grows back.\n respawn_timer: 15\n skill: woodcutting\n success:\n base: 0.32\n cap: 0.82\n per_level: 0.01\n tools:\n - axe\nname: teak tree\n","new_content":"color: \"B3\"\ngather:\n deplete_timer: 50\n drops:\n - depletes: false\n item_id: teak_logs\n level: 35\n success_message: You get some {B3}teak logs{/}.\n weight: 100\n xp: 85\n fail_message: You swing but get no logs.\n gather_message: You swing your axe at the teak tree...\n nest_chance: 256\n respawn_broadcast: A {name} grows back.\n respawn_timer: 15\n skill: woodcutting\n success:\n base: 0.32\n cap: 0.82\n per_level: 0.01\n tools:\n - axe\nname: teak tree\n","is_delete":false,"is_create":false},{"time":"2026-07-04T23:03:22-04:00","description":"delete directory objects/test","file_path":"data/objects/test","old_content":"","new_content":"","is_delete":true,"is_create":false}],"redo":null} \ No newline at end of file
diff --git a/internal/admin/server.go b/internal/admin/server.go
index b1f641a..04e68a6 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -118,24 +118,31 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/rooms/new-empty", s.handleCreateEmptyRoom)
apiMux.HandleFunc("/api/rooms/", s.handleRoomByID)
apiMux.HandleFunc("/api/objects/move", s.handleEntityMove("objects"))
+ apiMux.HandleFunc("/api/objects/dirs", s.handleEntityDirs("objects"))
apiMux.HandleFunc("/api/objects", s.handleObjects)
apiMux.HandleFunc("/api/objects/", s.handleObjectByID)
apiMux.HandleFunc("/api/items/move", s.handleEntityMove("items"))
+ apiMux.HandleFunc("/api/items/dirs", s.handleEntityDirs("items"))
apiMux.HandleFunc("/api/items", s.handleItems)
apiMux.HandleFunc("/api/items/", s.handleItemByID)
apiMux.HandleFunc("/api/mobs/move", s.handleEntityMove("mobs"))
+ apiMux.HandleFunc("/api/mobs/dirs", s.handleEntityDirs("mobs"))
apiMux.HandleFunc("/api/mobs", s.handleMobs)
apiMux.HandleFunc("/api/mobs/", s.handleMobByID)
apiMux.HandleFunc("/api/drops/move", s.handleEntityMove("drops"))
+ apiMux.HandleFunc("/api/drops/dirs", s.handleEntityDirs("drops"))
apiMux.HandleFunc("/api/drops", s.handleDrops)
apiMux.HandleFunc("/api/drops/", s.handleDropByID)
apiMux.HandleFunc("/api/hazards/move", s.handleEntityMove("hazards"))
+ apiMux.HandleFunc("/api/hazards/dirs", s.handleEntityDirs("hazards"))
apiMux.HandleFunc("/api/hazards", s.handleHazards)
apiMux.HandleFunc("/api/hazards/", s.handleHazardByID)
apiMux.HandleFunc("/api/techs/move", s.handleEntityMove("techs"))
+ apiMux.HandleFunc("/api/techs/dirs", s.handleEntityDirs("techs"))
apiMux.HandleFunc("/api/techs", s.handleTechs)
apiMux.HandleFunc("/api/techs/", s.handleTechByID)
apiMux.HandleFunc("/api/modules/move", s.handleEntityMove("modules"))
+ apiMux.HandleFunc("/api/modules/dirs", s.handleEntityDirs("modules"))
apiMux.HandleFunc("/api/modules", s.handleModules)
apiMux.HandleFunc("/api/modules/", s.handleModuleByID)
apiMux.HandleFunc("/api/players", s.handlePlayers)
@@ -466,6 +473,44 @@ func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, resp)
}
+func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Action string `json:"action"`
+ Name string `json:"name"`
+ Dir string `json:"dir"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid"})
+ return
+ }
+ switch body.Action {
+ case "create":
+ if err := createEntityDir(s.dataDir, entityType, body.Name); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true, "name": body.Name})
+ case "delete":
+ changes, err := deleteEntityDir(s.dataDir, entityType, body.Dir)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ for _, ch := range changes {
+ s.undoStack.Push(ch)
+ }
+ writeJSON(w, map[string]any{"ok": true, "moved": len(changes) - 1})
+ default:
+ writeJSON(w, map[string]any{"error": "unknown action"})
+ }
+ }
+}
+
func (s *AdminServer) handleEntityMove(entityType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 42e64fa..33dbdc4 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -2,7 +2,7 @@
*{box-sizing:border-box;margin:0;padding:0}
input[type=number]{-moz-appearance:textfield}
input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
-body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100vh}
+body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;flex-direction:column;height:100vh;overflow:hidden}
.nav{background:var(--panel);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.nav a{color:var(--text);text-decoration:none;padding:4px 10px;border-radius:3px;font-size:13px}
.nav a:hover,.nav a.active{background:var(--accent)}
@@ -11,7 +11,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.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;flex:1;text-align:right;margin-right:8px}
-.layout{display:flex;height:calc(100vh - 38px)}
+.layout{display:flex;flex:1;min-height:0}
.main{flex:1;overflow:auto;position:relative;user-select:none;-webkit-user-select:none}
#mapSvg{display:block}
.panel{width:300px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0;position:relative}
@@ -187,7 +187,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.color-picker{display:flex;flex-wrap:wrap;gap:2px;margin-top:4px;max-height:200px;overflow-y:auto}
.color-picker .swatch{width:18px;height:18px;border:1px solid transparent;cursor:pointer;border-radius:1px}
.color-picker .swatch:hover,.color-picker .swatch.selected{border-color:#fff;transform:scale(1.2);z-index:1}
-.editor-container{display:flex;height:calc(100vh - 38px)}
+.editor-container{display:flex;flex:1;min-height:0}
.editor-list{width:260px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;padding:10px;flex-shrink:0}
.editor-main{flex:1;overflow-y:auto;padding:16px}
.editor-list .item{padding:5px 8px;font-size:12px;cursor:pointer;border-radius:3px;margin-bottom:2px}
@@ -202,6 +202,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.dir-count{font-size:9px;color:#555}
.dir-items{padding-left:10px}
.dir-items .item{font-size:11px}
+.dir-drop-target{background:rgba(15,52,96,.3)!important;outline:1px dashed var(--accent);outline-offset:-2px}
.editor-main pre{background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;border:1px solid var(--border)}
.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 10px}
.form-grid .form-group{margin-bottom:4px}
@@ -306,7 +307,7 @@ g.ud-hover:hover text{font-weight:bold}
.obj-sub-remove{align-self:flex-end;padding:2px 6px;font-size:10px;min-width:auto;background:var(--danger);color:#fff;border:none;border-radius:3px;cursor:pointer;margin-bottom:3px}
.obj-sub-add{display:block;width:100%;margin-top:4px;padding:4px 8px;font-size:11px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:3px;cursor:pointer;font-family:monospace}
.obj-sub-add:hover{background:var(--hover)}
-.add-section-bar{display:flex;gap:6px;align-items:center;margin-top:12px;padding:8px 0;border-top:1px solid var(--border);max-width:calc(50% - 5px)}
+.add-section-bar{display:flex;gap:6px;align-items:center;margin-top:12px;padding:8px 0;border-top:1px solid var(--border);max-width:100%;flex-wrap:wrap}
.add-section-bar select{padding:4px 8px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;flex:1}
.add-section-bar button{padding:4px 12px;font-size:11px}
.obj-kv-list{margin-top:4px}
diff --git a/internal/admin/static/dropeditor.js b/internal/admin/static/dropeditor.js
index dddcddb..cbd51c7 100644
--- a/internal/admin/static/dropeditor.js
+++ b/internal/admin/static/dropeditor.js
@@ -66,7 +66,7 @@ function renderDropEditor(id, data) {
html += renderCard(sec, data);
});
html += '</div>';
- html += renderMoveBar();
+ html += '<div class="add-section-bar">' + renderMoveBar() + '</div>';
html += '</div>';
html += '<div class="tab-content" id="tabYaml" style="display:none">';
@@ -76,6 +76,7 @@ function renderDropEditor(id, data) {
html += '<div class="btn-row">';
html += '<button class="btn btn-primary" onclick="saveDrop()">Save</button>';
html += '<button class="btn btn-danger" onclick="deleteDrop()">Delete</button>';
+ html += '<button class="btn btn-sm" style="margin-left:auto" onclick="createNewDir()">New Directory</button>';
html += '</div>';
$('#editorMain').innerHTML = html;
diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js
index 35bdbe6..5b2d8ef 100644
--- a/internal/admin/static/editor.js
+++ b/internal/admin/static/editor.js
@@ -52,7 +52,7 @@ function renderList(filter) {
}
function renderDirGroup(name, ids, filter) {
- if (ids.length === 0 && !filter) return '';
+ if (ids.length === 0 && !filter && !name) return '';
var isFiltering = !!filter;
var displayIds = ids;
if (isFiltering) {
@@ -63,15 +63,15 @@ function renderDirGroup(name, ids, filter) {
var displayName = name || editorType.charAt(0).toUpperCase() + editorType.slice(1);
var h = '<div class="dir-group">';
if (name) {
- h += '<div class="dir-header" onclick="toggleDir(\'' + escAttr(name) + '\')">';
+ h += '<div class="dir-header" onclick="toggleDir(\'' + escAttr(name) + '\')" oncontextmenu="showDirContextMenu(event,\'' + escAttr(name) + '\')" ondragover="dirDragOver(event,\'' + escAttr(name) + '\')" ondragleave="dirDragLeave(event)" ondrop="dirDrop(event,\'' + escAttr(name) + '\')">';
h += '<span class="dir-arrow">' + (expanded ? '&#9660;' : '&#9654;') + '</span>';
h += '<span class="dir-name">' + esc(displayName) + '</span>';
h += '<span class="dir-count">' + (isFiltering ? displayIds.length + '/' + ids.length : ids.length) + '</span>';
h += '</div>';
}
- h += '<div class="dir-items"' + (name && !expanded ? ' style="display:none"' : '') + '>';
+ h += '<div class="dir-items"' + (name && !expanded ? ' style="display:none"' : '') + ' ondragover="dirDragOver(event,\'' + (name || '') + '\')" ondragleave="dirDragLeave(event)" ondrop="dirDrop(event,\'' + (name || '') + '\')">';
displayIds.forEach(function(id) {
- h += '<div class="item' + (id === currentID ? ' active' : '') + '" onclick="loadItem(\'' + esc(id) + '\')">' + esc(id) + '</div>';
+ h += '<div class="item' + (id === currentID ? ' active' : '') + '" onclick="loadItem(\'' + esc(id) + '\')" draggable="true" ondragstart="itemDragStart(event,\'' + escAttr(id) + '\')">' + esc(id) + '</div>';
});
h += '</div>';
h += '</div>';
@@ -236,8 +236,7 @@ window.refreshPage = function() { loadList(); if (currentID) loadItem(currentID)
function renderMoveBar() {
var dirs = Object.keys(treeData.dirs).sort();
- var html = '<div class="add-section-bar">';
- html += '<select id="moveDirSelect">';
+ var html = '<select id="moveDirSelect">';
html += '<option value="">Move to directory...</option>';
html += '<option value="">Root</option>';
dirs.forEach(function(dir) {
@@ -245,7 +244,6 @@ function renderMoveBar() {
});
html += '</select>';
html += '<button class="btn btn-primary btn-sm" onclick="moveToDirectory()">Move</button>';
- html += '</div>';
return html;
}
@@ -254,7 +252,6 @@ function moveToDirectory() {
var idx = sel.selectedIndex;
if (idx === 0) return;
var dir = sel.value;
- if (!confirm('Move ' + currentID + ' to ' + (dir || 'Root') + '?')) return;
API.post('/api/' + editorType + '/move', {id: currentID, dir: dir}).then(function() {
notify('Moved ' + currentID + ' to ' + (dir || 'Root'), 'success');
updateUndoBar();
@@ -262,3 +259,72 @@ function moveToDirectory() {
loadItem(currentID);
}).catch(function(e) { notify('Move failed: ' + e.message, 'error'); });
}
+
+function createNewDir() {
+ var name = prompt('New directory name:');
+ if (!name) return;
+ API.post('/api/' + editorType + '/dirs', {action: 'create', name: name}).then(function() {
+ notify('Directory ' + name + ' created', 'success');
+ updateUndoBar();
+ loadList();
+ }).catch(function(e) { notify('Failed to create directory: ' + e.message, 'error'); });
+}
+
+function showDirContextMenu(e, dir) {
+ e.preventDefault();
+ if (dir === 'Root') return;
+ 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 Directory';
+ delBtn.className = 'danger';
+ delBtn.onclick = function() {
+ overlay.remove(); menu.remove();
+ API.post('/api/' + editorType + '/dirs', {action: 'delete', dir: dir}).then(function(r) {
+ notify('Deleted directory ' + dir + (r.moved ? ' (' + r.moved + ' files moved to root)' : ''), 'success');
+ updateUndoBar();
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+ };
+ menu.appendChild(delBtn);
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(menu);
+}
+
+function dirDragOver(e, dir) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.currentTarget.classList.add('dir-drop-target');
+}
+
+function dirDragLeave(e) {
+ e.currentTarget.classList.remove('dir-drop-target');
+}
+
+function dirDrop(e, dir) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.currentTarget.classList.remove('dir-drop-target');
+ if (dir === 'Root') dir = '';
+ var id = e.dataTransfer.getData('text/plain');
+ if (!id) return;
+ API.post('/api/' + editorType + '/move', {id: id, dir: dir}).then(function() {
+ notify('Moved ' + id + ' to ' + (dir || 'Root'), 'success');
+ updateUndoBar();
+ loadList();
+ if (currentID === id) loadItem(id);
+ }).catch(function(e) { notify('Move failed: ' + e.message, 'error'); });
+}
+
+function itemDragStart(e, id) {
+ e.dataTransfer.setData('text/plain', id);
+ e.dataTransfer.effectAllowed = 'move';
+}
diff --git a/internal/admin/static/hazardeditor.js b/internal/admin/static/hazardeditor.js
index ad317cc..ac35e78 100644
--- a/internal/admin/static/hazardeditor.js
+++ b/internal/admin/static/hazardeditor.js
@@ -89,9 +89,9 @@ function renderHazardEditor(id, data) {
});
html += '</select>';
html += '<button class="btn btn-primary btn-sm" onclick="addSection()">Add</button>';
- html += '</div>';
html += renderMoveBar();
html += '</div>';
+ html += '</div>';
html += '<div class="tab-content" id="tabYaml" style="display:none">';
html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
@@ -100,6 +100,7 @@ function renderHazardEditor(id, data) {
html += '<div class="btn-row">';
html += '<button class="btn btn-primary" onclick="saveHazard()">Save</button>';
html += '<button class="btn btn-danger" onclick="deleteHazard()">Delete</button>';
+ html += '<button class="btn btn-sm" style="margin-left:auto" onclick="createNewDir()">New Directory</button>';
html += '</div>';
$('#editorMain').innerHTML = html;
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
index ce28581..84090a1 100644
--- a/internal/admin/static/itemeditor.js
+++ b/internal/admin/static/itemeditor.js
@@ -199,9 +199,9 @@ function renderItemEditor(id, data) {
});
html += '</select>';
html += '<button class="btn btn-primary btn-sm" onclick="addSection()">Add</button>';
- html += '</div>';
html += renderMoveBar();
html += '</div>';
+ html += '</div>';
html += '<div class="tab-content" id="tabYaml" style="display:none">';
html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
@@ -211,6 +211,7 @@ function renderItemEditor(id, data) {
html += '<button class="btn btn-primary" onclick="saveItem()">Save</button>';
html += '<button class="btn" onclick="duplicateItem()">Duplicate</button>';
html += '<button class="btn btn-danger" onclick="deleteItem()">Delete Item</button>';
+ html += '<button class="btn btn-sm" style="margin-left:auto" onclick="createNewDir()">New Directory</button>';
html += '</div>';
$('#editorMain').innerHTML = html;
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index 802b48a..1758673 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -173,9 +173,9 @@ function renderMobEditor(id, data) {
});
html += '</select>';
html += '<button class="btn btn-primary btn-sm" onclick="mobAddSection()">Add</button>';
+ html += renderMoveBar();
html += '</div>';
- html += renderMoveBar();
html += '</div>';
html += '<div class="tab-content" id="tabYaml" style="display:none">';
@@ -186,6 +186,7 @@ function renderMobEditor(id, data) {
html += '<button class="btn btn-primary" onclick="saveMob()">Save</button>';
html += '<button class="btn" onclick="duplicateMob()">Duplicate</button>';
html += '<button class="btn btn-danger" onclick="deleteMob()">Delete Mob</button>';
+ html += '<button class="btn btn-sm" style="margin-left:auto" onclick="createNewDir()">New Directory</button>';
html += '</div>';
$('#editorMain').innerHTML = html;
diff --git a/internal/admin/static/moduleeditor.js b/internal/admin/static/moduleeditor.js
index 1e7de30..6d6a3e0 100644
--- a/internal/admin/static/moduleeditor.js
+++ b/internal/admin/static/moduleeditor.js
@@ -90,9 +90,9 @@ function renderModuleEditor(id, data) {
});
html += '</select>';
html += '<button class="btn btn-primary btn-sm" onclick="addSection()">Add</button>';
- html += '</div>';
html += renderMoveBar();
html += '</div>';
+ html += '</div>';
html += '<div class="tab-content" id="tabYaml" style="display:none">';
html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
@@ -101,6 +101,7 @@ function renderModuleEditor(id, data) {
html += '<div class="btn-row">';
html += '<button class="btn btn-primary" onclick="saveModule()">Save</button>';
html += '<button class="btn btn-danger" onclick="deleteModule()">Delete</button>';
+ html += '<button class="btn btn-sm" style="margin-left:auto" onclick="createNewDir()">New Directory</button>';
html += '</div>';
$('#editorMain').innerHTML = html;
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index c51628f..3f2a875 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -175,9 +175,9 @@ function renderObjectEditor(id, data) {
});
html += '</select>';
html += '<button class="btn btn-primary btn-sm" onclick="addSection()">Add</button>';
+ html += renderMoveBar();
html += '</div>';
- html += renderMoveBar();
html += '</div>';
html += '<div class="tab-content" id="tabYaml" style="display:none">';
@@ -188,6 +188,7 @@ function renderObjectEditor(id, data) {
html += '<button class="btn btn-primary" onclick="saveObject()">Save</button>';
html += '<button class="btn" onclick="duplicateObject()">Duplicate</button>';
html += '<button class="btn btn-danger" onclick="deleteObject()">Delete Object</button>';
+ html += '<button class="btn btn-sm" style="margin-left:auto" onclick="createNewDir()">New Directory</button>';
html += '</div>';
$('#editorMain').innerHTML = html;
diff --git a/internal/admin/static/techeditor.js b/internal/admin/static/techeditor.js
index 4849fb1..cd4f50f 100644
--- a/internal/admin/static/techeditor.js
+++ b/internal/admin/static/techeditor.js
@@ -80,7 +80,7 @@ function renderTechEditor(id, data) {
html += renderCard(sec, data);
});
html += '</div>';
- html += renderMoveBar();
+ html += '<div class="add-section-bar">' + renderMoveBar() + '</div>';
html += '</div>';
html += '<div class="tab-content" id="tabYaml" style="display:none">';
@@ -90,6 +90,7 @@ function renderTechEditor(id, data) {
html += '<div class="btn-row">';
html += '<button class="btn btn-primary" onclick="saveTech()">Save</button>';
html += '<button class="btn btn-danger" onclick="deleteTech()">Delete</button>';
+ html += '<button class="btn btn-sm" style="margin-left:auto" onclick="createNewDir()">New Directory</button>';
html += '</div>';
$('#editorMain').innerHTML = html;
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index f69fd9f..0c5ebaa 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -35,7 +35,7 @@ func (c ChangeDesc) ShortDesc() string {
case c.IsDelete:
return fmt.Sprintf("Deleted %s", filepath.Base(c.FilePath))
case c.NewFilePath != "":
- return fmt.Sprintf("Renamed %s", filepath.Base(c.NewFilePath))
+ return fmt.Sprintf("Moved %s", filepath.Base(c.NewFilePath))
default:
return fmt.Sprintf("Edited %s", filepath.Base(c.FilePath))
}
@@ -129,17 +129,21 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
}
if c.IsDelete {
if c.OldContent == nil {
- return fmt.Errorf("no backup content to restore for %s", c.FilePath)
- }
- if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil {
- return fmt.Errorf("restore deleted %s: %w", c.FilePath, err)
+ os.MkdirAll(c.FilePath, 0755)
+ } else {
+ os.MkdirAll(filepath.Dir(c.FilePath), 0755)
+ if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil {
+ return fmt.Errorf("restore deleted %s: %w", c.FilePath, err)
+ }
}
for _, ef := range c.ExtraFiles {
+ os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
os.WriteFile(ef.FilePath, ef.OldContent, 0644)
}
} 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)
}
} else {
@@ -148,10 +152,12 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
if c.NewFilePath != "" {
target = c.FilePath
}
+ os.MkdirAll(filepath.Dir(target), 0755)
if err := os.WriteFile(target, content, 0644); err != nil {
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)
}
}
@@ -167,15 +173,18 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
if c.NewFilePath != "" {
target = c.NewFilePath
}
+ os.MkdirAll(filepath.Dir(target), 0755)
if err := os.WriteFile(target, c.NewContent, 0644); err != nil {
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)
}
} 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)
}
} else {
@@ -184,10 +193,12 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
if c.NewFilePath != "" {
target = c.NewFilePath
}
+ os.MkdirAll(filepath.Dir(target), 0755)
if err := os.WriteFile(target, content, 0644); err != nil {
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)
}
}
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
index 465be38..519a04c 100644
--- a/internal/admin/yaml_util.go
+++ b/internal/admin/yaml_util.go
@@ -6,6 +6,7 @@ import (
"log"
"os"
"path/filepath"
+ "strings"
"gopkg.in/yaml.v3"
)
@@ -120,6 +121,8 @@ func listYAMLTree(dataDir, subdir string) (*YAMLTree, error) {
}
if len(ids) > 0 {
tree.Dirs[e.Name()] = ids
+ } else {
+ tree.Dirs[e.Name()] = []string{}
}
}
}
@@ -198,3 +201,64 @@ func moveEntityFile(dataDir, subdir, id, targetDir string) (string, string, []by
}
return oldPath, newPath, data, nil
}
+
+func createEntityDir(dataDir, subdir, name string) error {
+ name = strings.TrimSpace(name)
+ if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
+ return fmt.Errorf("invalid directory name")
+ }
+ dirPath := filepath.Join(dataDir, subdir, name)
+ if _, err := os.Stat(dirPath); err == nil {
+ return fmt.Errorf("directory already exists")
+ }
+ return os.Mkdir(dirPath, 0755)
+}
+
+func deleteEntityDir(dataDir, subdir, dir string) ([]ChangeDesc, error) {
+ dir = strings.TrimSpace(dir)
+ if dir == "" || strings.ContainsAny(dir, "/\\") || dir == "." || dir == ".." {
+ return nil, fmt.Errorf("invalid directory")
+ }
+ srcDir := filepath.Join(dataDir, subdir, dir)
+ entries, err := os.ReadDir(srcDir)
+ if err != nil {
+ return nil, fmt.Errorf("directory not found: %s", dir)
+ }
+
+ var changes []ChangeDesc
+ for _, e := range entries {
+ if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
+ continue
+ }
+ oldPath := filepath.Join(srcDir, e.Name())
+ newPath := filepath.Join(dataDir, subdir, e.Name())
+ data, err := os.ReadFile(oldPath)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", e.Name(), err)
+ }
+ if err := os.WriteFile(newPath, data, 0644); err != nil {
+ return nil, fmt.Errorf("write %s: %w", e.Name(), err)
+ }
+ if err := os.Remove(oldPath); err != nil {
+ return nil, fmt.Errorf("remove %s: %w", e.Name(), err)
+ }
+ changes = append(changes, ChangeDesc{
+ Description: fmt.Sprintf("move %s %s to root", subdir, e.Name()),
+ FilePath: oldPath,
+ NewFilePath: newPath,
+ OldContent: data,
+ NewContent: data,
+ })
+ }
+
+ if err := os.Remove(srcDir); err != nil {
+ return nil, fmt.Errorf("remove directory: %w", err)
+ }
+ changes = append(changes, ChangeDesc{
+ Description: fmt.Sprintf("delete directory %s/%s", subdir, dir),
+ FilePath: srcDir,
+ IsDelete: true,
+ })
+
+ return changes, nil
+}