Resolve the page UI language once and let each ExApp fill its own Phrases.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+65
-25
@@ -12,10 +12,13 @@ const shellCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame
|
||||
// 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.
|
||||
// Phrase names the ExApp Phrase for Label. The English label stays in the
|
||||
// page until the script replaces it. Empty leaves the label as written.
|
||||
type Item struct {
|
||||
ID string
|
||||
Label string
|
||||
Icon string
|
||||
Phrase string
|
||||
Children []Item
|
||||
}
|
||||
|
||||
@@ -36,6 +39,15 @@ type AppNavigation struct {
|
||||
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
|
||||
// loads the active theme.
|
||||
DisableTheme bool
|
||||
// Cred reads the UI language for this page from Nextcloud.
|
||||
// The zero value leaves the page in English.
|
||||
// The caller does not pass a language.
|
||||
Cred Credentials
|
||||
// Phrases is the ExApp's set. The page script receives it.
|
||||
// Library chrome is not included. The zero value leaves the page in English.
|
||||
Phrases Phrases
|
||||
// MissingPhrase names the ExApp Phrase for MissingMessage.
|
||||
MissingPhrase string
|
||||
}
|
||||
|
||||
// Handler serves the shell. The selected item is the SelectKey query parameter.
|
||||
@@ -54,6 +66,8 @@ func (n AppNavigation) selectKey() string {
|
||||
}
|
||||
|
||||
func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
||||
lang := n.Cred.UILanguage(r)
|
||||
r = rememberUILanguage(r, lang)
|
||||
var items []Item
|
||||
if n.Items != nil {
|
||||
items = n.Items(r)
|
||||
@@ -97,15 +111,21 @@ func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
|
||||
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(),
|
||||
Selected: selected,
|
||||
Address: n.address(r, selected),
|
||||
Header: template.HTML(header),
|
||||
Items: viewItems(r, items, selected, n.address, lang),
|
||||
Page: template.HTML(page),
|
||||
Missing: msg,
|
||||
Title: title,
|
||||
Theme: !n.DisableTheme,
|
||||
Dialog: n.Cred.DialogHTML(r),
|
||||
Lang: lang,
|
||||
OpenLabel: chromePhrase(lang, "nav.open", nil),
|
||||
CloseLabel: chromePhrase(lang, "nav.close", nil),
|
||||
PhraseScript: n.Phrases.Script(lang),
|
||||
PhraseFill: FillScript(),
|
||||
MissingPhrase: n.MissingPhrase,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,15 +148,21 @@ func findItem(items []Item, id string) (Item, bool) {
|
||||
}
|
||||
|
||||
type shellView struct {
|
||||
Selected string
|
||||
Address string
|
||||
Header template.HTML
|
||||
Items []itemView
|
||||
Page template.HTML
|
||||
Missing string
|
||||
Title string
|
||||
Theme bool
|
||||
Dialog template.HTML
|
||||
Selected string
|
||||
Address string
|
||||
Header template.HTML
|
||||
Items []itemView
|
||||
Page template.HTML
|
||||
Missing string
|
||||
Title string
|
||||
Theme bool
|
||||
Dialog template.HTML
|
||||
Lang string
|
||||
OpenLabel string
|
||||
CloseLabel string
|
||||
PhraseScript template.HTML
|
||||
PhraseFill template.HTML
|
||||
MissingPhrase string
|
||||
}
|
||||
|
||||
type itemView struct {
|
||||
@@ -147,12 +173,21 @@ type itemView struct {
|
||||
Current bool
|
||||
HasChildren bool
|
||||
Children []itemView
|
||||
Fold string
|
||||
FoldPattern string
|
||||
Phrase string
|
||||
}
|
||||
|
||||
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string) []itemView {
|
||||
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string, lang string) []itemView {
|
||||
out := make([]itemView, 0, len(items))
|
||||
for _, it := range items {
|
||||
children := viewItems(r, it.Children, selected, href)
|
||||
children := viewItems(r, it.Children, selected, href, lang)
|
||||
fold := ""
|
||||
pattern := ""
|
||||
if len(children) > 0 {
|
||||
fold = chromePhrase(lang, "nav.fold", map[string]string{"label": it.Label})
|
||||
pattern = chromePhrase(lang, "nav.fold", nil)
|
||||
}
|
||||
out = append(out, itemView{
|
||||
ID: it.ID,
|
||||
Label: it.Label,
|
||||
@@ -161,17 +196,21 @@ func viewItems(r *http.Request, items []Item, selected string, href func(*http.R
|
||||
Current: it.ID == selected,
|
||||
HasChildren: len(children) > 0,
|
||||
Children: children,
|
||||
Fold: fold,
|
||||
FoldPattern: pattern,
|
||||
Phrase: it.Phrase,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
{{.PhraseScript}}
|
||||
{{if .Theme}}
|
||||
<link rel="stylesheet" href="/apps/theming/css/default.css">
|
||||
<link rel="stylesheet" href="/index.php/apps/theming/theme/default.css">
|
||||
@@ -355,7 +394,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
{{.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>
|
||||
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="{{.OpenLabel}}"><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>
|
||||
@@ -364,7 +403,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
<ul class="app-navigation-list">{{template "items" .Items}}</ul>
|
||||
</nav>
|
||||
<main>
|
||||
{{if .Missing}}<p id="nav-missing">{{.Missing}}</p>{{end}}
|
||||
{{if .Missing}}<p id="nav-missing"{{if .MissingPhrase}} data-phrase="{{.MissingPhrase}}"{{end}}>{{.Missing}}</p>{{end}}
|
||||
{{.Page}}
|
||||
</main>
|
||||
</div>
|
||||
@@ -396,7 +435,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
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");
|
||||
toggle.setAttribute("aria-label", open ? {{.CloseLabel}} : {{.OpenLabel}});
|
||||
}
|
||||
placeNav();
|
||||
window.addEventListener("resize", placeNav);
|
||||
@@ -416,8 +455,9 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{.PhraseFill}}
|
||||
</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}}
|
||||
{{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"{{if .Phrase}} data-phrase="{{.Phrase}}"{{end}}>{{.Label}}</span></a>{{if .HasChildren}}<div class="app-navigation-entry__utils"><button type="button" class="fold" aria-expanded="true" aria-label="{{.Fold}}" data-fold="{{.FoldPattern}}"><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