Files
go-nc-exapp/navigation.go
T
konradandCursor d4c18e63bb Load the Nextcloud theme in App navigation and name its core icons.
The shell paints the instance wallpaper by default, and ExApps pick menu icons from NextcloudIcons instead of raw paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 20:12:02 +02:00

332 lines
11 KiB
Go

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:; font-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.
// Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs.
type Item struct {
ID string
Label string
Icon 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
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
// loads the active theme.
DisableTheme bool
}
// 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,
Theme: !n.DisableTheme,
})
}
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
Theme bool
}
type itemView struct {
ID string
Label string
Icon 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,
Icon: it.Icon,
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>
{{if .Theme}}
<link rel="stylesheet" href="/apps/theming/css/default.css">
<link rel="stylesheet" href="/index.php/apps/theming/theme/default.css">
<link rel="stylesheet" href="/index.php/apps/theming/theme/dark.css">
<script>
(function () {
var root = document.documentElement;
try {
var parent = window.parent && window.parent !== window ? window.parent.document.documentElement : null;
if (parent) {
for (var i = 0; i < parent.attributes.length; i++) {
var attr = parent.attributes[i];
if (attr.name.indexOf("data-theme") === 0) {
root.setAttribute(attr.name, attr.value);
}
}
}
} catch (e) {}
if (!root.hasAttribute("data-theme-default") && !root.hasAttribute("data-theme-dark") && !root.hasAttribute("data-theme-light")) {
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.setAttribute(dark ? "data-theme-dark" : "data-theme-default", "");
}
})();
</script>
{{end}}
<style>
:root {
--color-main-background: #ffffff;
--color-main-text: #222222;
--color-background-hover: #f5f5f5;
--color-background-dark: #ededed;
--color-primary-element: #00679e;
--color-primary-element-light: #e5f0f8;
--color-border: #ededed;
--default-clickable-area: 34px;
--border-radius-element: 8px;
}
html, body {
min-height: 100%;
color: var(--color-main-text);
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background);
background-size: cover;
background-position: center;
background-attachment: fixed;
}
body { margin: 0; font: 15px/1.4 var(--font-face, system-ui, sans-serif); }
#app-nav { display: flex; flex-direction: column; min-height: 100vh; background: transparent; }
#nav-header { padding: 8px 12px; border-bottom: 1px solid var(--color-border); }
#app-nav-body { display: flex; flex: 1; min-height: 0; }
nav.app-navigation {
width: var(--navigation-width, 300px);
flex: none;
background-color: var(--color-main-background-translucent, var(--color-main-background));
backdrop-filter: var(--filter-background-blur, none);
-webkit-backdrop-filter: var(--filter-background-blur, none);
border-inline-end: 1px solid var(--color-border);
padding: 8px;
overflow: auto;
}
main { flex: 1; padding: 16px; min-width: 0; background-color: var(--color-main-background); }
.app-navigation-list, .app-navigation-entry__children { list-style: none; margin: 0; padding: 0; }
.app-navigation-entry__children { padding-inline-start: 10px; }
.app-navigation-entry-wrapper { display: flex; flex-wrap: wrap; width: 100%; }
.app-navigation-entry {
display: flex;
align-items: stretch;
width: 100%;
min-height: var(--default-clickable-area);
border-radius: var(--border-radius-element);
position: relative;
}
.app-navigation-entry:hover { background: var(--color-background-hover); }
.app-navigation-entry.active { background: var(--color-primary-element-light); }
.app-navigation-entry.active::before {
content: "";
position: absolute;
inset-block: 8px;
inset-inline-start: 0;
width: 3px;
background: var(--color-primary-element);
border-radius: 999px;
}
.app-navigation-entry-link {
display: flex;
flex: 1 1 auto;
align-items: center;
min-width: 0;
min-height: var(--default-clickable-area);
padding: 0 8px 0 12px;
color: inherit;
text-decoration: none;
font-weight: 500;
}
.app-navigation-entry-icon {
width: 16px;
height: 16px;
margin-inline-end: 8px;
flex: none;
filter: var(--background-invert-if-dark, none);
}
.app-navigation-entry__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-navigation-entry.active .app-navigation-entry__name { font-weight: 700; }
.app-navigation-entry__utils { display: flex; align-items: center; }
button.fold {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: var(--border-radius-element);
display: flex;
align-items: center;
justify-content: center;
}
button.fold:hover { background: var(--color-background-dark); }
button.fold svg { width: 16px; height: 16px; transition: transform .15s ease; }
li[data-expanded="false"] > .app-navigation-entry__children { display: none; }
li[data-expanded="false"] button.fold svg { transform: rotate(-90deg); }
#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 class="app-navigation" aria-label="App navigation">
<ul class="app-navigation-list">{{template "items" .Items}}</ul>
</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");
li.classList.toggle("app-navigation-entry--opened", !open);
btn.setAttribute("aria-expanded", open ? "false" : "true");
});
});
})();
</script>
</body>
</html>
{{define "items"}}{{range .}}<li class="app-navigation-entry-wrapper{{if .HasChildren}} app-navigation-entry--collapsible app-navigation-entry--opened{{end}}" data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}><div class="app-navigation-entry{{if .Current}} active{{end}}"><a class="app-navigation-entry-link" href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{if .Icon}}<img class="app-navigation-entry-icon" src="{{.Icon}}" alt="">{{end}}<span class="app-navigation-entry__name">{{.Label}}</span></a>{{if .HasChildren}}<div class="app-navigation-entry__utils"><button type="button" class="fold" aria-expanded="true" aria-label="Fold {{.Label}}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>{{end}}</div>{{if .HasChildren}}<ul class="app-navigation-entry__children">{{template "items" .Children}}</ul>{{end}}</li>{{end}}{{end}}
`))