Files
go-nc-exapp/navigation.go
T
konrad 0a387815a0 Add a Dialog an ExApp page can open for a message, confirm, or prompt.
The page waits for the choice. App navigation includes the same piece.
2026-09-28 21:10:20 +02:00

335 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,
Dialog: DialogHTML(),
})
}
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
Dialog template.HTML
}
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}}">
{{.Dialog}}
<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}}
`))