From 8ba6dcb704554bfbbd0a3875a1845697aa251ea1 Mon Sep 17 00:00:00 2001 From: uhlwoogi Date: Thu, 30 Apr 2026 17:16:08 +0000 Subject: [PATCH] Add column editing in dashboard edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-column header bar with size dropdown (small/full), left/right move arrows, and delete. New "+ Add column" button at the bottom of the page-columns container. Each action POSTs to a column endpoint and reloads. Backend endpoints: - POST /edit/api/pages/{slug}/columns — add (form: size) - POST /edit/api/pages/{slug}/columns/{col}/delete - POST /edit/api/pages/{slug}/columns/{col}/move?dir=left|right - POST /edit/api/pages/{slug}/columns/{col}/size — set size All go through configEditor's yaml.Node tree so comments and other config survive. Co-Authored-By: Claude Opus 4.7 --- internal/glance/admin-edit.go | 190 +++++++++++++++++++++++ internal/glance/glance.go | 4 + internal/glance/static/css/edit-mode.css | 80 ++++++++++ internal/glance/static/js/edit-mode.js | 116 ++++++++++++++ 4 files changed, 390 insertions(+) diff --git a/internal/glance/admin-edit.go b/internal/glance/admin-edit.go index 628f977..9645ebc 100644 --- a/internal/glance/admin-edit.go +++ b/internal/glance/admin-edit.go @@ -162,6 +162,35 @@ func widgetsOf(columnNode *yaml.Node) (*yaml.Node, error) { return findOrCreateKey(columnNode, "widgets", true, yaml.SequenceNode) } +func newColumnNode(size string) *yaml.Node { + if size == "" { + size = "full" + } + return &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "size"}, + {Kind: yaml.ScalarNode, Value: size}, + {Kind: yaml.ScalarNode, Value: "widgets"}, + {Kind: yaml.SequenceNode}, + }} +} + +func setColumnSize(columnNode *yaml.Node, size string) error { + if columnNode.Kind != yaml.MappingNode { + return fmt.Errorf("column is not a mapping") + } + for i := 0; i+1 < len(columnNode.Content); i += 2 { + if columnNode.Content[i].Value == "size" { + columnNode.Content[i+1] = &yaml.Node{Kind: yaml.ScalarNode, Value: size} + return nil + } + } + columnNode.Content = append(columnNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "size"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: size}, + ) + return nil +} + func newPageNode(title string) *yaml.Node { return &yaml.Node{ Kind: yaml.MappingNode, @@ -936,3 +965,164 @@ func (a *application) handleAdminWidgetSchemas(w http.ResponseWriter, r *http.Re http.Error(w, err.Error(), http.StatusInternalServerError) } } + +// ---------- column endpoints ---------- + +func (a *application) editColumns(slug string) (*configEditor, *yaml.Node, error) { + pageIdx, ok := a.freshPageIndexBySlug(slug) + if !ok { + return nil, nil, fmt.Errorf("page not found") + } + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + return nil, nil, err + } + pageNode, err := editor.pageNodeAt(pageIdx) + if err != nil { + return nil, nil, err + } + columns, err := columnsOf(pageNode) + if err != nil { + return nil, nil, err + } + return editor, columns, nil +} + +func (a *application) handleAdminAddColumn(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + size := strings.TrimSpace(r.FormValue("size")) + if size == "" { + size = "full" + } + + editor, columns, err := a.editColumns(r.PathValue("page")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + columns.Content = append(columns.Content, newColumnNode(size)) + + if err := editor.save(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +func (a *application) handleAdminDeleteColumn(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + http.Error(w, "bad column", http.StatusBadRequest) + return + } + + editor, columns, err := a.editColumns(r.PathValue("page")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if col < 0 || col >= len(columns.Content) { + http.Error(w, "column index out of range", http.StatusBadRequest) + return + } + if len(columns.Content) <= 1 { + http.Error(w, "cannot delete the last remaining column", http.StatusBadRequest) + return + } + columns.Content = append(columns.Content[:col], columns.Content[col+1:]...) + + if err := editor.save(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +func (a *application) handleAdminMoveColumn(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + http.Error(w, "bad column", http.StatusBadRequest) + return + } + dir := r.URL.Query().Get("dir") + delta := 0 + switch dir { + case "up", "left": + delta = -1 + case "down", "right": + delta = 1 + default: + http.Error(w, "dir must be up|down|left|right", http.StatusBadRequest) + return + } + + editor, columns, err := a.editColumns(r.PathValue("page")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + target := col + delta + if col < 0 || col >= len(columns.Content) || target < 0 || target >= len(columns.Content) { + http.Error(w, "cannot move further in that direction", http.StatusBadRequest) + return + } + columns.Content[col], columns.Content[target] = columns.Content[target], columns.Content[col] + + if err := editor.save(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +func (a *application) handleAdminColumnSize(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + http.Error(w, "bad column", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + size := strings.TrimSpace(r.FormValue("size")) + if size == "" { + http.Error(w, "size is required", http.StatusBadRequest) + return + } + + editor, columns, err := a.editColumns(r.PathValue("page")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if col < 0 || col >= len(columns.Content) { + http.Error(w, "column index out of range", http.StatusBadRequest) + return + } + if err := setColumnSize(columns.Content[col], size); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := editor.save(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/internal/glance/glance.go b/internal/glance/glance.go index adb74fa..0a22afd 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -470,6 +470,10 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("GET /edit/api/widget-schemas", a.handleAdminWidgetSchemas) mux.HandleFunc("POST /edit/api/validate/{kind}", a.handleAdminValidate) mux.HandleFunc("POST /edit/api/lookup/{kind}", a.handleAdminLookup) + mux.HandleFunc("POST /edit/api/pages/{page}/columns", a.handleAdminAddColumn) + mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/delete", a.handleAdminDeleteColumn) + mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/move", a.handleAdminMoveColumn) + mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize) if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) diff --git a/internal/glance/static/css/edit-mode.css b/internal/glance/static/css/edit-mode.css index 3733201..90cc1fb 100644 --- a/internal/glance/static/css/edit-mode.css +++ b/internal/glance/static/css/edit-mode.css @@ -477,3 +477,83 @@ textarea.edit-input { .edit-input-invalid { border-color: var(--color-negative); } + +/* ---------- Column controls ---------- */ + +.edit-column-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; + padding: 0.35rem 0.5rem; + background: var(--color-popover-background); + border: 1px solid var(--color-popover-border); + border-radius: 4px; + font-size: var(--font-size-h5); +} + +.edit-column-label { + color: var(--color-text-subdue); + font-weight: 600; +} + +.edit-column-spacer { + flex: 1; +} + +.edit-column-size { + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: 3px; + color: var(--color-text-base); + font: inherit; + font-size: var(--font-size-h6); + padding: 0.15rem 0.3rem; +} + +.edit-column-move, +.edit-column-delete { + background: transparent; + border: 1px solid var(--color-popover-border); + color: var(--color-text-subdue); + border-radius: 3px; + padding: 0.15rem 0.5rem; + cursor: pointer; + font: inherit; + font-size: var(--font-size-h6); +} + +.edit-column-move:hover, +.edit-column-delete:hover { + color: var(--color-primary); + border-color: var(--color-primary); +} + +.edit-column-move:disabled, +.edit-column-delete:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.edit-column-delete:hover:not(:disabled) { + color: var(--color-negative); + border-color: var(--color-negative); +} + +.edit-add-column { + display: block; + margin: var(--widget-gap) auto 0; + padding: 0.6rem 1.5rem; + background: transparent; + border: 1px dashed var(--color-text-subdue); + border-radius: var(--border-radius); + color: var(--color-text-subdue); + cursor: pointer; + font: inherit; + font-size: var(--font-size-h4); +} + +.edit-add-column:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} diff --git a/internal/glance/static/js/edit-mode.js b/internal/glance/static/js/edit-mode.js index fc51c12..e31c0a2 100644 --- a/internal/glance/static/js/edit-mode.js +++ b/internal/glance/static/js/edit-mode.js @@ -87,6 +87,90 @@ function removeColumnAddButtons() { document.querySelectorAll(".edit-add-widget").forEach((b) => b.remove()); } +function inferColumnSize(col) { + const m = String(col.className || "").match(/page-column-(\S+)/); + return m ? m[1] : "full"; +} + +function addColumnHeaders() { + const cols = document.querySelectorAll(".page-column"); + cols.forEach((col, colIdx) => { + if (col.querySelector(":scope > .edit-column-header")) return; + const size = inferColumnSize(col); + const header = document.createElement("div"); + header.className = "edit-column-header"; + header.dataset.col = String(colIdx); + header.innerHTML = ` + Column ${colIdx + 1} + +
+ + + + `; + col.insertBefore(header, col.firstChild); + }); + + // Add a "+ Add column" button in the page-columns container. + const container = document.querySelector(".page-columns"); + if (container && !container.parentElement.querySelector(":scope > .edit-add-column")) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "edit-add-column"; + btn.textContent = "+ Add column"; + container.parentElement.insertBefore(btn, container.nextSibling); + } +} + +function removeColumnHeaders() { + document.querySelectorAll(".edit-column-header").forEach((el) => el.remove()); + document.querySelectorAll(".edit-add-column").forEach((el) => el.remove()); +} + +async function postColumnAction(path, body) { + const { slug, baseURL } = pageInfo(); + const init = { method: "POST" }; + if (body instanceof URLSearchParams) { + init.headers = { "Content-Type": "application/x-www-form-urlencoded" }; + init.body = body.toString(); + } + setStatus("Saving…", "saving"); + const r = await fetch( + baseURL + "/edit/api/pages/" + encodeURIComponent(slug) + path, + init, + ); + if (!r.ok) { + setStatus("Failed: " + (await r.text()), "error"); + return false; + } + location.reload(); + return true; +} + +async function addColumn(size) { + const body = new URLSearchParams(); + body.set("size", size || "full"); + return postColumnAction("/columns", body); +} + +async function deleteColumn(col) { + if (!confirm("Delete this column and everything in it?")) return; + return postColumnAction(`/columns/${col}/delete`, null); +} + +async function moveColumn(col, dir) { + return postColumnAction(`/columns/${col}/move?dir=${dir}`, null); +} + +async function setColumnSize(col, size) { + const body = new URLSearchParams(); + body.set("size", size); + return postColumnAction(`/columns/${col}/size`, body); +} + function setStatus(text, kind) { let el = document.getElementById("edit-mode-status"); if (!el) { @@ -776,6 +860,7 @@ function enterEditMode() { document.querySelectorAll("#edit-mode-toggle").forEach((b) => b.classList.add("active")); indexWidgets(); addHandles(); + addColumnHeaders(); addColumnAddButtons(); setStatus("Edit mode — drag widgets to rearrange"); @@ -806,6 +891,7 @@ function exitEditMode() { state.sortables.forEach((s) => s.destroy()); state.sortables = []; removeHandles(); + removeColumnHeaders(); removeColumnAddButtons(); document.getElementById("edit-mode-status")?.remove(); localStorage.removeItem(STORAGE_KEY); @@ -864,6 +950,36 @@ document.addEventListener("click", (e) => { if (addBtn) { e.preventDefault(); showAddPicker(parseInt(addBtn.dataset.col, 10)); + return; + } + const addColBtn = e.target.closest(".edit-add-column"); + if (addColBtn) { + e.preventDefault(); + const size = prompt("Column size: 'small' or 'full'", "full"); + if (size && (size === "small" || size === "full")) addColumn(size); + return; + } + const moveColBtn = e.target.closest(".edit-column-move"); + if (moveColBtn) { + e.preventDefault(); + const header = moveColBtn.closest(".edit-column-header"); + moveColumn(parseInt(header.dataset.col, 10), moveColBtn.dataset.dir); + return; + } + const delColBtn = e.target.closest(".edit-column-delete"); + if (delColBtn) { + e.preventDefault(); + const header = delColBtn.closest(".edit-column-header"); + deleteColumn(parseInt(header.dataset.col, 10)); + return; + } +}); + +document.addEventListener("change", (e) => { + const sizeSel = e.target.closest(".edit-column-size"); + if (sizeSel) { + const header = sizeSel.closest(".edit-column-header"); + setColumnSize(parseInt(header.dataset.col, 10), sizeSel.value); } });