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>
243 lines
13 KiB
Go
243 lines
13 KiB
Go
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.
|
|
}
|