Add page management to the structured editor
Pages can now be reordered, renamed, and have their metadata edited
without touching YAML.
- Up/down arrows next to each page in the /edit list to reorder.
- "Settings" button per page opens /edit/pages/{slug}/settings — a
form covering name, slug, page width, desktop nav width, mobile
header, hide desktop nav, center vertically.
- POST /edit/api/pages/{slug}/fields applies metadata changes
surgically via the existing yaml.Node + atomic-save flow so
columns/widgets/comments stay untouched.
- POST /edit/api/pages/{slug}/move?dir=up|down swaps adjacent pages.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8ba6dcb704
commit
7f36f30dad
@@ -966,6 +966,135 @@ func (a *application) handleAdminWidgetSchemas(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- page settings + reorder ----------
|
||||||
|
|
||||||
|
// pageMetadataKeys is the set of page-level keys the settings form handles.
|
||||||
|
// Anything else (head-widgets, columns) stays untouched on save.
|
||||||
|
var pageMetadataKeys = map[string]bool{
|
||||||
|
"name": true,
|
||||||
|
"slug": true,
|
||||||
|
"width": true,
|
||||||
|
"desktop-navigation-width": true,
|
||||||
|
"show-mobile-header": true,
|
||||||
|
"hide-desktop-navigation": true,
|
||||||
|
"center-vertically": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminUpdatePageFields(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
|
||||||
|
}
|
||||||
|
slug := r.PathValue("page")
|
||||||
|
pageIdx, ok := a.freshPageIndexBySlug(slug)
|
||||||
|
if !ok {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor, err := loadConfigEditor(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageNode, err := editor.pageNodeAt(pageIdx)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make(map[string]interface{})
|
||||||
|
for key := range pageMetadataKeys {
|
||||||
|
if !r.Form.Has(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(r.FormValue(key))
|
||||||
|
switch key {
|
||||||
|
case "show-mobile-header", "hide-desktop-navigation", "center-vertically":
|
||||||
|
fields[key] = raw == "on" || raw == "true" || raw == "1"
|
||||||
|
default:
|
||||||
|
if raw == "" {
|
||||||
|
fields[key] = nil // remove the key from yaml
|
||||||
|
} else {
|
||||||
|
fields[key] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Browsers don't post unchecked checkboxes; explicitly set them false so
|
||||||
|
// turning a flag off persists.
|
||||||
|
for _, key := range []string{"show-mobile-header", "hide-desktop-navigation", "center-vertically"} {
|
||||||
|
if _, set := fields[key]; !set {
|
||||||
|
fields[key] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, ok := fields["name"].(string); !ok || name == "" {
|
||||||
|
adminError(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := applyFieldsToWidget(pageNode, fields); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slug may have changed if the user edited it (or the title without an
|
||||||
|
// explicit slug); redirect to the page list rather than a possibly-stale URL.
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminMovePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := r.PathValue("page")
|
||||||
|
idx, ok := a.freshPageIndexBySlug(slug)
|
||||||
|
if !ok {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delta := 0
|
||||||
|
switch r.URL.Query().Get("dir") {
|
||||||
|
case "up":
|
||||||
|
delta = -1
|
||||||
|
case "down":
|
||||||
|
delta = 1
|
||||||
|
default:
|
||||||
|
adminError(w, http.StatusBadRequest, "dir must be up|down")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor, err := loadConfigEditor(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages, err := editor.pagesNode(false)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := idx + delta
|
||||||
|
if idx < 0 || idx >= len(pages.Content) || target < 0 || target >= len(pages.Content) {
|
||||||
|
adminError(w, http.StatusBadRequest, "cannot move further in that direction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages.Content[idx], pages.Content[target] = pages.Content[target], pages.Content[idx]
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- column endpoints ----------
|
// ---------- column endpoints ----------
|
||||||
|
|
||||||
func (a *application) editColumns(slug string) (*configEditor, *yaml.Node, error) {
|
func (a *application) editColumns(slug string) (*configEditor, *yaml.Node, error) {
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
adminIndexTemplate = mustParseTemplate("admin.html", "document.html", "footer.html")
|
adminIndexTemplate = mustParseTemplate("admin.html", "document.html", "footer.html")
|
||||||
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
||||||
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
||||||
|
adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||||
@@ -208,6 +209,8 @@ type adminPageSummary struct {
|
|||||||
Title string
|
Title string
|
||||||
Slug string
|
Slug string
|
||||||
WidgetCount int
|
WidgetCount int
|
||||||
|
IsFirst bool
|
||||||
|
IsLast bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminPageDetail struct {
|
type adminPageDetail struct {
|
||||||
@@ -217,6 +220,16 @@ type adminPageDetail struct {
|
|||||||
Columns []adminColumnView
|
Columns []adminColumnView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminPageSettings struct {
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Width string
|
||||||
|
DesktopNavigationWidth string
|
||||||
|
ShowMobileHeader bool
|
||||||
|
HideDesktopNavigation bool
|
||||||
|
CenterVertically bool
|
||||||
|
}
|
||||||
|
|
||||||
type adminTemplateData struct {
|
type adminTemplateData struct {
|
||||||
App *application
|
App *application
|
||||||
Request templateRequestData
|
Request templateRequestData
|
||||||
@@ -232,6 +245,10 @@ type adminTemplateData struct {
|
|||||||
DocsURL string
|
DocsURL string
|
||||||
IsNew bool
|
IsNew bool
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
|
|
||||||
|
PageSettings *adminPageSettings
|
||||||
|
IsFirstPage bool
|
||||||
|
IsLastPage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -240,6 +257,7 @@ func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pages := a.freshPagesFromDisk()
|
pages := a.freshPagesFromDisk()
|
||||||
|
last := len(pages) - 1
|
||||||
summaries := make([]adminPageSummary, 0, len(pages))
|
summaries := make([]adminPageSummary, 0, len(pages))
|
||||||
for p := range pages {
|
for p := range pages {
|
||||||
page := &pages[p]
|
page := &pages[p]
|
||||||
@@ -251,6 +269,8 @@ func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Slug: page.Slug,
|
Slug: page.Slug,
|
||||||
WidgetCount: count,
|
WidgetCount: count,
|
||||||
|
IsFirst: p == 0,
|
||||||
|
IsLast: p == last,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +383,36 @@ func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminPageSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, idx, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings := &adminPageSettings{
|
||||||
|
Name: page.Title,
|
||||||
|
Slug: page.Slug,
|
||||||
|
Width: page.Width,
|
||||||
|
DesktopNavigationWidth: page.DesktopNavigationWidth,
|
||||||
|
ShowMobileHeader: page.ShowMobileHeader,
|
||||||
|
HideDesktopNavigation: page.HideDesktopNavigation,
|
||||||
|
CenterVertically: page.CenterVertically,
|
||||||
|
}
|
||||||
|
data := adminTemplateData{
|
||||||
|
App: a,
|
||||||
|
Page: &adminPageDetail{Title: page.Title, Slug: page.Slug},
|
||||||
|
PageSettings: settings,
|
||||||
|
IsFirstPage: idx == 0,
|
||||||
|
IsLastPage: idx == len(pages)-1,
|
||||||
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminPageSettingsTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) handleAdminNewWidget(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handleAdminNewWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.adminAccessAllowed(w, r) {
|
if !a.adminAccessAllowed(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /edit", a.handleAdminIndex)
|
mux.HandleFunc("GET /edit", a.handleAdminIndex)
|
||||||
mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage)
|
mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage)
|
||||||
|
mux.HandleFunc("GET /edit/pages/{page}/settings", a.handleAdminPageSettings)
|
||||||
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
|
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
|
||||||
|
|
||||||
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage)
|
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage)
|
||||||
@@ -474,6 +475,8 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/delete", a.handleAdminDeleteColumn)
|
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}/move", a.handleAdminMoveColumn)
|
||||||
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize)
|
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/fields", a.handleAdminUpdatePageFields)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage)
|
||||||
|
|
||||||
if a.RequiresAuth {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Settings: {{ .Page.Title }} - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
|
<span class="color-subdue"> / </span>
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}">{{ .Page.Title }}</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-25">Page settings</h1>
|
||||||
|
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/pages/{{ .Page.Slug }}/fields"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Name <span style="color:var(--color-negative);">*</span>
|
||||||
|
<input type="text" name="name" class="input" required value="{{ .PageSettings.Name }}">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
URL slug
|
||||||
|
<input type="text" name="slug" class="input" value="{{ .PageSettings.Slug }}" placeholder="auto from name">
|
||||||
|
<small class="color-subdue size-h5">Leave blank to auto-generate from the name.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Page width
|
||||||
|
<select name="width" class="input">
|
||||||
|
<option value="" {{ if eq .PageSettings.Width "" }}selected{{ end }}>default</option>
|
||||||
|
<option value="slim" {{ if eq .PageSettings.Width "slim" }}selected{{ end }}>slim</option>
|
||||||
|
<option value="wide" {{ if eq .PageSettings.Width "wide" }}selected{{ end }}>wide</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Desktop nav width
|
||||||
|
<select name="desktop-navigation-width" class="input">
|
||||||
|
<option value="" {{ if eq .PageSettings.DesktopNavigationWidth "" }}selected{{ end }}>same as page width</option>
|
||||||
|
<option value="slim" {{ if eq .PageSettings.DesktopNavigationWidth "slim" }}selected{{ end }}>slim</option>
|
||||||
|
<option value="wide" {{ if eq .PageSettings.DesktopNavigationWidth "wide" }}selected{{ end }}>wide</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="show-mobile-header" {{ if .PageSettings.ShowMobileHeader }}checked{{ end }}>
|
||||||
|
<span>Show mobile header (page title bar)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="hide-desktop-navigation" {{ if .PageSettings.HideDesktopNavigation }}checked{{ end }}>
|
||||||
|
<span>Hide desktop nav (single-page mode)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="center-vertically" {{ if .PageSettings.CenterVertically }}checked{{ end }}>
|
||||||
|
<span>Center vertically</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -8,7 +8,12 @@
|
|||||||
<div class="margin-bottom-15">
|
<div class="margin-bottom-15">
|
||||||
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="size-h1">{{ .Page.Title }}</h1>
|
<div class="flex items-center gap-10">
|
||||||
|
<h1 class="size-h1 grow">{{ .Page.Title }}</h1>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}/settings">
|
||||||
|
<button type="button">Page settings</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<p class="color-subdue margin-bottom-25">/{{ .Page.Slug }}</p>
|
<p class="color-subdue margin-bottom-25">/{{ .Page.Slug }}</p>
|
||||||
|
|
||||||
{{- if .Page.HeadWidgets }}
|
{{- if .Page.HeadWidgets }}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@
|
|||||||
<span class="color-subdue"> — {{ .WidgetCount }} widget{{ if ne .WidgetCount 1 }}s{{ end }}</span>
|
<span class="color-subdue"> — {{ .WidgetCount }} widget{{ if ne .WidgetCount 1 }}s{{ end }}</span>
|
||||||
<div class="color-subdue size-h5">/{{ .Slug }}</div>
|
<div class="color-subdue size-h5">/{{ .Slug }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/move?dir=up">
|
||||||
|
<button type="submit" {{ if .IsFirst }}disabled{{ end }} title="Move up">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/move?dir=down">
|
||||||
|
<button type="submit" {{ if .IsLast }}disabled{{ end }} title="Move down">↓</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ .Slug }}/settings" title="Page settings">
|
||||||
|
<button type="button">Settings</button>
|
||||||
|
</a>
|
||||||
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/delete"
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/delete"
|
||||||
onsubmit="return confirm('Delete page "{{ .Title }}" and all its widgets?');">
|
onsubmit="return confirm('Delete page "{{ .Title }}" and all its widgets?');">
|
||||||
<button type="submit" class="color-negative">Delete</button>
|
<button type="submit" class="color-negative">Delete</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user