From 2bacbb08d1ac67c4f63ee535728cd4e8760e3211 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 11 Jul 2026 18:30:58 -0400 Subject: feat(admin): unify context menu, add context menu to files tab --- internal/admin/api_files.go | 65 ++++++++++++++++++ internal/admin/server.go | 1 + internal/admin/static/ctxmenu.js | 47 +++++++++++++ internal/admin/static/editor.js | 106 +++++----------------------- internal/admin/templates/files.html | 130 ++++++++++++++++++++++++++++++++--- internal/admin/templates/layout.html | 1 + 6 files changed, 251 insertions(+), 99 deletions(-) create mode 100644 internal/admin/static/ctxmenu.js (limited to 'internal/admin') diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index 2a87dd3..496b08d 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -8,6 +8,71 @@ import ( "strings" ) +func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Path string `json:"path"` + NewName string `json:"new_name"` + } + if err := readJSON(r, &body); err != nil || body.Path == "" || body.NewName == "" { + writeJSON(w, map[string]any{"error": "missing path or new_name"}) + return + } + + newName := strings.TrimSpace(body.NewName) + if newName == "" || newName == "." || newName == ".." || strings.ContainsAny(newName, "/\\") { + writeJSON(w, map[string]any{"error": "invalid file name"}) + return + } + for _, c := range newName { + if c < 32 || c == 127 { + writeJSON(w, map[string]any{"error": "file name contains invalid characters"}) + return + } + } + + relPath := strings.TrimPrefix(body.Path, "/") + relPath = strings.TrimPrefix(relPath, "data/") + oldAbs := filepath.Join(s.dataDir, relPath) + + oldContent, err := snapshotFile(oldAbs) + if err != nil { + writeJSON(w, map[string]any{"error": "file not found: " + relPath}) + return + } + + newAbs := filepath.Join(filepath.Dir(oldAbs), newName) + if _, err := os.Stat(newAbs); err == nil { + writeJSON(w, map[string]any{"error": "a file named " + newName + " already exists"}) + return + } + + if err := os.WriteFile(newAbs, oldContent, 0644); err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + if err := os.Remove(oldAbs); err != nil { + writeJSON(w, map[string]any{"error": "remove error: " + err.Error()}) + return + } + + newRelPath := filepath.Join(filepath.Dir(relPath), newName) + if filepath.Dir(relPath) == "." { + newRelPath = newName + } + s.undoStack.Push(ChangeDesc{ + Description: "Renamed file " + relPath + " to " + newRelPath, + FilePath: oldAbs, + NewFilePath: newAbs, + OldContent: oldContent, + NewContent: oldContent, + }) + writeJSON(w, map[string]any{"ok": true, "path": newRelPath}) +} + func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { relPath := r.URL.Query().Get("path") relPath = strings.TrimPrefix(relPath, "/") diff --git a/internal/admin/server.go b/internal/admin/server.go index f07bdd4..11efb5c 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -177,6 +177,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor apiMux.HandleFunc("/api/undo/redo", s.doRedo) apiMux.HandleFunc("/api/next-room-id", s.handleNextRoomID) apiMux.HandleFunc("/api/files", s.handleFiles) + apiMux.HandleFunc("/api/files/rename", s.handleFileRename) apiMux.HandleFunc("/api/duplicate-rooms", s.handleDuplicateRooms) apiMux.HandleFunc("/api/rooms/resolve-duplicate", s.handleResolveDuplicate) apiMux.HandleFunc("/api/triggers", s.handleTriggers) diff --git a/internal/admin/static/ctxmenu.js b/internal/admin/static/ctxmenu.js new file mode 100644 index 0000000..96e92a9 --- /dev/null +++ b/internal/admin/static/ctxmenu.js @@ -0,0 +1,47 @@ +function openContextMenu(e, items) { + e.preventDefault(); + e.stopPropagation(); + closeContextMenu(); + + var overlay = document.createElement('div'); + overlay.className = 'cm-overlay'; + overlay.id = '_ctxMenuOverlay'; + overlay.onclick = function() { closeContextMenu(); }; + overlay.addEventListener('contextmenu', function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + var cx = ev.clientX, cy = ev.clientY; + closeContextMenu(); + var el = document.elementFromPoint(cx, cy); + if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2})); + }); + + var menu = document.createElement('div'); + menu.className = 'cm-menu'; + menu.id = '_ctxMenu'; + menu.style.left = e.clientX + 'px'; + menu.style.top = e.clientY + 'px'; + menu.oncontextmenu = function(ev) { ev.preventDefault(); }; + + function addBtn(label, cls, handler) { + var btn = document.createElement('button'); + btn.textContent = label; + if (cls) btn.className = cls; + btn.onclick = function() { closeContextMenu(); handler(); }; + menu.appendChild(btn); + } + + for (var i = 0; i < items.length; i++) { + addBtn(items[i].label, items[i].cls || '', items[i].handler); + } + + document.body.appendChild(overlay); + document.body.appendChild(menu); +} + +function closeContextMenu() { + var o = document.getElementById('_ctxMenuOverlay'); + var m = document.getElementById('_ctxMenu'); + if (o) o.remove(); + if (m) m.remove(); +} diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js index d5fb5c7..fe0ef10 100644 --- a/internal/admin/static/editor.js +++ b/internal/admin/static/editor.js @@ -271,48 +271,17 @@ function createNewDir() { } function showDirContextMenu(e, dir) { - e.preventDefault(); - closeContextMenu(); if (dir === 'Root') return; - var overlay = document.createElement('div'); - overlay.className = 'cm-overlay'; - overlay.id = '_ctxMenuOverlay'; - overlay.onclick = function() { closeContextMenu(); }; - overlay.addEventListener('contextmenu', function(ev) { - ev.preventDefault(); - ev.stopPropagation(); - var cx = ev.clientX, cy = ev.clientY; - closeContextMenu(); - var el = document.elementFromPoint(cx, cy); - if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2})); - }); - - var menu = document.createElement('div'); - menu.className = 'cm-menu'; - menu.id = '_ctxMenu'; - menu.style.left = e.clientX + 'px'; - menu.style.top = e.clientY + 'px'; - menu.oncontextmenu = function(ev) { ev.preventDefault(); }; - - function addBtn(label, cls, handler) { - var btn = document.createElement('button'); - btn.textContent = label; - if (cls) btn.className = cls; - btn.onclick = function() { closeContextMenu(); handler(); }; - menu.appendChild(btn); - } - - addBtn('Rename Directory', '', function() { renameDirInline(dir); }); - addBtn('Delete Directory', 'danger', function() { - 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'); }); - }); - - document.body.appendChild(overlay); - document.body.appendChild(menu); + openContextMenu(e, [ + {label: 'Rename Directory', handler: function() { renameDirInline(dir); }}, + {label: 'Delete Directory', cls: 'danger', handler: function() { + 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'); }); + }}, + ]); } function renameDirInline(dir) { @@ -325,44 +294,12 @@ function renameDirInline(dir) { } function showItemContextMenu(e, id) { - e.preventDefault(); - e.stopPropagation(); - closeContextMenu(); - - var overlay = document.createElement('div'); - overlay.className = 'cm-overlay'; - overlay.id = '_ctxMenuOverlay'; - overlay.onclick = function() { closeContextMenu(); }; - overlay.addEventListener('contextmenu', function(ev) { - ev.preventDefault(); - ev.stopPropagation(); - var cx = ev.clientX, cy = ev.clientY; - closeContextMenu(); - var el = document.elementFromPoint(cx, cy); - if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2})); - }); - - var menu = document.createElement('div'); - menu.className = 'cm-menu'; - menu.id = '_ctxMenu'; - menu.style.left = e.clientX + 'px'; - menu.style.top = e.clientY + 'px'; - menu.oncontextmenu = function(ev) { ev.preventDefault(); }; - - function addBtn(label, cls, handler) { - var btn = document.createElement('button'); - btn.textContent = label; - if (cls) btn.className = cls; - btn.onclick = function() { closeContextMenu(); handler(); }; - menu.appendChild(btn); - } - - addBtn('Rename', '', function() { startInlineItemRename(id); }); - addBtn('Duplicate', '', function() { duplicateFromMenu(id); }); - addBtn('Remove', 'danger', function() { deleteItem(id); }); - - document.body.appendChild(overlay); - document.body.appendChild(menu); + var row = e.currentTarget; + openContextMenu(e, [ + {label: 'Rename', handler: function() { startInlineItemRename(id, row); }}, + {label: 'Duplicate', handler: function() { duplicateFromMenu(id); }}, + {label: 'Remove', cls: 'danger', handler: function() { deleteItem(id); }}, + ]); } function duplicateFromMenu(id) { @@ -409,15 +346,8 @@ function duplicateFromMenu(id) { }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); }); } -function closeContextMenu() { - var o = document.getElementById('_ctxMenuOverlay'); - var m = document.getElementById('_ctxMenu'); - if (o) o.remove(); - if (m) m.remove(); -} - -function startInlineItemRename(id) { - var el = document.querySelector('.item.active'); +function startInlineItemRename(id, row) { + var el = row; if (!el) return; var origHTML = el.innerHTML; el.innerHTML = ''; diff --git a/internal/admin/templates/files.html b/internal/admin/templates/files.html index b89a142..5a5106f 100644 --- a/internal/admin/templates/files.html +++ b/internal/admin/templates/files.html @@ -9,12 +9,14 @@ + -- cgit v1.2.3