diff --git a/docs/glance.yml b/docs/glance.yml index 17cec8b..e8df2db 100644 --- a/docs/glance.yml +++ b/docs/glance.yml @@ -32,31 +32,14 @@ pages: 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 + - type: server-stats - size: full widgets: - type: group widgets: - type: hacker-news - - type: lobsters - type: videos channels: - UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips @@ -75,33 +58,18 @@ pages: - size: small widgets: - type: weather - location: London, United Kingdom - units: metric # alternatively "imperial" - hour-format: 12h # alternatively "24h" + location: Houston, US + units: imperial + hour-format: 12h # Optionally hide the location from being displayed in the widget # hide-location: true + + hide-location: true + show-area-name: false - 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 + - symbol: AMZN + name: Amazon.com, Inc. # Add more pages here: # - name: Your page name diff --git a/internal/glance/admin-edit.go b/internal/glance/admin-edit.go index f0506ac..628f977 100644 --- a/internal/glance/admin-edit.go +++ b/internal/glance/admin-edit.go @@ -1,6 +1,7 @@ package glance import ( + "encoding/json" "fmt" "log" "net/http" @@ -553,3 +554,385 @@ func (a *application) handleAdminCreateWidget(w http.ResponseWriter, r *http.Req } http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/pages/"+slug, http.StatusSeeOther) } + +// handleAdminLayout accepts a JSON description of a page's new column/widget +// layout and rewrites the yaml.Node tree by moving widget nodes between +// columns. Used by the dashboard edit-mode drag-drop UI. The request must +// reference each existing widget exactly once so we never lose or duplicate +// nodes. Edits to widget contents stay untouched — only positions move. +func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + + slug := r.PathValue("page") + pageIdx, ok := a.freshPageIndexBySlug(slug) + if !ok { + http.Error(w, "page not found", http.StatusNotFound) + return + } + + var req struct { + Columns [][]struct { + Col int `json:"col"` + Idx int `json:"idx"` + } `json:"columns"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + pageNode, err := editor.pageNodeAt(pageIdx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + columns, err := columnsOf(pageNode) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if len(req.Columns) != len(columns.Content) { + http.Error(w, fmt.Sprintf("layout has %d columns, page has %d", len(req.Columns), len(columns.Content)), http.StatusBadRequest) + return + } + + // Snapshot the existing widget nodes by [col][idx] so we can splice them + // into the new layout without mutating during traversal. + snapshot := make([][]*yaml.Node, len(columns.Content)) + totalCount := 0 + for c, colNode := range columns.Content { + widgets, err := widgetsOf(colNode) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + snapshot[c] = make([]*yaml.Node, len(widgets.Content)) + copy(snapshot[c], widgets.Content) + totalCount += len(widgets.Content) + } + + // Validate: each widget referenced exactly once, all references in range. + seen := make(map[[2]int]bool, totalCount) + refCount := 0 + for _, newCol := range req.Columns { + for _, ref := range newCol { + if ref.Col < 0 || ref.Col >= len(snapshot) || ref.Idx < 0 || ref.Idx >= len(snapshot[ref.Col]) { + http.Error(w, fmt.Sprintf("invalid widget reference col=%d idx=%d", ref.Col, ref.Idx), http.StatusBadRequest) + return + } + key := [2]int{ref.Col, ref.Idx} + if seen[key] { + http.Error(w, fmt.Sprintf("widget col=%d idx=%d referenced more than once", ref.Col, ref.Idx), http.StatusBadRequest) + return + } + seen[key] = true + refCount++ + } + } + if refCount != totalCount { + http.Error(w, fmt.Sprintf("layout references %d widgets but page has %d", refCount, totalCount), http.StatusBadRequest) + return + } + + // Apply: rebuild each column's widgets sequence from the snapshot. + for c, newCol := range req.Columns { + widgets, _ := widgetsOf(columns.Content[c]) + newContent := make([]*yaml.Node, 0, len(newCol)) + for _, ref := range newCol { + newContent = append(newContent, snapshot[ref.Col][ref.Idx]) + } + widgets.Content = newContent + } + + if err := editor.save(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// jsonToYAMLNode converts the JSON-decoded value (string/float64/bool/nil/ +// []any/map[string]any) into a yaml.Node so it can be spliced into the +// config tree. Numbers come in as float64 from encoding/json; we render them +// as ints when they have no fractional part (most widget fields are int). +func jsonToYAMLNode(v interface{}) (*yaml.Node, error) { + switch x := v.(type) { + case nil: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil + case string: + return &yaml.Node{Kind: yaml.ScalarNode, Value: x}, nil + case bool: + return &yaml.Node{Kind: yaml.ScalarNode, Value: strconv.FormatBool(x)}, nil + case float64: + if x == float64(int64(x)) { + return &yaml.Node{Kind: yaml.ScalarNode, Value: strconv.FormatInt(int64(x), 10)}, nil + } + return &yaml.Node{Kind: yaml.ScalarNode, Value: strconv.FormatFloat(x, 'f', -1, 64)}, nil + case []interface{}: + seq := &yaml.Node{Kind: yaml.SequenceNode} + for _, item := range x { + n, err := jsonToYAMLNode(item) + if err != nil { + return nil, err + } + seq.Content = append(seq.Content, n) + } + return seq, nil + case map[string]interface{}: + m := &yaml.Node{Kind: yaml.MappingNode} + for k, vv := range x { + if vv == nil { + // Skip null fields rather than writing `title: null`. + continue + } + n, err := jsonToYAMLNode(vv) + if err != nil { + return nil, err + } + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: k}, + n, + ) + } + return m, nil + } + return nil, fmt.Errorf("unsupported JSON type %T", v) +} + +// applyFieldsToWidget surgically updates a widget mapping node from a JSON +// fields map. Existing keys are replaced; new keys are appended. A null +// value removes the key. Other keys (and comments) on the widget are left +// untouched, so users can still hand-edit advanced fields the dialog +// doesn't know about. +func applyFieldsToWidget(widget *yaml.Node, fields map[string]interface{}) error { + if widget.Kind != yaml.MappingNode { + return fmt.Errorf("widget node is not a mapping") + } + + for key, val := range fields { + existing := -1 + for i := 0; i+1 < len(widget.Content); i += 2 { + if widget.Content[i].Value == key { + existing = i + break + } + } + + if val == nil { + if existing != -1 { + widget.Content = append(widget.Content[:existing], widget.Content[existing+2:]...) + } + continue + } + + valNode, err := jsonToYAMLNode(val) + if err != nil { + return fmt.Errorf("field %q: %w", key, err) + } + if existing == -1 { + widget.Content = append(widget.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: key}, + valNode, + ) + } else { + widget.Content[existing+1] = valNode + } + } + return nil +} + +// handleAdminUpdateFields applies JSON form values to an existing widget +// without touching its other YAML fields. Used by the inline edit dialog. +func (a *application) handleAdminUpdateFields(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + slug := r.PathValue("page") + pageIdx, ok := a.freshPageIndexBySlug(slug) + if !ok { + http.Error(w, "page not found", http.StatusNotFound) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + http.Error(w, "bad column", http.StatusBadRequest) + return + } + idx, err := strconv.Atoi(r.PathValue("idx")) + if err != nil { + http.Error(w, "bad index", http.StatusBadRequest) + return + } + + var fields map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&fields); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + seq, pos, err := editor.widgetSlot(pageIdx, col, idx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := applyFieldsToWidget(seq.Content[pos], fields); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := editor.save(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleAdminCreateFromFields creates a new widget at the end of a column +// from a JSON {type, fields} payload. The dialog uses this when the user +// clicks the "+" button in edit mode and fills in a form. +func (a *application) handleAdminCreateFromFields(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + slug := r.PathValue("page") + pageIdx, ok := a.freshPageIndexBySlug(slug) + if !ok { + http.Error(w, "page not found", http.StatusNotFound) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + http.Error(w, "bad column", http.StatusBadRequest) + return + } + + var req struct { + Type string `json:"type"` + Fields map[string]interface{} `json:"fields"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if req.Type == "" { + http.Error(w, "type is required", http.StatusBadRequest) + return + } + if _, err := newWidget(req.Type); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + pageNode, err := editor.pageNodeAt(pageIdx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + columns, err := columnsOf(pageNode) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if col < 0 || col >= len(columns.Content) { + http.Error(w, "column index out of range", http.StatusBadRequest) + return + } + widgets, err := widgetsOf(columns.Content[col]) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + widget := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "type"}, + {Kind: yaml.ScalarNode, Value: req.Type}, + }} + if err := applyFieldsToWidget(widget, req.Fields); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + widgets.Content = append(widgets.Content, widget) + + if err := editor.save(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleAdminGetFields returns the current values of one widget as JSON, +// drawn from the on-disk yaml.Node so unresolved ${env:X} tokens stay as +// the user typed them. The dialog form generator uses this to populate +// initial values when editing an existing widget. +func (a *application) handleAdminGetFields(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + slug := r.PathValue("page") + pageIdx, ok := a.freshPageIndexBySlug(slug) + if !ok { + http.Error(w, "page not found", http.StatusNotFound) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + http.Error(w, "bad column", http.StatusBadRequest) + return + } + idx, err := strconv.Atoi(r.PathValue("idx")) + if err != nil { + http.Error(w, "bad index", http.StatusBadRequest) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + seq, pos, err := editor.widgetSlot(pageIdx, col, idx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var fields map[string]interface{} + if err := seq.Content[pos].Decode(&fields); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(fields); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// handleAdminWidgetSchemas exposes the widget field schemas as JSON for the +// edit-mode dialog. Cached client-side; no auth-related data here. +func (a *application) handleAdminWidgetSchemas(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(widgetSchemas); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/internal/glance/admin-schemas.go b/internal/glance/admin-schemas.go new file mode 100644 index 0000000..7e4b26b --- /dev/null +++ b/internal/glance/admin-schemas.go @@ -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="}, + {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. +} diff --git a/internal/glance/admin-validate.go b/internal/glance/admin-validate.go new file mode 100644 index 0000000..27072cd --- /dev/null +++ b/internal/glance/admin-validate.go @@ -0,0 +1,333 @@ +package glance + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/mmcdole/gofeed" +) + +// admin-validate.go — server-side validation and lookup helpers used by the +// edit-mode dialog. Each kind has both: +// - validate(value): is this exact value usable? +// - lookup(query): suggestions for typing-as-you-go +// Where one of those isn't applicable (e.g. there's no "search" for an +// arbitrary RSS URL), only the relevant function is implemented. + +type validationSuggestion struct { + Value string `json:"value"` // the string to put into the input + Display string `json:"display"` // human-readable label for the dropdown + Hint string `json:"hint,omitempty"` // small caption (e.g. "AAPL — Apple Inc.") + Extra map[string]string `json:"extra,omitempty"` // sibling field values to auto-fill (e.g. {"name": "Apple Inc."}) +} + +type validationResult struct { + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` + Hint string `json:"hint,omitempty"` // shown next to a successful field + Suggestions []validationSuggestion `json:"suggestions,omitempty"` +} + +var validateClient = &http.Client{Timeout: 8 * time.Second} + +func ctxWithTimeout(d time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), d) +} + +func httpGetJSON(rawURL string, into interface{}) error { + ctx, cancel := ctxWithTimeout(8 * time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) + if err != nil { + return err + } + // Yahoo's undocumented endpoints reject the default Go UA. Use a browser-y + // string so search/quote actually return data. + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json,text/plain,*/*") + resp, err := validateClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return json.NewDecoder(resp.Body).Decode(into) +} + +// ---------- weather-location (Open-Meteo geocoding) ---------- + +type openMeteoGeo struct { + Results []struct { + Name string `json:"name"` + Country string `json:"country"` + Admin1 string `json:"admin1"` + CountryCode string `json:"country_code"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + } `json:"results"` +} + +func geocodeWeatherLocation(query string) ([]validationSuggestion, error) { + q := strings.TrimSpace(query) + if q == "" { + return nil, nil + } + endpoint := "https://geocoding-api.open-meteo.com/v1/search?count=8&format=json&name=" + url.QueryEscape(q) + var data openMeteoGeo + if err := httpGetJSON(endpoint, &data); err != nil { + return nil, err + } + out := make([]validationSuggestion, 0, len(data.Results)) + for _, r := range data.Results { + // Glance's weather widget accepts strings like "London, GB". We use that + // canonical form as the suggestion value so saving works without changes. + v := r.Name + if r.CountryCode != "" { + v = v + ", " + r.CountryCode + } + display := r.Name + if r.Admin1 != "" { + display += ", " + r.Admin1 + } + if r.Country != "" { + display += ", " + r.Country + } + out = append(out, validationSuggestion{ + Value: v, + Display: display, + Hint: fmt.Sprintf("%.2f, %.2f", r.Latitude, r.Longitude), + }) + } + return out, nil +} + +func validateWeatherLocation(value string) validationResult { + v := strings.TrimSpace(value) + if v == "" { + return validationResult{Valid: false, Error: "Location is required."} + } + // Try to verify against Open-Meteo, but never block the save on failure + // — the user might know better than us, and the weather widget will + // surface a clear error at update time if it really can't geocode. + searchQuery := v + if comma := strings.IndexByte(v, ','); comma > 0 { + searchQuery = strings.TrimSpace(v[:comma]) + } + suggestions, err := geocodeWeatherLocation(searchQuery) + if err != nil || len(suggestions) == 0 { + return validationResult{Valid: true, Hint: "couldn't verify — will be tested when the widget updates"} + } + return validationResult{Valid: true, Hint: suggestions[0].Display} +} + +// ---------- market-symbol (Yahoo Finance) ---------- + +type yahooSearchResp struct { + Quotes []struct { + Symbol string `json:"symbol"` + ShortName string `json:"shortname"` + LongName string `json:"longname"` + QuoteType string `json:"quoteType"` + Exchange string `json:"exchDisp"` + } `json:"quotes"` +} + +type yahooChartResp struct { + Chart struct { + Result []struct { + Meta struct { + Symbol string `json:"symbol"` + LongName string `json:"longName"` + ShortName string `json:"shortName"` + ExchangeName string `json:"exchangeName"` + FullExchangeName string `json:"fullExchangeName"` + Currency string `json:"currency"` + RegularMarketPrice float64 `json:"regularMarketPrice"` + } `json:"meta"` + } `json:"result"` + Error *struct { + Code string `json:"code"` + Description string `json:"description"` + } `json:"error"` + } `json:"chart"` +} + +func lookupMarketSymbol(query string) ([]validationSuggestion, error) { + q := strings.TrimSpace(query) + if q == "" { + return nil, nil + } + // Try query1 first; fall back to query2 since one or the other is usually up. + var data yahooSearchResp + q1 := "https://query1.finance.yahoo.com/v1/finance/search?quotesCount=8&newsCount=0&q=" + url.QueryEscape(q) + if err := httpGetJSON(q1, &data); err != nil || len(data.Quotes) == 0 { + q2 := "https://query2.finance.yahoo.com/v1/finance/search?quotesCount=8&newsCount=0&q=" + url.QueryEscape(q) + if err2 := httpGetJSON(q2, &data); err2 != nil { + return nil, err2 + } + } + out := make([]validationSuggestion, 0, len(data.Quotes)) + for _, qr := range data.Quotes { + name := qr.LongName + if name == "" { + name = qr.ShortName + } + if qr.Symbol == "" { + continue + } + display := qr.Symbol + if name != "" { + display = qr.Symbol + " — " + name + } + hint := qr.QuoteType + if qr.Exchange != "" { + hint = strings.TrimSpace(strings.Join([]string{qr.QuoteType, qr.Exchange}, " · ")) + } + extra := map[string]string{} + if name != "" { + extra["name"] = name + } + out = append(out, validationSuggestion{ + Value: qr.Symbol, + Display: display, + Hint: hint, + Extra: extra, + }) + } + return out, nil +} + +func validateMarketSymbol(value string) validationResult { + sym := strings.ToUpper(strings.TrimSpace(value)) + if sym == "" { + return validationResult{Valid: false, Error: "Symbol is required."} + } + // v8 chart endpoint is the most reliable Yahoo entry point — it serves + // public data and tends to ignore the auth cookies the v7 quote API + // started requiring. We just need to confirm the symbol resolves to a + // real instrument; we don't care about price. + endpoint := "https://query1.finance.yahoo.com/v8/finance/chart/" + url.PathEscape(sym) + "?range=1d&interval=1d" + var data yahooChartResp + if err := httpGetJSON(endpoint, &data); err != nil { + return validationResult{Valid: true, Hint: "couldn't verify (Yahoo unreachable)"} + } + if data.Chart.Error != nil && data.Chart.Error.Code != "" { + return validationResult{Valid: false, Error: "Yahoo: " + data.Chart.Error.Description} + } + if len(data.Chart.Result) == 0 { + return validationResult{Valid: false, Error: "No data returned for " + sym + "."} + } + m := data.Chart.Result[0].Meta + name := m.LongName + if name == "" { + name = m.ShortName + } + hint := name + if m.FullExchangeName != "" && name != "" { + hint = name + " · " + m.FullExchangeName + } + return validationResult{Valid: true, Hint: hint} +} + +// ---------- rss-feed (gofeed) ---------- + +func validateRSSFeed(value string) validationResult { + u := strings.TrimSpace(value) + if u == "" { + return validationResult{Valid: false, Error: "URL is required."} + } + if _, err := url.ParseRequestURI(u); err != nil { + return validationResult{Valid: false, Error: "Doesn't look like a URL."} + } + ctx, cancel := ctxWithTimeout(8 * time.Second) + defer cancel() + parser := gofeed.NewParser() + parser.Client = validateClient + feed, err := parser.ParseURLWithContext(u, ctx) + if err != nil { + return validationResult{Valid: false, Error: "Feed parse failed: " + err.Error()} + } + hint := feed.Title + if feed.Items != nil { + hint = fmt.Sprintf("%s · %d items", feed.Title, len(feed.Items)) + } + return validationResult{Valid: true, Hint: hint} +} + +// ---------- HTTP handlers ---------- + +func (a *application) handleAdminValidate(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + kind := r.PathValue("kind") + var body struct { + Value string `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var result validationResult + switch kind { + case "weather-location": + result = validateWeatherLocation(body.Value) + case "market-symbol": + result = validateMarketSymbol(body.Value) + case "rss-feed": + result = validateRSSFeed(body.Value) + default: + http.Error(w, "unknown validator: "+kind, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +func (a *application) handleAdminLookup(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + kind := r.PathValue("kind") + var body struct { + Query string `json:"query"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var ( + suggestions []validationSuggestion + err error + ) + switch kind { + case "weather-location": + suggestions, err = geocodeWeatherLocation(body.Query) + case "market-symbol": + suggestions, err = lookupMarketSymbol(body.Query) + default: + http.Error(w, "unknown lookup: "+kind, http.StatusNotFound) + return + } + if err != nil { + // Lookup failures shouldn't be hard errors — return empty suggestions + // so the UI just shows nothing rather than alarming the user. + suggestions = nil + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + Suggestions []validationSuggestion `json:"suggestions"` + }{Suggestions: suggestions}) +} diff --git a/internal/glance/glance.go b/internal/glance/glance.go index dc46ade..adb74fa 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -463,6 +463,13 @@ func (a *application) server() (func() error, func() error) { 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) + mux.HandleFunc("POST /edit/api/pages/{page}/layout", a.handleAdminLayout) + mux.HandleFunc("GET /edit/api/pages/{page}/widgets/{col}/{idx}/fields", a.handleAdminGetFields) + mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/fields", a.handleAdminUpdateFields) + mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/create", a.handleAdminCreateFromFields) + mux.HandleFunc("GET /edit/api/widget-schemas", a.handleAdminWidgetSchemas) + mux.HandleFunc("POST /edit/api/validate/{kind}", a.handleAdminValidate) + mux.HandleFunc("POST /edit/api/lookup/{kind}", a.handleAdminLookup) if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) diff --git a/internal/glance/static/css/edit-mode.css b/internal/glance/static/css/edit-mode.css new file mode 100644 index 0000000..3733201 --- /dev/null +++ b/internal/glance/static/css/edit-mode.css @@ -0,0 +1,479 @@ +/* Edit mode — in-place dashboard editing UI. Hidden unless body[data-edit-mode]. */ + +#edit-mode-toggle { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + color: var(--color-text-subdue); +} + +#edit-mode-toggle.active { + color: var(--color-primary); +} + +body[data-edit-mode] .widget { + position: relative; + outline: 1px dashed var(--color-text-subdue); + outline-offset: 4px; + transition: outline-color 0.15s; +} + +body[data-edit-mode] .widget:hover { + outline-color: var(--color-primary); +} + +body[data-edit-mode] .widget.sortable-ghost { + opacity: 0.4; +} + +body[data-edit-mode] .widget.sortable-drag { + cursor: grabbing; +} + +.edit-mode-handles { + display: none; + position: absolute; + top: -10px; + right: 8px; + z-index: 10; + gap: 4px; + background: var(--color-popover-background); + border: 1px solid var(--color-popover-border); + border-radius: 4px; + padding: 2px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +body[data-edit-mode] .edit-mode-handles { + display: flex; +} + +.edit-handle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--color-text-base); + cursor: pointer; + border-radius: 3px; + font-size: 14px; + line-height: 1; + text-decoration: none; +} + +.edit-handle:hover { + background: var(--color-widget-background-highlight); + color: var(--color-primary); +} + +.edit-handle-drag { + cursor: grab; +} + +.edit-handle-drag:active { + cursor: grabbing; +} + +.edit-handle-delete:hover { + color: var(--color-negative); +} + +#edit-mode-status { + display: none; + position: fixed; + bottom: 1rem; + right: 1rem; + background: var(--color-popover-background); + border: 1px solid var(--color-popover-border); + border-radius: 4px; + padding: 0.5rem 1rem; + font-size: var(--font-size-h5); + color: var(--color-text-base); + z-index: 100; +} + +body[data-edit-mode] #edit-mode-status { + display: block; +} + +#edit-mode-status.saving { + color: var(--color-primary); +} + +#edit-mode-status.error { + color: var(--color-negative); + border-color: var(--color-negative); +} + +.edit-add-widget { + display: block; + width: 100%; + margin-top: var(--widget-gap); + padding: 0.75rem; + background: transparent; + border: 1px dashed var(--color-text-subdue); + border-radius: var(--border-radius); + color: var(--color-text-subdue); + cursor: pointer; + font: inherit; + font-size: var(--font-size-h4); +} + +.edit-add-widget:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* ---------- Dialog ---------- */ + +.edit-dialog-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + overflow-y: auto; +} + +.edit-dialog { + background: var(--color-popover-background); + border: 1px solid var(--color-popover-border); + border-radius: 6px; + width: 100%; + max-width: 640px; + max-height: calc(100vh - 2rem); + display: flex; + flex-direction: column; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +.edit-dialog-narrow { + max-width: 480px; +} + +.edit-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-widget-content-border); +} + +.edit-dialog-header h2 { + margin: 0; + font-size: var(--font-size-h2); + color: var(--color-text-highlight); +} + +.edit-dialog-close { + background: transparent; + border: none; + color: var(--color-text-subdue); + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + width: 28px; + height: 28px; + border-radius: 3px; +} + +.edit-dialog-close:hover { + background: var(--color-widget-background-highlight); + color: var(--color-text-base); +} + +.edit-dialog-body { + padding: 1rem 1.25rem; + overflow-y: auto; + flex: 1; + color: var(--color-text-base); +} + +.edit-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--color-widget-content-border); +} + +.edit-dialog-actions button { + padding: 0.5rem 1rem; + border-radius: 4px; + border: 1px solid var(--color-popover-border); + background: var(--color-widget-background); + color: var(--color-text-base); + cursor: pointer; + font: inherit; + font-size: var(--font-size-h4); +} + +.edit-dialog-actions .edit-dialog-save { + background: var(--color-primary); + color: var(--color-background); + border-color: var(--color-primary); + font-weight: bold; +} + +.edit-dialog-actions button:hover { + filter: brightness(1.1); +} + +.edit-dialog-error { + margin: 0 1.25rem 1rem; + padding: 0.75rem; + background: hsla(0, 70%, 50%, 0.15); + border: 1px solid var(--color-negative); + border-radius: 4px; + color: var(--color-negative); + font-size: var(--font-size-h5); + white-space: pre-wrap; +} + +/* ---------- Form fields ---------- */ + +.edit-field { + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.edit-label { + font-size: var(--font-size-h5); + color: var(--color-text-highlight); + font-weight: 600; +} + +.edit-required { + color: var(--color-negative); + margin-left: 0.25rem; +} + +.edit-help { + font-size: var(--font-size-h6); + color: var(--color-text-subdue); + line-height: 1.4; +} + +.edit-input { + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: 3px; + padding: 0.5rem 0.6rem; + color: var(--color-text-base); + font: inherit; + font-size: var(--font-size-h4); + width: 100%; + box-sizing: border-box; +} + +.edit-input:focus { + outline: none; + border-color: var(--color-primary); +} + +textarea.edit-input { + resize: vertical; + font-family: monospace; + font-size: var(--font-size-h5); +} + +.edit-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: var(--font-size-h4); + color: var(--color-text-highlight); + font-weight: 600; +} + +.edit-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +/* ---------- List editors ---------- */ + +.edit-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.edit-list-items { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.edit-list-strings .edit-list-item { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.edit-list-strings .edit-list-item .edit-input { + flex: 1; +} + +.edit-list-object { + border: 1px solid var(--color-widget-content-border); + border-radius: 4px; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.1); + position: relative; +} + +.edit-list-object .edit-list-remove { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.25rem 0.6rem; + font-size: var(--font-size-h6); +} + +.edit-list-remove { + background: transparent; + border: 1px solid var(--color-popover-border); + color: var(--color-text-subdue); + border-radius: 3px; + cursor: pointer; + font: inherit; + padding: 0.25rem 0.5rem; +} + +.edit-list-remove:hover { + color: var(--color-negative); + border-color: var(--color-negative); +} + +.edit-list-add { + align-self: flex-start; + background: transparent; + border: 1px dashed var(--color-text-subdue); + color: var(--color-text-subdue); + border-radius: 3px; + padding: 0.4rem 0.8rem; + cursor: pointer; + font: inherit; + font-size: var(--font-size-h5); +} + +.edit-list-add:hover { + color: var(--color-primary); + border-color: var(--color-primary); +} + +/* ---------- Type picker ---------- */ + +.edit-type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.5rem; +} + +.edit-type-option { + padding: 0.75rem 0.5rem; + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: 4px; + color: var(--color-text-base); + cursor: pointer; + font: inherit; + font-size: var(--font-size-h5); + text-align: center; +} + +.edit-type-option:hover { + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-widget-background-highlight); +} + +/* ---------- Autocomplete ---------- */ + +.edit-autocomplete { + position: relative; +} + +.edit-autocomplete-list { + position: absolute; + top: calc(100% + 2px); + left: 0; + right: 0; + z-index: 10; + background: var(--color-popover-background); + border: 1px solid var(--color-popover-border); + border-radius: 4px; + max-height: 18rem; + overflow-y: auto; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); +} + +.edit-autocomplete-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + border-bottom: 1px solid var(--color-widget-content-border); + padding: 0.5rem 0.6rem; + color: var(--color-text-base); + cursor: pointer; + font: inherit; +} + +.edit-autocomplete-item:last-child { + border-bottom: none; +} + +.edit-autocomplete-item:hover, +.edit-autocomplete-item:focus { + background: var(--color-widget-background-highlight); +} + +.edit-autocomplete-display { + font-size: var(--font-size-h5); + color: var(--color-text-highlight); +} + +.edit-autocomplete-hint { + font-size: var(--font-size-h6); + color: var(--color-text-subdue); + margin-top: 2px; +} + +/* ---------- Per-field validation state ---------- */ + +.edit-validate-state { + font-size: var(--font-size-h6); + margin-top: 0.25rem; + min-height: 1em; +} + +.edit-validate-checking { + color: var(--color-text-subdue); + font-style: italic; +} + +.edit-validate-ok { + color: var(--color-positive); +} + +.edit-validate-error { + color: var(--color-negative); +} + +.edit-input-invalid { + border-color: var(--color-negative); +} diff --git a/internal/glance/static/css/main.css b/internal/glance/static/css/main.css index 8d0d779..934a542 100644 --- a/internal/glance/static/css/main.css +++ b/internal/glance/static/css/main.css @@ -64,3 +64,4 @@ @import "popover.css"; @import "utils.css"; @import "mobile.css"; +@import "edit-mode.css"; diff --git a/internal/glance/static/js/edit-mode.js b/internal/glance/static/js/edit-mode.js new file mode 100644 index 0000000..fc51c12 --- /dev/null +++ b/internal/glance/static/js/edit-mode.js @@ -0,0 +1,874 @@ +/* Edit mode for the dashboard. + * + * Toggle: #edit-mode-toggle. Adds drag/edit/delete handles per widget + + * "+ Add widget" buttons per column. Drag-drop saves to /edit/api/.../layout. + * Edit handle opens an inline form dialog driven by widget schema (falls + * back to the YAML editor for unscheaded widget types). Delete handle posts + * to /edit/api/.../delete. Persisted across reloads via localStorage. + */ + +const STORAGE_KEY = "glance_edit_mode"; + +const state = { + active: false, + sortables: [], + schemas: null, // cached schema map, keyed by widget type +}; + +function pageInfo() { + return { + slug: typeof pageData !== "undefined" ? pageData.slug || "" : "", + baseURL: typeof pageData !== "undefined" ? pageData.baseURL || "" : "", + }; +} + +function api(path) { + return pageInfo().baseURL + path; +} + +function escapeHTML(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); +} + +function cssEscape(s) { + if (window.CSS && CSS.escape) return CSS.escape(s); + return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); +} + +function widgetTypeOf(widget) { + const m = String(widget.className || "").match(/widget-type-(\S+)/); + return m ? m[1] : ""; +} + +/* ---------- Layout (drag-drop) ---------- */ + +function indexWidgets() { + document.querySelectorAll(".page-column").forEach((col, colIdx) => { + col.dataset.colIdx = String(colIdx); + col.querySelectorAll(":scope > .widget").forEach((w, idx) => { + w.dataset.origCol = String(colIdx); + w.dataset.origIdx = String(idx); + }); + }); +} + +function addHandles() { + document.querySelectorAll(".page-column > .widget").forEach((w) => { + if (w.querySelector(":scope > .edit-mode-handles")) return; + const handles = document.createElement("div"); + handles.className = "edit-mode-handles"; + handles.innerHTML = + '' + + '' + + ''; + w.appendChild(handles); + }); +} + +function removeHandles() { + document.querySelectorAll(".edit-mode-handles").forEach((el) => el.remove()); +} + +function addColumnAddButtons() { + document.querySelectorAll(".page-column").forEach((col, colIdx) => { + if (col.querySelector(":scope > .edit-add-widget")) return; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "edit-add-widget"; + btn.dataset.col = String(colIdx); + btn.textContent = "+ Add widget"; + col.appendChild(btn); + }); +} + +function removeColumnAddButtons() { + document.querySelectorAll(".edit-add-widget").forEach((b) => b.remove()); +} + +function setStatus(text, kind) { + let el = document.getElementById("edit-mode-status"); + if (!el) { + el = document.createElement("div"); + el.id = "edit-mode-status"; + document.body.appendChild(el); + } + el.textContent = text; + el.className = kind || ""; +} + +async function saveLayout() { + const { slug } = pageInfo(); + const columns = []; + document.querySelectorAll(".page-column").forEach((col) => { + const refs = []; + col.querySelectorAll(":scope > .widget").forEach((w) => { + refs.push({ + col: parseInt(w.dataset.origCol, 10), + idx: parseInt(w.dataset.origIdx, 10), + }); + }); + columns.push(refs); + }); + + setStatus("Saving…", "saving"); + try { + const r = await fetch(api(`/edit/api/pages/${encodeURIComponent(slug)}/layout`), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ columns }), + }); + if (!r.ok) { + setStatus("Save failed: " + (await r.text()), "error"); + return; + } + location.reload(); + } catch (e) { + setStatus("Save failed: " + e.message, "error"); + } +} + +async function deleteWidget(widget) { + if (!confirm("Delete this widget?")) return; + const { slug } = pageInfo(); + const col = widget.dataset.origCol; + const idx = widget.dataset.origIdx; + setStatus("Deleting…", "saving"); + try { + const r = await fetch( + api(`/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}/delete`), + { method: "POST" }, + ); + if (!r.ok && !r.redirected) { + setStatus("Delete failed: " + (await r.text()), "error"); + return; + } + location.reload(); + } catch (e) { + setStatus("Delete failed: " + e.message, "error"); + } +} + +/* ---------- Schema-driven dialog ---------- */ + +async function getSchemas() { + if (state.schemas !== null) return state.schemas; + try { + const r = await fetch(api("/edit/api/widget-schemas")); + state.schemas = r.ok ? (await r.json()) || {} : {}; + } catch { + state.schemas = {}; + } + return state.schemas; +} + +async function getWidgetFields(col, idx) { + const { slug } = pageInfo(); + const r = await fetch( + api(`/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}/fields`), + ); + if (!r.ok) throw new Error(await r.text()); + return (await r.json()) || {}; +} + +function renderField(field, value) { + const required = field.required ? '*' : ""; + const help = field.help ? `${escapeHTML(field.help)}` : ""; + const wrap = (input) => ` +
+ + ${help} + ${input} +
`; + + const lookupAttr = field.lookup ? ` data-lookup="${escapeHTML(field.lookup)}"` : ""; + const validatorAttr = field.validator ? ` data-validator="${escapeHTML(field.validator)}"` : ""; + + switch (field.type) { + case "string": + if (field.lookup) { + return wrap(` +
+ + +
+
`); + } + return wrap( + `` + + (field.validator ? '
' : ""), + ); + case "multiline": + return wrap( + ``, + ); + case "number": + return wrap( + ``, + ); + case "boolean": + return ` +
+ + ${help} +
`; + case "select": { + const opts = (field.options || []) + .map( + (o) => + ``, + ) + .join(""); + return wrap( + ``, + ); + } + case "list-strings": + return wrap(renderListStrings(value)); + case "list-objects": + return wrap(renderListObjects(field.items, value)); + default: + return wrap(`Unsupported type: ${escapeHTML(field.type)}`); + } +} + +function renderListStrings(values) { + values = Array.isArray(values) ? values : []; + const items = values + .map( + (v) => ` +
+ + +
`, + ) + .join(""); + return ` +
+
${items}
+ +
`; +} + +function renderListObjects(itemsSchema, values) { + values = Array.isArray(values) ? values : []; + const items = values.map((v) => renderListObjectItem(itemsSchema, v)).join(""); + const schemaJSON = escapeHTML(JSON.stringify(itemsSchema)); + return ` +
+
${items}
+ +
`; +} + +function renderListObjectItem(itemsSchema, value) { + const fields = itemsSchema.map((f) => renderField(f, value?.[f.key])).join(""); + return ` +
+ ${fields} + +
`; +} + +function renderForm(schema, values) { + return schema.map((f) => renderField(f, values?.[f.key])).join(""); +} + +/* ---------- Collect values ---------- */ + +function collectValues(container, schema) { + const result = {}; + for (const f of schema) { + const fieldEl = container.querySelector( + `:scope > .edit-field[data-field-key="${cssEscape(f.key)}"]`, + ); + result[f.key] = fieldEl ? collectFieldValue(fieldEl, f) : null; + } + return result; +} + +function collectFieldValue(fieldEl, field) { + switch (field.type) { + case "string": + case "multiline": { + // Inputs may be wrapped in .edit-autocomplete when the schema + // declares a lookup, so we can't rely on a direct-child selector. + const el = fieldEl.querySelector("input, textarea"); + const v = (el?.value ?? "").trim(); + return v === "" ? null : v; + } + case "number": { + const el = fieldEl.querySelector("input"); + const v = (el?.value ?? "").trim(); + if (v === "") return null; + const n = Number(v); + return Number.isNaN(n) ? null : n; + } + case "boolean": { + const el = fieldEl.querySelector(":scope > .edit-checkbox > input[type=checkbox]"); + return !!el?.checked; + } + case "select": { + const el = fieldEl.querySelector(":scope > select"); + return el?.value || null; + } + case "list-strings": { + const inputs = fieldEl.querySelectorAll( + ":scope > .edit-list > .edit-list-items > .edit-list-item > input", + ); + const arr = [...inputs].map((i) => i.value.trim()).filter(Boolean); + return arr.length ? arr : null; + } + case "list-objects": { + const items = fieldEl.querySelectorAll( + ":scope > .edit-list > .edit-list-items > .edit-list-object", + ); + const arr = [...items].map((item) => collectValues(item, field.items)); + const filtered = arr.filter((o) => + Object.values(o).some( + (v) => v !== null && v !== "" && !(Array.isArray(v) && v.length === 0), + ), + ); + return filtered.length ? filtered : null; + } + } + return null; +} + +/* ---------- Autocomplete + validation ---------- */ + +const lookupCache = new Map(); // key: kind + ":" + query -> suggestions + +async function fetchLookup(kind, query) { + const key = kind + ":" + query; + if (lookupCache.has(key)) return lookupCache.get(key); + try { + const r = await fetch(api(`/edit/api/lookup/${encodeURIComponent(kind)}`), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }); + if (!r.ok) return []; + const data = await r.json(); + const list = data.suggestions || []; + lookupCache.set(key, list); + return list; + } catch { + return []; + } +} + +async function fetchValidation(kind, value) { + try { + const r = await fetch(api(`/edit/api/validate/${encodeURIComponent(kind)}`), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + if (!r.ok) return { valid: false, error: await r.text() }; + return await r.json(); + } catch (e) { + return { valid: false, error: e.message }; + } +} + +function debounce(fn, ms) { + let t = null; + return (...args) => { + clearTimeout(t); + t = setTimeout(() => fn(...args), ms); + }; +} + +function renderSuggestions(listEl, suggestions) { + if (!suggestions || suggestions.length === 0) { + listEl.hidden = true; + listEl.innerHTML = ""; + return; + } + listEl.hidden = false; + listEl.innerHTML = suggestions + .map( + (s, i) => ` + `, + ) + .join(""); + // Stash data on the list element for the click handler. + listEl._suggestions = suggestions; +} + +function attachLookup(input) { + if (input._lookupAttached) return; + input._lookupAttached = true; + + const kind = input.dataset.lookup; + const wrapper = input.closest(".edit-autocomplete"); + const listEl = wrapper?.querySelector(":scope > .edit-autocomplete-list"); + if (!wrapper || !listEl) return; + + const updateSuggestions = debounce(async () => { + const q = input.value.trim(); + if (q.length < 2) { + listEl.hidden = true; + return; + } + const suggestions = await fetchLookup(kind, q); + if (input.value.trim() !== q) return; // user kept typing + renderSuggestions(listEl, suggestions); + }, 300); + + input.addEventListener("input", updateSuggestions); + input.addEventListener("focus", () => { + if (listEl.children.length > 0) listEl.hidden = false; + }); + input.addEventListener("blur", () => { + // Delay so a click on a suggestion can register first. + setTimeout(() => (listEl.hidden = true), 150); + }); + + listEl.addEventListener("mousedown", (e) => { + const btn = e.target.closest(".edit-autocomplete-item"); + if (!btn) return; + e.preventDefault(); // keep focus on the input + const idx = parseInt(btn.dataset.index, 10); + const s = listEl._suggestions?.[idx]; + if (!s) return; + input.value = s.value; + // Auto-fill sibling fields if the suggestion provides extras. + if (s.extra) { + const itemScope = + input.closest(".edit-list-object") || input.closest(".edit-dialog-body"); + for (const [k, v] of Object.entries(s.extra)) { + const sibling = itemScope?.querySelector( + `:scope > .edit-field[data-field-key="${cssEscape(k)}"] input, :scope > .edit-field[data-field-key="${cssEscape(k)}"] textarea`, + ); + if (sibling && !sibling.value.trim()) { + sibling.value = v; + } + } + } + listEl.hidden = true; + listEl.innerHTML = ""; + clearValidationState(input); + }); +} + +function setValidationState(input, kind, message) { + const stateEl = + input + .closest(".edit-autocomplete, .edit-field") + ?.querySelector(":scope > .edit-validate-state, :scope .edit-validate-state"); + if (!stateEl) return; + stateEl.className = "edit-validate-state edit-validate-" + kind; + stateEl.textContent = message || ""; + input.classList.toggle("edit-input-invalid", kind === "error"); +} + +function clearValidationState(input) { + const stateEl = + input + .closest(".edit-autocomplete, .edit-field") + ?.querySelector(":scope > .edit-validate-state, :scope .edit-validate-state"); + if (stateEl) { + stateEl.className = "edit-validate-state"; + stateEl.textContent = ""; + } + input.classList.remove("edit-input-invalid"); +} + +function isEmpty(v) { + if (v === null || v === undefined) return true; + if (typeof v === "string") return v.trim() === ""; + if (Array.isArray(v)) return v.length === 0; + return false; +} + +// Walk the form recursively and return errors for any required field that's +// empty. This duplicates HTML5's `required`, but the dialog's Save button +// isn't an actual form-submit, so we have to enforce it ourselves. +function checkRequired(container, schema) { + const errors = []; + for (const f of schema) { + const fieldEl = container.querySelector( + `:scope > .edit-field[data-field-key="${cssEscape(f.key)}"]`, + ); + if (!fieldEl) continue; + const value = collectFieldValue(fieldEl, f); + if (f.required && isEmpty(value)) { + const input = fieldEl.querySelector("input, textarea, select"); + errors.push({ input, fieldEl, message: `${f.label} is required.` }); + } + if (f.type === "list-objects") { + const items = fieldEl.querySelectorAll( + ":scope > .edit-list > .edit-list-items > .edit-list-object", + ); + items.forEach((item) => { + const itemValues = collectValues(item, f.items); + // Skip all-empty rows; they'll be filtered out on save. + if (Object.values(itemValues).every(isEmpty)) return; + errors.push(...checkRequired(item, f.items)); + }); + } + } + return errors; +} + +async function preSubmitValidate(dialogBody, schema) { + const errors = []; + + // First pass: required-field check. + const requiredErrors = checkRequired(dialogBody, schema); + for (const e of requiredErrors) { + if (e.input) setValidationState(e.input, "error", e.message); + errors.push(e); + } + if (errors.length > 0) return errors; + + // Second pass: network validators (only for non-empty inputs). + const inputs = dialogBody.querySelectorAll("input[data-validator]"); + for (const input of inputs) { + const value = input.value.trim(); + if (value === "") continue; + const kind = input.dataset.validator; + setValidationState(input, "checking", "Checking…"); + const r = await fetchValidation(kind, value); + if (r.valid) { + setValidationState(input, "ok", r.hint ? "✓ " + r.hint : "✓"); + } else { + setValidationState(input, "error", r.error || "Invalid value"); + errors.push({ input, message: r.error }); + } + } + return errors; +} + +/* ---------- Dialog ---------- */ + +function openDialog({ title, schema, values, submitLabel, onSubmit }) { + const overlay = document.createElement("div"); + overlay.className = "edit-dialog-overlay"; + overlay.innerHTML = ` + `; + + const close = () => overlay.remove(); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) { + close(); + return; + } + if (e.target.closest(".edit-dialog-close, .edit-dialog-cancel")) { + close(); + return; + } + const removeBtn = e.target.closest(".edit-list-remove"); + if (removeBtn) { + removeBtn.closest(".edit-list-item").remove(); + return; + } + const addBtn = e.target.closest(".edit-list-add"); + if (addBtn) { + const list = addBtn.closest(".edit-list"); + const items = list.querySelector(":scope > .edit-list-items"); + if (list.classList.contains("edit-list-strings")) { + const div = document.createElement("div"); + div.className = "edit-list-item"; + div.innerHTML = + ''; + items.appendChild(div); + div.querySelector("input").focus(); + } else if (list.classList.contains("edit-list-objects")) { + const itemsSchema = JSON.parse(list.dataset.itemsSchema); + const tmp = document.createElement("div"); + tmp.innerHTML = renderListObjectItem(itemsSchema, {}); + items.appendChild(tmp.firstElementChild); + items.lastElementChild.querySelector("input, textarea, select")?.focus(); + } + } + }); + + overlay.addEventListener("keydown", (e) => { + if (e.key === "Escape") close(); + }); + + overlay.querySelector(".edit-dialog-save").addEventListener("click", async () => { + const errEl = overlay.querySelector(".edit-dialog-error"); + const saveBtn = overlay.querySelector(".edit-dialog-save"); + errEl.hidden = true; + saveBtn.disabled = true; + try { + const validationErrors = await preSubmitValidate( + overlay.querySelector(".edit-dialog-body"), + schema, + ); + if (validationErrors.length > 0) { + errEl.textContent = "Some fields didn't validate — see details next to each one."; + errEl.hidden = false; + validationErrors[0].input?.focus(); + return; + } + const newValues = collectValues(overlay.querySelector(".edit-dialog-body"), schema); + await onSubmit(newValues); + } catch (e) { + errEl.textContent = e.message || String(e); + errEl.hidden = false; + errEl.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } finally { + saveBtn.disabled = false; + } + }); + + document.body.appendChild(overlay); + overlay.querySelector("input, textarea, select")?.focus(); + // Wire up autocomplete on any pre-rendered or future inputs with a lookup. + overlay.querySelectorAll("input[data-lookup]").forEach(attachLookup); + new MutationObserver((records) => { + for (const r of records) { + r.addedNodes?.forEach((n) => { + if (n.nodeType !== 1) return; + n.querySelectorAll?.("input[data-lookup]").forEach(attachLookup); + if (n.matches?.("input[data-lookup]")) attachLookup(n); + }); + } + }).observe(overlay, { childList: true, subtree: true }); + // Clear validation hints when user types in a validator-flagged input. + overlay.addEventListener("input", (e) => { + if (e.target.matches?.("input[data-validator]")) { + clearValidationState(e.target); + } + }); +} + +async function openEditDialog(col, idx, widgetType) { + const { slug, baseURL } = pageInfo(); + const schemas = await getSchemas(); + const schema = schemas[widgetType]; + if (!schema) { + // No schema — fall back to YAML editor for this widget type. + location.href = `${baseURL}/edit/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}`; + return; + } + let values = {}; + try { + values = await getWidgetFields(col, idx); + } catch (e) { + alert("Couldn't load widget fields: " + e.message); + return; + } + + openDialog({ + title: `Edit ${widgetType}`, + schema, + values, + submitLabel: "Save", + async onSubmit(newValues) { + const r = await fetch( + api( + `/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}/fields`, + ), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newValues), + }, + ); + if (!r.ok) throw new Error(await r.text()); + location.reload(); + }, + }); +} + +async function openCreateDialog(col, widgetType) { + const { slug, baseURL } = pageInfo(); + const schemas = await getSchemas(); + const schema = schemas[widgetType]; + if (!schema) { + location.href = `${baseURL}/edit/pages/${encodeURIComponent(slug)}/widgets/${col}/new?type=${encodeURIComponent(widgetType)}`; + return; + } + + openDialog({ + title: `Add ${widgetType}`, + schema, + values: {}, + submitLabel: "Create", + async onSubmit(newValues) { + const r = await fetch( + api(`/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/create`), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: widgetType, fields: newValues }), + }, + ); + if (!r.ok) throw new Error(await r.text()); + location.reload(); + }, + }); +} + +async function showAddPicker(col) { + const { slug, baseURL } = pageInfo(); + const schemas = await getSchemas(); + const types = Object.keys(schemas).sort(); + + const overlay = document.createElement("div"); + overlay.className = "edit-dialog-overlay"; + overlay.innerHTML = ` + `; + const close = () => overlay.remove(); + overlay.addEventListener("click", (e) => { + if (e.target === overlay || e.target.closest(".edit-dialog-close")) { + close(); + return; + } + const btn = e.target.closest(".edit-type-option"); + if (btn) { + close(); + openCreateDialog(col, btn.dataset.type); + } + }); + overlay.addEventListener("keydown", (e) => { + if (e.key === "Escape") close(); + }); + document.body.appendChild(overlay); +} + +/* ---------- Mode toggle ---------- */ + +function enterEditMode() { + if (state.active) return; + if (typeof Sortable === "undefined") { + console.error("edit-mode: Sortable library not loaded"); + return; + } + state.active = true; + document.body.dataset.editMode = "true"; + document.querySelectorAll("#edit-mode-toggle").forEach((b) => b.classList.add("active")); + indexWidgets(); + addHandles(); + addColumnAddButtons(); + setStatus("Edit mode — drag widgets to rearrange"); + + document.querySelectorAll(".page-column").forEach((col) => { + state.sortables.push( + Sortable.create(col, { + group: "glance-widgets", + handle: ".edit-handle-drag", + draggable: ".widget", + animation: 150, + ghostClass: "sortable-ghost", + dragClass: "sortable-drag", + onEnd: () => saveLayout(), + }), + ); + }); + + localStorage.setItem(STORAGE_KEY, "1"); + // Pre-warm schema cache so first dialog open is instant. + getSchemas(); +} + +function exitEditMode() { + if (!state.active) return; + state.active = false; + document.body.removeAttribute("data-edit-mode"); + document.querySelectorAll("#edit-mode-toggle").forEach((b) => b.classList.remove("active")); + state.sortables.forEach((s) => s.destroy()); + state.sortables = []; + removeHandles(); + removeColumnAddButtons(); + document.getElementById("edit-mode-status")?.remove(); + localStorage.removeItem(STORAGE_KEY); +} + +function toggleEditMode() { + state.active ? exitEditMode() : enterEditMode(); +} + +function waitForContent(callback) { + const target = document.getElementById("page-content"); + if (!target) return; + if (target.children.length > 0) { + callback(); + return; + } + const obs = new MutationObserver(() => { + if (target.children.length > 0) { + obs.disconnect(); + callback(); + } + }); + obs.observe(target, { childList: true }); +} + +/* ---------- Click routing ---------- */ + +document.addEventListener("click", (e) => { + const toggle = e.target.closest("#edit-mode-toggle"); + if (toggle) { + e.preventDefault(); + toggleEditMode(); + return; + } + const editBtn = e.target.closest(".edit-handle-edit"); + if (editBtn) { + e.preventDefault(); + const widget = editBtn.closest(".widget"); + if (widget) { + openEditDialog( + parseInt(widget.dataset.origCol, 10), + parseInt(widget.dataset.origIdx, 10), + widgetTypeOf(widget), + ); + } + return; + } + const delBtn = e.target.closest(".edit-handle-delete"); + if (delBtn) { + e.preventDefault(); + const widget = delBtn.closest(".widget"); + if (widget) deleteWidget(widget); + return; + } + const addBtn = e.target.closest(".edit-add-widget"); + if (addBtn) { + e.preventDefault(); + showAddPicker(parseInt(addBtn.dataset.col, 10)); + } +}); + +waitForContent(() => { + if (localStorage.getItem(STORAGE_KEY) === "1") { + enterEditMode(); + } +}); diff --git a/internal/glance/static/js/vendor/Sortable.min.js b/internal/glance/static/js/vendor/Sortable.min.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/internal/glance/static/js/vendor/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY +{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }} + + +{{ end }} {{ end }} {{ define "navigation-links" }} @@ -44,7 +48,12 @@ {{ end }} {{- if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }} - + + @@ -100,8 +109,14 @@ {{ end }} {{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }} + -
Edit dashboard
+
Advanced editor