Add in-app edit UI for dashboard config
A web editor at /edit for managing pages and widgets without hand-editing YAML:
- Page list and per-page widget views with breadcrumb navigation
- Add/delete pages, add/delete/reorder widgets within a column
- Per-widget textarea editor with curated starter templates, widget-specific
help (example, full field reference, link to upstream docs), and inline
error rendering that preserves the user's edits on validation failure
- Pre-save validation via newConfigFromYAML; atomic temp+rename writes with
.bak backup; YAML edits go through yaml.Node so comments and unresolved
${env:X}/${secret:X} tokens survive round-trips
- Auth gate: editing requires auth.users to be configured, or
admin.allow-without-auth: true as an explicit opt-in for trusted networks
- Pencil button on the dashboard near the theme picker for quick access
- Admin views read fresh from disk so the list updates immediately after
saves rather than lagging the fsnotify watcher
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6c5b7a3f4c
commit
2e198a19b4
@@ -3,3 +3,4 @@
|
|||||||
/playground
|
/playground
|
||||||
/.idea
|
/.idea
|
||||||
/glance*.yml
|
/glance*.yml
|
||||||
|
*.yml.bak
|
||||||
|
|||||||
+34
-20
@@ -1,3 +1,23 @@
|
|||||||
|
# --- Edit access ---
|
||||||
|
# /edit is locked down by default. Pick ONE of the two blocks below.
|
||||||
|
#
|
||||||
|
# Option A (recommended): require login.
|
||||||
|
# Generate values with:
|
||||||
|
# go run . secret:make
|
||||||
|
# go run . password:hash <your-password>
|
||||||
|
#
|
||||||
|
# auth:
|
||||||
|
# secret-key: <paste base64 secret here>
|
||||||
|
# users:
|
||||||
|
# yourname:
|
||||||
|
# password-hash: <paste bcrypt hash here>
|
||||||
|
#
|
||||||
|
# Option B: open /edit without authentication. ONLY on a trusted network —
|
||||||
|
# anyone who can reach this server can rewrite your config.
|
||||||
|
# (The config key is still named `admin` for backward compatibility.)
|
||||||
|
#
|
||||||
|
admin:
|
||||||
|
allow-without-auth: true
|
||||||
pages:
|
pages:
|
||||||
- name: Home
|
- name: Home
|
||||||
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
||||||
@@ -7,7 +27,6 @@ pages:
|
|||||||
widgets:
|
widgets:
|
||||||
- type: calendar
|
- type: calendar
|
||||||
first-day-of-week: monday
|
first-day-of-week: monday
|
||||||
|
|
||||||
- type: rss
|
- type: rss
|
||||||
limit: 10
|
limit: 10
|
||||||
collapse-after: 3
|
collapse-after: 3
|
||||||
@@ -22,7 +41,8 @@ pages:
|
|||||||
- url: https://samwho.dev/rss.xml
|
- url: https://samwho.dev/rss.xml
|
||||||
- url: https://ishadeed.com/feed.xml
|
- url: https://ishadeed.com/feed.xml
|
||||||
title: Ahmad Shadeed
|
title: Ahmad Shadeed
|
||||||
|
- url: https://www.techmeme.com/feed.xml
|
||||||
|
title: TechMeme
|
||||||
- type: twitch-channels
|
- type: twitch-channels
|
||||||
channels:
|
channels:
|
||||||
- theprimeagen
|
- theprimeagen
|
||||||
@@ -31,14 +51,12 @@ pages:
|
|||||||
- cohhcarnage
|
- cohhcarnage
|
||||||
- christitustech
|
- christitustech
|
||||||
- EJ_SA
|
- EJ_SA
|
||||||
|
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
- type: hacker-news
|
- type: hacker-news
|
||||||
- type: lobsters
|
- type: lobsters
|
||||||
|
|
||||||
- type: videos
|
- type: videos
|
||||||
channels:
|
channels:
|
||||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
||||||
@@ -46,7 +64,6 @@ pages:
|
|||||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
||||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
||||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
||||||
|
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
- type: reddit
|
- type: reddit
|
||||||
@@ -55,7 +72,6 @@ pages:
|
|||||||
- type: reddit
|
- type: reddit
|
||||||
subreddit: selfhosted
|
subreddit: selfhosted
|
||||||
show-thumbnails: true
|
show-thumbnails: true
|
||||||
|
|
||||||
- size: small
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
- type: weather
|
- type: weather
|
||||||
@@ -64,7 +80,6 @@ pages:
|
|||||||
hour-format: 12h # alternatively "24h"
|
hour-format: 12h # alternatively "24h"
|
||||||
# Optionally hide the location from being displayed in the widget
|
# Optionally hide the location from being displayed in the widget
|
||||||
# hide-location: true
|
# hide-location: true
|
||||||
|
|
||||||
- type: markets
|
- type: markets
|
||||||
markets:
|
markets:
|
||||||
- symbol: SPY
|
- symbol: SPY
|
||||||
@@ -77,7 +92,6 @@ pages:
|
|||||||
name: Apple
|
name: Apple
|
||||||
- symbol: MSFT
|
- symbol: MSFT
|
||||||
name: Microsoft
|
name: Microsoft
|
||||||
|
|
||||||
- type: releases
|
- type: releases
|
||||||
cache: 1d
|
cache: 1d
|
||||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
||||||
@@ -89,17 +103,17 @@ pages:
|
|||||||
- immich-app/immich
|
- immich-app/immich
|
||||||
- syncthing/syncthing
|
- syncthing/syncthing
|
||||||
|
|
||||||
# Add more pages here:
|
# Add more pages here:
|
||||||
# - name: Your page name
|
# - name: Your page name
|
||||||
# columns:
|
# columns:
|
||||||
# - size: small
|
# - size: small
|
||||||
# widgets:
|
# widgets:
|
||||||
# # Add widgets here
|
# # Add widgets here
|
||||||
|
|
||||||
# - size: full
|
# - size: full
|
||||||
# widgets:
|
# widgets:
|
||||||
# # Add widgets here
|
# # Add widgets here
|
||||||
|
|
||||||
# - size: small
|
# - size: small
|
||||||
# widgets:
|
# widgets:
|
||||||
# # Add widgets here
|
# # Add widgets here
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configEditor manipulates glance.yml as a yaml.Node tree, preserving
|
||||||
|
// comments, ordering, and unresolved ${env:X}/${secret:X} tokens that
|
||||||
|
// would be destroyed by round-tripping through the parsed config struct.
|
||||||
|
type configEditor struct {
|
||||||
|
path string
|
||||||
|
root yaml.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigEditor(path string) (*configEditor, error) {
|
||||||
|
_, includes, err := parseYAMLIncludes(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
if len(includes) > 0 {
|
||||||
|
return nil, fmt.Errorf("admin editing is not supported when the config uses include directives — edit included files manually for now")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &configEditor{path: path}
|
||||||
|
if err := yaml.Unmarshal(raw, &e.root); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config yaml: %w", err)
|
||||||
|
}
|
||||||
|
if e.root.Kind != yaml.DocumentNode || len(e.root.Content) == 0 {
|
||||||
|
return nil, fmt.Errorf("config has no document content")
|
||||||
|
}
|
||||||
|
if e.root.Content[0].Kind != yaml.MappingNode {
|
||||||
|
return nil, fmt.Errorf("config top-level is not a mapping")
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// save validates the edited tree against newConfigFromYAML, writes a
|
||||||
|
// .bak of the previous file, then writes via temp+rename for atomicity.
|
||||||
|
func (e *configEditor) save() error {
|
||||||
|
out, err := yaml.Marshal(&e.root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := newConfigFromYAML(out); err != nil {
|
||||||
|
return fmt.Errorf("edited config is invalid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, err := os.ReadFile(e.path); err == nil {
|
||||||
|
if werr := os.WriteFile(e.path+".bak", existing, 0644); werr != nil {
|
||||||
|
log.Printf("admin: failed to write backup: %v", werr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := e.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, out, 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, e.path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("renaming temp file into place: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *configEditor) topMapping() *yaml.Node {
|
||||||
|
return e.root.Content[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrCreateKey returns the value node for the given key under a mapping.
|
||||||
|
// If create is true and the key does not exist, it is appended with a fresh
|
||||||
|
// value node of the given kind.
|
||||||
|
func findOrCreateKey(m *yaml.Node, key string, create bool, valueKind yaml.Kind) (*yaml.Node, error) {
|
||||||
|
if m.Kind != yaml.MappingNode {
|
||||||
|
return nil, fmt.Errorf("node is not a mapping")
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(m.Content); i += 2 {
|
||||||
|
if m.Content[i].Value == key {
|
||||||
|
return m.Content[i+1], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !create {
|
||||||
|
return nil, fmt.Errorf("key %q not found", key)
|
||||||
|
}
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
||||||
|
valNode := &yaml.Node{Kind: valueKind}
|
||||||
|
m.Content = append(m.Content, keyNode, valNode)
|
||||||
|
return valNode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *configEditor) pagesNode(create bool) (*yaml.Node, error) {
|
||||||
|
return findOrCreateKey(e.topMapping(), "pages", create, yaml.SequenceNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetSlot returns the sequence node holding the widget and the widget's
|
||||||
|
// position within it. Caller can read via seq.Content[pos] or replace by
|
||||||
|
// assigning to seq.Content[pos]. Use colIdx = -1 for head widgets.
|
||||||
|
func (e *configEditor) widgetSlot(pageIdx, colIdx, widgetIdx int) (*yaml.Node, int, error) {
|
||||||
|
pageNode, err := e.pageNodeAt(pageIdx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq *yaml.Node
|
||||||
|
if colIdx == -1 {
|
||||||
|
seq, err = findOrCreateKey(pageNode, "head-widgets", false, yaml.SequenceNode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
columns, err := columnsOf(pageNode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if colIdx < 0 || colIdx >= len(columns.Content) {
|
||||||
|
return nil, 0, fmt.Errorf("column index out of range")
|
||||||
|
}
|
||||||
|
seq, err = widgetsOf(columns.Content[colIdx])
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if widgetIdx < 0 || widgetIdx >= len(seq.Content) {
|
||||||
|
return nil, 0, fmt.Errorf("widget index out of range")
|
||||||
|
}
|
||||||
|
return seq, widgetIdx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *configEditor) pageNodeAt(idx int) (*yaml.Node, error) {
|
||||||
|
pages, err := e.pagesNode(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pages.Kind != yaml.SequenceNode {
|
||||||
|
return nil, fmt.Errorf("pages is not a sequence")
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(pages.Content) {
|
||||||
|
return nil, fmt.Errorf("page index %d out of range", idx)
|
||||||
|
}
|
||||||
|
return pages.Content[idx], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnsOf(pageNode *yaml.Node) (*yaml.Node, error) {
|
||||||
|
return findOrCreateKey(pageNode, "columns", false, yaml.SequenceNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func widgetsOf(columnNode *yaml.Node) (*yaml.Node, error) {
|
||||||
|
return findOrCreateKey(columnNode, "widgets", true, yaml.SequenceNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPageNode(title string) *yaml.Node {
|
||||||
|
return &yaml.Node{
|
||||||
|
Kind: yaml.MappingNode,
|
||||||
|
Content: []*yaml.Node{
|
||||||
|
{Kind: yaml.ScalarNode, Value: "name"},
|
||||||
|
{Kind: yaml.ScalarNode, Value: title},
|
||||||
|
{Kind: yaml.ScalarNode, Value: "columns"},
|
||||||
|
{Kind: yaml.SequenceNode, Content: []*yaml.Node{
|
||||||
|
{Kind: yaml.MappingNode, Content: []*yaml.Node{
|
||||||
|
{Kind: yaml.ScalarNode, Value: "size"},
|
||||||
|
{Kind: yaml.ScalarNode, Value: "full"},
|
||||||
|
{Kind: yaml.ScalarNode, Value: "widgets"},
|
||||||
|
{Kind: yaml.SequenceNode},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminError renders a minimal HTML error page so failed mutations don't
|
||||||
|
// leave the user staring at a blank screen. The hot-reload watcher will
|
||||||
|
// still pick up any successful intermediate save state.
|
||||||
|
func adminError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
w.Write([]byte("<!doctype html><meta charset=utf-8><title>Edit error</title><body style=\"font-family:sans-serif;padding:2rem;max-width:50rem\"><h1>Couldn't save</h1><p>The change wasn't applied. Your config file is unchanged.</p><pre style=\"white-space:pre-wrap;background:#f4f4f4;padding:1rem;border-radius:4px\">"))
|
||||||
|
w.Write([]byte(htmlEscape(msg)))
|
||||||
|
w.Write([]byte("</pre><p><a href=\"javascript:history.back()\">Back</a></p>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- handlers ----
|
||||||
|
|
||||||
|
func (a *application) handleAdminAddPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(r.FormValue("title"))
|
||||||
|
if title == "" {
|
||||||
|
adminError(w, http.StatusBadRequest, "page title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor, err := loadConfigEditor(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages, err := editor.pagesNode(true)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages.Content = append(pages.Content, newPageNode(title))
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminDeletePage(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if idx >= len(pages.Content) {
|
||||||
|
adminError(w, http.StatusInternalServerError, "page index out of range — config may have been edited externally")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(pages.Content) <= 1 {
|
||||||
|
adminError(w, http.StatusBadRequest, "cannot delete the last remaining page")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages.Content = append(pages.Content[:idx], pages.Content[idx+1:]...)
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminDeleteWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := r.PathValue("page")
|
||||||
|
pageIdx, ok := a.freshPageIndexBySlug(slug)
|
||||||
|
if !ok {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
columns, err := columnsOf(pageNode)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if col < 0 || col >= len(columns.Content) {
|
||||||
|
adminError(w, http.StatusBadRequest, "column index out of range")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgets, err := widgetsOf(columns.Content[col])
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(widgets.Content) {
|
||||||
|
adminError(w, http.StatusBadRequest, "widget index out of range")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgets.Content = append(widgets.Content[:idx], widgets.Content[idx+1:]...)
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/pages/"+slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminMoveWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := r.PathValue("page")
|
||||||
|
pageIdx, ok := a.freshPageIndexBySlug(slug)
|
||||||
|
if !ok {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := r.URL.Query().Get("dir")
|
||||||
|
delta := 0
|
||||||
|
switch dir {
|
||||||
|
case "up":
|
||||||
|
delta = -1
|
||||||
|
case "down":
|
||||||
|
delta = 1
|
||||||
|
default:
|
||||||
|
adminError(w, http.StatusBadRequest, "dir must be 'up' or 'down'")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
columns, err := columnsOf(pageNode)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if col < 0 || col >= len(columns.Content) {
|
||||||
|
adminError(w, http.StatusBadRequest, "column index out of range")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgets, err := widgetsOf(columns.Content[col])
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := idx + delta
|
||||||
|
if idx < 0 || idx >= len(widgets.Content) || target < 0 || target >= len(widgets.Content) {
|
||||||
|
adminError(w, http.StatusBadRequest, "cannot move further in that direction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgets.Content[idx], widgets.Content[target] = widgets.Content[target], widgets.Content[idx]
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/pages/"+slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminEditWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := r.PathValue("page")
|
||||||
|
page, pageIdx, exists := findPageBySlug(a.freshPagesFromDisk(), slug)
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlText := r.FormValue("yaml")
|
||||||
|
|
||||||
|
rerenderError := func(msg string) {
|
||||||
|
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||||
|
pageTitle: page.Title,
|
||||||
|
pageSlug: page.Slug,
|
||||||
|
col: col,
|
||||||
|
idx: idx,
|
||||||
|
widgetType: widgetTypeFromYAML(yamlText),
|
||||||
|
yamlText: yamlText,
|
||||||
|
errorMessage: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newNode, err := parseWidgetYAML(yamlText)
|
||||||
|
if err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor, err := loadConfigEditor(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seq, pos, err := editor.widgetSlot(pageIdx, col, idx)
|
||||||
|
if err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seq.Content[pos] = newNode
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r,
|
||||||
|
fmt.Sprintf("%s/edit/pages/%s/widgets/%d/%d", a.Config.Server.BaseURL, slug, col, idx),
|
||||||
|
http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWidgetYAML parses the editor's submitted text into a single mapping
|
||||||
|
// node suitable for splicing into the config tree.
|
||||||
|
func parseWidgetYAML(yamlText string) (*yaml.Node, error) {
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := yaml.Unmarshal([]byte(yamlText), &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("YAML parse error: %w", err)
|
||||||
|
}
|
||||||
|
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
|
||||||
|
return nil, fmt.Errorf("submitted YAML is empty")
|
||||||
|
}
|
||||||
|
node := doc.Content[0]
|
||||||
|
if node.Kind != yaml.MappingNode {
|
||||||
|
return nil, fmt.Errorf("widget YAML must be a mapping (key: value pairs)")
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminCreateWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := r.PathValue("page")
|
||||||
|
page, pageIdx, exists := findPageBySlug(a.freshPagesFromDisk(), slug)
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlText := r.FormValue("yaml")
|
||||||
|
|
||||||
|
rerenderError := func(msg string) {
|
||||||
|
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||||
|
pageTitle: page.Title,
|
||||||
|
pageSlug: page.Slug,
|
||||||
|
isNew: true,
|
||||||
|
col: col,
|
||||||
|
widgetType: widgetTypeFromYAML(yamlText),
|
||||||
|
yamlText: yamlText,
|
||||||
|
errorMessage: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newNode, err := parseWidgetYAML(yamlText)
|
||||||
|
if err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor, err := loadConfigEditor(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageNode, err := editor.pageNodeAt(pageIdx)
|
||||||
|
if err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
columns, err := columnsOf(pageNode)
|
||||||
|
if err != nil {
|
||||||
|
rerenderError("page has no columns; add a column first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if col < 0 || col >= len(columns.Content) {
|
||||||
|
rerenderError("column index out of range")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgets, err := widgetsOf(columns.Content[col])
|
||||||
|
if err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgets.Content = append(widgets.Content, newNode)
|
||||||
|
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
rerenderError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/pages/"+slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
adminIndexTemplate = mustParseTemplate("admin.html", "document.html", "footer.html")
|
||||||
|
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
||||||
|
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||||
|
var allWidgetTypes = []string{
|
||||||
|
"bookmarks", "calendar", "change-detection", "clock", "custom-api",
|
||||||
|
"dns-stats", "docker-containers", "extension", "group", "hacker-news",
|
||||||
|
"html", "iframe", "lobsters", "markets", "monitor", "reddit",
|
||||||
|
"releases", "repository", "rss", "search", "server-stats",
|
||||||
|
"split-column", "to-do", "twitch-channels", "twitch-top-games",
|
||||||
|
"videos", "weather",
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetFieldReferences caches the marshaled YAML of an empty instance of
|
||||||
|
// each widget type, used to show users what fields are available when
|
||||||
|
// editing. Computed once at startup.
|
||||||
|
var widgetFieldReferences = func() map[string]string {
|
||||||
|
out := make(map[string]string, len(allWidgetTypes))
|
||||||
|
for _, t := range allWidgetTypes {
|
||||||
|
w, err := newWidget(t)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := yaml.Marshal(w)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[t] = string(b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}()
|
||||||
|
|
||||||
|
// widgetDocsAnchors maps a widget type to its anchor in upstream Glance docs.
|
||||||
|
// Anchors are GitHub-rendered from the headings in docs/configuration.md.
|
||||||
|
var widgetDocsAnchors = map[string]string{
|
||||||
|
"bookmarks": "bookmarks",
|
||||||
|
"calendar": "calendar",
|
||||||
|
"calendar-legacy": "calendar-legacy",
|
||||||
|
"change-detection": "changedetectionio",
|
||||||
|
"clock": "clock",
|
||||||
|
"custom-api": "custom-api",
|
||||||
|
"dns-stats": "dns-stats",
|
||||||
|
"docker-containers": "docker-containers",
|
||||||
|
"extension": "extension",
|
||||||
|
"group": "group",
|
||||||
|
"hacker-news": "hacker-news",
|
||||||
|
"lobsters": "lobsters",
|
||||||
|
"markets": "markets",
|
||||||
|
"monitor": "monitor",
|
||||||
|
"reddit": "reddit",
|
||||||
|
"releases": "releases",
|
||||||
|
"repository": "repository",
|
||||||
|
"rss": "rss",
|
||||||
|
"search": "search-widget",
|
||||||
|
"server-stats": "server-stats",
|
||||||
|
"split-column": "split-column",
|
||||||
|
"to-do": "todo",
|
||||||
|
"twitch-channels": "twitch-channels",
|
||||||
|
"twitch-top-games": "twitch-top-games",
|
||||||
|
"videos": "videos",
|
||||||
|
"weather": "weather",
|
||||||
|
}
|
||||||
|
|
||||||
|
func widgetDocsURL(widgetType string) string {
|
||||||
|
anchor, ok := widgetDocsAnchors[widgetType]
|
||||||
|
if !ok {
|
||||||
|
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#widgets"
|
||||||
|
}
|
||||||
|
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#" + anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetStarters provides hand-curated minimal-but-meaningful YAML for each
|
||||||
|
// widget type, drawn from the upstream docs. Used to pre-fill the editor
|
||||||
|
// when adding a new widget so users see a working shape they can adapt.
|
||||||
|
var widgetStarters = map[string]string{
|
||||||
|
"bookmarks": `type: bookmarks
|
||||||
|
groups:
|
||||||
|
- title: Sites
|
||||||
|
links:
|
||||||
|
- title: Glance
|
||||||
|
url: https://github.com/glanceapp/glance
|
||||||
|
`,
|
||||||
|
"calendar": "type: calendar\n",
|
||||||
|
"calendar-legacy": "type: calendar-legacy\n",
|
||||||
|
"change-detection": `type: change-detection
|
||||||
|
instance-url: https://changedetection.example.com/
|
||||||
|
token: ${CHANGE_DETECTION_TOKEN}
|
||||||
|
`,
|
||||||
|
"clock": "type: clock\n",
|
||||||
|
"custom-api": `type: custom-api
|
||||||
|
url: https://api.example.com/data
|
||||||
|
template: |
|
||||||
|
<p>Replace with a Go template that renders the JSON response.</p>
|
||||||
|
`,
|
||||||
|
"dns-stats": `type: dns-stats
|
||||||
|
service: pihole
|
||||||
|
url: http://pi.hole
|
||||||
|
allow-insecure: true
|
||||||
|
username: admin
|
||||||
|
password: ${DNS_PASSWORD}
|
||||||
|
`,
|
||||||
|
"docker-containers": "type: docker-containers\n",
|
||||||
|
"extension": `type: extension
|
||||||
|
url: https://example.com/extension
|
||||||
|
`,
|
||||||
|
"group": `type: group
|
||||||
|
widgets:
|
||||||
|
- type: hacker-news
|
||||||
|
- type: lobsters
|
||||||
|
`,
|
||||||
|
"hacker-news": "type: hacker-news\n",
|
||||||
|
"html": `type: html
|
||||||
|
source: |
|
||||||
|
<p>Hello from a custom HTML widget.</p>
|
||||||
|
`,
|
||||||
|
"iframe": `type: iframe
|
||||||
|
source: https://example.com
|
||||||
|
`,
|
||||||
|
"lobsters": "type: lobsters\n",
|
||||||
|
"markets": `type: markets
|
||||||
|
markets:
|
||||||
|
- symbol: SPY
|
||||||
|
name: S&P 500
|
||||||
|
- symbol: BTC-USD
|
||||||
|
name: Bitcoin
|
||||||
|
`,
|
||||||
|
"monitor": `type: monitor
|
||||||
|
title: Services
|
||||||
|
sites:
|
||||||
|
- title: Example
|
||||||
|
url: https://example.com
|
||||||
|
`,
|
||||||
|
"reddit": `type: reddit
|
||||||
|
subreddit: technology
|
||||||
|
`,
|
||||||
|
"releases": `type: releases
|
||||||
|
repositories:
|
||||||
|
- glanceapp/glance
|
||||||
|
`,
|
||||||
|
"repository": `type: repository
|
||||||
|
repository: glanceapp/glance
|
||||||
|
`,
|
||||||
|
"rss": `type: rss
|
||||||
|
feeds:
|
||||||
|
- url: https://www.theverge.com/rss/index.xml
|
||||||
|
title: The Verge
|
||||||
|
`,
|
||||||
|
"search": "type: search\n",
|
||||||
|
"server-stats": "type: server-stats\n",
|
||||||
|
"split-column": `type: split-column
|
||||||
|
widgets:
|
||||||
|
- type: clock
|
||||||
|
- type: search
|
||||||
|
`,
|
||||||
|
"to-do": "type: to-do\n",
|
||||||
|
"twitch-channels": `type: twitch-channels
|
||||||
|
channels:
|
||||||
|
- shroud
|
||||||
|
`,
|
||||||
|
"twitch-top-games": "type: twitch-top-games\n",
|
||||||
|
"videos": `type: videos
|
||||||
|
channels:
|
||||||
|
- UCXuqSBlHAE6Xw-yeJA0Tunw
|
||||||
|
`,
|
||||||
|
"weather": `type: weather
|
||||||
|
location: London, GB
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func widgetStarterYAML(widgetType string) string {
|
||||||
|
if s, ok := widgetStarters[widgetType]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "type: " + widgetType + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminWidgetView struct {
|
||||||
|
ColumnIndex int
|
||||||
|
WidgetIndex int
|
||||||
|
ID uint64
|
||||||
|
Title string
|
||||||
|
Type string
|
||||||
|
IsFirst bool
|
||||||
|
IsLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminColumnView struct {
|
||||||
|
Index int
|
||||||
|
Size string
|
||||||
|
Widgets []adminWidgetView
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPageSummary struct {
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
WidgetCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPageDetail struct {
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
HeadWidgets []adminWidgetView
|
||||||
|
Columns []adminColumnView
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminTemplateData struct {
|
||||||
|
App *application
|
||||||
|
Request templateRequestData
|
||||||
|
|
||||||
|
Pages []adminPageSummary
|
||||||
|
Page *adminPageDetail
|
||||||
|
Widget *adminWidgetView
|
||||||
|
WidgetTypes []string
|
||||||
|
WidgetYAML string
|
||||||
|
LoadError string
|
||||||
|
FieldReference string
|
||||||
|
WidgetExample string
|
||||||
|
DocsURL string
|
||||||
|
IsNew bool
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
summaries := make([]adminPageSummary, 0, len(pages))
|
||||||
|
for p := range pages {
|
||||||
|
page := &pages[p]
|
||||||
|
count := len(page.HeadWidgets)
|
||||||
|
for c := range page.Columns {
|
||||||
|
count += len(page.Columns[c].Widgets)
|
||||||
|
}
|
||||||
|
summaries = append(summaries, adminPageSummary{
|
||||||
|
Title: page.Title,
|
||||||
|
Slug: page.Slug,
|
||||||
|
WidgetCount: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := adminTemplateData{App: a, Pages: summaries}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminIndexTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := adminPageDetail{Title: page.Title, Slug: page.Slug}
|
||||||
|
|
||||||
|
for i, widget := range page.HeadWidgets {
|
||||||
|
detail.HeadWidgets = append(detail.HeadWidgets, adminWidgetView{
|
||||||
|
ColumnIndex: -1,
|
||||||
|
WidgetIndex: i,
|
||||||
|
ID: widget.GetID(),
|
||||||
|
Title: widget.GetTitle(),
|
||||||
|
Type: widget.GetType(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for c := range page.Columns {
|
||||||
|
col := &page.Columns[c]
|
||||||
|
view := adminColumnView{Index: c, Size: col.Size}
|
||||||
|
last := len(col.Widgets) - 1
|
||||||
|
for w, widget := range col.Widgets {
|
||||||
|
view.Widgets = append(view.Widgets, adminWidgetView{
|
||||||
|
ColumnIndex: c,
|
||||||
|
WidgetIndex: w,
|
||||||
|
ID: widget.GetID(),
|
||||||
|
Title: widget.GetTitle(),
|
||||||
|
Type: widget.GetType(),
|
||||||
|
IsFirst: w == 0,
|
||||||
|
IsLast: w == last,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
detail.Columns = append(detail.Columns, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := adminTemplateData{App: a, Page: &detail, WidgetTypes: allWidgetTypes}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminPageTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, pageIdx, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget widget
|
||||||
|
if col == -1 {
|
||||||
|
if idx < 0 || idx >= len(page.HeadWidgets) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widget = page.HeadWidgets[idx]
|
||||||
|
} else {
|
||||||
|
if col < 0 || col >= len(page.Columns) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
column := &page.Columns[col]
|
||||||
|
if idx < 0 || idx >= len(column.Widgets) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widget = column.Widgets[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlText, loadErr := loadWidgetYAML(a.ConfigPath, pageIdx, col, idx)
|
||||||
|
|
||||||
|
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||||
|
pageTitle: page.Title,
|
||||||
|
pageSlug: page.Slug,
|
||||||
|
col: col,
|
||||||
|
idx: idx,
|
||||||
|
widgetType: widget.GetType(),
|
||||||
|
widgetTitle: widget.GetTitle(),
|
||||||
|
yamlText: yamlText,
|
||||||
|
loadError: loadErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminNewWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil || col < 0 || col >= len(page.Columns) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgetType := r.URL.Query().Get("type")
|
||||||
|
if _, err := newWidget(widgetType); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, "unknown widget type: "+widgetType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||||
|
pageTitle: page.Title,
|
||||||
|
pageSlug: page.Slug,
|
||||||
|
isNew: true,
|
||||||
|
col: col,
|
||||||
|
widgetType: widgetType,
|
||||||
|
yamlText: widgetStarterYAML(widgetType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type editorRenderInput struct {
|
||||||
|
pageTitle string
|
||||||
|
pageSlug string
|
||||||
|
isNew bool
|
||||||
|
col int
|
||||||
|
idx int
|
||||||
|
widgetType string
|
||||||
|
widgetTitle string
|
||||||
|
yamlText string
|
||||||
|
loadError string
|
||||||
|
errorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) renderWidgetEditor(w http.ResponseWriter, r *http.Request, in editorRenderInput) {
|
||||||
|
view := adminWidgetView{
|
||||||
|
ColumnIndex: in.col,
|
||||||
|
WidgetIndex: in.idx,
|
||||||
|
Title: in.widgetTitle,
|
||||||
|
Type: in.widgetType,
|
||||||
|
}
|
||||||
|
detail := adminPageDetail{Title: in.pageTitle, Slug: in.pageSlug}
|
||||||
|
|
||||||
|
data := adminTemplateData{
|
||||||
|
App: a,
|
||||||
|
Page: &detail,
|
||||||
|
Widget: &view,
|
||||||
|
IsNew: in.isNew,
|
||||||
|
WidgetYAML: in.yamlText,
|
||||||
|
LoadError: in.loadError,
|
||||||
|
ErrorMessage: in.errorMessage,
|
||||||
|
FieldReference: widgetFieldReferences[in.widgetType],
|
||||||
|
WidgetExample: widgetStarters[in.widgetType],
|
||||||
|
DocsURL: widgetDocsURL(in.widgetType),
|
||||||
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminWidgetTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetTypeFromYAML extracts just the `type:` field from a YAML mapping.
|
||||||
|
// Used when re-rendering the editor after a save error so docs/reference
|
||||||
|
// match what the user is actually editing.
|
||||||
|
func widgetTypeFromYAML(yamlText string) string {
|
||||||
|
var probe struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
_ = yaml.Unmarshal([]byte(yamlText), &probe)
|
||||||
|
return probe.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshPagesFromDisk reads the on-disk config and returns the parsed pages
|
||||||
|
// with slugs auto-generated. Admin views use this instead of a.Config.Pages
|
||||||
|
// because the fsnotify watcher debounces 500ms after a save, and during that
|
||||||
|
// window a.Config still reflects the pre-save state. Falls back to a copy
|
||||||
|
// of the cached config on any error.
|
||||||
|
func (a *application) freshPagesFromDisk() []page {
|
||||||
|
contents, _, err := parseYAMLIncludes(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return a.Config.Pages
|
||||||
|
}
|
||||||
|
cfg, err := newConfigFromYAML(contents)
|
||||||
|
if err != nil {
|
||||||
|
return a.Config.Pages
|
||||||
|
}
|
||||||
|
for p := range cfg.Pages {
|
||||||
|
if cfg.Pages[p].Slug == "" {
|
||||||
|
cfg.Pages[p].Slug = titleToSlug(cfg.Pages[p].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg.Pages
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPageBySlug(pages []page, slug string) (*page, int, bool) {
|
||||||
|
for i := range pages {
|
||||||
|
if pages[i].Slug == slug {
|
||||||
|
return &pages[i], i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, -1, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshPageIndexBySlug looks up the on-disk page index by slug. Mutating
|
||||||
|
// handlers must use this rather than a.pageIndexBySlug because the cached
|
||||||
|
// a.Config can lag the file by up to ~500ms after a save.
|
||||||
|
func (a *application) freshPageIndexBySlug(slug string) (int, bool) {
|
||||||
|
_, idx, ok := findPageBySlug(a.freshPagesFromDisk(), slug)
|
||||||
|
return idx, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWidgetYAML reads the on-disk config and returns the marshaled YAML for
|
||||||
|
// one widget. Errors are returned as a string so the template can show them
|
||||||
|
// without preventing the page from rendering.
|
||||||
|
func loadWidgetYAML(path string, pageIdx, col, idx int) (string, string) {
|
||||||
|
editor, err := loadConfigEditor(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
seq, pos, err := editor.widgetSlot(pageIdx, col, idx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
out, err := yaml.Marshal(seq.Content[pos])
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
return string(out), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminAccessAllowed enforces that admin endpoints either require auth, or
|
||||||
|
// have been explicitly opted into without auth via config.admin.allow-without-auth.
|
||||||
|
// Returns false if the response has been written and the caller should bail.
|
||||||
|
func (a *application) adminAccessAllowed(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if !a.RequiresAuth && !a.Config.Admin.AllowWithoutAuth {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`<!doctype html><meta charset=utf-8><title>Editing disabled</title>` +
|
||||||
|
`<body style="font-family:sans-serif;padding:2rem;max-width:50rem;line-height:1.5">` +
|
||||||
|
`<h1>Editing is disabled</h1>` +
|
||||||
|
`<p>The edit UI requires authentication. Either:</p>` +
|
||||||
|
`<ul>` +
|
||||||
|
`<li>Configure <code>auth.users</code> in your config (recommended), or</li>` +
|
||||||
|
`<li>Set <code>admin.allow-without-auth: true</code> in your config if you accept that anyone reachable on the network can edit it. (The legacy <code>admin</code> key still works for this option.)</li>` +
|
||||||
|
`</ul>` +
|
||||||
|
`</body>`))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAdminTemplate(w http.ResponseWriter, t *template.Template, data adminTemplateData) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.Execute(&buf, data); err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
@@ -65,6 +65,12 @@ type config struct {
|
|||||||
AppBackgroundColor string `yaml:"app-background-color"`
|
AppBackgroundColor string `yaml:"app-background-color"`
|
||||||
} `yaml:"branding"`
|
} `yaml:"branding"`
|
||||||
|
|
||||||
|
Admin struct {
|
||||||
|
// AllowWithoutAuth permits access to /admin even when auth.users is empty.
|
||||||
|
// Off by default because admin can rewrite the config file.
|
||||||
|
AllowWithoutAuth bool `yaml:"allow-without-auth"`
|
||||||
|
} `yaml:"admin"`
|
||||||
|
|
||||||
Pages []page `yaml:"pages"`
|
Pages []page `yaml:"pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type application struct {
|
|||||||
Version string
|
Version string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Config config
|
Config config
|
||||||
|
ConfigPath string
|
||||||
|
|
||||||
parsedManifest []byte
|
parsedManifest []byte
|
||||||
|
|
||||||
@@ -44,11 +45,12 @@ type application struct {
|
|||||||
failedAuthAttempts map[string]*failedAuthAttempt
|
failedAuthAttempts map[string]*failedAuthAttempt
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApplication(c *config) (*application, error) {
|
func newApplication(c *config, configPath string) (*application, error) {
|
||||||
app := &application{
|
app := &application{
|
||||||
Version: buildVersion,
|
Version: buildVersion,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Config: *c,
|
Config: *c,
|
||||||
|
ConfigPath: configPath,
|
||||||
slugToPage: make(map[string]*page),
|
slugToPage: make(map[string]*page),
|
||||||
widgetByID: make(map[uint64]widget),
|
widgetByID: make(map[uint64]widget),
|
||||||
}
|
}
|
||||||
@@ -450,6 +452,18 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /edit", a.handleAdminIndex)
|
||||||
|
mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage)
|
||||||
|
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/{page}/delete", a.handleAdminDeletePage)
|
||||||
|
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/new", a.handleAdminNewWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/new", a.handleAdminCreateWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}", a.handleAdminEditWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/delete", a.handleAdminDeleteWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/move", a.handleAdminMoveWidget)
|
||||||
|
|
||||||
if a.RequiresAuth {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func serveApp(configPath string) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := newApplication(config)
|
app, err := newApplication(config, configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create application: %v", err)
|
log.Printf("Failed to create application: %v", err)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ func serveApp(configPath string) error {
|
|||||||
return fmt.Errorf("validating config file: %w", err)
|
return fmt.Errorf("validating config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := newApplication(config)
|
app, err := newApplication(config, configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating application: %w", err)
|
return fmt.Errorf("creating application: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Edit {{ .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>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1">{{ .Page.Title }}</h1>
|
||||||
|
<p class="color-subdue margin-bottom-25">/{{ .Page.Slug }}</p>
|
||||||
|
|
||||||
|
{{- if .Page.HeadWidgets }}
|
||||||
|
<h2 class="size-h2 margin-bottom-10">Head widgets</h2>
|
||||||
|
<ul class="list list-gap-10 margin-bottom-25">
|
||||||
|
{{- range .Page.HeadWidgets }}
|
||||||
|
<li>
|
||||||
|
<a class="color-primary" href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ $.Page.Slug }}/widgets/-1/{{ .WidgetIndex }}">
|
||||||
|
{{ if .Title }}{{ .Title }}{{ else }}<em>(untitled)</em>{{ end }}
|
||||||
|
</a>
|
||||||
|
<span class="color-subdue"> — {{ .Type }}</span>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<h2 class="size-h2 margin-bottom-10">Columns</h2>
|
||||||
|
{{- if not .Page.Columns }}
|
||||||
|
<p class="color-subdue">No columns configured on this page. Edit the YAML manually to add columns — column editing comes in a later phase.</p>
|
||||||
|
{{- end }}
|
||||||
|
{{- range $colIdx, $col := .Page.Columns }}
|
||||||
|
<section class="margin-bottom-25">
|
||||||
|
<h3 class="size-h3 margin-bottom-10">
|
||||||
|
Column {{ $col.Index }}
|
||||||
|
<span class="color-subdue size-h5">({{ if $col.Size }}{{ $col.Size }}{{ else }}default{{ end }})</span>
|
||||||
|
</h3>
|
||||||
|
{{- if not $col.Widgets }}
|
||||||
|
<p class="color-subdue">No widgets in this column.</p>
|
||||||
|
{{- else }}
|
||||||
|
<ul class="list list-gap-10 margin-bottom-15">
|
||||||
|
{{- range $col.Widgets }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
<div class="grow">
|
||||||
|
<a class="color-primary" href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}">
|
||||||
|
{{ if .Title }}{{ .Title }}{{ else }}<em>(untitled)</em>{{ end }}
|
||||||
|
</a>
|
||||||
|
<span class="color-subdue"> — {{ .Type }}</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}/move?dir=up">
|
||||||
|
<button type="submit" {{ if .IsFirst }}disabled{{ end }}>↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}/move?dir=down">
|
||||||
|
<button type="submit" {{ if .IsLast }}disabled{{ end }}>↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}/delete"
|
||||||
|
onsubmit="return confirm('Delete this widget?');">
|
||||||
|
<button type="submit" class="color-negative">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<form method="get" action="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ $.Page.Slug }}/widgets/{{ $col.Index }}/new" class="flex gap-10 items-center">
|
||||||
|
<select name="type" required>
|
||||||
|
<option value="">— add widget —</option>
|
||||||
|
{{- range $.WidgetTypes }}
|
||||||
|
<option value="{{ . }}">{{ . }}</option>
|
||||||
|
{{- end }}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Configure & add</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end }}
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}{{ if .IsNew }}Add {{ .Widget.Type }}{{ else }}Edit {{ if .Widget.Title }}{{ .Widget.Title }}{{ else }}{{ .Widget.Type }}{{ end }}{{ end }} - {{ .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">
|
||||||
|
{{- if .IsNew -}}
|
||||||
|
Add widget <span class="color-subdue size-h4">— {{ .Widget.Type }}</span>
|
||||||
|
{{- else -}}
|
||||||
|
{{ if .Widget.Title }}{{ .Widget.Title }}{{ else }}<em>(untitled)</em>{{ end }}
|
||||||
|
<span class="color-subdue size-h4">— {{ .Widget.Type }}</span>
|
||||||
|
{{- end }}
|
||||||
|
</h1>
|
||||||
|
<p class="color-subdue margin-bottom-25">
|
||||||
|
{{- if .IsNew -}}
|
||||||
|
New widget for column {{ .Widget.ColumnIndex }}
|
||||||
|
{{- else if eq .Widget.ColumnIndex -1 -}}
|
||||||
|
head widget #{{ .Widget.WidgetIndex }}
|
||||||
|
{{- else -}}
|
||||||
|
column {{ .Widget.ColumnIndex }}, widget #{{ .Widget.WidgetIndex }}
|
||||||
|
{{- end }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{- if .ErrorMessage }}
|
||||||
|
<div class="margin-bottom-15" style="padding:1rem;background:#fee;border:1px solid #c00;border-radius:4px;color:#700;">
|
||||||
|
<strong>Couldn't save:</strong>
|
||||||
|
<pre style="white-space:pre-wrap;margin:0.5rem 0 0;">{{ .ErrorMessage }}</pre>
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if .LoadError }}
|
||||||
|
<div class="margin-bottom-15" style="padding:1rem;background:#fee;border:1px solid #c00;border-radius:4px;">
|
||||||
|
<strong>Can't load YAML:</strong>
|
||||||
|
<pre style="white-space:pre-wrap;margin-top:0.5rem;">{{ .LoadError }}</pre>
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if or .WidgetYAML .IsNew }}
|
||||||
|
<details class="margin-bottom-15" style="background:var(--color-widget-content-frame-background);border:1px solid var(--color-widget-content-border);border-radius:4px;padding:0.75rem;" {{ if .IsNew }}open{{ end }}>
|
||||||
|
<summary style="cursor:pointer;font-weight:bold;">How to edit a {{ if .Widget.Type }}<code>{{ .Widget.Type }}</code>{{ else }}widget{{ end }}</summary>
|
||||||
|
<div style="margin-top:0.75rem;line-height:1.6;">
|
||||||
|
{{- if .WidgetExample }}
|
||||||
|
<p style="margin-bottom:0.25rem;">A working example of a <code>{{ .Widget.Type }}</code> widget:</p>
|
||||||
|
<pre style="margin:0 0 0.75rem;padding:0.5rem;background:rgba(0,0,0,0.15);border-radius:3px;font-size:0.85rem;overflow:auto;">{{- .WidgetExample -}}</pre>
|
||||||
|
{{- end }}
|
||||||
|
{{- if .FieldReference }}
|
||||||
|
<p style="margin-bottom:0.25rem;">All fields supported by <code>{{ .Widget.Type }}</code> (defaults shown — fields with <code>yaml:"-"</code> tags or empty defaults still apply):</p>
|
||||||
|
<pre style="margin:0 0 0.75rem;padding:0.5rem;background:rgba(0,0,0,0.15);border-radius:3px;font-size:0.85rem;overflow:auto;max-height:18rem;">{{- .FieldReference -}}</pre>
|
||||||
|
{{- end }}
|
||||||
|
<p style="margin-bottom:0.25rem;">YAML quick tips:</p>
|
||||||
|
<ul style="padding-left:1.5rem;margin:0 0 0.75rem;">
|
||||||
|
<li>Indentation matters — use two spaces per level, never tabs.</li>
|
||||||
|
<li>Strings, numbers, booleans go inline: <code>limit: 10</code>, <code>hide-header: true</code>.</li>
|
||||||
|
<li>List items start with <code>-</code> and align under the parent key.</li>
|
||||||
|
<li>Pull secrets from the environment: <code>token: ${env:MY_TOKEN}</code>.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Validation runs before saving — if the YAML is invalid, your file isn't touched and the error appears above.</p>
|
||||||
|
{{- if .Widget.Type }}
|
||||||
|
<p>Full upstream reference: <a href="{{ .DocsURL }}" target="_blank" rel="noreferrer">{{ .Widget.Type }} docs ↗</a></p>
|
||||||
|
{{- end }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
action="{{- if .IsNew -}}
|
||||||
|
{{ .App.Config.Server.BaseURL }}/edit/api/pages/{{ .Page.Slug }}/widgets/{{ .Widget.ColumnIndex }}/new
|
||||||
|
{{- else -}}
|
||||||
|
{{ .App.Config.Server.BaseURL }}/edit/api/pages/{{ .Page.Slug }}/widgets/{{ .Widget.ColumnIndex }}/{{ .Widget.WidgetIndex }}
|
||||||
|
{{- end }}">
|
||||||
|
<label for="widget-yaml" class="form-label widget-header margin-bottom-10">Widget YAML</label>
|
||||||
|
<textarea id="widget-yaml" name="yaml" rows="20" spellcheck="false"
|
||||||
|
style="width:100%;font-family:monospace;font-size:0.9rem;padding:0.75rem;background:var(--color-widget-content-frame-background);color:var(--color-text-base);border:1px solid var(--color-widget-content-border);border-radius:4px;">{{- .WidgetYAML -}}</textarea>
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">{{ if .IsNew }}Create{{ else }}Save{{ end }}</button>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}" class="color-subdue" style="align-self:center;">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{{- if not .IsNew }}
|
||||||
|
<p class="color-subdue size-h5" style="margin-top:0.5rem;">
|
||||||
|
You can change <code>type:</code> here to convert this widget to any type.
|
||||||
|
</p>
|
||||||
|
{{- end }}
|
||||||
|
</form>
|
||||||
|
{{- end }}
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Edit - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<h1 class="size-h1 margin-bottom-15">Edit</h1>
|
||||||
|
<p class="color-subdue margin-bottom-25">
|
||||||
|
Editing <code>{{ .App.ConfigPath }}</code>. Changes take effect via hot-reload.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="size-h2 margin-bottom-10">Pages</h2>
|
||||||
|
<ul class="list list-gap-10 margin-bottom-25">
|
||||||
|
{{- range .Pages }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
<div class="grow">
|
||||||
|
<a class="color-primary" href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ .Slug }}">
|
||||||
|
{{ .Title }}
|
||||||
|
</a>
|
||||||
|
<span class="color-subdue"> — {{ .WidgetCount }} widget{{ if ne .WidgetCount 1 }}s{{ end }}</span>
|
||||||
|
<div class="color-subdue size-h5">/{{ .Slug }}</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/delete"
|
||||||
|
onsubmit="return confirm('Delete page "{{ .Title }}" and all its widgets?');">
|
||||||
|
<button type="submit" class="color-negative">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 class="size-h3 margin-bottom-10">Add a page</h3>
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/pages" class="flex gap-10 items-center">
|
||||||
|
<input type="text" name="title" required placeholder="Page title" class="input grow">
|
||||||
|
<button type="submit">Add page</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -43,6 +43,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{- if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
|
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/edit" title="Edit dashboard">
|
||||||
|
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{- end }}
|
||||||
{{- if .App.RequiresAuth }}
|
{{- if .App.RequiresAuth }}
|
||||||
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
|
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
|
||||||
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
@@ -92,6 +99,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit" class="flex justify-between items-center">
|
||||||
|
<div class="size-h3">Edit dashboard</div>
|
||||||
|
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ if .App.RequiresAuth }}
|
{{ if .App.RequiresAuth }}
|
||||||
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
||||||
<div class="size-h3">Logout</div>
|
<div class="size-h3">Logout</div>
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ type widget interface {
|
|||||||
Render() template.HTML
|
Render() template.HTML
|
||||||
GetType() string
|
GetType() string
|
||||||
GetID() uint64
|
GetID() uint64
|
||||||
|
GetTitle() string
|
||||||
|
|
||||||
initialize() error
|
initialize() error
|
||||||
requiresUpdate(*time.Time) bool
|
requiresUpdate(*time.Time) bool
|
||||||
@@ -210,6 +211,10 @@ func (w *widgetBase) GetType() string {
|
|||||||
return w.Type
|
return w.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *widgetBase) GetTitle() string {
|
||||||
|
return w.Title
|
||||||
|
}
|
||||||
|
|
||||||
func (w *widgetBase) setProviders(providers *widgetProviders) {
|
func (w *widgetBase) setProviders(providers *widgetProviders) {
|
||||||
w.Providers = providers
|
w.Providers = providers
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user