Add an optional App navigation shell for ExApp pages.
ExApps that mount it share one Files-style tree; an ExApp that never mounts it keeps its own page. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
package gonexapp
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// shellCSP lets the ExApp iframe render this document. AppAPI blanks a frame
|
||||
// whose response omits frame-ancestors.
|
||||
const shellCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:"
|
||||
|
||||
// Item is one App navigation entry. The ExApp chooses the ids and labels.
|
||||
// Children may nest. The shell does not interpret the ids.
|
||||
type Item struct {
|
||||
ID string
|
||||
Label string
|
||||
Children []Item
|
||||
}
|
||||
|
||||
// AppNavigation is the Files-style shell an ExApp mounts.
|
||||
// Items, Page, and DefaultID are required. Header and MissingMessage are
|
||||
// optional. SelectKey defaults to "item". Every other query parameter,
|
||||
// including the Visit folder, is copied onto the corrected address.
|
||||
// An ExApp that never calls Handler keeps its own page.
|
||||
type AppNavigation struct {
|
||||
Items func(*http.Request) []Item
|
||||
Page func(*http.Request, string) (string, bool)
|
||||
Header func(*http.Request) string
|
||||
DefaultID string
|
||||
MissingMessage string
|
||||
SelectKey string
|
||||
// Title is the document title. Empty means "App navigation".
|
||||
Title string
|
||||
}
|
||||
|
||||
// Handler serves the shell. The selected item is the SelectKey query parameter.
|
||||
// A missing item renders DefaultID, includes MissingMessage, and publishes the
|
||||
// corrected query on data-address so the page can replace the address.
|
||||
// Every parent starts expanded. Folding is client state for this document only.
|
||||
func (n AppNavigation) Handler() http.Handler {
|
||||
return http.HandlerFunc(n.serve)
|
||||
}
|
||||
|
||||
func (n AppNavigation) selectKey() string {
|
||||
if n.SelectKey != "" {
|
||||
return n.SelectKey
|
||||
}
|
||||
return "item"
|
||||
}
|
||||
|
||||
func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
||||
var items []Item
|
||||
if n.Items != nil {
|
||||
items = n.Items(r)
|
||||
}
|
||||
asked := r.URL.Query().Get(n.selectKey())
|
||||
selected := n.DefaultID
|
||||
missing := false
|
||||
if asked != "" {
|
||||
if _, ok := findItem(items, asked); ok {
|
||||
selected = asked
|
||||
} else {
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
page := ""
|
||||
if n.Page != nil {
|
||||
body, ok := n.Page(r, selected)
|
||||
if !ok && selected != n.DefaultID {
|
||||
missing = true
|
||||
selected = n.DefaultID
|
||||
body, ok = n.Page(r, selected)
|
||||
}
|
||||
if ok {
|
||||
page = body
|
||||
}
|
||||
}
|
||||
header := ""
|
||||
if n.Header != nil {
|
||||
header = n.Header(r)
|
||||
}
|
||||
msg := ""
|
||||
if missing {
|
||||
msg = n.MissingMessage
|
||||
}
|
||||
title := n.Title
|
||||
if title == "" {
|
||||
title = "App navigation"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Security-Policy", shellCSP)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Status is already committed; a template error cannot change the response.
|
||||
_ = shellTmpl.Execute(w, shellView{
|
||||
Selected: selected,
|
||||
Address: n.address(r, selected),
|
||||
Header: template.HTML(header),
|
||||
Items: viewItems(r, items, selected, n.address),
|
||||
Page: template.HTML(page),
|
||||
Missing: msg,
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
|
||||
func (n AppNavigation) address(r *http.Request, id string) string {
|
||||
q := r.URL.Query()
|
||||
q.Set(n.selectKey(), id)
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
func findItem(items []Item, id string) (Item, bool) {
|
||||
for _, it := range items {
|
||||
if it.ID == id {
|
||||
return it, true
|
||||
}
|
||||
if child, ok := findItem(it.Children, id); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
return Item{}, false
|
||||
}
|
||||
|
||||
type shellView struct {
|
||||
Selected string
|
||||
Address string
|
||||
Header template.HTML
|
||||
Items []itemView
|
||||
Page template.HTML
|
||||
Missing string
|
||||
Title string
|
||||
}
|
||||
|
||||
type itemView struct {
|
||||
ID string
|
||||
Label string
|
||||
Href string
|
||||
Current bool
|
||||
HasChildren bool
|
||||
Children []itemView
|
||||
}
|
||||
|
||||
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string) []itemView {
|
||||
out := make([]itemView, 0, len(items))
|
||||
for _, it := range items {
|
||||
children := viewItems(r, it.Children, selected, href)
|
||||
out = append(out, itemView{
|
||||
ID: it.ID,
|
||||
Label: it.Label,
|
||||
Href: href(r, it.ID),
|
||||
Current: it.ID == selected,
|
||||
HasChildren: len(children) > 0,
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body { margin: 0; font: 16px/1.4 sans-serif; }
|
||||
#app-nav { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
#nav-header { padding: 0.75rem 1rem; border-bottom: 1px solid #ccc; }
|
||||
#app-nav-body { display: flex; flex: 1; }
|
||||
nav { width: 16rem; flex: none; border-right: 1px solid #ccc; padding: 0.5rem 0; }
|
||||
main { flex: 1; padding: 1rem; }
|
||||
nav ul { list-style: none; margin: 0; padding-left: 0.75rem; }
|
||||
nav li { margin: 0.15rem 0; }
|
||||
nav a[aria-current="page"] { font-weight: 700; }
|
||||
li[data-expanded="false"] > ul { display: none; }
|
||||
button.fold { border: 0; background: transparent; cursor: pointer; }
|
||||
#nav-missing { margin: 0 0 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-selected="{{.Selected}}" data-address="{{.Address}}">
|
||||
<div id="app-nav">
|
||||
<div id="nav-header">{{.Header}}</div>
|
||||
<div id="app-nav-body">
|
||||
<nav aria-label="App navigation">
|
||||
{{template "items" .Items}}
|
||||
</nav>
|
||||
<main>
|
||||
{{if .Missing}}<p id="nav-missing">{{.Missing}}</p>{{end}}
|
||||
{{.Page}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var addr = document.body.getAttribute("data-address");
|
||||
if (addr && addr !== location.search) {
|
||||
history.replaceState(null, "", addr);
|
||||
}
|
||||
document.querySelectorAll("button.fold").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var li = btn.closest("li");
|
||||
var open = li.getAttribute("data-expanded") === "true";
|
||||
li.setAttribute("data-expanded", open ? "false" : "true");
|
||||
btn.setAttribute("aria-expanded", open ? "false" : "true");
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{define "items"}}<ul>{{range .}}<li data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}>{{if .HasChildren}}<button type="button" class="fold" aria-expanded="true" aria-label="Fold {{.Label}}"></button>{{end}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{if .HasChildren}}{{template "items" .Children}}{{end}}</li>{{end}}</ul>{{end}}
|
||||
`))
|
||||
Reference in New Issue
Block a user