Round out the editor: head widgets, undo, group/split-column, site settings
- Head widgets are now first-class in dashboard edit mode: they get drag/edit/delete handles, their own SortableJS group (so they can't cross into columns), and the layout endpoint accepts a headWidgets[] sequence alongside columns[]. - New POST /edit/api/restore swaps glance.yml ↔ glance.yml.bak — effectively undo (and click again to redo). Surfaced as a "Restore previous version" button on /edit. - group and split-column are now editable inline via a new list-of-widgets schema type. Each item picks a widget type from a dropdown (excluding group/split-column to honor the backend's no-nesting rule) and renders that type's schema-driven sub-form. Switching the type re-renders the body. - New /edit/site-settings page with a form for branding (app name, logo, favicon, footer, app icon/background). POST applies via the same surgical yaml.Node update used for widgets. Theme/server intentionally still YAML for now. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7f36f30dad
commit
e108feb74b
+172
-12
@@ -601,11 +601,13 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type widgetRef struct {
|
||||||
|
Col int `json:"col"`
|
||||||
|
Idx int `json:"idx"`
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Columns [][]struct {
|
HeadWidgets []widgetRef `json:"headWidgets"`
|
||||||
Col int `json:"col"`
|
Columns [][]widgetRef `json:"columns"`
|
||||||
Idx int `json:"idx"`
|
|
||||||
} `json:"columns"`
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -632,6 +634,19 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot head widgets (col = -1). Optional — pages may not have any.
|
||||||
|
var headWidgetsNode *yaml.Node
|
||||||
|
var headSnapshot []*yaml.Node
|
||||||
|
if hw, err := findOrCreateKey(pageNode, "head-widgets", false, yaml.SequenceNode); err == nil {
|
||||||
|
headWidgetsNode = hw
|
||||||
|
headSnapshot = make([]*yaml.Node, len(hw.Content))
|
||||||
|
copy(headSnapshot, hw.Content)
|
||||||
|
}
|
||||||
|
if len(req.HeadWidgets) != len(headSnapshot) {
|
||||||
|
http.Error(w, fmt.Sprintf("layout has %d head widgets, page has %d", len(req.HeadWidgets), len(headSnapshot)), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot the existing widget nodes by [col][idx] so we can splice them
|
// Snapshot the existing widget nodes by [col][idx] so we can splice them
|
||||||
// into the new layout without mutating during traversal.
|
// into the new layout without mutating during traversal.
|
||||||
snapshot := make([][]*yaml.Node, len(columns.Content))
|
snapshot := make([][]*yaml.Node, len(columns.Content))
|
||||||
@@ -648,25 +663,51 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate: each widget referenced exactly once, all references in range.
|
// Validate: each widget referenced exactly once, all references in range.
|
||||||
seen := make(map[[2]int]bool, totalCount)
|
// Column widgets use col >= 0 keys; head widgets use col = -1.
|
||||||
|
seen := make(map[[2]int]bool, totalCount+len(headSnapshot))
|
||||||
|
checkRef := func(ref widgetRef) error {
|
||||||
|
if ref.Col == -1 {
|
||||||
|
if ref.Idx < 0 || ref.Idx >= len(headSnapshot) {
|
||||||
|
return fmt.Errorf("invalid head widget reference idx=%d", ref.Idx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ref.Col < 0 || ref.Col >= len(snapshot) || ref.Idx < 0 || ref.Idx >= len(snapshot[ref.Col]) {
|
||||||
|
return fmt.Errorf("invalid widget reference col=%d idx=%d", ref.Col, ref.Idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := [2]int{ref.Col, ref.Idx}
|
||||||
|
if seen[key] {
|
||||||
|
return fmt.Errorf("widget col=%d idx=%d referenced more than once", ref.Col, ref.Idx)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, ref := range req.HeadWidgets {
|
||||||
|
if ref.Col != -1 {
|
||||||
|
http.Error(w, "head widgets must use col=-1", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := checkRef(ref); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
refCount := 0
|
refCount := 0
|
||||||
for _, newCol := range req.Columns {
|
for _, newCol := range req.Columns {
|
||||||
for _, ref := range newCol {
|
for _, ref := range newCol {
|
||||||
if ref.Col < 0 || ref.Col >= len(snapshot) || ref.Idx < 0 || ref.Idx >= len(snapshot[ref.Col]) {
|
if ref.Col == -1 {
|
||||||
http.Error(w, fmt.Sprintf("invalid widget reference col=%d idx=%d", ref.Col, ref.Idx), http.StatusBadRequest)
|
http.Error(w, "head widgets cannot move into columns", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := [2]int{ref.Col, ref.Idx}
|
if err := checkRef(ref); err != nil {
|
||||||
if seen[key] {
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
http.Error(w, fmt.Sprintf("widget col=%d idx=%d referenced more than once", ref.Col, ref.Idx), http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[key] = true
|
|
||||||
refCount++
|
refCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if refCount != totalCount {
|
if refCount != totalCount {
|
||||||
http.Error(w, fmt.Sprintf("layout references %d widgets but page has %d", refCount, totalCount), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("layout references %d column widgets but page has %d", refCount, totalCount), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,6 +720,14 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
widgets.Content = newContent
|
widgets.Content = newContent
|
||||||
}
|
}
|
||||||
|
// And rebuild head widgets sequence.
|
||||||
|
if headWidgetsNode != nil {
|
||||||
|
newHead := make([]*yaml.Node, 0, len(req.HeadWidgets))
|
||||||
|
for _, ref := range req.HeadWidgets {
|
||||||
|
newHead = append(newHead, headSnapshot[ref.Idx])
|
||||||
|
}
|
||||||
|
headWidgetsNode.Content = newHead
|
||||||
|
}
|
||||||
|
|
||||||
if err := editor.save(); err != nil {
|
if err := editor.save(); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -966,6 +1015,117 @@ func (a *application) handleAdminWidgetSchemas(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- site settings (branding) ----------
|
||||||
|
|
||||||
|
var brandingFieldKeys = []string{
|
||||||
|
"hide-footer",
|
||||||
|
"custom-footer",
|
||||||
|
"logo-text",
|
||||||
|
"logo-url",
|
||||||
|
"favicon-url",
|
||||||
|
"app-name",
|
||||||
|
"app-icon-url",
|
||||||
|
"app-background-color",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminUpdateSiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editor, err := loadConfigEditor(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
branding, err := findOrCreateKey(editor.topMapping(), "branding", true, yaml.MappingNode)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make(map[string]interface{})
|
||||||
|
for _, key := range brandingFieldKeys {
|
||||||
|
if !r.Form.Has(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(r.FormValue(key))
|
||||||
|
if key == "hide-footer" {
|
||||||
|
fields[key] = raw == "on" || raw == "true" || raw == "1"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
fields[key] = nil
|
||||||
|
} else {
|
||||||
|
fields[key] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Force unchecked checkboxes to false (browsers omit them).
|
||||||
|
if _, ok := fields["hide-footer"]; !ok {
|
||||||
|
fields["hide-footer"] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applyFieldsToWidget(branding, fields); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := editor.save(); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/site-settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- restore from .bak ----------
|
||||||
|
|
||||||
|
// handleAdminRestore swaps the contents of glance.yml and glance.yml.bak.
|
||||||
|
// Each save writes a .bak of the previous version, so this is effectively an
|
||||||
|
// undo. Calling it again redoes since we put the just-current contents into
|
||||||
|
// the new .bak.
|
||||||
|
func (a *application) handleAdminRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bakPath := a.ConfigPath + ".bak"
|
||||||
|
bakContent, err := os.ReadFile(bakPath)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, "no backup to restore: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := newConfigFromYAML(bakContent); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, "backup is invalid: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContent, err := os.ReadFile(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Move current → .bak (so user can re-restore).
|
||||||
|
if err := os.WriteFile(bakPath, currentContent, 0644); err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, "writing new backup: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Atomic write of .bak content into main file.
|
||||||
|
tmpPath := a.ConfigPath + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, bakContent, 0644); err != nil {
|
||||||
|
adminError(w, http.StatusInternalServerError, "writing temp file: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, a.ConfigPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
adminError(w, http.StatusInternalServerError, "renaming temp file: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- page settings + reorder ----------
|
// ---------- page settings + reorder ----------
|
||||||
|
|
||||||
// pageMetadataKeys is the set of page-level keys the settings form handles.
|
// pageMetadataKeys is the set of page-level keys the settings form handles.
|
||||||
|
|||||||
@@ -236,7 +236,15 @@ var widgetSchemas = map[string][]widgetFieldSchema{
|
|||||||
{Key: "frameless", Label: "Hide widget frame", Type: "boolean"},
|
{Key: "frameless", Label: "Hide widget frame", Type: "boolean"},
|
||||||
},
|
},
|
||||||
|
|
||||||
// group + split-column intentionally skipped: their `widgets:` field is a
|
"group": {
|
||||||
// recursive list of widget objects, which the inline form generator can't
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
// render usefully yet. They fall through to the YAML editor.
|
{Key: "widgets", Label: "Widgets in group", Type: "list-of-widgets", Required: true,
|
||||||
|
Help: "Each tab inside the group. Nested groups and split-columns aren't allowed."},
|
||||||
|
},
|
||||||
|
|
||||||
|
"split-column": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "max-columns", Label: "Maximum columns (≥2)", Type: "number"},
|
||||||
|
{Key: "widgets", Label: "Widgets shown in the split", Type: "list-of-widgets", Required: true},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var (
|
|||||||
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
||||||
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
||||||
adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html")
|
adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html")
|
||||||
|
adminSiteSettingsTemplate = mustParseTemplate("admin-site-settings.html", "document.html", "footer.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||||
@@ -249,6 +250,19 @@ type adminTemplateData struct {
|
|||||||
PageSettings *adminPageSettings
|
PageSettings *adminPageSettings
|
||||||
IsFirstPage bool
|
IsFirstPage bool
|
||||||
IsLastPage bool
|
IsLastPage bool
|
||||||
|
|
||||||
|
SiteSettings *adminSiteSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSiteSettings struct {
|
||||||
|
AppName string
|
||||||
|
LogoText string
|
||||||
|
LogoURL string
|
||||||
|
FaviconURL string
|
||||||
|
AppIconURL string
|
||||||
|
AppBackgroundColor string
|
||||||
|
HideFooter bool
|
||||||
|
CustomFooter string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -383,6 +397,26 @@ func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := a.Config.Branding
|
||||||
|
settings := &adminSiteSettings{
|
||||||
|
AppName: b.AppName,
|
||||||
|
LogoText: b.LogoText,
|
||||||
|
LogoURL: b.LogoURL,
|
||||||
|
FaviconURL: b.FaviconURL,
|
||||||
|
AppIconURL: b.AppIconURL,
|
||||||
|
AppBackgroundColor: b.AppBackgroundColor,
|
||||||
|
HideFooter: b.HideFooter,
|
||||||
|
CustomFooter: string(b.CustomFooter),
|
||||||
|
}
|
||||||
|
data := adminTemplateData{App: a, SiteSettings: settings}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminSiteSettingsTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) handleAdminPageSettings(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handleAdminPageSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.adminAccessAllowed(w, r) {
|
if !a.adminAccessAllowed(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
mux.HandleFunc("GET /edit", a.handleAdminIndex)
|
mux.HandleFunc("GET /edit", a.handleAdminIndex)
|
||||||
mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage)
|
mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage)
|
||||||
mux.HandleFunc("GET /edit/pages/{page}/settings", a.handleAdminPageSettings)
|
mux.HandleFunc("GET /edit/pages/{page}/settings", a.handleAdminPageSettings)
|
||||||
|
mux.HandleFunc("GET /edit/site-settings", a.handleAdminSiteSettings)
|
||||||
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
|
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
|
||||||
|
|
||||||
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage)
|
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage)
|
||||||
@@ -477,6 +478,8 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize)
|
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize)
|
||||||
mux.HandleFunc("POST /edit/api/pages/{page}/fields", a.handleAdminUpdatePageFields)
|
mux.HandleFunc("POST /edit/api/pages/{page}/fields", a.handleAdminUpdatePageFields)
|
||||||
mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage)
|
mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage)
|
||||||
|
mux.HandleFunc("POST /edit/api/restore", a.handleAdminRestore)
|
||||||
|
mux.HandleFunc("POST /edit/api/site-settings", a.handleAdminUpdateSiteSettings)
|
||||||
|
|
||||||
if a.RequiresAuth {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
|
|||||||
@@ -557,3 +557,34 @@ textarea.edit-input {
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Nested widget editor (group / split-column) ---------- */
|
||||||
|
|
||||||
|
.edit-nested-widget {
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-nested-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-nested-type {
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-nested-body > .edit-field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ function widgetTypeOf(widget) {
|
|||||||
/* ---------- Layout (drag-drop) ---------- */
|
/* ---------- Layout (drag-drop) ---------- */
|
||||||
|
|
||||||
function indexWidgets() {
|
function indexWidgets() {
|
||||||
|
document.querySelectorAll(".head-widgets > .widget").forEach((w, idx) => {
|
||||||
|
w.dataset.origCol = "-1";
|
||||||
|
w.dataset.origIdx = String(idx);
|
||||||
|
});
|
||||||
document.querySelectorAll(".page-column").forEach((col, colIdx) => {
|
document.querySelectorAll(".page-column").forEach((col, colIdx) => {
|
||||||
col.dataset.colIdx = String(colIdx);
|
col.dataset.colIdx = String(colIdx);
|
||||||
col.querySelectorAll(":scope > .widget").forEach((w, idx) => {
|
col.querySelectorAll(":scope > .widget").forEach((w, idx) => {
|
||||||
@@ -55,16 +59,18 @@ function indexWidgets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addHandles() {
|
function addHandles() {
|
||||||
document.querySelectorAll(".page-column > .widget").forEach((w) => {
|
document
|
||||||
if (w.querySelector(":scope > .edit-mode-handles")) return;
|
.querySelectorAll(".page-column > .widget, .head-widgets > .widget")
|
||||||
const handles = document.createElement("div");
|
.forEach((w) => {
|
||||||
handles.className = "edit-mode-handles";
|
if (w.querySelector(":scope > .edit-mode-handles")) return;
|
||||||
handles.innerHTML =
|
const handles = document.createElement("div");
|
||||||
'<button type="button" class="edit-handle edit-handle-drag" title="Drag to reorder" aria-label="Drag">⋮⋮</button>' +
|
handles.className = "edit-mode-handles";
|
||||||
'<button type="button" class="edit-handle edit-handle-edit" title="Edit" aria-label="Edit">✎</button>' +
|
handles.innerHTML =
|
||||||
'<button type="button" class="edit-handle edit-handle-delete" title="Delete" aria-label="Delete">✕</button>';
|
'<button type="button" class="edit-handle edit-handle-drag" title="Drag to reorder" aria-label="Drag">⋮⋮</button>' +
|
||||||
w.appendChild(handles);
|
'<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() {
|
function removeHandles() {
|
||||||
@@ -184,6 +190,15 @@ function setStatus(text, kind) {
|
|||||||
|
|
||||||
async function saveLayout() {
|
async function saveLayout() {
|
||||||
const { slug } = pageInfo();
|
const { slug } = pageInfo();
|
||||||
|
const headWidgets = [];
|
||||||
|
document
|
||||||
|
.querySelectorAll(".head-widgets > .widget")
|
||||||
|
.forEach((w) =>
|
||||||
|
headWidgets.push({
|
||||||
|
col: parseInt(w.dataset.origCol, 10),
|
||||||
|
idx: parseInt(w.dataset.origIdx, 10),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const columns = [];
|
const columns = [];
|
||||||
document.querySelectorAll(".page-column").forEach((col) => {
|
document.querySelectorAll(".page-column").forEach((col) => {
|
||||||
const refs = [];
|
const refs = [];
|
||||||
@@ -201,7 +216,7 @@ async function saveLayout() {
|
|||||||
const r = await fetch(api(`/edit/api/pages/${encodeURIComponent(slug)}/layout`), {
|
const r = await fetch(api(`/edit/api/pages/${encodeURIComponent(slug)}/layout`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ columns }),
|
body: JSON.stringify({ headWidgets, columns }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
setStatus("Save failed: " + (await r.text()), "error");
|
setStatus("Save failed: " + (await r.text()), "error");
|
||||||
@@ -312,11 +327,52 @@ function renderField(field, value) {
|
|||||||
return wrap(renderListStrings(value));
|
return wrap(renderListStrings(value));
|
||||||
case "list-objects":
|
case "list-objects":
|
||||||
return wrap(renderListObjects(field.items, value));
|
return wrap(renderListObjects(field.items, value));
|
||||||
|
case "list-of-widgets":
|
||||||
|
return wrap(renderListOfWidgets(value));
|
||||||
default:
|
default:
|
||||||
return wrap(`<em>Unsupported type: ${escapeHTML(field.type)}</em>`);
|
return wrap(`<em>Unsupported type: ${escapeHTML(field.type)}</em>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget types allowed inside a group/split-column. Nested groups and
|
||||||
|
// split-columns are rejected by the backend, so we hide them in the picker.
|
||||||
|
function nestedWidgetTypes() {
|
||||||
|
const all = Object.keys(state.schemas || {});
|
||||||
|
return all
|
||||||
|
.filter((t) => t !== "group" && t !== "split-column")
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListOfWidgets(values) {
|
||||||
|
values = Array.isArray(values) ? values : [];
|
||||||
|
const items = values
|
||||||
|
.map((v) => renderNestedWidget(v?.type || "clock", v || {}))
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<div class="edit-list edit-list-widgets">
|
||||||
|
<div class="edit-list-items">${items}</div>
|
||||||
|
<button type="button" class="edit-list-add" data-add-widget="1">+ Add widget</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNestedWidget(widgetType, values) {
|
||||||
|
const schema = state.schemas?.[widgetType] || [];
|
||||||
|
const typeOptions = nestedWidgetTypes()
|
||||||
|
.map(
|
||||||
|
(t) =>
|
||||||
|
`<option value="${escapeHTML(t)}" ${t === widgetType ? "selected" : ""}>${escapeHTML(t)}</option>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<div class="edit-list-item edit-nested-widget" data-type="${escapeHTML(widgetType)}">
|
||||||
|
<div class="edit-nested-header">
|
||||||
|
<select class="edit-nested-type" title="Widget type">${typeOptions}</select>
|
||||||
|
<button type="button" class="edit-list-remove">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="edit-nested-body">${renderForm(schema, values)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderListStrings(values) {
|
function renderListStrings(values) {
|
||||||
values = Array.isArray(values) ? values : [];
|
values = Array.isArray(values) ? values : [];
|
||||||
const items = values
|
const items = values
|
||||||
@@ -416,6 +472,26 @@ function collectFieldValue(fieldEl, field) {
|
|||||||
);
|
);
|
||||||
return filtered.length ? filtered : null;
|
return filtered.length ? filtered : null;
|
||||||
}
|
}
|
||||||
|
case "list-of-widgets": {
|
||||||
|
const items = fieldEl.querySelectorAll(
|
||||||
|
":scope > .edit-list > .edit-list-items > .edit-nested-widget",
|
||||||
|
);
|
||||||
|
const arr = [...items].map((item) => {
|
||||||
|
const type = item.dataset.type;
|
||||||
|
const body = item.querySelector(":scope > .edit-nested-body");
|
||||||
|
const schema = state.schemas?.[type] || [];
|
||||||
|
const values = collectValues(body, schema);
|
||||||
|
// Drop null/empty fields; emit type plus whatever was filled in.
|
||||||
|
const out = { type };
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
if (v !== null && !(Array.isArray(v) && v.length === 0)) {
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
return arr.length ? arr : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -682,10 +758,31 @@ function openDialog({ title, schema, values, submitLabel, onSubmit }) {
|
|||||||
tmp.innerHTML = renderListObjectItem(itemsSchema, {});
|
tmp.innerHTML = renderListObjectItem(itemsSchema, {});
|
||||||
items.appendChild(tmp.firstElementChild);
|
items.appendChild(tmp.firstElementChild);
|
||||||
items.lastElementChild.querySelector("input, textarea, select")?.focus();
|
items.lastElementChild.querySelector("input, textarea, select")?.focus();
|
||||||
|
} else if (list.classList.contains("edit-list-widgets")) {
|
||||||
|
const defaultType = nestedWidgetTypes()[0] || "clock";
|
||||||
|
const tmp = document.createElement("div");
|
||||||
|
tmp.innerHTML = renderNestedWidget(defaultType, {});
|
||||||
|
items.appendChild(tmp.firstElementChild);
|
||||||
|
items.lastElementChild
|
||||||
|
.querySelector(".edit-nested-body input, .edit-nested-body textarea, .edit-nested-body select")
|
||||||
|
?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Type-switching for nested widgets: re-render the item's body with the
|
||||||
|
// schema for the newly chosen type.
|
||||||
|
overlay.addEventListener("change", (e) => {
|
||||||
|
const sel = e.target.closest(".edit-nested-type");
|
||||||
|
if (!sel) return;
|
||||||
|
const item = sel.closest(".edit-nested-widget");
|
||||||
|
const newType = sel.value;
|
||||||
|
item.dataset.type = newType;
|
||||||
|
const body = item.querySelector(":scope > .edit-nested-body");
|
||||||
|
const schema = state.schemas?.[newType] || [];
|
||||||
|
body.innerHTML = renderForm(schema, {});
|
||||||
|
});
|
||||||
|
|
||||||
overlay.addEventListener("keydown", (e) => {
|
overlay.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape") close();
|
if (e.key === "Escape") close();
|
||||||
});
|
});
|
||||||
@@ -877,6 +974,21 @@ function enterEditMode() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
// Head widgets get their own group so they can't be dragged into columns
|
||||||
|
// (they live in a separate yaml sequence and render differently).
|
||||||
|
document.querySelectorAll(".head-widgets").forEach((hw) => {
|
||||||
|
state.sortables.push(
|
||||||
|
Sortable.create(hw, {
|
||||||
|
group: "glance-head-widgets",
|
||||||
|
handle: ".edit-handle-drag",
|
||||||
|
draggable: ".widget",
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: "sortable-ghost",
|
||||||
|
dragClass: "sortable-drag",
|
||||||
|
onEnd: () => saveLayout(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEY, "1");
|
localStorage.setItem(STORAGE_KEY, "1");
|
||||||
// Pre-warm schema cache so first dialog open is instant.
|
// Pre-warm schema cache so first dialog open is instant.
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Site settings - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-15">Site settings</h1>
|
||||||
|
<p class="color-subdue margin-bottom-25">
|
||||||
|
Branding shown in the header and footer. Theme colors, server settings,
|
||||||
|
and authentication still need YAML — edit <code>{{ .App.ConfigPath }}</code>
|
||||||
|
directly for those.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/site-settings"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
App name
|
||||||
|
<input type="text" name="app-name" class="input" value="{{ .SiteSettings.AppName }}" placeholder="Glance">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Logo text (header)
|
||||||
|
<input type="text" name="logo-text" class="input" value="{{ .SiteSettings.LogoText }}">
|
||||||
|
<small class="color-subdue size-h5">Shown if no logo URL is set.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Logo URL
|
||||||
|
<input type="text" name="logo-url" class="input" value="{{ .SiteSettings.LogoURL }}" placeholder="/assets/logo.svg">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Favicon URL
|
||||||
|
<input type="text" name="favicon-url" class="input" value="{{ .SiteSettings.FaviconURL }}">
|
||||||
|
<small class="color-subdue size-h5">Defaults to the bundled glance favicon.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
App icon URL (PWA)
|
||||||
|
<input type="text" name="app-icon-url" class="input" value="{{ .SiteSettings.AppIconURL }}">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
App background color (PWA)
|
||||||
|
<input type="text" name="app-background-color" class="input" value="{{ .SiteSettings.AppBackgroundColor }}" placeholder="#000000">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="hide-footer" {{ if .SiteSettings.HideFooter }}checked{{ end }}>
|
||||||
|
<span>Hide footer</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Custom footer (HTML)
|
||||||
|
<textarea name="custom-footer" class="input" rows="4">{{ .SiteSettings.CustomFooter }}</textarea>
|
||||||
|
<small class="color-subdue size-h5">Replaces the default Glance footer if set.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -5,7 +5,12 @@
|
|||||||
{{- define "document-body" }}
|
{{- define "document-body" }}
|
||||||
<div class="flex flex-column body-content">
|
<div class="flex flex-column body-content">
|
||||||
<main class="content-bounds" style="padding-block: 2rem;">
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
<h1 class="size-h1 margin-bottom-15">Edit</h1>
|
<div class="flex items-center gap-10">
|
||||||
|
<h1 class="size-h1 grow">Edit</h1>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/site-settings">
|
||||||
|
<button type="button">Site settings</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<p class="color-subdue margin-bottom-25">
|
<p class="color-subdue margin-bottom-25">
|
||||||
Editing <code>{{ .App.ConfigPath }}</code>. Changes take effect via hot-reload.
|
Editing <code>{{ .App.ConfigPath }}</code>. Changes take effect via hot-reload.
|
||||||
</p>
|
</p>
|
||||||
@@ -43,6 +48,17 @@
|
|||||||
<input type="text" name="title" required placeholder="Page title" class="input grow">
|
<input type="text" name="title" required placeholder="Page title" class="input grow">
|
||||||
<button type="submit">Add page</button>
|
<button type="submit">Add page</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<hr style="margin-block: 2rem; border: none; border-top: 1px solid var(--color-widget-content-border);">
|
||||||
|
<h3 class="size-h3 margin-bottom-10">Undo last save</h3>
|
||||||
|
<p class="color-subdue margin-bottom-10">
|
||||||
|
Restores the previous contents of <code>{{ .App.ConfigPath }}</code> from the
|
||||||
|
<code>.bak</code> file written on each save. Click again to redo.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/restore"
|
||||||
|
onsubmit="return confirm('Restore the previous version of your config?');">
|
||||||
|
<button type="submit">Restore previous version</button>
|
||||||
|
</form>
|
||||||
</main>
|
</main>
|
||||||
{{ template "footer.html" . }}
|
{{ template "footer.html" . }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user