Add in-place dashboard edit mode

A toggle on the dashboard flips into edit mode where widgets get
drag/edit/delete handles and a "+ Add widget" button per column. Most
edits no longer require touching YAML.

- Drag-drop reorder via SortableJS, persisted through a bulk-layout
  endpoint that rebuilds each column's widgets sequence in the
  yaml.Node tree.
- Inline form dialog driven by per-widget field schemas
  (admin-schemas.go) covering 25 widget types. Surgical updates apply
  changed fields to the widget node so untouched keys and comments
  survive. Falls back to the existing YAML editor for group and
  split-column (those need a recursive nested-widget UI).
- Per-column "+ Add widget" picker that opens the dialog pre-filled
  with sensible defaults for the chosen type.
- Field validation and autocomplete for weather location (Open-Meteo
  geocoding), market symbol (Yahoo Finance), and RSS feed URL
  (gofeed). Network failures soft-pass for weather/markets so the
  save isn't blocked when external APIs are unreachable.
- Picking a market suggestion auto-fills the company name field via
  per-suggestion `extra` fields.
- Edit mode persists across reloads via localStorage so saves don't
  drop the user back into view mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
uhlwoogi
2026-04-30 16:51:52 +00:00
co-authored by Claude Opus 4.7
parent 2e198a19b4
commit 4b8a34a2ae
10 changed files with 2347 additions and 43 deletions
+242
View File
@@ -0,0 +1,242 @@
package glance
// widgetFieldSchema describes one editable field on a widget for the dialog
// form generator. Marshaled to JSON and consumed by edit-mode.js.
type widgetFieldSchema struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // string | multiline | number | boolean | select | list-strings | list-objects
Help string `json:"help,omitempty"`
Required bool `json:"required,omitempty"`
Options []string `json:"options,omitempty"`
Items []widgetFieldSchema `json:"items,omitempty"` // for list-objects
Validator string `json:"validator,omitempty"`
Lookup string `json:"lookup,omitempty"`
}
// widgetSchemas maps widget type to the form fields shown in the dialog
// editor. Widgets not listed here fall back to the textarea YAML editor.
// Keep field order — that's the order the dialog renders them.
var widgetSchemas = map[string][]widgetFieldSchema{
"rss": {
{Key: "title", Label: "Custom title", Type: "string", Help: "Override the widget header. Leave blank for the default."},
{Key: "feeds", Label: "Feeds", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
{Key: "url", Label: "Feed URL", Type: "string", Required: true, Validator: "rss-feed", Help: "Tested when you save."},
{Key: "title", Label: "Custom title (optional)", Type: "string", Help: "Defaults to the title from the feed itself."},
}},
{Key: "limit", Label: "Items to show", Type: "number", Help: "Default: 25"},
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards", "horizontal-cards-2", "detailed-list"}},
},
"weather": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "location", Label: "Location", Type: "string", Required: true, Help: "City and country, e.g. London, GB. Start typing to search.", Lookup: "weather-location", Validator: "weather-location"},
{Key: "units", Label: "Units", Type: "select", Options: []string{"metric", "imperial"}},
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
{Key: "hide-location", Label: "Hide location label", Type: "boolean"},
{Key: "show-area-name", Label: "Show area name", Type: "boolean"},
},
"markets": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "markets", Label: "Symbols", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
{Key: "symbol", Label: "Ticker symbol", Type: "string", Required: true, Help: "Search a company name or type a symbol like AAPL or BTC-USD.", Lookup: "market-symbol", Validator: "market-symbol"},
{Key: "name", Label: "Display name (optional)", Type: "string"},
}},
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"absolute-change", "relative-change"}},
},
"monitor": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "sites", Label: "Sites", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
{Key: "title", Label: "Display name", Type: "string", Required: true},
{Key: "url", Label: "URL", Type: "string", Required: true},
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
{Key: "alt-status-codes", Label: "Other OK status codes (comma-separated)", Type: "string", Help: "e.g. 401,403 — codes that should still show OK."},
}},
{Key: "show-failing-only", Label: "Show failing only", Type: "boolean"},
},
"reddit": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "subreddit", Label: "Subreddit", Type: "string", Required: true, Help: "Just the name, no /r/"},
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new", "top", "rising"}},
{Key: "top-period", Label: "Top period (when sort-by is 'top')", Type: "select", Options: []string{"day", "week", "month", "year", "all"}},
{Key: "show-thumbnails", Label: "Show thumbnails", Type: "boolean"},
{Key: "show-flairs", Label: "Show flairs", Type: "boolean"},
{Key: "limit", Label: "Items to show", Type: "number"},
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards"}},
},
"hacker-news": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"top", "new", "best"}},
{Key: "limit", Label: "Items to show", Type: "number"},
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
},
"lobsters": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new"}},
{Key: "tags", Label: "Tags (filter)", Type: "list-strings"},
{Key: "limit", Label: "Items to show", Type: "number"},
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
},
"videos": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "channels", Label: "YouTube channel IDs", Type: "list-strings", Required: true, Help: "The UC... ID, not the @handle"},
{Key: "limit", Label: "Videos to show", Type: "number"},
{Key: "style", Label: "Style", Type: "select", Options: []string{"horizontal-cards", "grid-cards", "vertical-list"}},
},
"twitch-channels": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "channels", Label: "Twitch channel names", Type: "list-strings", Required: true},
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
},
"repository": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "repository", Label: "Repository", Type: "string", Required: true, Help: "owner/name, e.g. glanceapp/glance"},
{Key: "token", Label: "GitHub token (optional)", Type: "string", Help: "Increases rate limits. Use ${env:VAR} to read from env."},
{Key: "pull-requests-limit", Label: "Pull requests to show", Type: "number"},
{Key: "issues-limit", Label: "Issues to show", Type: "number"},
{Key: "commits-limit", Label: "Commits to show", Type: "number"},
},
"releases": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "repositories", Label: "Repositories", Type: "list-strings", Required: true, Help: "owner/name per line. Add prefixes like docker:, gitlab:, codeberg: for non-GitHub sources."},
{Key: "token", Label: "GitHub token (optional)", Type: "string"},
{Key: "show-source-icon", Label: "Show source icon", Type: "boolean"},
{Key: "limit", Label: "Releases to show", Type: "number"},
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
},
"bookmarks": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "groups", Label: "Groups", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
{Key: "title", Label: "Group title", Type: "string", Required: true},
{Key: "color", Label: "Color (HSL, optional)", Type: "string", Help: "e.g. 200 50 50"},
{Key: "links", Label: "Links", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
{Key: "title", Label: "Link title", Type: "string", Required: true},
{Key: "url", Label: "URL", Type: "string", Required: true},
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
}},
}},
},
"clock": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
{Key: "timezones", Label: "Extra timezones to show", Type: "list-objects", Items: []widgetFieldSchema{
{Key: "timezone", Label: "Timezone (e.g. Europe/London)", Type: "string", Required: true},
{Key: "label", Label: "Display label", Type: "string"},
}},
},
"calendar": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "first-day-of-week", Label: "First day of week", Type: "select", Options: []string{"monday", "sunday"}},
{Key: "show-week-numbers", Label: "Show week numbers", Type: "boolean"},
},
"search": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "search-engine", Label: "Search engine", Type: "select", Options: []string{"duckduckgo", "google", "bing", "kagi", "startpage", "perplexity"}},
{Key: "new-tab", Label: "Open results in new tab", Type: "boolean"},
{Key: "autofocus", Label: "Autofocus on page load", Type: "boolean"},
},
"iframe": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "source", Label: "URL to embed", Type: "string", Required: true},
{Key: "height", Label: "Height (CSS)", Type: "string", Help: "e.g. 400px, 50vh"},
},
"html": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "source", Label: "HTML", Type: "multiline", Required: true},
},
"server-stats": {
{Key: "title", Label: "Custom title", Type: "string"},
},
"to-do": {
{Key: "title", Label: "Custom title", Type: "string"},
},
"dns-stats": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "service", Label: "Service", Type: "select", Required: true,
Options: []string{"adguard", "pihole", "pihole-v6", "technitium"}},
{Key: "url", Label: "Service URL", Type: "string", Required: true,
Help: "Base URL of your DNS resolver, e.g. http://pi.hole or http://192.168.1.10"},
{Key: "username", Label: "Username (AdGuard / Pi-hole v6)", Type: "string"},
{Key: "password", Label: "Password", Type: "string",
Help: "Use ${env:DNS_PASSWORD} to read from an env var instead of inlining."},
{Key: "token", Label: "API token (Pi-hole v5, Technitium)", Type: "string"},
{Key: "allow-insecure", Label: "Allow insecure TLS (self-signed certs)", Type: "boolean"},
{Key: "hide-graph", Label: "Hide hourly graph", Type: "boolean"},
{Key: "hide-top-domains", Label: "Hide top blocked/queried domains", Type: "boolean"},
{Key: "hour-format", Label: "Hour format", Type: "select",
Options: []string{"24h", "12h"}},
},
"docker-containers": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "sock-path", Label: "Docker socket path", Type: "string",
Help: "Default: /var/run/docker.sock"},
{Key: "category", Label: "Category filter", Type: "string",
Help: "Show only containers labelled glance.category=<value>"},
{Key: "hide-by-default", Label: "Hide containers unless labelled glance.hide=false", Type: "boolean"},
{Key: "running-only", Label: "Show running containers only", Type: "boolean"},
{Key: "format-container-names", Label: "Format container names", Type: "boolean",
Help: "Title-cases names like 'home_assistant' → 'Home Assistant'"},
},
"extension": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "url", Label: "Extension URL", Type: "string", Required: true,
Help: "URL of the Glance extension endpoint. See the Extensions docs."},
{Key: "fallback-content-type", Label: "Fallback content type", Type: "select",
Options: []string{"", "html", "iframe"},
Help: "Used when the response has no Widget-Content-Type header."},
{Key: "allow-potentially-dangerous-html", Label: "Allow raw HTML output", Type: "boolean",
Help: "Only enable for extensions you trust — they can inject scripts otherwise."},
},
"change-detection": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "instance-url", Label: "ChangeDetection.io URL", Type: "string",
Help: "Defaults to https://www.changedetection.io. Use your own instance for self-hosted."},
{Key: "token", Label: "API token", Type: "string",
Help: "From your changedetection.io profile. Use ${env:CD_TOKEN} for env-based config."},
{Key: "watches", Label: "Watch UUIDs (optional)", Type: "list-strings",
Help: "Filter to specific watches. Leave empty to show all."},
{Key: "limit", Label: "Items to show", Type: "number"},
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
},
"custom-api": {
{Key: "title", Label: "Custom title", Type: "string"},
{Key: "url", Label: "API URL", Type: "string", Required: true},
{Key: "method", Label: "HTTP method", Type: "select",
Options: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}},
{Key: "body-type", Label: "Body type (when POST/PUT/PATCH)", Type: "select",
Options: []string{"", "json", "string"}},
{Key: "body", Label: "Request body", Type: "multiline",
Help: "Optional. Sent as the request body."},
{Key: "template", Label: "Output template (Go html/template)", Type: "multiline", Required: true,
Help: "Renders the response. See the Custom API docs for available helpers like {{ .JSON.String \"path.to.field\" }}."},
{Key: "frameless", Label: "Hide widget frame", Type: "boolean"},
},
// group + split-column intentionally skipped: their `widgets:` field is a
// recursive list of widget objects, which the inline form generator can't
// render usefully yet. They fall through to the YAML editor.
}