A width at or below Nextcloud's 1024px breakpoint keeps the tree off the page so the content stays usable, and the navigation button brings it back. Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
14 KiB
Go
424 lines
14 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">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<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 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
#nav-header-text { flex: 1; min-width: 0; }
|
|
#nav-toggle {
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 44px;
|
|
height: 44px;
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
flex: none;
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
border-radius: var(--border-radius-element);
|
|
}
|
|
#nav-toggle:hover { background: var(--color-background-hover); }
|
|
#nav-toggle svg { width: 20px; height: 20px; }
|
|
#nav-backdrop { display: none; }
|
|
#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; }
|
|
@media (max-width: 1024px) {
|
|
#nav-header {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2100;
|
|
background-color: var(--color-main-background);
|
|
}
|
|
#nav-toggle { display: flex; }
|
|
nav.app-navigation {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 2000;
|
|
top: var(--nav-header-offset, 0px);
|
|
bottom: 0;
|
|
inset-inline-start: 0;
|
|
width: min(var(--navigation-width, 300px), 85vw);
|
|
}
|
|
body.nav-open nav.app-navigation { display: block; }
|
|
body.nav-open #nav-backdrop {
|
|
display: block;
|
|
position: fixed;
|
|
z-index: 1990;
|
|
top: var(--nav-header-offset, 0px);
|
|
right: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
background: rgba(0, 0, 0, 0.45);
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body data-selected="{{.Selected}}" data-address="{{.Address}}">
|
|
{{.Dialog}}
|
|
<div id="app-nav">
|
|
<div id="nav-header">
|
|
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="Open navigation"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg></button>
|
|
<div id="nav-header-text">{{.Header}}</div>
|
|
</div>
|
|
<div id="nav-backdrop"></div>
|
|
<div id="app-nav-body">
|
|
<nav id="app-navigation" 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");
|
|
});
|
|
});
|
|
var toggle = document.getElementById("nav-toggle");
|
|
var backdrop = document.getElementById("nav-backdrop");
|
|
function placeNav() {
|
|
var header = document.getElementById("nav-header");
|
|
if (!header) return;
|
|
document.documentElement.style.setProperty("--nav-header-offset", header.offsetHeight + "px");
|
|
}
|
|
function setNavOpen(open) {
|
|
placeNav();
|
|
document.body.classList.toggle("nav-open", open);
|
|
if (!toggle) return;
|
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
toggle.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
|
|
}
|
|
placeNav();
|
|
window.addEventListener("resize", placeNav);
|
|
var narrow = window.matchMedia("(max-width: 1024px)");
|
|
if (narrow.addEventListener) narrow.addEventListener("change", placeNav);
|
|
if (toggle) {
|
|
toggle.addEventListener("click", function () {
|
|
setNavOpen(!document.body.classList.contains("nav-open"));
|
|
});
|
|
}
|
|
if (backdrop) backdrop.addEventListener("click", function () { setNavOpen(false); });
|
|
document.addEventListener("keydown", function (e) {
|
|
if (e.key !== "Escape") return;
|
|
var dialog = document.getElementById("exapp-dialog");
|
|
if (dialog && dialog.open) return;
|
|
setNavOpen(false);
|
|
});
|
|
})();
|
|
</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}}
|
|
`))
|