Add in-place dashboard edit mode
A toggle on the dashboard flips into edit mode where widgets get drag/edit/delete handles and a "+ Add widget" button per column. Most edits no longer require touching YAML. - Drag-drop reorder via SortableJS, persisted through a bulk-layout endpoint that rebuilds each column's widgets sequence in the yaml.Node tree. - Inline form dialog driven by per-widget field schemas (admin-schemas.go) covering 25 widget types. Surgical updates apply changed fields to the widget node so untouched keys and comments survive. Falls back to the existing YAML editor for group and split-column (those need a recursive nested-widget UI). - Per-column "+ Add widget" picker that opens the dialog pre-filled with sensible defaults for the chosen type. - Field validation and autocomplete for weather location (Open-Meteo geocoding), market symbol (Yahoo Finance), and RSS feed URL (gofeed). Network failures soft-pass for weather/markets so the save isn't blocked when external APIs are unreachable. - Picking a market suggestion auto-fills the company name field via per-suggestion `extra` fields. - Edit mode persists across reloads via localStorage so saves don't drop the user back into view mode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
2e198a19b4
commit
4b8a34a2ae
+9
-41
@@ -32,31 +32,14 @@ pages:
|
|||||||
collapse-after: 3
|
collapse-after: 3
|
||||||
cache: 12h
|
cache: 12h
|
||||||
feeds:
|
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
|
- url: https://www.techmeme.com/feed.xml
|
||||||
title: TechMeme
|
title: TechMeme
|
||||||
- type: twitch-channels
|
- type: server-stats
|
||||||
channels:
|
|
||||||
- theprimeagen
|
|
||||||
- j_blow
|
|
||||||
- giantwaffle
|
|
||||||
- cohhcarnage
|
|
||||||
- christitustech
|
|
||||||
- EJ_SA
|
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
- type: hacker-news
|
- type: hacker-news
|
||||||
- type: lobsters
|
|
||||||
- type: videos
|
- type: videos
|
||||||
channels:
|
channels:
|
||||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
||||||
@@ -75,33 +58,18 @@ pages:
|
|||||||
- size: small
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
- type: weather
|
- type: weather
|
||||||
location: London, United Kingdom
|
location: Houston, US
|
||||||
units: metric # alternatively "imperial"
|
units: imperial
|
||||||
hour-format: 12h # alternatively "24h"
|
hour-format: 12h
|
||||||
# Optionally hide the location from being displayed in the widget
|
# Optionally hide the location from being displayed in the widget
|
||||||
# hide-location: true
|
# hide-location: true
|
||||||
|
|
||||||
|
hide-location: true
|
||||||
|
show-area-name: false
|
||||||
- type: markets
|
- type: markets
|
||||||
markets:
|
markets:
|
||||||
- symbol: SPY
|
- symbol: AMZN
|
||||||
name: S&P 500
|
name: Amazon.com, Inc.
|
||||||
- symbol: BTC-USD
|
|
||||||
name: Bitcoin
|
|
||||||
- symbol: NVDA
|
|
||||||
name: NVIDIA
|
|
||||||
- symbol: AAPL
|
|
||||||
name: Apple
|
|
||||||
- symbol: MSFT
|
|
||||||
name: Microsoft
|
|
||||||
- type: releases
|
|
||||||
cache: 1d
|
|
||||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
|
||||||
# read-only token from your Github account settings and use it here to increase the limit.
|
|
||||||
# token: ...
|
|
||||||
repositories:
|
|
||||||
- glanceapp/glance
|
|
||||||
- go-gitea/gitea
|
|
||||||
- immich-app/immich
|
|
||||||
- syncthing/syncthing
|
|
||||||
|
|
||||||
# Add more pages here:
|
# Add more pages here:
|
||||||
# - name: Your page name
|
# - name: Your page name
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package glance
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
// widgetFieldSchema describes one editable field on a widget for the dialog
|
||||||
|
// form generator. Marshaled to JSON and consumed by edit-mode.js.
|
||||||
|
type widgetFieldSchema struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"` // string | multiline | number | boolean | select | list-strings | list-objects
|
||||||
|
Help string `json:"help,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
Items []widgetFieldSchema `json:"items,omitempty"` // for list-objects
|
||||||
|
Validator string `json:"validator,omitempty"`
|
||||||
|
Lookup string `json:"lookup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetSchemas maps widget type to the form fields shown in the dialog
|
||||||
|
// editor. Widgets not listed here fall back to the textarea YAML editor.
|
||||||
|
// Keep field order — that's the order the dialog renders them.
|
||||||
|
var widgetSchemas = map[string][]widgetFieldSchema{
|
||||||
|
"rss": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string", Help: "Override the widget header. Leave blank for the default."},
|
||||||
|
{Key: "feeds", Label: "Feeds", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "url", Label: "Feed URL", Type: "string", Required: true, Validator: "rss-feed", Help: "Tested when you save."},
|
||||||
|
{Key: "title", Label: "Custom title (optional)", Type: "string", Help: "Defaults to the title from the feed itself."},
|
||||||
|
}},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number", Help: "Default: 25"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards", "horizontal-cards-2", "detailed-list"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"weather": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "location", Label: "Location", Type: "string", Required: true, Help: "City and country, e.g. London, GB. Start typing to search.", Lookup: "weather-location", Validator: "weather-location"},
|
||||||
|
{Key: "units", Label: "Units", Type: "select", Options: []string{"metric", "imperial"}},
|
||||||
|
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
|
||||||
|
{Key: "hide-location", Label: "Hide location label", Type: "boolean"},
|
||||||
|
{Key: "show-area-name", Label: "Show area name", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"markets": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "markets", Label: "Symbols", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "symbol", Label: "Ticker symbol", Type: "string", Required: true, Help: "Search a company name or type a symbol like AAPL or BTC-USD.", Lookup: "market-symbol", Validator: "market-symbol"},
|
||||||
|
{Key: "name", Label: "Display name (optional)", Type: "string"},
|
||||||
|
}},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"absolute-change", "relative-change"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"monitor": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sites", Label: "Sites", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "title", Label: "Display name", Type: "string", Required: true},
|
||||||
|
{Key: "url", Label: "URL", Type: "string", Required: true},
|
||||||
|
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
|
||||||
|
{Key: "alt-status-codes", Label: "Other OK status codes (comma-separated)", Type: "string", Help: "e.g. 401,403 — codes that should still show OK."},
|
||||||
|
}},
|
||||||
|
{Key: "show-failing-only", Label: "Show failing only", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"reddit": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "subreddit", Label: "Subreddit", Type: "string", Required: true, Help: "Just the name, no /r/"},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new", "top", "rising"}},
|
||||||
|
{Key: "top-period", Label: "Top period (when sort-by is 'top')", Type: "select", Options: []string{"day", "week", "month", "year", "all"}},
|
||||||
|
{Key: "show-thumbnails", Label: "Show thumbnails", Type: "boolean"},
|
||||||
|
{Key: "show-flairs", Label: "Show flairs", Type: "boolean"},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"hacker-news": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"top", "new", "best"}},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"lobsters": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new"}},
|
||||||
|
{Key: "tags", Label: "Tags (filter)", Type: "list-strings"},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"videos": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "channels", Label: "YouTube channel IDs", Type: "list-strings", Required: true, Help: "The UC... ID, not the @handle"},
|
||||||
|
{Key: "limit", Label: "Videos to show", Type: "number"},
|
||||||
|
{Key: "style", Label: "Style", Type: "select", Options: []string{"horizontal-cards", "grid-cards", "vertical-list"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"twitch-channels": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "channels", Label: "Twitch channel names", Type: "list-strings", Required: true},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"repository": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "repository", Label: "Repository", Type: "string", Required: true, Help: "owner/name, e.g. glanceapp/glance"},
|
||||||
|
{Key: "token", Label: "GitHub token (optional)", Type: "string", Help: "Increases rate limits. Use ${env:VAR} to read from env."},
|
||||||
|
{Key: "pull-requests-limit", Label: "Pull requests to show", Type: "number"},
|
||||||
|
{Key: "issues-limit", Label: "Issues to show", Type: "number"},
|
||||||
|
{Key: "commits-limit", Label: "Commits to show", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"releases": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "repositories", Label: "Repositories", Type: "list-strings", Required: true, Help: "owner/name per line. Add prefixes like docker:, gitlab:, codeberg: for non-GitHub sources."},
|
||||||
|
{Key: "token", Label: "GitHub token (optional)", Type: "string"},
|
||||||
|
{Key: "show-source-icon", Label: "Show source icon", Type: "boolean"},
|
||||||
|
{Key: "limit", Label: "Releases to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"bookmarks": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "groups", Label: "Groups", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "title", Label: "Group title", Type: "string", Required: true},
|
||||||
|
{Key: "color", Label: "Color (HSL, optional)", Type: "string", Help: "e.g. 200 50 50"},
|
||||||
|
{Key: "links", Label: "Links", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "title", Label: "Link title", Type: "string", Required: true},
|
||||||
|
{Key: "url", Label: "URL", Type: "string", Required: true},
|
||||||
|
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"clock": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
|
||||||
|
{Key: "timezones", Label: "Extra timezones to show", Type: "list-objects", Items: []widgetFieldSchema{
|
||||||
|
{Key: "timezone", Label: "Timezone (e.g. Europe/London)", Type: "string", Required: true},
|
||||||
|
{Key: "label", Label: "Display label", Type: "string"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"calendar": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "first-day-of-week", Label: "First day of week", Type: "select", Options: []string{"monday", "sunday"}},
|
||||||
|
{Key: "show-week-numbers", Label: "Show week numbers", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"search": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "search-engine", Label: "Search engine", Type: "select", Options: []string{"duckduckgo", "google", "bing", "kagi", "startpage", "perplexity"}},
|
||||||
|
{Key: "new-tab", Label: "Open results in new tab", Type: "boolean"},
|
||||||
|
{Key: "autofocus", Label: "Autofocus on page load", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"iframe": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "source", Label: "URL to embed", Type: "string", Required: true},
|
||||||
|
{Key: "height", Label: "Height (CSS)", Type: "string", Help: "e.g. 400px, 50vh"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"html": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "source", Label: "HTML", Type: "multiline", Required: true},
|
||||||
|
},
|
||||||
|
|
||||||
|
"server-stats": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"to-do": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"dns-stats": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "service", Label: "Service", Type: "select", Required: true,
|
||||||
|
Options: []string{"adguard", "pihole", "pihole-v6", "technitium"}},
|
||||||
|
{Key: "url", Label: "Service URL", Type: "string", Required: true,
|
||||||
|
Help: "Base URL of your DNS resolver, e.g. http://pi.hole or http://192.168.1.10"},
|
||||||
|
{Key: "username", Label: "Username (AdGuard / Pi-hole v6)", Type: "string"},
|
||||||
|
{Key: "password", Label: "Password", Type: "string",
|
||||||
|
Help: "Use ${env:DNS_PASSWORD} to read from an env var instead of inlining."},
|
||||||
|
{Key: "token", Label: "API token (Pi-hole v5, Technitium)", Type: "string"},
|
||||||
|
{Key: "allow-insecure", Label: "Allow insecure TLS (self-signed certs)", Type: "boolean"},
|
||||||
|
{Key: "hide-graph", Label: "Hide hourly graph", Type: "boolean"},
|
||||||
|
{Key: "hide-top-domains", Label: "Hide top blocked/queried domains", Type: "boolean"},
|
||||||
|
{Key: "hour-format", Label: "Hour format", Type: "select",
|
||||||
|
Options: []string{"24h", "12h"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"docker-containers": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sock-path", Label: "Docker socket path", Type: "string",
|
||||||
|
Help: "Default: /var/run/docker.sock"},
|
||||||
|
{Key: "category", Label: "Category filter", Type: "string",
|
||||||
|
Help: "Show only containers labelled glance.category=<value>"},
|
||||||
|
{Key: "hide-by-default", Label: "Hide containers unless labelled glance.hide=false", Type: "boolean"},
|
||||||
|
{Key: "running-only", Label: "Show running containers only", Type: "boolean"},
|
||||||
|
{Key: "format-container-names", Label: "Format container names", Type: "boolean",
|
||||||
|
Help: "Title-cases names like 'home_assistant' → 'Home Assistant'"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"extension": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "url", Label: "Extension URL", Type: "string", Required: true,
|
||||||
|
Help: "URL of the Glance extension endpoint. See the Extensions docs."},
|
||||||
|
{Key: "fallback-content-type", Label: "Fallback content type", Type: "select",
|
||||||
|
Options: []string{"", "html", "iframe"},
|
||||||
|
Help: "Used when the response has no Widget-Content-Type header."},
|
||||||
|
{Key: "allow-potentially-dangerous-html", Label: "Allow raw HTML output", Type: "boolean",
|
||||||
|
Help: "Only enable for extensions you trust — they can inject scripts otherwise."},
|
||||||
|
},
|
||||||
|
|
||||||
|
"change-detection": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "instance-url", Label: "ChangeDetection.io URL", Type: "string",
|
||||||
|
Help: "Defaults to https://www.changedetection.io. Use your own instance for self-hosted."},
|
||||||
|
{Key: "token", Label: "API token", Type: "string",
|
||||||
|
Help: "From your changedetection.io profile. Use ${env:CD_TOKEN} for env-based config."},
|
||||||
|
{Key: "watches", Label: "Watch UUIDs (optional)", Type: "list-strings",
|
||||||
|
Help: "Filter to specific watches. Leave empty to show all."},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"custom-api": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "url", Label: "API URL", Type: "string", Required: true},
|
||||||
|
{Key: "method", Label: "HTTP method", Type: "select",
|
||||||
|
Options: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}},
|
||||||
|
{Key: "body-type", Label: "Body type (when POST/PUT/PATCH)", Type: "select",
|
||||||
|
Options: []string{"", "json", "string"}},
|
||||||
|
{Key: "body", Label: "Request body", Type: "multiline",
|
||||||
|
Help: "Optional. Sent as the request body."},
|
||||||
|
{Key: "template", Label: "Output template (Go html/template)", Type: "multiline", Required: true,
|
||||||
|
Help: "Renders the response. See the Custom API docs for available helpers like {{ .JSON.String \"path.to.field\" }}."},
|
||||||
|
{Key: "frameless", Label: "Hide widget frame", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
// group + split-column intentionally skipped: their `widgets:` field is a
|
||||||
|
// recursive list of widget objects, which the inline form generator can't
|
||||||
|
// render usefully yet. They fall through to the YAML editor.
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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}", 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}/delete", a.handleAdminDeleteWidget)
|
||||||
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/move", a.handleAdminMoveWidget)
|
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 {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -64,3 +64,4 @@
|
|||||||
@import "popover.css";
|
@import "popover.css";
|
||||||
@import "utils.css";
|
@import "utils.css";
|
||||||
@import "mobile.css";
|
@import "mobile.css";
|
||||||
|
@import "edit-mode.css";
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
'<button type="button" class="edit-handle edit-handle-drag" title="Drag to reorder" aria-label="Drag">⋮⋮</button>' +
|
||||||
|
'<button type="button" class="edit-handle edit-handle-edit" title="Edit" aria-label="Edit">✎</button>' +
|
||||||
|
'<button type="button" class="edit-handle edit-handle-delete" title="Delete" aria-label="Delete">✕</button>';
|
||||||
|
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 ? '<span class="edit-required">*</span>' : "";
|
||||||
|
const help = field.help ? `<small class="edit-help">${escapeHTML(field.help)}</small>` : "";
|
||||||
|
const wrap = (input) => `
|
||||||
|
<div class="edit-field" data-field-key="${escapeHTML(field.key)}" data-field-type="${escapeHTML(field.type)}">
|
||||||
|
<label class="edit-label">${escapeHTML(field.label)}${required}</label>
|
||||||
|
${help}
|
||||||
|
${input}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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(`
|
||||||
|
<div class="edit-autocomplete">
|
||||||
|
<input type="text" class="edit-input"${lookupAttr}${validatorAttr} value="${escapeHTML(value)}" ${field.required ? "required" : ""} autocomplete="off">
|
||||||
|
<div class="edit-autocomplete-list" hidden></div>
|
||||||
|
<div class="edit-validate-state"></div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
return wrap(
|
||||||
|
`<input type="text" class="edit-input"${validatorAttr} value="${escapeHTML(value)}" ${field.required ? "required" : ""}>` +
|
||||||
|
(field.validator ? '<div class="edit-validate-state"></div>' : ""),
|
||||||
|
);
|
||||||
|
case "multiline":
|
||||||
|
return wrap(
|
||||||
|
`<textarea class="edit-input" rows="6" ${field.required ? "required" : ""}>${escapeHTML(value)}</textarea>`,
|
||||||
|
);
|
||||||
|
case "number":
|
||||||
|
return wrap(
|
||||||
|
`<input type="number" class="edit-input" value="${value ?? ""}" ${field.required ? "required" : ""}>`,
|
||||||
|
);
|
||||||
|
case "boolean":
|
||||||
|
return `
|
||||||
|
<div class="edit-field" data-field-key="${escapeHTML(field.key)}" data-field-type="boolean">
|
||||||
|
<label class="edit-checkbox"><input type="checkbox" ${value ? "checked" : ""}><span>${escapeHTML(field.label)}</span></label>
|
||||||
|
${help}
|
||||||
|
</div>`;
|
||||||
|
case "select": {
|
||||||
|
const opts = (field.options || [])
|
||||||
|
.map(
|
||||||
|
(o) =>
|
||||||
|
`<option value="${escapeHTML(o)}" ${value === o ? "selected" : ""}>${escapeHTML(o)}</option>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return wrap(
|
||||||
|
`<select class="edit-input" ${field.required ? "required" : ""}><option value=""></option>${opts}</select>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "list-strings":
|
||||||
|
return wrap(renderListStrings(value));
|
||||||
|
case "list-objects":
|
||||||
|
return wrap(renderListObjects(field.items, value));
|
||||||
|
default:
|
||||||
|
return wrap(`<em>Unsupported type: ${escapeHTML(field.type)}</em>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListStrings(values) {
|
||||||
|
values = Array.isArray(values) ? values : [];
|
||||||
|
const items = values
|
||||||
|
.map(
|
||||||
|
(v) => `
|
||||||
|
<div class="edit-list-item">
|
||||||
|
<input type="text" class="edit-input" value="${escapeHTML(v)}">
|
||||||
|
<button type="button" class="edit-list-remove" aria-label="Remove">×</button>
|
||||||
|
</div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<div class="edit-list edit-list-strings">
|
||||||
|
<div class="edit-list-items">${items}</div>
|
||||||
|
<button type="button" class="edit-list-add">+ Add</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="edit-list edit-list-objects" data-items-schema="${schemaJSON}">
|
||||||
|
<div class="edit-list-items">${items}</div>
|
||||||
|
<button type="button" class="edit-list-add">+ Add</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListObjectItem(itemsSchema, value) {
|
||||||
|
const fields = itemsSchema.map((f) => renderField(f, value?.[f.key])).join("");
|
||||||
|
return `
|
||||||
|
<div class="edit-list-item edit-list-object">
|
||||||
|
${fields}
|
||||||
|
<button type="button" class="edit-list-remove">Remove</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<button type="button" class="edit-autocomplete-item" data-index="${i}">
|
||||||
|
<div class="edit-autocomplete-display">${escapeHTML(s.display || s.value)}</div>
|
||||||
|
${s.hint ? `<div class="edit-autocomplete-hint">${escapeHTML(s.hint)}</div>` : ""}
|
||||||
|
</button>`,
|
||||||
|
)
|
||||||
|
.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 = `
|
||||||
|
<div class="edit-dialog" role="dialog" aria-modal="true">
|
||||||
|
<div class="edit-dialog-header">
|
||||||
|
<h2>${escapeHTML(title)}</h2>
|
||||||
|
<button type="button" class="edit-dialog-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="edit-dialog-body">${renderForm(schema, values)}</div>
|
||||||
|
<div class="edit-dialog-error" hidden></div>
|
||||||
|
<div class="edit-dialog-actions">
|
||||||
|
<button type="button" class="edit-dialog-cancel">Cancel</button>
|
||||||
|
<button type="button" class="edit-dialog-save">${escapeHTML(submitLabel || "Save")}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<input type="text" class="edit-input"><button type="button" class="edit-list-remove" aria-label="Remove">×</button>';
|
||||||
|
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 = `
|
||||||
|
<div class="edit-dialog edit-dialog-narrow" role="dialog" aria-modal="true">
|
||||||
|
<div class="edit-dialog-header">
|
||||||
|
<h2>Add widget</h2>
|
||||||
|
<button type="button" class="edit-dialog-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="edit-dialog-body">
|
||||||
|
<div class="edit-type-grid">
|
||||||
|
${types.map((t) => `<button type="button" class="edit-type-option" data-type="${escapeHTML(t)}">${escapeHTML(t)}</button>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p class="edit-help" style="margin-top:1rem;">
|
||||||
|
Need a widget type that isn't listed? Use the
|
||||||
|
<a href="${baseURL}/edit/pages/${encodeURIComponent(slug)}" target="_blank">advanced editor</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
{{ define "document-head-after" }}
|
{{ define "document-head-after" }}
|
||||||
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
|
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
|
||||||
|
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
|
<script src='{{ .App.StaticAssetPath "js/vendor/Sortable.min.js" }}'></script>
|
||||||
|
<script src='{{ .App.StaticAssetPath "js/edit-mode.js" }}'></script>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
@@ -44,7 +48,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{- if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
{{- if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/edit" title="Edit dashboard">
|
<button id="edit-mode-toggle" class="block self-center" title="Toggle edit mode" aria-label="Toggle edit mode">
|
||||||
|
<svg class="logout-button" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/edit" title="Open advanced editor">
|
||||||
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -100,8 +109,14 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
|
<button id="edit-mode-toggle" class="flex justify-between items-center" style="background:none;border:none;padding:0;width:100%;cursor:pointer;color:inherit;font:inherit;">
|
||||||
|
<div class="size-h3">Edit mode</div>
|
||||||
|
<svg class="ui-icon" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<a href="{{ .App.Config.Server.BaseURL }}/edit" class="flex justify-between items-center">
|
<a href="{{ .App.Config.Server.BaseURL }}/edit" class="flex justify-between items-center">
|
||||||
<div class="size-h3">Edit dashboard</div>
|
<div class="size-h3">Advanced editor</div>
|
||||||
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user