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:
uhlwoogi
2026-04-30 14:12:18 +00:00
co-authored by Claude Opus 4.7
parent 6c5b7a3f4c
commit 2e198a19b4
12 changed files with 1468 additions and 107 deletions
+1
View File
@@ -3,3 +3,4 @@
/playground /playground
/.idea /.idea
/glance*.yml /glance*.yml
*.yml.bak
+115 -101
View File
@@ -1,105 +1,119 @@
# --- 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
# hide-desktop-navigation: true # hide-desktop-navigation: true
columns: columns:
- size: small - size: small
widgets: widgets:
- type: calendar - type: calendar
first-day-of-week: monday first-day-of-week: monday
- type: rss
limit: 10
collapse-after: 3
cache: 12h
feeds:
- url: https://selfh.st/rss/
title: selfh.st
limit: 4
- url: https://ciechanow.ski/atom.xml
- url: https://www.joshwcomeau.com/rss.xml
title: Josh Comeau
- url: https://samwho.dev/rss.xml
- url: https://ishadeed.com/feed.xml
title: Ahmad Shadeed
- url: https://www.techmeme.com/feed.xml
title: TechMeme
- type: twitch-channels
channels:
- theprimeagen
- j_blow
- giantwaffle
- cohhcarnage
- christitustech
- EJ_SA
- size: full
widgets:
- type: group
widgets:
- type: hacker-news
- type: lobsters
- type: videos
channels:
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
- UCsBjURrPoezykLs9EqgamOA # Fireship
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
- type: group
widgets:
- type: reddit
subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
- size: small
widgets:
- type: weather
location: London, United Kingdom
units: metric # alternatively "imperial"
hour-format: 12h # alternatively "24h"
# Optionally hide the location from being displayed in the widget
# hide-location: true
- type: markets
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
name: Bitcoin
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
- type: releases
cache: 1d
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
# read-only token from your Github account settings and use it here to increase the limit.
# token: ...
repositories:
- glanceapp/glance
- go-gitea/gitea
- immich-app/immich
- syncthing/syncthing
- type: rss # Add more pages here:
limit: 10 # - name: Your page name
collapse-after: 3 # columns:
cache: 12h # - size: small
feeds: # widgets:
- url: https://selfh.st/rss/ # # Add widgets here
title: selfh.st
limit: 4
- url: https://ciechanow.ski/atom.xml
- url: https://www.joshwcomeau.com/rss.xml
title: Josh Comeau
- url: https://samwho.dev/rss.xml
- url: https://ishadeed.com/feed.xml
title: Ahmad Shadeed
- type: twitch-channels # - size: full
channels: # widgets:
- theprimeagen # # Add widgets here
- j_blow
- giantwaffle
- cohhcarnage
- christitustech
- EJ_SA
- size: full # - size: small
widgets: # widgets:
- type: group # # Add widgets here
widgets:
- type: hacker-news
- type: lobsters
- type: videos
channels:
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
- UCsBjURrPoezykLs9EqgamOA # Fireship
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
- type: group
widgets:
- type: reddit
subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
- size: small
widgets:
- type: weather
location: London, United Kingdom
units: metric # alternatively "imperial"
hour-format: 12h # alternatively "24h"
# Optionally hide the location from being displayed in the widget
# hide-location: true
- type: markets
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
name: Bitcoin
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
- type: releases
cache: 1d
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
# read-only token from your Github account settings and use it here to increase the limit.
# token: ...
repositories:
- glanceapp/glance
- go-gitea/gitea
- immich-app/immich
- syncthing/syncthing
# Add more pages here:
# - name: Your page name
# columns:
# - size: small
# widgets:
# # Add widgets here
# - size: full
# widgets:
# # Add widgets here
# - size: small
# widgets:
# # Add widgets here
+555
View File
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;")
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)
}
+537
View File
@@ -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())
}
+6
View File
@@ -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"`
} }
+18 -4
View File
@@ -28,9 +28,10 @@ const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
var reservedPageSlugs = []string{"login", "logout"} var reservedPageSlugs = []string{"login", "logout"}
type application struct { 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)
+2 -2
View File
@@ -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)
} }
+79
View File
@@ -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 }}
+40
View File
@@ -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 &quot;{{ .Title }}&quot; 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 }}
+16
View File
@@ -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>
+5
View File
@@ -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
} }