diff options
| author | historia <[not public]> | 2026-07-04 23:04:00 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-04 23:04:00 -0400 |
| commit | 4385244d39b37d003dbb7a66a2ff3b05079b7047 (patch) | |
| tree | 6498a06879cc4ff06612abed259b1403f071ecb2 | |
| parent | ad22ea3526a146ea8ce305c8375c35836465cc30 (diff) | |
| download | thehouseoficarus-4385244d39b37d003dbb7a66a2ff3b05079b7047.tar.gz | |
feat: drag/drop files in gui to change directories
| -rw-r--r-- | data/.admin_history.json | 1 | ||||
| -rw-r--r-- | internal/admin/server.go | 45 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 9 | ||||
| -rw-r--r-- | internal/admin/static/dropeditor.js | 3 | ||||
| -rw-r--r-- | internal/admin/static/editor.js | 82 | ||||
| -rw-r--r-- | internal/admin/static/hazardeditor.js | 3 | ||||
| -rw-r--r-- | internal/admin/static/itemeditor.js | 3 | ||||
| -rw-r--r-- | internal/admin/static/mobeditor.js | 3 | ||||
| -rw-r--r-- | internal/admin/static/moduleeditor.js | 3 | ||||
| -rw-r--r-- | internal/admin/static/objecteditor.js | 3 | ||||
| -rw-r--r-- | internal/admin/static/techeditor.js | 3 | ||||
| -rw-r--r-- | internal/admin/undo.go | 21 | ||||
| -rw-r--r-- | internal/admin/yaml_util.go | 64 |
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 ? '▼' : '▶') + '</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 +} |
