Changeset 0.27.0 (#166)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
server/pages/templates/surfaces/extension.html
Normal file
21
server/pages/templates/surfaces/extension.html
Normal 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}}
|
||||
Reference in New Issue
Block a user