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:
2026-09-28 20:12:02 +02:00
co-authored by Cursor
parent 7a915aee7b
commit d4c18e63bb
4 changed files with 276 additions and 16 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
- **Groups** — Users and Groups reads: `UserGroups`, `GroupMembers`, `ListGroups` (no search/paging). Directory calls (`GroupMembers` / `ListGroups`) run as the Credentials user and need an admin or subadmin
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
- **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS)
- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp.
- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. By default the shell loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page’s `data-theme-*` markers; set `DisableTheme` to skip the stylesheets. An item may set `Icon` to a same-origin image URL. `NextcloudIcons` names the core SVGs Nextcloud already serves (`NextcloudIcons.Folder`, `NextcloudIcons.Password`, and the rest). Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp.
**Excluded**
+93
View File
@@ -0,0 +1,93 @@
package gonexapp
// NextcloudIcons are SVG paths served by Nextcloud core under /core/img/.
// The library does not ship the files. Set Item.Icon to one of these.
// Dark mode inverts them with Nextcloud’s --background-invert-if-dark filter.
var NextcloudIcons = nextcloudIcons{
Folder: "/core/img/filetypes/folder.svg",
FolderShared: "/core/img/filetypes/folder-shared.svg",
FolderPublic: "/core/img/filetypes/folder-public.svg",
FolderStarred: "/core/img/filetypes/folder-starred.svg",
FolderEncrypted: "/core/img/filetypes/folder-encrypted.svg",
File: "/core/img/filetypes/file.svg",
Text: "/core/img/filetypes/text.svg",
Image: "/core/img/filetypes/image.svg",
Audio: "/core/img/filetypes/audio.svg",
Video: "/core/img/filetypes/video.svg",
PDF: "/core/img/filetypes/application-pdf.svg",
Document: "/core/img/filetypes/x-office-document.svg",
Spreadsheet: "/core/img/filetypes/x-office-spreadsheet.svg",
Presentation: "/core/img/filetypes/x-office-presentation.svg",
Files: "/core/img/places/files.svg",
Home: "/core/img/places/home.svg",
Calendar: "/core/img/places/calendar.svg",
Contacts: "/core/img/places/contacts.svg",
Add: "/core/img/actions/add.svg",
Delete: "/core/img/actions/delete.svg",
Edit: "/core/img/actions/edit.svg",
Rename: "/core/img/actions/rename.svg",
Download: "/core/img/actions/download.svg",
Upload: "/core/img/actions/upload.svg",
Share: "/core/img/actions/share.svg",
Search: "/core/img/actions/search.svg",
Settings: "/core/img/actions/settings.svg",
Info: "/core/img/actions/info.svg",
History: "/core/img/actions/history.svg",
Password: "/core/img/actions/password.svg",
Confirm: "/core/img/actions/confirm.svg",
Close: "/core/img/actions/close.svg",
Star: "/core/img/actions/star.svg",
User: "/core/img/actions/user.svg",
Group: "/core/img/actions/group.svg",
Mail: "/core/img/actions/mail.svg",
Menu: "/core/img/actions/menu.svg",
External: "/core/img/actions/external.svg",
Filter: "/core/img/actions/filter.svg",
Recent: "/core/img/actions/recent.svg",
Tag: "/core/img/actions/tag.svg",
}
// nextcloudIcons is the named set of core SVGs. Fields are paths, not image bytes.
type nextcloudIcons struct {
Folder string
FolderShared string
FolderPublic string
FolderStarred string
FolderEncrypted string
File string
Text string
Image string
Audio string
Video string
PDF string
Document string
Spreadsheet string
Presentation string
Files string
Home string
Calendar string
Contacts string
Add string
Delete string
Edit string
Rename string
Download string
Upload string
Share string
Search string
Settings string
Info string
History string
Password string
Confirm string
Close string
Star string
User string
Group string
Mail string
Menu string
External string
Filter string
Recent string
Tag string
}
+135 -15
View File
@@ -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}}
`))
+47
View File
@@ -146,6 +146,53 @@ func TestAppNavigationChildExpandsParents(t *testing.T) {
}
}
func TestAppNavigationLoadsNextcloudTheme(t *testing.T) {
body := navBody(t, "/")
for _, need := range []string{
"/apps/theming/css/default.css",
"/index.php/apps/theming/theme/default.css",
"/index.php/apps/theming/theme/dark.css",
} {
if !strings.Contains(body, need) {
t.Fatalf("missing %s in %s", need, body)
}
}
}
func TestAppNavigationThemeCanBeDisabled(t *testing.T) {
nav := sampleNavigation()
nav.DisableTheme = true
rec := httptest.NewRecorder()
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if strings.Contains(rec.Body.String(), "/apps/theming/css/default.css") {
t.Fatalf("theme stylesheet still present: %s", rec.Body.String())
}
}
func TestAppNavigationFoldControlFollowsLabel(t *testing.T) {
body := navBody(t, "/?item=domains")
name := strings.Index(body, ">Domains<")
fold := strings.Index(body, `aria-label="Fold Domains"`)
if name < 0 || fold < 0 || fold < name {
t.Fatalf("name %d fold %d", name, fold)
}
}
func TestAppNavigationRendersIconBeforeLabel(t *testing.T) {
nav := sampleNavigation()
nav.Items = func(*http.Request) []gonexapp.Item {
return []gonexapp.Item{{ID: "domains", Label: "Domains", Icon: gonexapp.NextcloudIcons.Folder}}
}
rec := httptest.NewRecorder()
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?item=domains", nil))
body := rec.Body.String()
icon := strings.Index(body, `src="/core/img/filetypes/folder.svg"`)
name := strings.Index(body, ">Domains<")
if icon < 0 || name < 0 || icon > name {
t.Fatalf("icon %d name %d", icon, name)
}
}
func TestAppNavigationHeaderPrecedesTree(t *testing.T) {
body := navBody(t, "/?item=domains")
header := strings.Index(body, "folder-line")