Changeset 0.27.0 (#166)

This commit is contained in:
2026-03-10 16:38:06 +00:00
parent 400f7dd176
commit 7e4f1581f2
15 changed files with 1721 additions and 520 deletions

View File

@@ -91,6 +91,9 @@ type PageData struct {
// v0.25.0: Enabled surface IDs for conditional nav rendering.
EnabledSurfaces []string `json:"-"`
// v0.27.0: Extension surfaces for sidebar nav rendering.
ExtensionSurfaces []ExtensionNavItem `json:"-"`
// v0.22.7: Login/splash page fields
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
@@ -110,6 +113,52 @@ func (pd PageData) SurfaceEnabled(id string) bool {
return false
}
// ExtensionNavItem holds the minimal info needed to render an extension
// surface link in the sidebar nav.
type ExtensionNavItem struct {
ID string
Title string
Route string
}
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
// Queries the DB at render time so newly installed surfaces appear immediately.
func (e *Engine) extensionNavItems() []ExtensionNavItem {
if e.stores.Surfaces == nil {
return nil
}
ctx := context.Background()
surfaces, err := e.stores.Surfaces.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
return nil
}
var items []ExtensionNavItem
for _, sr := range surfaces {
if sr.Source == "core" || !sr.Enabled {
continue
}
// Editor is Source="extension" but hardcoded as core — skip from dynamic nav
if sr.ID == "editor" {
continue
}
route := ""
if sr.Manifest != nil {
route, _ = sr.Manifest["route"].(string)
}
if route == "" {
route = "/s/" + sr.ID
}
items = append(items, ExtensionNavItem{
ID: sr.ID,
Title: sr.Title,
Route: route,
})
}
return items
}
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
@@ -297,12 +346,74 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
}
e.Render(c, "base.html", PageData{
Surface: surfaceID,
Section: section,
User: user,
Data: data,
Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(),
Surface: surfaceID,
Section: section,
User: user,
Data: data,
Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
})
}
}
// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
// No restart required after installing a surface via admin API.
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
return func(c *gin.Context) {
slug := c.Param("slug")
if slug == "" {
c.String(http.StatusNotFound, "Surface not found")
return
}
if e.stores.Surfaces == nil {
c.String(http.StatusNotFound, "Surface registry not available")
return
}
// Look up by ID (slug == surface ID)
sr, err := e.stores.Surfaces.Get(c.Request.Context(), slug)
if err != nil || sr == nil {
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
if !sr.Enabled {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
return
}
if sr.Source == "core" {
// Core surfaces have their own routes — don't serve them here
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
user := e.getUserContext(c)
// Build manifest from DB record
route, _ := sr.Manifest["route"].(string)
if route == "" {
route = "/s/" + sr.ID
}
layout, _ := sr.Manifest["layout"].(string)
if layout == "" {
layout = "single"
}
manifest := &SurfaceManifest{
ID: sr.ID,
Route: route,
Title: sr.Title,
Template: "surface-extension",
Layout: layout,
Source: "extension",
}
e.Render(c, "base.html", PageData{
Surface: sr.ID,
User: user,
Manifest: manifest,
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
})
}
}
@@ -341,6 +452,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
handler := e.RenderSurface(s.ID)
registerRoutes(group, s, handler)
}
// v0.27.0: Extension surface catch-all — DB lookup at request time.
// No restart needed after installing new surfaces via admin API.
group.GET("/s/:slug", e.RenderExtensionSurface())
}
// Admin surfaces — auth + admin role middleware

View File

@@ -22,8 +22,13 @@
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}
{{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}}
{{if and .Manifest (eq .Manifest.Source "extension")}}
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
{{end}}
<style>
:root {
--banner-h: 28px;
@@ -63,6 +68,7 @@
{{else if eq .Surface "editor"}}{{template "surface-editor" .}}
{{else if eq .Surface "notes"}}{{template "surface-notes" .}}
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
{{end}}
</div>
@@ -120,6 +126,10 @@
{{if eq .Surface "editor"}}{{template "scripts-editor" .}}{{end}}
{{if eq .Surface "notes"}}{{template "scripts-notes" .}}{{end}}
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
{{if and .Manifest (eq .Manifest.Source "extension")}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
{{end}}
{{/* ── Debug Log Modal (all surfaces) ───── */}}
<div id="debugModal" class="modal-overlay">

View File

@@ -189,6 +189,13 @@ window.addEventListener('unhandledrejection', function(e) {
<span class="sb-label">Editor</span>
</a>
{{end}}
{{/* v0.27.0: Extension surface nav items */}}
{{range .ExtensionSurfaces}}
<a href="{{$.BasePath}}{{.Route}}" class="sb-btn sidebar-extension-btn" title="{{.Title}}" data-surface-id="{{.ID}}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span class="sb-label">{{.Title}}</span>
</a>
{{end}}
<div class="sidebar-bottom-divider"></div>
{{template "user-menu" dict "ID" ""}}
</div>

View File

@@ -0,0 +1,21 @@
{{/* v0.27.0: Generic container for extension surfaces.
Extension JS mounts into #extension-mount. The manifest is available
as window.__MANIFEST__ (injected in base.html). Platform primitives
(Theme, UI, API, ChatPane, etc.) are loaded by base.html before this
script runs.
Extensions can use any component available on the page:
- ChatPane.create(container, opts)
- UI.* primitives (toast, confirm, etc.)
- API.* (authenticated fetch)
- Theme.* (dark/light queries)
*/}}
{{define "surface-extension"}}
<div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}">
{{/* User menu — must pass dict with ID field, not raw PageData */}}
{{template "user-menu" dict "ID" "ext"}}
<div id="extension-mount" class="extension-mount"></div>
</div>
{{end}}