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
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user