templates: Add a proper menu and navigation

This commit is contained in:
Tom Wiesing 2023-01-11 14:24:13 +01:00
parent 0bb7f99fa3
commit a00195be16
No known key found for this signature in database
76 changed files with 336 additions and 233 deletions

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,7 @@ func (dis *Distillery) init() {
lazy.RegisterPoolGroup[component.Cronable](&dis.pool)
lazy.RegisterPoolGroup[component.UserDeleteHook](&dis.pool)
lazy.RegisterPoolGroup[component.Table](&dis.pool)
lazy.RegisterPoolGroup[component.Menuable](&dis.pool)
})
}

View file

@ -25,6 +25,7 @@ type Auth struct {
var (
_ component.Routeable = (*Auth)(nil)
_ component.Menuable = (*Auth)(nil)
_ component.Table = (*Auth)(nil)
)

View file

@ -35,6 +35,9 @@ func (panel *UserPanel) Routes() component.Routes {
Prefix: "/user/",
CSRF: true,
Decorator: panel.Dependencies.Auth.Require(nil),
MenuPriority: component.MenuUser,
MenuTitle: "User",
}
}
@ -81,13 +84,20 @@ type userFormContext struct {
User *models.User
}
func (panel *UserPanel) UserFormContext(ctx httpx.FormContext, r *http.Request) any {
user, err := panel.Dependencies.Auth.UserOf(r)
uctx := userFormContext{FormContext: ctx}
panel.Dependencies.Custom.Update(&uctx, r)
if err == nil {
uctx.User = &user.User
func (panel *UserPanel) UserFormContext(last component.MenuItem) func(ctx httpx.FormContext, r *http.Request) any {
crumbs := []component.MenuItem{
{Title: "User", Path: "/user/"},
last,
}
return uctx
return func(ctx httpx.FormContext, r *http.Request) any {
user, err := panel.Dependencies.Auth.UserOf(r)
uctx := userFormContext{FormContext: ctx}
panel.Dependencies.Custom.Update(&uctx, r, crumbs)
if err == nil {
uctx.User = &user.User
}
return uctx
}
}

View file

@ -7,6 +7,7 @@ import (
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx/field"
@ -37,7 +38,7 @@ func (panel *UserPanel) routePassword(ctx context.Context) http.Handler {
FieldTemplate: field.PureCSSFieldTemplate,
RenderTemplate: passwordTemplate,
RenderTemplateContext: panel.UserFormContext,
RenderTemplateContext: panel.UserFormContext(component.MenuItem{Title: "Change Password", Path: "/user/password/"}),
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
old, passcode, new, new2 := values["old"], values["otp"], values["new"], values["new2"]

View file

@ -1,13 +1,3 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Change Password{{ end }}
{{ define "form/button" }}Update{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/user/">{{ .User.User }}</a> &gt;
<a class="pure-button pure-button-primary" href="/user/password/">Change Password</a>
</p>
<p>
<a class="pure-button pure-button-small" href="/auth/logout/">Logout</a>
</p>
{{ end }}

View file

@ -2,16 +2,6 @@
{{ define "form/title" }}Disable TOTP{{ end }}
{{ define "form/button" }}Disable{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/user/">{{ .User.User }}</a> &gt;
<a class="pure-button pure-button-primary" href="/user/totp/disable/">Disable TOTP</a>
</p>
<p>
<a class="pure-button pure-button-small" href="/auth/logout/">Logout</a>
</p>
{{ end }}
{{ define "form/inside" }}
<div>
<ul>

View file

@ -1,15 +1,6 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Enable TOTP{{ end }}
{{ define "form/button" }}Enable{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/user/">{{ .User.User }}</a> &gt;
<a class="pure-button pure-button-primary" href="/user/totp/enable/">Enable TOTP</a>
</p>
<p>
<a class="pure-button pure-button-small" href="/auth/logout/">Logout</a>
</p>
{{ end }}
{{ define "form/inside" }}
<div>
<ul>

View file

@ -1,15 +1,6 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Enable TOTP{{ end }}
{{ define "form/button" }}Enable{{ end }}
{{ define "header" }}
<p>
<a class="pure-button" href="/user/">{{ .User.User }}</a> &gt;
<a class="pure-button pure-button-primary" href="/user/totp/enroll/">Enroll TOTP</a>
</p>
<p>
<a class="pure-button pure-button-small" href="/auth/logout/">Logout</a>
</p>
{{ end }}
{{ define "form/inside" }}
<div>
<a href="{{ .TOTPURL }}">

View file

@ -1,15 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}User{{ end }}
{{ define "header"}}
<p>
<a class="pure-button pure-button-primary" href="/user/">{{ .User.User }}</a>
</p>
<p>
<a class="pure-button pure-button-small" href="/auth/logout/">Logout</a>
</p>
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
<p>

View file

@ -5,6 +5,7 @@ import (
"html/template"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
@ -32,7 +33,7 @@ func (panel *UserPanel) routeTOTPEnable(ctx context.Context) http.Handler {
},
RenderTemplate: totpEnableTemplate,
RenderTemplateContext: panel.UserFormContext,
RenderTemplateContext: panel.UserFormContext(component.MenuItem{Title: "Enable TOTP", Path: "/user/totp/enable/"}),
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
password := values["password"]
@ -77,7 +78,10 @@ type totpEnrollContext struct {
func (panel *UserPanel) routeTOTPEnroll(ctx context.Context) http.Handler {
totpEnrollTemplate := panel.Dependencies.Custom.Template(totpEnrollTemplate)
crumbs := []component.MenuItem{
{Title: "User", Path: "/user/"},
{Title: "Enable TOTP", Path: "/user/totp/enable/"},
}
return &httpx.Form[struct{}]{
Fields: []field.Field{
{Name: "password", Type: field.Password, Autocomplete: field.CurrentPassword, EmptyOnError: true, Label: "Current Password"},
@ -99,7 +103,7 @@ func (panel *UserPanel) routeTOTPEnroll(ctx context.Context) http.Handler {
FormContext: context,
},
}
panel.Dependencies.Custom.Update(&ctx.userFormContext, r)
panel.Dependencies.Custom.Update(&ctx.userFormContext, r, crumbs)
if err == nil && user != nil {
ctx.userFormContext.User = &user.User
@ -164,7 +168,7 @@ func (panel *UserPanel) routeTOTPDisable(ctx context.Context) http.Handler {
return struct{}{}, err == nil && user != nil && !user.IsTOTPEnabled()
},
RenderTemplate: totpDisableTemplate,
RenderTemplateContext: panel.UserFormContext,
RenderTemplateContext: panel.UserFormContext(component.MenuItem{Title: "Disable TOTP", Path: "/user/totp/disable/"}),
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
password, otp := values["password"], values["otp"]

View file

@ -7,6 +7,7 @@ import (
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom"
@ -35,9 +36,13 @@ type GrantWithURL struct {
func (panel *UserPanel) routeUser(ctx context.Context) http.Handler {
userTemplate := panel.Dependencies.Custom.Template(userTemplate)
crumbs := []component.MenuItem{
{Title: "User", Path: "/user/"},
}
return &httpx.HTMLHandler[routeUserContext]{
Handler: func(r *http.Request) (ruc routeUserContext, err error) {
panel.Dependencies.Custom.Update(&ruc, r)
panel.Dependencies.Custom.Update(&ruc, r, crumbs)
// find the user
ruc.AuthUser, err = panel.Dependencies.Auth.UserOf(r)

View file

@ -80,6 +80,17 @@ func (auth *Auth) Require(perm Permission) func(http.Handler) http.Handler {
}
}
// Has checks if the given request has the given permission.
// If an error occurs, returns false.
func (auth *Auth) Has(perm Permission, r *http.Request) bool {
user, err := auth.UserOf(r)
if err != nil || user == nil {
return false
}
ok, err := perm.Permit(user, r)
return err == nil && ok.Granted()
}
// Admin represents a permission that checks if a user is an administrator and has totp enabled.
var Admin Permission = func(user *AuthUser, r *http.Request) (ok Grant, err error) {
return Bool2Grant(user != nil && user.IsAdmin() && user.IsTOTPEnabled(), "user needs to have admin permissions and passcode enabled"), nil

View file

@ -3,8 +3,10 @@ package auth
import (
"context"
"errors"
"html/template"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
@ -67,6 +69,22 @@ func (auth *Auth) session(r *http.Request) (*sessions.Session, error) {
}).Get(r, control.SessionCookie)
}
func (auth *Auth) Menu(r *http.Request) []component.MenuItem {
user, err := auth.UserOf(r)
if user == nil || err != nil {
return nil
}
return []component.MenuItem{
{
Title: "Logout",
Path: "/auth/logout",
Priority: component.MenuAuth,
},
}
}
type contextUserKey struct{}
var ctxUserKey = contextUserKey{}
@ -126,7 +144,9 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
if context.Err != nil {
context.Err = errLoginFailed
}
httpx.WriteHTML(auth.Dependencies.Custom.NewForm(context, r), nil, loginTemplate, "", w, r)
httpx.WriteHTML(auth.Dependencies.Custom.NewForm(context, r, []component.MenuItem{
{Title: "Login", Path: template.URL(r.URL.RequestURI())},
}), nil, loginTemplate, "", w, r)
},
Validate: func(r *http.Request, values map[string]string) (*AuthUser, error) {

View file

@ -40,6 +40,7 @@ type Admin struct {
var (
_ component.DistilleryFetcher = (*Admin)(nil)
_ component.Routeable = (*Admin)(nil)
_ component.Menuable = (*Admin)(nil)
)
func (admin *Admin) Routes() component.Routes {
@ -50,6 +51,19 @@ func (admin *Admin) Routes() component.Routes {
}
}
func (admin *Admin) Menu(r *http.Request) []component.MenuItem {
if !admin.Dependencies.Auth.Has(auth.Admin, r) {
return nil
}
return []component.MenuItem{
{
Title: "Admin",
Path: "/admin/",
Priority: component.MenuAdmin,
},
}
}
func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http.Handler, err error) {
router := httprouter.New()
@ -62,17 +76,17 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
}
}
// handle everything
router.HandlerFunc(http.MethodGet, route, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, route+"index", http.StatusTemporaryRedirect)
})
// add a handler for the index page
router.Handler(http.MethodGet, route+"index", httpx.HTMLHandler[indexContext]{
router.Handler(http.MethodGet, route, httpx.HTMLHandler[indexContext]{
Handler: admin.index,
Template: admin.Dependencies.Custom.Template(indexTemplate),
})
// fallback to the "/" page
router.HandlerFunc(http.MethodGet, route+"index", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, route, http.StatusTemporaryRedirect)
})
// add a handler for the user page
router.Handler(http.MethodGet, route+"users", httpx.HTMLHandler[userContext]{
Handler: admin.users,

View file

@ -1,10 +1,12 @@
package admin
import (
"html/template"
"net/http"
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
@ -28,7 +30,10 @@ type componentContext struct {
}
func (admin *Admin) components(r *http.Request) (cp componentContext, err error) {
admin.Dependencies.Custom.Update(&cp, r)
admin.Dependencies.Custom.Update(&cp, r, []component.MenuItem{
{Title: "Admin", Path: "/admin/"},
{Title: "Components", Path: "/admin/components/"},
})
cp.Analytics = *admin.Analytics
return
@ -49,10 +54,15 @@ type ingredientsContext struct {
}
func (admin *Admin) ingredients(r *http.Request) (cp ingredientsContext, err error) {
admin.Dependencies.Custom.Update(&cp, r)
slug := httprouter.ParamsFromContext(r.Context()).ByName("slug")
admin.Dependencies.Custom.Update(&cp, r, []component.MenuItem{
{Title: "Admin", Path: "/admin/"},
{Title: "Instance", Path: template.URL("/admin/instance/" + slug)},
{Title: "Ingredients", Path: template.URL("/admin/instance/" + slug + "/ingredients/")},
})
// find the instance itself!
slug := httprouter.ParamsFromContext(r.Context()).ByName("slug")
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), slug)
if err == instances.ErrWissKINotFound {
return cp, httpx.ErrNotFound

View file

@ -3,8 +3,10 @@ package admin
import (
_ "embed"
"fmt"
"html/template"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
@ -38,7 +40,11 @@ type grantsContext struct {
}
func (gc *grantsContext) use(r *http.Request, slug string, admin *Admin) (err error) {
admin.Dependencies.Custom.Update(gc, r)
admin.Dependencies.Custom.Update(gc, r, []component.MenuItem{
{Title: "Admin", Path: "/admin/"},
{Title: "Instance", Path: template.URL("/admin/instance/" + slug)},
{Title: "Grants", Path: template.URL("/admin/instance/" + slug + "/grants/")},
})
// find the instance itself
gc.instance, err = admin.Dependencies.Instances.WissKI(r.Context(), slug)

View file

@ -1,13 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery Admin - Components Page{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Admin</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/components">Components</a>
</p>
{{ end }}
{{ define "content" }}
{{ template "_anal.html" .Analytics }}
{{ end }}

View file

@ -1,14 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery Admin - {{ .Instance.Slug }} - Grants{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Admin</a> &gt;
<a class="pure-button" href="/admin/instance/{{ .Instance.Slug }}">Instance</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/grants/{{ .Instance.Slug }}">Grants</a>
</p>
{{ end }}
{{ define "content" }}
{{ $csrf := .CSRF }}
{{ $slug := .Instance.Slug }}

View file

@ -1,10 +1,7 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery Admin{{ end }}
{{ define "header"}}
<p>
<a class="pure-button pure-button-primary" href="/admin/index">Admin</a>
</p>
{{ define "header" }}
<p>
<div class="pure-button-group" role="group" aria-label="Actions">
<a class="pure-button" href="/admin/users">Users</a>

View file

@ -1,14 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery Admin - {{ .Instance.Slug }} - Ingredients{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Admin</a> &gt;
<a class="pure-button" href="/admin/instance/{{ .Instance.Slug }}">Instance</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/ingredients/{{ .Instance.Slug }}">Ingredients</a>
</p>
{{ end }}
{{ define "content" }}
{{ template "_anal.html" .Analytics }}
{{ end }}

View file

@ -1,11 +1,7 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery Admin - {{ .Instance.Slug }}{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Admin</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/instance/{{ .Instance.Slug }}">Instance</a>
</p>
{{ define "header" }}
<p>
<div class="pure-button-group" role="group" aria-label="Actions">
<a class="pure-button" href="/admin/grants/{{ .Info.Slug }}">Grants</a>

View file

@ -2,10 +2,3 @@
{{ define "form/title" }}Distillery Admin - Create User{{ end }}
{{ define "form/button" }}Create{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Admin</a> &gt;
<a class="pure-button" href="/admin/users">Users</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/users/create">Create</a>
</p>
{{ end }}

View file

@ -1,11 +1,7 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery Admin - Users{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Admin</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/users">Users</a>
</p>
{{ define "header" }}
<p>
<div class="pure-button-group" role="group" aria-label="Actions">
<a class="pure-button" href="/admin/users/create">Create New</a>

View file

@ -87,7 +87,9 @@ type indexContext struct {
}
func (admin *Admin) index(r *http.Request) (idx indexContext, err error) {
admin.Dependencies.Custom.Update(&idx, r)
admin.Dependencies.Custom.Update(&idx, r, []component.MenuItem{
{Title: "Admin", Path: "/admin/"},
})
idx.Distillery, idx.Instances, err = admin.Status(r.Context(), true)
return
}

View file

@ -2,8 +2,10 @@ package admin
import (
_ "embed"
"html/template"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
@ -28,10 +30,14 @@ type instanceContext struct {
}
func (admin *Admin) instance(r *http.Request) (is instanceContext, err error) {
admin.Dependencies.Custom.Update(&is, r)
slug := httprouter.ParamsFromContext(r.Context()).ByName("slug")
admin.Dependencies.Custom.Update(&is, r, []component.MenuItem{
{Title: "Admin", Path: "/admin/"},
{Title: "Instance", Path: template.URL("/admin/instance/" + slug)},
})
// find the instance itself!
slug := httprouter.ParamsFromContext(r.Context()).ByName("slug")
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), slug)
if err == instances.ErrWissKINotFound {
return is, httpx.ErrNotFound

View file

@ -8,6 +8,7 @@ import (
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom"
@ -31,7 +32,10 @@ type userContext struct {
}
func (admin *Admin) users(r *http.Request) (uc userContext, err error) {
admin.Dependencies.Custom.Update(&uc, r)
admin.Dependencies.Custom.Update(&uc, r, []component.MenuItem{
{Title: "Admin", Path: "/admin/"},
{Title: "Users", Path: "/admin/users/"},
})
uc.Error = r.URL.Query().Get("error")
uc.Users, err = admin.Dependencies.Auth.Users(r.Context())
@ -58,6 +62,11 @@ type createUserResult struct {
func (admin *Admin) createUser(ctx context.Context) http.Handler {
userCreateTemplate := admin.Dependencies.Custom.Template(userCreateTemplate)
crumbs := []component.MenuItem{
{Title: "Admin", Path: "/admin/"},
{Title: "Users", Path: "/admin/users"},
{Title: "Create", Path: "/admin/users/create"},
}
return &httpx.Form[createUserResult]{
Fields: []field.Field{
@ -67,8 +76,10 @@ func (admin *Admin) createUser(ctx context.Context) http.Handler {
},
FieldTemplate: field.PureCSSFieldTemplate,
RenderTemplate: userCreateTemplate,
RenderTemplateContext: admin.Dependencies.Custom.RenderContext,
RenderTemplate: userCreateTemplate,
RenderTemplateContext: func(ctx httpx.FormContext, r *http.Request) any {
return admin.Dependencies.Custom.NewForm(ctx, r, crumbs)
},
Validate: func(r *http.Request, values map[string]string) (cu createUserResult, err error) {
cu.User, cu.Passsword, cu.Admin = values["username"], values["password"], values["admin"] == field.CheckboxChecked

View file

@ -32,6 +32,9 @@ func (*Home) Routes() component.Routes {
Prefix: "/",
MatchAllDomains: true,
CSRF: false,
MenuTitle: "WissKI Distillery",
MenuPriority: component.MenuHome,
}
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static/custom"
"github.com/FAU-CDI/wisski-distillery/internal/status"
@ -14,7 +15,7 @@ import (
//go:embed "public.html"
var publicHTMLStr string
var publicTemplate = static.AssetsHome.MustParseShared("public.html", publicHTMLStr)
var publicTemplate = static.AssetsDefault.MustParseShared("public.html", publicHTMLStr)
type publicContext struct {
custom.BaseContext
@ -24,6 +25,9 @@ type publicContext struct {
}
func (home *Home) publicHandler(ctx context.Context) http.Handler {
crumbs := []component.MenuItem{
{Title: "WissKI Distillery", Path: "/"},
}
return httpx.HTMLHandler[publicContext]{
Handler: func(r *http.Request) (pc publicContext, err error) {
// only act on the root path!
@ -31,7 +35,7 @@ func (home *Home) publicHandler(ctx context.Context) http.Handler {
return pc, httpx.ErrNotFound
}
home.Dependencies.Custom.Update(&pc, r)
home.Dependencies.Custom.Update(&pc, r, crumbs)
pc.Instances = home.homeInstances.Get(nil)
pc.SelfRedirect = home.Config.SelfRedirect.String()

View file

@ -1,16 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}WissKI Distillery{{ end }}
{{ define "header"}}
<p>
<a class="pure-button pure-button-primary" href="/">Home</a>
</p>
<p>
<a class="pure-button" href="/news/">News</a>
</p>
{{ end }}
{{ define "content" }}
{{ block "@custom/about" . }}
<div class="pure-u-1">

View file

@ -28,7 +28,7 @@ var (
//go:embed "legal.html"
var legalTemplateString string
var legalTemplate = static.AssetsLegal.MustParseShared("legal.html", legalTemplateString)
var legalTemplate = static.AssetsDefault.MustParseShared("legal.html", legalTemplateString)
func (legal *Legal) Routes() component.Routes {
return component.Routes{
@ -59,7 +59,9 @@ type legalContext struct {
}
func (legal *Legal) context(r *http.Request) (lc legalContext, err error) {
legal.Dependencies.Custom.Update(&lc, r)
legal.Dependencies.Custom.Update(&lc, r, []component.MenuItem{
{Title: "Legal", Path: "/legal/"},
})
lc.LegalNotices = cli.LegalNotices

View file

@ -1,10 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}Legal{{ end }}
{{ define "header"}}
<!-- no header -->
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
<h2 id="cookies">Cookie Usage</h2>

View file

@ -36,6 +36,9 @@ func (*News) Routes() component.Routes {
Prefix: "/news/",
Exact: true,
CSRF: false,
MenuTitle: "News",
MenuPriority: component.MenuNews,
}
}
@ -111,7 +114,7 @@ func Items() ([]Item, error) {
//go:embed "news.html"
var newsHTMLStr string
var newsTemplate = static.AssetsHome.MustParseShared("news.html", newsHTMLStr)
var newsTemplate = static.AssetsDefault.MustParseShared("news.html", newsHTMLStr)
type newsContext struct {
custom.BaseContext
@ -120,6 +123,10 @@ type newsContext struct {
// HandleRoute returns the handler for the requested path
func (news *News) HandleRoute(ctx context.Context, path string) (http.Handler, error) {
crumbs := []component.MenuItem{
{Title: "News", Path: "/news/"},
}
items, itemsErr := Items()
if itemsErr != nil {
zerolog.Ctx(ctx).Err(itemsErr).Msg("Unable to load news items")
@ -127,7 +134,7 @@ func (news *News) HandleRoute(ctx context.Context, path string) (http.Handler, e
return httpx.HTMLHandler[newsContext]{
Handler: func(r *http.Request) (nc newsContext, err error) {
news.Dependencies.Custom.Update(&nc, r)
news.Dependencies.Custom.Update(&nc, r, crumbs)
nc.Items, err = items, itemsErr
return

View file

@ -1,13 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}News{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/">Home</a> &gt;
<a class="pure-button pure-button-primary" href="/news/">News</a>
</p>
{{ end }}
{{ define "content" }}
<div class="pure-u-1">

View file

@ -21,7 +21,7 @@ type Assets struct {
Styles string // <link> tags inserted by the asset
}
//go:generate node build.mjs Home User Admin Legal
//go:generate node build.mjs Default User Admin
// MustParse parses a new template from the given source
// and calls [RegisterAssoc] on it.

View file

@ -7,26 +7,20 @@ import _ "embed"
//go:embed "assets_disclaimer.txt"
var AssetsDisclaimer string
// AssetsHome contains assets for the 'Home' entrypoint.
var AssetsHome = Assets{
Scripts: `<script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Home.4b303448.css"><link rel="stylesheet" href="/static/Home.f9675eae.css">`,
// AssetsDefault contains assets for the 'Default' entrypoint.
var AssetsDefault = Assets{
Scripts: `<script type="module" src="/static/Default.38d394c2.js"></script><script src="/static/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Default.38d394c2.js"></script><script src="/static/Default.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Default.db26a303.css"><link rel="stylesheet" href="/static/Default.f9675eae.css">`,
}
// AssetsUser contains assets for the 'User' entrypoint.
var AssetsUser = Assets{
Scripts: `<script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/User.4197014b.js"></script><script src="/static/User.30d54198.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Home.4b303448.css"><link rel="stylesheet" href="/static/User.38d394c2.css">`,
Scripts: `<script type="module" src="/static/Default.38d394c2.js"></script><script src="/static/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/User.4197014b.js"></script><script src="/static/User.30d54198.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Default.db26a303.css"><link rel="stylesheet" href="/static/User.38d394c2.css">`,
}
// AssetsAdmin contains assets for the 'Admin' entrypoint.
var AssetsAdmin = Assets{
Scripts: `<script nomodule="" defer src="/static/User.30d54198.js"></script><script type="module" src="/static/User.4197014b.js"></script><script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Admin.4ca3cb6f.js"></script><script src="/static/Admin.9750ba9c.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Home.4b303448.css"><link rel="stylesheet" href="/static/Admin.6d59e220.css"><link rel="stylesheet" href="/static/User.38d394c2.css"><link rel="stylesheet" href="/static/Admin.6d2ae968.css">`,
}
// AssetsLegal contains assets for the 'Legal' entrypoint.
var AssetsLegal = Assets{
Scripts: `<script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Legal.38d394c2.js"></script><script src="/static/Legal.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Home.4b303448.css"><link rel="stylesheet" href="/static/Legal.d1531eba.css"><link rel="stylesheet" href="/static/Legal.20259812.css">`,
Scripts: `<script nomodule="" defer src="/static/User.30d54198.js"></script><script type="module" src="/static/User.4197014b.js"></script><script type="module" src="/static/Default.38d394c2.js"></script><script src="/static/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Admin.4ca3cb6f.js"></script><script src="/static/Admin.9750ba9c.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Default.db26a303.css"><link rel="stylesheet" href="/static/Admin.6d59e220.css"><link rel="stylesheet" href="/static/User.38d394c2.css"><link rel="stylesheet" href="/static/Admin.6d2ae968.css">`,
}

View file

@ -10,6 +10,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/gorilla/csrf"
"github.com/tkw1536/goprogram/lib/reflectx"
"golang.org/x/exp/slices"
)
// baseContextName is the name of the [BaseContext] type
@ -25,6 +26,10 @@ type BaseContext struct {
GeneratedAt time.Time // time this page was generated at
// Menu and breadcrumbs
Menu []component.MenuItem
Crumbs []component.MenuItem
CSRF template.HTML // CSRF Field
}
@ -43,7 +48,8 @@ const (
// The given request *must not* be nil.
//
// For convenience the passed context is also returned.
func (tc *BaseContext) use(base component.Base, r *http.Request) *BaseContext {
func (tc *BaseContext) use(custom *Custom, r *http.Request, crumbs []component.MenuItem) *BaseContext {
// tc.custom = custom
tc.inited = true
tc.requestWasNil = r == nil
@ -55,9 +61,20 @@ func (tc *BaseContext) use(base component.Base, r *http.Request) *BaseContext {
tc.CSRF = csrf.TemplateField(r)
}
// build the menu
tc.Menu = custom.BuildMenu(r)
// build the breadcrumbs
tc.Crumbs = slices.Clone(crumbs)
last := len(tc.Crumbs) - 1
for i := range tc.Crumbs {
tc.Crumbs[i].Active = i == last
}
return tc
}
// DoInitCheck is called by the template to check that the BaseContext was initialized properly
func (bc BaseContext) DoInitCheck() template.HTML {
if !bc.inited {
return initError
@ -69,26 +86,21 @@ func (bc BaseContext) DoInitCheck() template.HTML {
}
// NewForm is like New, but returns a new BaseFormContext
func (custom *Custom) NewForm(context httpx.FormContext, r *http.Request) (ctx BaseFormContext) {
func (custom *Custom) NewForm(context httpx.FormContext, r *http.Request, crumbs []component.MenuItem) (ctx BaseFormContext) {
ctx.FormContext = context
ctx.use(custom.Base, r)
ctx.use(custom, r, crumbs)
return
}
// RenderContext is exactly like NewForm, but returns any to be used as httpx.Form.RenderTemplateContext
func (custom *Custom) RenderContext(ctx httpx.FormContext, r *http.Request) any {
return custom.NewForm(ctx, r)
}
// Update updates an embedded BaseContext field in context.
//
// Assumes that context is a pointer to a struct type.
// If this is not the case, might call panic().
func (custom *Custom) Update(context any, r *http.Request) *BaseContext {
func (custom *Custom) Update(context any, r *http.Request, crumbs []component.MenuItem) *BaseContext {
ctx := reflect.ValueOf(context).
Elem().FieldByName(baseContextName).Addr().
Interface().(*BaseContext)
ctx.use(custom.Base, r)
ctx.use(custom, r, crumbs)
return ctx
}

View file

@ -2,16 +2,20 @@ package custom
import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
)
// Custom implements theme and page customization.
type Custom struct {
component.Base
Dependencies struct {
// nothing yet
Routeables []component.Routeable
Menuable []component.Menuable
}
menu lazy.Lazy[[]component.MenuItem]
}
var (
_ component.Backupable = (*Custom)(nil)
_ component.Menuable = (*Custom)(nil)
)

View file

@ -0,0 +1,47 @@
package custom
import (
"html/template"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/pkg/mux"
"golang.org/x/exp/slices"
)
// getMenuItems gets a fresh copy of the cached slice of menu items
func (custom *Custom) Menu(r *http.Request) []component.MenuItem {
return custom.menu.Get(func() []component.MenuItem {
items := make([]component.MenuItem, 0, len(custom.Dependencies.Routeables))
for _, route := range custom.Dependencies.Routeables {
routes := route.Routes()
if routes.MenuTitle == "" {
continue
}
items = append(items, component.MenuItem{
Title: routes.MenuTitle,
Priority: routes.MenuPriority,
Path: template.URL(routes.Prefix),
})
}
slices.SortFunc(items, component.MenuItemSort)
return items
})
}
func (custom *Custom) BuildMenu(r *http.Request) []component.MenuItem {
// NOTE(twiesing): Don't name this method "Menu", as it will cause
// a stack overflow.
path := mux.NormalizePath(r.URL.Path)
// get the static menu items, and then return all the regular ones
var items []component.MenuItem
for _, m := range custom.Dependencies.Menuable {
items = append(items, m.Menu(r)...)
}
for i, item := range items {
items[i].Active = string(item.Path) == path
}
slices.SortFunc(items, component.MenuItemSort)
return items
}

View file

@ -1 +0,0 @@
html{background-color:#87a485}body{max-width:80vw!important}

File diff suppressed because one or more lines are too long

View file

@ -8,6 +8,30 @@ footer {
margin: 2em;
}
nav.pure-menu, nav.breadcrumbs {
padding-top: 1em;
padding-bottom: 1em;
border-bottom: 1px solid black;
}
nav.breadcrumbs {
padding-left: 1em;
font-size: small;
}
nav.breadcrumbs a:not(:last-child)::after {
cursor: default;
content: " > ";
color: black;
}
nav.breadcrumbs a {
text-decoration: none;
color: blue !important;
}
nav.breadcrumbs a.active {
font-weight: bold;
}
footer {
font-size: small;
border-top: 1px solid black;

View file

@ -1,7 +0,0 @@
html {
background-color: #87A485;
}
body {
max-width: 80vw !important;
}

View file

@ -1 +0,0 @@
import "latex.css/style.min.css"

View file

@ -10,6 +10,21 @@
<body>
{{ .BaseContext.DoInitCheck }}
<nav class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
{{ range .BaseContext.Menu }}
<li class="pure-menu-item{{ if .Active }} pure-menu-selected{{ end }}">
<a href="{{ .Path }}" class="pure-menu-link">{{ .Title }}</a>
</li>
{{ end }}
</ul>
</nav>
<nav class="breadcrumbs" role="group" aria-label="Breadcrumbs">
{{ range .BaseContext.Crumbs }}
<a class="{{ if .Active }}active{{ end }}" href="{{ .Path }}">{{ .Title }}</a>
{{ end }}
</nav>
<header>
<h1 id="top">{{ template "title" . }}</h1>
{{ block "header" . }}<!-- no header by default -->{{ end }}

View file

@ -1,7 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}{{ block "form/title" . }}Form{{ end }}{{ end }}
{{ define "header" }}<!-- no header -->{{ end }}
{{ define "content" }}
<div class="pure-u-1">
{{ block "form/extra" . }}<!-- no extra -->{{ end }}

View file

@ -0,0 +1,36 @@
package component
import (
"html/template"
"net/http"
)
// Menuable is a component that provides a menu
type Menuable interface {
Component
Menu(r *http.Request) []MenuItem
}
type MenuItem struct {
Title string
Path template.URL
Active bool
Priority MenuPriority // menu priority
}
func MenuItemSort(a, b MenuItem) bool {
return a.Priority < b.Priority
}
type MenuPriority int
// Menu* indicates priorities of the menu
const (
MenuHome MenuPriority = iota
MenuNews
MenuResolver
MenuUser
MenuAdmin
MenuAuth
)

View file

@ -41,12 +41,15 @@ func (resolver *Resolver) Routes() component.Routes {
Prefix: "/wisski/get/",
Aliases: []string{"/go/"},
CSRF: false,
MenuTitle: "Resolver",
MenuPriority: component.MenuResolver,
}
}
//go:embed "resolver.html"
var resolverHTMLStr string
var resolverTemplate = static.AssetsHome.MustParseShared("resolver.html", resolverHTMLStr)
var resolverTemplate = static.AssetsDefault.MustParseShared("resolver.html", resolverHTMLStr)
type resolverContext struct {
custom.BaseContext
@ -55,6 +58,9 @@ type resolverContext struct {
func (resolver *Resolver) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
resolverTemplate := resolver.Dependencies.Custom.Template(resolverTemplate)
crumbs := []component.MenuItem{
{Title: "Resolver", Path: "/wisski/get/"},
}
logger := zerolog.Ctx(ctx)
@ -65,7 +71,7 @@ func (resolver *Resolver) HandleRoute(ctx context.Context, route string) (http.H
ctx := resolverContext{
IndexContext: context,
}
resolver.Dependencies.Custom.Update(&ctx, r)
resolver.Dependencies.Custom.Update(&ctx, r, crumbs)
httpx.WriteHTML(ctx, nil, resolverTemplate, "", w, r)
}

View file

@ -1,10 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}WissKI Resolver{{ end }}
{{ define "header"}}
<!-- no header -->
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
<h1>wdresolve</h1>

View file

@ -26,6 +26,10 @@ type Routes struct {
// MatchAllDomains indicates that all domains, even the non-default domain, should be matched
MatchAllDomains bool
// MenuTitle and MenuPriority return the priority and title of this menu item
MenuTitle string
MenuPriority MenuPriority
// Exact indicates that only the exact prefix, as opposed to any sub-paths, are matched.
// Trailing '/'s are automatically trimmed, even with an exact match.
Exact bool

View file

@ -52,7 +52,7 @@ func (mux *Mux[T]) Add(path string, predicate Predicate, exact bool, h http.Hand
mux.prefixes = make(map[string][]handler)
}
mPath := normalizePath(path)
mPath := NormalizePath(path)
mHandler := handler{Predicate: predicate, Handler: h}
if exact {
mux.exacts[mPath] = append(mux.exacts[mPath], mHandler)
@ -71,7 +71,7 @@ func (mux *Mux[T]) Match(r *http.Request, prepare bool) (http.Handler, bool) {
r = mux.Prepare(r)
}
candidate := normalizePath(r.URL.Path)
candidate := NormalizePath(r.URL.Path)
// match the exact path first
for _, h := range mux.exacts[candidate] {

View file

@ -4,9 +4,9 @@ import (
"path"
)
// normalizePath normalizes the provided path.
// NormalizePath normalizes the provided path.
// It ensures that there is both a leading and trailing slash.
func normalizePath(value string) string {
func NormalizePath(value string) string {
value = path.Clean(value)
if value != "/" {
value = value + "/"