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>
This commit is contained in:
+135
-15
@@ -7,13 +7,15 @@ import (
|
||||
|
||||
// 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:"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -31,6 +33,9 @@ type AppNavigation struct {
|
||||
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.
|
||||
@@ -99,6 +104,7 @@ func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
||||
Page: template.HTML(page),
|
||||
Missing: msg,
|
||||
Title: title,
|
||||
Theme: !n.DisableTheme,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,11 +134,13 @@ type shellView struct {
|
||||
Page template.HTML
|
||||
Missing string
|
||||
Title string
|
||||
Theme bool
|
||||
}
|
||||
|
||||
type itemView struct {
|
||||
ID string
|
||||
Label string
|
||||
Icon string
|
||||
Href string
|
||||
Current bool
|
||||
HasChildren bool
|
||||
@@ -146,6 +154,7 @@ func viewItems(r *http.Request, items []Item, selected string, href func(*http.R
|
||||
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,
|
||||
@@ -160,18 +169,128 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
<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>
|
||||
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; }
|
||||
: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>
|
||||
@@ -179,8 +298,8 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
<div id="app-nav">
|
||||
<div id="nav-header">{{.Header}}</div>
|
||||
<div id="app-nav-body">
|
||||
<nav aria-label="App navigation">
|
||||
{{template "items" .Items}}
|
||||
<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}}
|
||||
@@ -199,6 +318,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -207,5 +327,5 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
</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}}
|
||||
{{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}}
|
||||
`))
|
||||
|
||||
Reference in New Issue
Block a user