Templ sunrise

- Updated makefile so "make serve" watches for .templ files and
    rebuild files upon changes
- Added a "templ" make target
- Added a top level "components" module
- Added a "components/forms" module
- Added server.RenderComponent method
- Added server.RenderTurboStreamComponent
- Preferences lazy loading

We're now ready to roll!
This commit is contained in:
Olivier Meunier
2026-04-19 22:15:26 +02:00
parent 614c6bf772
commit 150596399f
30 changed files with 5217 additions and 18 deletions
+2
View File
@@ -0,0 +1,2 @@
*_templ.go linguist-generated
*_templ.go binary
+43 -12
View File
@@ -32,6 +32,7 @@ export GOARCH?=
SITECONFIG_SRC=./ftr-site-config
SITECONFIG_DEST=pkg/extract/contentscripts/assets/site-config
TEMPL_PKG ?= github.com/a-h/templ/cmd/templ@latest
GOLANGCI_PKG ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9.0
AIR_PKG ?= github.com/air-verse/air@v1.64.5
SLOC_PKG ?= github.com/boyter/scc/v3@v3.6.0
@@ -69,6 +70,9 @@ setup:
$(GO) mod download
${MAKE} -C web setup
templ:
$(GO) run $(TEMPL_PKG) generate
# Build the server
.PHONY: build
build:
@@ -155,41 +159,68 @@ update-site-config:
# These targets provide helper for autoreload during development.
# `make dev` starts all the needed watch/autoreload and is only
# needed when working on web/* or docs/src/*.
# When working only on go files, `make serve` is enough.
# When working only on go and templ files, `make serve` is enough.
# Starts the HTTP server
# It runs air watching the source files and the assets. It builds and reloads
# the server on any change.
.PHONY: serve
serve:
${MAKE} -j2 watch/templ watch/server
# Starts 3 watchers/reloaders for a full autoreload
# dev server.
# The initial errors during startup are normal
.PHONY: dev
dev:
${MAKE} -j3 docs-watch web-watch serve
${MAKE} -j3 watch/docs watch/web serve
# Starts the HTTP server
# It runs air watching the source files and the assets. It builds and reloads
# the server on any change.
.PHONY: serve
serve: SERVE_CMD ?= serve
serve:
.PHONY: watch/server
watch/server: SERVE_CMD ?= serve
watch/server:
$(GO) run $(AIR_PKG) \
--tmp_dir "dist" \
--build.log "" \
--build.cmd "${MAKE} DATE= build" \
--build.bin "dist/readeck" \
--build.args_bin "$(SERVE_CMD)" \
--build.delay 2000 \
--build.exclude_dir "" \
--build.include_dir "assets,configs,docs/api,docs/assets,locales,internal,pkg" \
--build.include_dir "assets,components,configs,docs/assets,locales,internal,pkg" \
--build.include_ext "go,html,json,js,mo,tmpl,toml,xsl" \
--build.delay 2000
--build.kill_delay "10s" \
--build.stop_on_error "false" \
--misc.clean_on_exit false
# Rebuild components on change.
# It runs air watching the .templ files and rebuilds them on any change.
watch/templ:
$(GO) run $(AIR_PKG) \
--tmp_dir "dist" \
--build.log "" \
--build.cmd "$(GO) run $(TEMPL_PKG) generate -lazy" \
--build.bin "/usr/bin/true" \
--build.delay 1000 \
--build.include_dir "components,internal" \
--build.include_ext "templ" \
--build.exclude_regex ".*_templ.go" \
--build.kill_delay "0s" \
--build.send_interrupt "false" \
--build.stop_on_error "true" \
--misc.clean_on_exit false
# Watch the docs/src folder and rebuild the documentation
# on changes.
.PHONY: docs-watch
docs-watch:
.PHONY: watch/docs
watch/docs:
$(GO) run $(AIR_PKG) \
--tmp_dir "dist" \
--build.log "" \
--build.cmd "${MAKE} docs-build" \
--build.bin "" \
--build.bin "/usr/bin/true" \
--build.exclude_dir "" \
--build.include_file "CHANGELOG.md" \
--build.include_dir "docs/src/en,docs/translations,docs/api" \
@@ -197,8 +228,8 @@ docs-watch:
--build.delay 100
# Starts the watcher on the web folder.
.PHONY: web-watch
web-watch:
.PHONY: watch/web
watch/web:
@$(MAKE) -C web watch
+1
View File
@@ -22,6 +22,7 @@ SPDX-License-Identifier = "CC-BY-4.0"
[[annotations]]
path = [
".dockerignore",
".gitattributes",
".gitignore",
".go-version",
".golangci.yml",
+84
View File
@@ -0,0 +1,84 @@
---
SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
SPDX-License-Identifier: AGPL-3.0-only
TODO: these notes should move to a main CONTRIB.md file, when we have one.
---
# Readeck templates
Readeck uses [Templ](https://templ.guide/) as a template/component engine.
Templ provides type safe templates and very fast execution. Here are some conventions used in Readeck when it comes to templates.
## Views and Components
A view is a page rendered by an `http.Handler`.
- Views don't have global variables but always have a context.
- Views are functions that take (typed) arguments.
- Views live in the same package as their handler and, therefore, have access to all the package's members (exported or not).
- `.templ` files are always prefixed with `x-` (only for file organization)
To avoid crowding a package with a lot of functions, a package providing views **must** declare
empty structs and add methods for each view and/or components.
The scructs cannot contain anything so they occupy no storage space and all have the same address.
```templ
package something
import . "codeberg.org/readeck/readeck/components"
type Views struct{}
templ (_ Views) menu() {
@SideMenuTitle() {
Cookbook
}
<menu class="mt-4">
// menu items...
</menu>
}
templ (v Views) base(title string) {
@SideMenuLayout(title, v.menu()) {
{ children... }
}
}
templ (v Views) somePage() {
@v.base("a title") {
// content...
}
}
```
Then a handler can call the view like this:
```go
server.RenderComponent(w, r, 200, Views{}.somePage())
```
On complex views and components, it's best to split `Views{}` and `Components{}` in their respective structs.
## Component helpers
The package `codeberg.org/readeck/readeck/components` provides helper functions to work with components.
It can be imported globally as:
```go
import (
. "codeberg.org/readeck/readeck/components"
)
```
Form helpers are in `codeberg.org/readeck/readeck/components/forms`, which is usually imported with
a shortcut:
```go
import (
F "codeberg.org/readeck/readeck/components/forms"
)
```
+12
View File
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import "codeberg.org/readeck/readeck/pkg/ctxr"
type ctxBreadcrumbKey struct{}
// WithBreadcrumb adds [BreadcrumbList] to the context.
var WithBreadcrumb, checkBreadcrumb = ctxr.WithChecker[[][2]string](ctxBreadcrumbKey{})
+29
View File
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
templ Breadcrumbs() {
{{ items, _ := checkBreadcrumb(ctx) }}
if len(items) > 0 {
<nav class="breadcrumbs">
<ol>
<li>
<a href={ URL(ctx, "/") } title={ L(ctx).Gettext("Home Page") }>
@Icon("o-home")
</a>
</li>
for _, item := range items {
<li>
if item[1] != "" {
<a href={ item[1] }>{ item[0] }</a>
} else {
{ item[0] }
}
</li>
}
</ol>
</nav>
}
}
+139
View File
@@ -0,0 +1,139 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Breadcrumbs() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
items, _ := checkBreadcrumb(ctx)
if len(items) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<nav class=\"breadcrumbs\"><ol><li><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, "/"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/breadcrumbs.templ`, Line: 13, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Home Page"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/breadcrumbs.templ`, Line: 13, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-home").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item[1] != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(item[1])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/breadcrumbs.templ`, Line: 20, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item[0])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/breadcrumbs.templ`, Line: 20, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item[0])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/breadcrumbs.templ`, Line: 22, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</ol></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+138
View File
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
// Package components contains the shared templ components.
package components
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/a-h/templ"
"codeberg.org/readeck/readeck/internal/profile/preferences"
"codeberg.org/readeck/readeck/internal/server"
"codeberg.org/readeck/readeck/internal/server/urls"
"codeberg.org/readeck/readeck/locales"
"codeberg.org/readeck/readeck/pkg/glob"
"codeberg.org/readeck/readeck/pkg/strftime"
)
// S is a shortcut to [fmt.Sprintf].
var S = fmt.Sprintf
// L returns the context's current [locales.Locale].
func L(ctx context.Context) *locales.Locale {
return server.LocaleContext(ctx)
}
// URL returns a [templ.SafeURL] (path only) for a given internal URL.
func URL(ctx context.Context, args ...string) templ.SafeURL {
return templ.URL(urls.PathOnly(urls.AbsoluteURLContext(ctx, args...)))
}
// Asset returns an asset path.
func Asset(ctx context.Context, name string) templ.SafeURL {
return templ.URL(urls.PathOnly(urls.AssetURLContext(ctx, name)))
}
// CSPNonce returns the CSP nonce for stylesheets and scripts.
func CSPNonce(ctx context.Context) string {
s, _ := server.GetCSPNonce(ctx)
return s
}
// CurrentPath returns the current path without the app prefix.
func CurrentPath(ctx context.Context) string {
return urls.CurrentPath(server.GetRequest(ctx))
}
// PathIs returns whether one of the given paths p matches the current
// request's path (query parameters excluded).
func PathIs(ctx context.Context, p ...string) bool {
cp := "/" + strings.TrimPrefix(server.GetRequest(ctx).URL.Path, urls.Prefix())
for _, x := range p {
if glob.Glob(x, cp) {
return true
}
}
return false
}
// HasPermission returns whether the current user is granted permissions.
func HasPermission(ctx context.Context, obj, act string) bool {
return server.GetUser(ctx).HasPermission(obj, act)
}
// IsAnonymous returns whether the current user is anonymous.
func IsAnonymous(ctx context.Context) bool {
return server.GetUser(ctx).IsAnonymous()
}
// Strftime calls [strftime.Formatter.Strftime] with the user's locale.
func Strftime(ctx context.Context, t time.Time, f string) string {
return strftime.New(L(ctx)).Strftime(f, t)
}
// Preferences returns the user's preferences.
// Preferences are loaded only once, the first time they are needed.
func Preferences(ctx context.Context) *preferences.Preferences {
p := server.GetPreferences(ctx)
if !p.IsLoaded() {
p2 := preferences.New(server.GetUser(ctx), server.GetSession(server.GetRequest(ctx)))
*p = *p2
}
return p
}
// Tern is a poor man's ternary operator.
// Never to be used outside a component!
func Tern[T any](t func() bool, whenTrue T, whenFalse T) T {
if t() {
return whenTrue
}
return whenFalse
}
// JSON returns a string of the marshaled data into JSON.
// The result is not HTML escaped and can be indented if needed.
func JSON(data any, indent bool) string {
buf := new(bytes.Buffer)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
if indent {
enc.SetIndent("", " ")
}
enc.Encode(data) //nolint:errcheck
return buf.String()
}
// HTML copies directly the input [io.Reader] to the response writer.
func HTML(r io.Reader) templ.Component {
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
if r == nil {
return nil
}
_, err := io.Copy(w, r)
return err
})
}
// JetTemplate renders a Jet template.
// TODO: remove after migration.
func JetTemplate(name string, tc any) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
t, err := server.GetTemplate(name)
if err != nil {
return err
}
return t.Execute(w, server.TemplateVars(server.GetRequest(ctx)), tc)
})
}
+227
View File
@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
// Package forms provide form related components.
package forms
import (
"maps"
"slices"
"github.com/a-h/templ"
"codeberg.org/readeck/readeck/pkg/forms"
)
type baseWidget struct {
name string
value any
label string
help string
required bool
classes templ.CSSClasses
controllAttrs templ.Attributes
inputType string
inputClasses templ.CSSClasses
inputAttrs templ.Attributes
}
// FieldOption is a function to set [baseWidget] properties.
type FieldOption func(f *baseWidget)
type fieldWidget struct {
forms.Field
baseWidget
}
func widget(field forms.Field, options ...FieldOption) fieldWidget {
f := fieldWidget{
Field: field,
baseWidget: baseWidget{
name: field.Name(),
value: field.Value(),
classes: templ.CSSClasses{},
controllAttrs: templ.Attributes{},
inputClasses: templ.CSSClasses{},
inputAttrs: templ.Attributes{},
},
}
for _, option := range options {
option(&f.baseWidget)
}
return f
}
func (f *fieldWidget) ariaAttrs() (attrs templ.Attributes) {
attrs = templ.Attributes{}
if f.help != "" {
attrs["aria-describedby"] = "description-" + f.name
}
if len(f.Errors()) > 0 {
attrs["aria-errormessage"] = "errors-" + f.name
attrs["aria-invalid"] = "true"
}
return attrs
}
// Value set the field's value.
func Value(v any) FieldOption {
return func(f *baseWidget) {
f.value = v
}
}
// Name sets the field's name.
func Name(v string) FieldOption {
return func(f *baseWidget) {
f.name = v
}
}
// Label sets the field's label.
func Label(v string) FieldOption {
return func(f *baseWidget) {
f.label = v
}
}
// Help sets the field's help.
func Help(v string) FieldOption {
return func(f *baseWidget) {
f.help = v
}
}
// Required sets the field's required flag.
func Required(v bool) FieldOption {
return func(f *baseWidget) {
f.required = v
}
}
// Classes sets the field's classes.
func Classes(args ...any) FieldOption {
return func(f *baseWidget) {
f.classes = templ.Classes(args...)
}
}
// ControlAttrs sets the field's control attributes.
func ControlAttrs(attrs templ.Attributes) FieldOption {
return func(f *baseWidget) {
maps.Copy(f.controllAttrs, attrs)
}
}
// InputType sets the field's input type.
// Works only for <input> elements.
func InputType(v string) FieldOption {
return func(f *baseWidget) {
f.inputType = v
}
}
// InputClasses sets the field's input classes.
func InputClasses(args ...any) FieldOption {
return func(f *baseWidget) {
f.inputClasses = templ.Classes(args...)
}
}
// InputAttr adds a attribute to the field's input.
func InputAttr(name string, value any) FieldOption {
return func(f *baseWidget) {
f.inputAttrs[name] = value
}
}
// InputAttrs sets the field's input attributes.
func InputAttrs(attrs templ.Attributes) FieldOption {
return func(f *baseWidget) {
maps.Copy(f.inputAttrs, attrs)
}
}
type textField struct {
fieldWidget
}
// TextField renders a text field (text, email, etc.)
func TextField(field forms.Field, options ...FieldOption) templ.Component {
return (&textField{widget(
field,
append([]FieldOption{InputType("text")}, options...)...,
)}).component()
}
// DateField renders a [TextField] with a date type.
func DateField(field forms.Field, options ...FieldOption) templ.Component {
if f, ok := field.(*forms.DatetimeField); ok {
options = slices.Insert(options, 0, Value(f.String()))
}
return TextField(
field,
append([]FieldOption{InputType("date")}, options...)...,
)
}
type textAreaField struct {
fieldWidget
}
// TextAreaField renders a textarea field.
func TextAreaField(field forms.Field, options ...FieldOption) templ.Component {
return (&textAreaField{widget(field, options...)}).component()
}
type checkboxField struct {
fieldWidget
}
// CheckboxField renders a checkbox field.
func CheckboxField(field forms.Field, options ...FieldOption) templ.Component {
return (&checkboxField{widget(field, options...)}).component()
}
type selectField[T comparable] struct {
fieldWidget
}
// SelectField renders a select field with options.
func SelectField[T comparable](field forms.Field, options ...FieldOption) templ.Component {
return (&selectField[T]{widget(field, options...)}).component()
}
type multiSelectField[T comparable] struct {
fieldWidget
}
// MultiSelectField renders a list of checkboxes.
func MultiSelectField[T comparable](field forms.Field, options ...FieldOption) templ.Component {
return (&multiSelectField[T]{widget(field, options...)}).component()
}
type passwordField struct {
fieldWidget
}
// PasswordField renders a password field with a reveal controller.
func PasswordField(field forms.Field, options ...FieldOption) templ.Component {
return (&passwordField{widget(
field,
append([]FieldOption{InputType("text")}, options...)...,
)}).component()
}
type timeTokenField struct {
fieldWidget
}
// TimeTokenField renders a field with a helper to select time tokens.
func TimeTokenField(field forms.Field, options ...FieldOption) templ.Component {
return (&timeTokenField{widget(field, options...)}).component()
}
+406
View File
@@ -0,0 +1,406 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package forms
import (
"maps"
"slices"
"codeberg.org/readeck/readeck/pkg/forms"
. "codeberg.org/readeck/readeck/components"
)
templ Errors(f forms.Binder) {
if !f.IsValid() {
<div class="max-w-std" role="alert">
@Message(WithMessageType("error")) {
<strong class="text-red-800">{ L(ctx).Gettext("Errors") }</strong>
if errors := f.Errors(); len(errors) > 0 {
for _, err := range errors {
<p>{ err.Error() }</p>
}
} else {
<p>{ L(ctx).Gettext("Please check your form for errors.") }</p>
}
}
</div>
}
}
templ (f *fieldWidget) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field")
if len(f.Errors()) > 0 {
f.classes = append(f.classes, "field--err with-errors")
}
}}
<div class={ f.classes }>
if f.label != "" {
<label for={ f.name }>
{ f.label }
if f.required {
<span
title={ L(ctx).Gettext("Required") }
aria-hidden="true"
class="text-red-600"
>&#x2009;•</span>
<span aria-hidden="true" class="sr-only">{ L(ctx).Gettext("Required") }</span>
}
</label>
}
<div { f.controllAttrs... }>
{ children... }
if errors := f.Errors(); len(errors) > 0 {
<ul id={ "errors-" + f.name } class="field--errors">
for _, e := range errors {
<li>{ e.Error() }</li>
}
</ul>
}
if f.help != "" {
<span id={ "description-" + f.name } class="field--help">{ f.help }</span>
}
</div>
</div>
}
templ (f *textField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--text")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-input w-full")
}
if f.required {
f.inputAttrs["required"] = true
}
if f.inputType != "password" {
f.inputAttrs["value"] = f.value
} else {
f.inputAttrs["value"] = ""
}
parent := f.fieldWidget.component
}}
@parent() {
<input
type={ f.inputType }
id={ f.name }
name={ f.name }
class={ f.inputClasses }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
/>
{ children... }
}
}
templ (f *textAreaField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--textarea")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-textarea w-full")
}
if _, ok := f.inputAttrs["rows"]; !ok {
f.inputAttrs["rows"] = 3
}
if f.value == nil {
f.value = f.String()
}
if f.required {
f.inputAttrs["required"] = true
}
parent := f.fieldWidget.component
}}
@parent() {
<textarea
id={ f.name }
name={ f.name }
class={ f.inputClasses }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
>{ S("%s", f.value) }</textarea>
}
}
templ (f *checkboxField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field", "field--checkbox")
if len(f.Errors()) > 0 {
f.classes = append(f.classes, "field--err with-errors")
}
if v, ok := f.value.(bool); ok && v {
f.inputAttrs["checked"] = "checked"
}
}}
<div class={ f.classes }>
<span class="field--spacer"></span>
<div>
<input
type="checkbox"
id={ f.name }
name={ f.name }
class="form-checkbox"
value="t"
{ f.inputAttrs... }
{ f.ariaAttrs()... }
/>
// end with false so the field is bound
<input type="hidden" name={ f.name } value="f"/>
<label for={ f.name }>{ f.label }</label>
if errors := f.Errors(); len(errors) > 0 {
<ul id={ "errors-" + f.name } class="field--errors">
for _, e := range errors {
<li>{ e.Error() }</li>
}
</ul>
}
if f.help != "" {
<span id={ "description-" + f.name } class="field--help">{ f.help }</span>
}
</div>
</div>
}
templ (f *selectField[T]) component() {
{{
choices := forms.ValueChoices[T]{}
if fc, ok := f.Field.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
if fc, ok := f.inputAttrs["choices"]; ok {
delete(f.inputAttrs, "choices")
if fc, ok := fc.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
}
f.classes = slices.Insert(f.classes, 0, "field--select")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-select w-full")
}
if f.required {
f.inputAttrs["required"] = true
}
parent := f.fieldWidget.component
}}
@parent() {
<select
id={ f.name }
name={ f.name }
class={ f.inputClasses }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
>
for _, c := range choices {
<option value={ S("%s", c.Value) } selected?={ f.Field.(forms.TypedField[T]).V() == c.Value }>{ c.Name }</option>
}
</select>
}
}
templ (f *multiSelectField[T]) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field", "field--multiselect")
if len(f.Errors()) > 0 {
f.classes = append(f.classes, "field--err with-errors")
}
choices := forms.ValueChoices[T]{}
if fc, ok := f.Field.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
if fc, ok := f.inputAttrs["choices"]; ok {
delete(f.inputAttrs, "choices")
if fc, ok := fc.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
}
}}
<div class={ f.classes }>
<label>{ f.label }</label>
<div>
// null value to bind the field
<input type="hidden" name={ f.name } value="&#xff00"/>
<ul class="field--choices">
{{
inputType := "checkbox"
inputClasses := append(f.inputClasses, "form-checkbox")
if _, ok := f.Field.Value().(T); ok {
inputType = "radio"
inputClasses = append(f.inputClasses, "form-radio")
}
}}
for _, c := range choices {
{{
checked := false
if v, ok := f.Field.Value().(T); ok {
checked = v == c.Value
} else if v, ok := f.Field.Value().([]T); ok {
checked = slices.Contains(v, c.Value)
}
}}
<li>
<input
type={ inputType }
id={ S("%s_%s", f.name, c.Value) }
name={ f.name }
value={ S("%s", c.Value) }
checked?={ checked }
class={ inputClasses }
aria-label={ f.label + ": " + c.Name }
/>
<label for={ S("%s_%s", f.name, c.Value) }>{ c.Name }</label>
</li>
}
</ul>
if errors := f.Errors(); len(errors) > 0 {
<ul id={ "errors-" + f.name } class="field--errors">
for _, e := range errors {
<li>{ e.Error() }</li>
}
</ul>
}
if f.help != "" {
<span id={ "description-" + f.name } class="field--help">{ f.help }</span>
}
</div>
</div>
}
templ (f *passwordField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--text field--composed field--password")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-input w-full")
}
f.controllAttrs["data-controller"] = "pass-reveal"
if f.required {
f.inputAttrs["required"] = true
}
f.inputAttrs["data-pass-reveal-target"] = "field"
parent := f.fieldWidget.component
}}
@parent() {
<div class={ f.inputClasses }>
<input
type="password"
id={ f.name }
name={ f.name }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
/>
<button type="button" data-pass-reveal-target="btn" data-action="pass-reveal#toggle"></button>
<template data-pass-reveal-target="show">
@Icon("o-show")
</template>
<template data-pass-reveal-target="hide">
@Icon("o-hide")
</template>
</div>
}
}
templ (f *timeTokenField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--text field--composed field--timetoken")
f.inputClasses = slices.Insert(f.inputClasses, 0, "form-input w-full")
f.inputAttrs["data-timetoken-target"] = "field"
if f.required {
f.inputAttrs["required"] = true
}
parent := f.fieldWidget.component
}}
@parent() {
<div
class={ f.inputClasses }
data-controller="timetoken"
data-timetoken-hidden-class="hidden"
>
<input
type="text"
id={ f.name }
name={ f.name }
value={ f.String() }
{ f.inputAttrs... }
/>
<button type="button" data-timetoken-target="btn">
@Icon("o-calendar")
</button>
<template data-timetoken-target="template">
<div class="timetoken">
<label>{ L(ctx).Gettext("The previous") }</label>
<div>
<input
type="number"
size="3"
min="0"
class="form-input"
data-timetoken-target="value"
data-action="timetoken#update"
/>
<select
class="form-select"
data-action="timetoken#update"
data-timetoken-target="unit"
>
<option value="d">{ L(ctx).Gettext("Day(s)") }</option>
<option value="w">{ L(ctx).Gettext("Week(s)") }</option>
<option value="m">{ L(ctx).Gettext("Month(s)") }</option>
<option value="y">{ L(ctx).Gettext("Year(s)") }</option>
</select>
</div>
<p class="my-2">- { L(ctx).Pgettext("word", "or") } -</p>
<input type="date" class="form-input" data-action="timetoken#update" data-timetoken-target="absolute"/>
</div>
</template>
</div>
}
}
templ FiledDropField(field forms.Field, options ...FieldOption) {
{{
f := textField{widget(field, options...)}
f.inputType = "file"
f.classes = slices.Insert(f.classes, 0, "field--filedrop")
f.inputClasses = slices.Insert(f.inputClasses, 0, "js:hidden")
maps.Copy(f.controllAttrs, templ.Attributes{
"data-controller": "dropzone",
"data-dropzone-hidden-class": "hidden",
"data-dropzone-focus-class": "bg-primary-100",
})
parent := f.component
}}
@parent() {
<p data-dropzone-target="zone">
<button
type="button"
class="mx-auto link"
data-dropzone-target="placeholder"
data-action="click->dropzone#select"
>{ L(ctx).Gettext("Select or drop file") }</button>
<span class="max-w-full wrap-anywhere font-semibold" data-dropzone-target="fileinfo"></span>
<button
type="button"
class="hidden text-btn-danger hf:text-btn-danger-hover"
data-dropzone-target="clearbtn"
data-action="dropzone#clear:stop:prevent"
>
{ L(ctx).Gettext("remove") }
</button>
</p>
}
}
+1728
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import (
"maps"
"github.com/a-h/templ"
)
type icon struct {
src string
w int
h int
class templ.CSSClasses
svgClass templ.CSSClasses
attrs templ.Attributes
}
// Icon renders an icon from a source sprite.
// The default source is "img/icons.svg".
func Icon(name string, options ...func(*icon)) templ.Component {
i := &icon{
src: "img/icons.svg",
w: 24,
h: 24,
class: templ.CSSClasses{"svgicon"},
svgClass: templ.CSSClasses{"w-4"},
attrs: templ.Attributes{},
}
for _, f := range options {
f(i)
}
return i.component(name)
}
// WithIconSrc sets the icon's sprite source.
func WithIconSrc(src string) func(*icon) {
return func(i *icon) {
i.src = src
}
}
// WithIconClass sets the icon wrapper's class.
func WithIconClass(c ...any) func(*icon) {
return func(i *icon) {
i.class = templ.Classes(c...)
}
}
// WithIconSvgClass sets the icon svg's class.
func WithIconSvgClass(c ...any) func(*icon) {
return func(i *icon) {
i.svgClass = templ.Classes(c...)
}
}
// WithIconSize sets the icon's dimension.
func WithIconSize(w, h int) func(*icon) {
return func(i *icon) {
if w == 0 {
i.w = 24
}
if h == 0 {
i.h = 24
}
}
}
// WithIconAttrs sets the icon wrapper's attributes.
func WithIconAttrs(attrs templ.Attributes) func(*icon) {
return func(i *icon) {
maps.Copy(i.attrs, attrs)
}
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
templ (i *icon) component(name string) {
<span class={ i.class } { i.attrs... }>
<svg
xmlns="http://www.w3.org/2000/svg"
viewbox={ S("0 0 %d %d", i.w, i.h) }
width={ i.w }
height={ i.h }
class={ templ.CSSClasses{"inline-block", i.svgClass} }
>
<use href={ S("%s#%s", Asset(ctx, i.src), name) }></use>
</svg>
</span>
}
// Spinner renders a spinner icon.
templ Spinner() {
@Icon("o-spinner", WithIconSvgClass("animate-spin stroke-current"))
}
+176
View File
@@ -0,0 +1,176 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func (i *icon) component(name string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{i.class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/icon.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, i.attrs)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 = []any{templ.CSSClasses{"inline-block", i.svgClass}}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<svg xmlns=\"http://www.w3.org/2000/svg\" viewbox=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(S("0 0 %d %d", i.w, i.h))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/icon.templ`, Line: 11, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" width=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(i.w)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/icon.templ`, Line: 12, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" height=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(i.h)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/icon.templ`, Line: 13, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/icon.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><use href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(S("%s#%s", Asset(ctx, i.src), name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/icon.templ`, Line: 16, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"></use></svg></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Spinner renders a spinner icon.
func Spinner() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Icon("o-spinner", WithIconSvgClass("animate-spin stroke-current")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+39
View File
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import "github.com/a-h/templ"
// BasePage renders an empty page with default and required head elements.
func BasePage(title string) templ.Component {
return defaultLayout.BasePage(title)
}
// SideMenuLayout renders a page with a side menu.
func SideMenuLayout(title string, sidemenu templ.Component) templ.Component {
return defaultLayout.SideMenuLayout(title, sidemenu, defaultLayout.SideMenuWrapper())
}
// SideMenuStdLayout renders a page with a side menu.
// The content has a standard maximum width.
func SideMenuStdLayout(title string, sidemenu templ.Component) templ.Component {
return defaultLayout.SideMenuLayout(title, sidemenu, defaultLayout.SideMenuStdWrapper())
}
// Layout is our base layout for every Readeck page.
type Layout struct {
Head templ.Component
}
// defaultLayout is an instance of [Layout] that we reuse
// about everywhere.
var defaultLayout = NewLayout()
// NewLayout returns a new [Layout].
func NewLayout() (l *Layout) {
return &Layout{
Head: templ.NopComponent,
}
}
+203
View File
@@ -0,0 +1,203 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
// BasePage renders the layout's base page with head elements.
templ (c *Layout) BasePage(title string) {
<!DOCTYPE html>
<html
lang={ L(ctx).Tag.String() }
class="overscroll-y-none scroll-pt-20 max-sm:scroll-pt-40"
>
<head>
<meta charset="UTF-8"/>
<title>{ title } - Readeck</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="csp-nonce" content={ CSPNonce(ctx) }/>
<meta name="robots" content="noindex nofollow noarchive"/>
<link rel="stylesheet" href={ Asset(ctx, "bundle.css") } nonce={ CSPNonce(ctx) }/>
<script type="module" src={ Asset(ctx, "bundle.js") } nonce={ CSPNonce(ctx) }></script>
<script nonce={ CSPNonce(ctx) }>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
</script>
<link rel="icon" href={ Asset(ctx, "img/fi/favicon.ico") } sizes="48x48"/>
<link rel="icon" href={ Asset(ctx, `img/fi/favicon.svg`) } sizes="any" type="image/svg+xml"/>
<link rel="apple-touch-icon" href={ Asset(ctx, `img/fi/apple-touch-icon.png`) }/>
<link rel="manifest" href={ URL(ctx, "/manifest.webmanifest") }/>
<meta name="theme-color" content="#064c5c"/>
@c.Head
</head>
<body
class="
no-js bg-app-bg text-app-fg font-sans leading-tight tracking-normal
relative
print:text-black print:bg-white
overscroll-y-none
"
data-turbo="false"
data-turbo-prefetch="false"
data-controller="scroll-tracker"
data-scroll-tracker-down-class="scrolled-down"
>
<script nonce={ CSPNonce(ctx) }>
if ('noModule' in HTMLScriptElement.prototype) {
document.body.classList.remove("no-js")
document.body.classList.add("js")
}
</script>
{ children... }
</body>
</html>
}
templ (c *Layout) Menu(menuBtn templ.Component) {
<div class="layout-topnav" id="menu">
@menuBtn {
@Icon("o-menu")
}
<div class="logo">
<a href={ URL(ctx, "/") } title={ L(ctx).Gettext("Home page") }>
@Icon("o-logo-square", WithIconClass(), WithIconSvgClass("h-11 w-11"))
</a>
</div>
<div class="mainmenu">
<menu class="grow">
if HasPermission(ctx, "bookmarks", "read") {
@c.mainMenuItem(L(ctx).Gettext("Bookmarks"), "/bookmarks/unread", "o-library", PathIs(ctx, "/bookmarks", "/bookmarks/*"))
}
</menu>
if !IsAnonymous(ctx) {
<menu class="flex-shrink-0">
<li class="no-js:hidden">
<button
type="button"
data-controller="theme"
data-action="theme#toggleTheme"
title={ L(ctx).Gettext("Change color theme") }
>
<span data-theme-target="icon">
@Icon("o-theme-system")
</span>
<span class="sr-only">{ L(ctx).Gettext("Change color theme") }</span>
<template data-theme-target="iconLight">
@Icon("o-theme-light")
</template>
<template data-theme-target="iconDark">
@Icon("o-theme-dark")
</template>
<template data-theme-target="iconSystem">
@Icon("o-theme-system")
</template>
</button>
</li>
if HasPermission(ctx, "profile", "read") {
@c.mainMenuItem(L(ctx).Gettext("Settings"), "/profile", "o-settings", PathIs(ctx, "/admin", "/admin/*", "/profile", "/profile/*"))
}
@c.mainMenuItem(L(ctx).Gettext("Documentation"), "/docs/", "o-help", PathIs(ctx, "/docs/*"))
</menu>
}
</div>
</div>
}
templ (c *Layout) menuButton(name string) {
<button
type="button"
title={ name }
class="sidemenu--button"
data-action="click->panel#toggle"
data-panel-target="button"
aria-expanded="false"
aria-controls="sidemenu"
>
{ children... }
<span class="sr-only">{ name }</span>
</button>
}
templ (c *Layout) mainMenuItem(name, path, icon string, current bool) {
<li>
<a href={ URL(ctx, path) } title={ name } data-current={ current }>
@Icon(icon)
<span class="sr-only">{ name }</span>
</a>
</li>
}
// QuickAccessMenu renders an a11y menu.
templ QuickAccessMenu(items [][2]string) {
if len(items) > 0 {
<ul label={ L(ctx).Gettext("Quick access menu") } class="a11y-nav">
for _, item := range items {
<li><a href={ "#" + item[0] } class="link">{ item[1] }</a></li>
}
</ul>
}
}
// SideMenuTitle renders a title used in side menus.
templ SideMenuTitle() {
<h2
id="sidemenu-title"
class="h-topnav border-b border-gray-200 uppercase flex items-center justify-center"
>
{ children... }
</h2>
}
// SideMenuLayout renders a layout with a side menu.
templ (c *Layout) SideMenuLayout(title string, sidemenu templ.Component, contentWrapper templ.Component) {
@c.BasePage(title) {
@QuickAccessMenu([][2]string{
{"menu", L(ctx).Gettext("Menu")},
{"sidemenu", L(ctx).Gettext("Secondary Menu")},
{"content", L(ctx).Gettext("Main content")},
})
<div
class="layout"
data-controller="panel"
data-panel-hidden-class="sidemenu--hidden"
data-panel-body-class="max-sm:overflow-hidden"
>
@c.Menu(c.menuButton(L(ctx).Gettext("Open Menu")))
<nav
id="sidemenu"
tabindex="-1"
data-panel-target="panel"
aria-labelledby="sidemenu-title"
data-action="keydown.esc->panel#toggle"
class="sidemenu sidemenu--hidden"
>
@c.menuButton(L(ctx).Gettext("Close menu")) {
@Icon("o-close", WithIconClass("inline-block"), WithIconSvgClass("w-8 h-8"))
}
@sidemenu
</nav>
@contentWrapper {
{ children... }
}
</div>
}
}
templ (c *Layout) SideMenuWrapper() {
<div class="layout-content" id="content">
@Flashes()
{ children... }
</div>
}
templ (c *Layout) SideMenuStdWrapper() {
<main class="layout-content max-w-std" id="content">
@Flashes()
@Breadcrumbs()
{ children... }
</main>
}
+890
View File
@@ -0,0 +1,890 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// BasePage renders the layout's base page with head elements.
func (c *Layout) BasePage(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Tag.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 11, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"overscroll-y-none scroll-pt-20 max-sm:scroll-pt-40\"><head><meta charset=\"UTF-8\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 16, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " - Readeck</title><meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta name=\"csp-nonce\" content=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(CSPNonce(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 19, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><meta name=\"robots\" content=\"noindex nofollow noarchive\"><link rel=\"stylesheet\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(Asset(ctx, "bundle.css"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 21, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" nonce=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(CSPNonce(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 21, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><script type=\"module\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(Asset(ctx, "bundle.js"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 22, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" nonce=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(CSPNonce(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 22, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"></script><script nonce=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(CSPNonce(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 23, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">\n\t\t\t\tif (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {\n\t\t\t\t\tdocument.documentElement.classList.add('dark')\n\t\t\t\t} else {\n\t\t\t\t\tdocument.documentElement.classList.remove('dark')\n\t\t\t\t}\n\t\t\t</script><link rel=\"icon\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(Asset(ctx, "img/fi/favicon.ico"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 30, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" sizes=\"48x48\"><link rel=\"icon\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(Asset(ctx, `img/fi/favicon.svg`))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 31, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" sizes=\"any\" type=\"image/svg+xml\"><link rel=\"apple-touch-icon\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(Asset(ctx, `img/fi/apple-touch-icon.png`))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 32, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><link rel=\"manifest\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, "/manifest.webmanifest"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 33, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><meta name=\"theme-color\" content=\"#064c5c\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = c.Head.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</head><body class=\"no-js bg-app-bg text-app-fg font-sans leading-tight tracking-normal relative print:text-black print:bg-white overscroll-y-none\" data-turbo=\"false\" data-turbo-prefetch=\"false\" data-controller=\"scroll-tracker\" data-scroll-tracker-down-class=\"scrolled-down\"><script nonce=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(CSPNonce(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 49, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">\n\t\t\t\tif ('noModule' in HTMLScriptElement.prototype) {\n\t\t\t\t\tdocument.body.classList.remove(\"no-js\")\n\t\t\t\t\tdocument.body.classList.add(\"js\")\n\t\t\t\t}\n\t\t\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func (c *Layout) Menu(menuBtn templ.Component) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"layout-topnav\" id=\"menu\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var16 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = Icon("o-menu").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = menuBtn.Render(templ.WithChildren(ctx, templ_7745c5c3_Var16), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"logo\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, "/"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 66, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Home page"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 66, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-logo-square", WithIconClass(), WithIconSvgClass("h-11 w-11")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a></div><div class=\"mainmenu\"><menu class=\"grow\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if HasPermission(ctx, "bookmarks", "read") {
templ_7745c5c3_Err = c.mainMenuItem(L(ctx).Gettext("Bookmarks"), "/bookmarks/unread", "o-library", PathIs(ctx, "/bookmarks", "/bookmarks/*")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</menu> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !IsAnonymous(ctx) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<menu class=\"flex-shrink-0\"><li class=\"no-js:hidden\"><button type=\"button\" data-controller=\"theme\" data-action=\"theme#toggleTheme\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Change color theme"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 83, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"><span data-theme-target=\"icon\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-theme-system").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> <span class=\"sr-only\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Change color theme"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 88, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span><template data-theme-target=\"iconLight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-theme-light").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</template><template data-theme-target=\"iconDark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-theme-dark").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</template><template data-theme-target=\"iconSystem\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-theme-system").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</template></button></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if HasPermission(ctx, "profile", "read") {
templ_7745c5c3_Err = c.mainMenuItem(L(ctx).Gettext("Settings"), "/profile", "o-settings", PathIs(ctx, "/admin", "/admin/*", "/profile", "/profile/*")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = c.mainMenuItem(L(ctx).Gettext("Documentation"), "/docs/", "o-help", PathIs(ctx, "/docs/*")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</menu>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func (c *Layout) menuButton(name string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
if templ_7745c5c3_Var21 == nil {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<button type=\"button\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 113, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"sidemenu--button\" data-action=\"click->panel#toggle\" data-panel-target=\"button\" aria-expanded=\"false\" aria-controls=\"sidemenu\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var21.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span class=\"sr-only\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 121, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func (c *Layout) mainMenuItem(name, path, icon string, current bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var24 := templ.GetChildren(ctx)
if templ_7745c5c3_Var24 == nil {
templ_7745c5c3_Var24 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<li><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 templ.SafeURL
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, path))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 127, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 127, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" data-current=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(current)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 127, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon(icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"sr-only\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 129, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span></a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// QuickAccessMenu renders an a11y menu.
func QuickAccessMenu(items [][2]string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var29 := templ.GetChildren(ctx)
if templ_7745c5c3_Var29 == nil {
templ_7745c5c3_Var29 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if len(items) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<ul label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Quick access menu"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 137, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"a11y-nav\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<li><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item[0])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 139, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"link\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(item[1])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/layout.templ`, Line: 139, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
// SideMenuTitle renders a title used in side menus.
func SideMenuTitle() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
if templ_7745c5c3_Var33 == nil {
templ_7745c5c3_Var33 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<h2 id=\"sidemenu-title\" class=\"h-topnav border-b border-gray-200 uppercase flex items-center justify-center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var33.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// SideMenuLayout renders a layout with a side menu.
func (c *Layout) SideMenuLayout(title string, sidemenu templ.Component, contentWrapper templ.Component) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
if templ_7745c5c3_Var34 == nil {
templ_7745c5c3_Var34 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var35 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = QuickAccessMenu([][2]string{
{"menu", L(ctx).Gettext("Menu")},
{"sidemenu", L(ctx).Gettext("Secondary Menu")},
{"content", L(ctx).Gettext("Main content")},
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " <div class=\"layout\" data-controller=\"panel\" data-panel-hidden-class=\"sidemenu--hidden\" data-panel-body-class=\"max-sm:overflow-hidden\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = c.Menu(c.menuButton(L(ctx).Gettext("Open Menu"))).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<nav id=\"sidemenu\" tabindex=\"-1\" data-panel-target=\"panel\" aria-labelledby=\"sidemenu-title\" data-action=\"keydown.esc->panel#toggle\" class=\"sidemenu sidemenu--hidden\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var36 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = Icon("o-close", WithIconClass("inline-block"), WithIconSvgClass("w-8 h-8")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = c.menuButton(L(ctx).Gettext("Close menu")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = sidemenu.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templ_7745c5c3_Var34.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = contentWrapper.Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = c.BasePage(title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var35), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func (c *Layout) SideMenuWrapper() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
if templ_7745c5c3_Var38 == nil {
templ_7745c5c3_Var38 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<div class=\"layout-content\" id=\"content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Flashes().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var38.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func (c *Layout) SideMenuStdWrapper() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
if templ_7745c5c3_Var39 == nil {
templ_7745c5c3_Var39 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<main class=\"layout-content max-w-std\" id=\"content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Flashes().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Breadcrumbs().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var39.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+22
View File
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
templ List(class ...string) {
<div class={ append([]string{"border divide-y shadow rounded"}, class...) }>
{ children... }
</div>
}
templ ListItem(class ...string) {
{{
if len(class) == 0 {
class = []string{"p-4"}
}
}}
<div class={ class }>
{ children... }
</div>
}
+130
View File
@@ -0,0 +1,130 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func List(class ...string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{append([]string{"border divide-y shadow rounded"}, class...)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ListItem(class ...string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if len(class) == 0 {
class = []string{"p-4"}
}
var templ_7745c5c3_Var5 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var4.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+90
View File
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import (
"maps"
"github.com/a-h/templ"
)
type message struct {
typeClass string
icon string
removable bool
delay int
class templ.CSSClasses
attrs templ.Attributes
}
func newMessage(options ...func(*message)) *message {
m := &message{
typeClass: "info",
icon: "o-info",
attrs: templ.Attributes{},
}
for _, f := range options {
f(m)
}
return m
}
// Message renders a message component.
func Message(options ...func(*message)) templ.Component {
return newMessage(options...).component()
}
// WithMessageType sets the message's type.
func WithMessageType(s string) func(*message) {
return func(mc *message) {
mc.typeClass = s
switch mc.typeClass {
case "info":
mc.icon = "o-info"
case "success":
mc.icon = "o-check-on"
case "error":
mc.icon = "o-error"
}
}
}
// WithMessageIcon sets the message's icon.
func WithMessageIcon(s string) func(*message) {
return func(mc *message) {
mc.icon = s
}
}
// WithMessageRemovable marks the message as removable.
func WithMessageRemovable(mc *message) {
mc.removable = true
mc.attrs["data-controller"] = "remover"
}
// WithMessageDelay sets a delay to the message for its removal.
func WithMessageDelay(d int) func(*message) {
return func(mc *message) {
mc.delay = d
mc.attrs["data-controller"] = "remover"
mc.attrs["data-remover-delay-value"] = mc.delay
}
}
// WithMessageClass adds a class to the message.
func WithMessageClass(c string) func(*message) {
return func(mc *message) {
mc.class = append(mc.class, c)
}
}
// WithMessageAttrs adds the given attributes to the message.
func WithMessageAttrs(attrs templ.Attributes) func(*message) {
return func(mc *message) {
maps.Copy(mc.attrs, attrs)
}
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import (
"strings"
"codeberg.org/readeck/readeck/internal/server"
)
templ (mc *message) component() {
<div class={ templ.Classes("message", mc.typeClass, mc.class) } { mc.attrs... }>
if mc.icon != "" {
<div>
@Icon(mc.icon)
</div>
}
<div class="grow">
{ children... }
</div>
if mc.removable {
<div class="no-js:hidden">
<button data-action="remover#remove" class="remover">
@Icon("o-cross")
</button>
</div>
}
</div>
}
// Flashes shows the flash messages extracted from the user's session.
templ Flashes() {
if flashes := server.Flashes(server.GetRequest(ctx)); len(flashes) > 0 {
<div class="toaster">
for _, f := range flashes {
if !strings.HasPrefix(f.Type, "_") {
{{
msg := newMessage(
WithMessageType(f.Type),
WithMessageDelay(5),
WithMessageRemovable,
)
switch f.Type {
case "error":
msg.attrs["role"] = "alert"
default:
msg.attrs["role"] = "status"
}
}}
<div class="drop-shadow-sm mb-1">
@msg.component() {
<strong>{ f.Message }</strong>
}
</div>
}
}
</div>
}
}
+216
View File
@@ -0,0 +1,216 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"strings"
"codeberg.org/readeck/readeck/internal/server"
)
func (mc *message) component() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{templ.Classes("message", mc.typeClass, mc.class)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/message.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, mc.attrs)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if mc.icon != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon(mc.icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"grow\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if mc.removable {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"no-js:hidden\"><button data-action=\"remover#remove\" class=\"remover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-cross").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Flashes shows the flash messages extracted from the user's session.
func Flashes() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if flashes := server.Flashes(server.GetRequest(ctx)); len(flashes) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"toaster\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, f := range flashes {
if !strings.HasPrefix(f.Type, "_") {
msg := newMessage(
WithMessageType(f.Type),
WithMessageDelay(5),
WithMessageRemovable,
)
switch f.Type {
case "error":
msg.attrs["role"] = "alert"
default:
msg.attrs["role"] = "status"
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"drop-shadow-sm mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(f.Message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/message.templ`, Line: 54, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = msg.component().Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+55
View File
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import "codeberg.org/readeck/readeck/internal/server"
templ Pagination(p *server.Pagination) {
if p.TotalPages > 1 {
<nav class="paginator print:hidden">
<div class="paginator--basic">
if p.PreviousPage != "" {
<a href={ URL(ctx, p.PreviousPage) }>
{ L(ctx).Pgettext("page","Previous") }
</a>
} else {
<span></span>
}
if p.NextPage != "" {
<a href={ URL(ctx, p.NextPage) }>
{ L(ctx).Pgettext("page","Next") }
</a>
}
</div>
<div class="paginator--extended">
if p.PreviousPage != "" {
<a href={ URL(ctx, p.PreviousPage) } aria-label={ L(ctx).Gettext("Go to previous page") }>
@Icon("o-chevron-l")
</a>
}
for _, page := range p.PageLinks {
switch {
case page.Index == p.CurrentPage:
<span class="paginator--current">{ page.Index }</span>
case page.Index != 0:
<a
href={ URL(ctx, page.URL) }
aria-label={ L(ctx).Gettext("Go to page %d", page.Index) }
>
{ page.Index }
</a>
default:
<span class="paginator--hellip">&hellip;</span>
}
}
if p.NextPage != "" {
<a href={ URL(ctx, p.NextPage) } aria-label={ L(ctx).Gettext("Go to next page") }>
@Icon("o-chevron-r")
</a>
}
</div>
</nav>
}
}
+278
View File
@@ -0,0 +1,278 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "codeberg.org/readeck/readeck/internal/server"
func Pagination(p *server.Pagination) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if p.TotalPages > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<nav class=\"paginator print:hidden\"><div class=\"paginator--basic\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.PreviousPage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, p.PreviousPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 14, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Pgettext("page", "Previous"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 15, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if p.NextPage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, p.NextPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 21, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Pgettext("page", "Next"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 22, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><div class=\"paginator--extended\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.PreviousPage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, p.PreviousPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 28, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Go to previous page"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 28, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-chevron-l").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, page := range p.PageLinks {
switch {
case page.Index == p.CurrentPage:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"paginator--current\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(page.Index)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 35, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case page.Index != 0:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, page.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 38, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Go to page %d", page.Index))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 39, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(page.Index)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 41, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"paginator--hellip\">&hellip;</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
if p.NextPage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(URL(ctx, p.NextPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 48, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Go to next page"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/pagination.templ`, Line: 48, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("o-chevron-r").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+41
View File
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import (
"encoding/base64"
"image/color"
"strings"
"github.com/skip2/go-qrcode"
)
// ColorPrimary is blue-800.
var ColorPrimary = color.RGBA{6, 76, 92, 255}
// QRCodeB64 returns a QR code as a base64 data URI.
// It panics when src is too big.
// Size can be a negative number. See [qrcode.QRCode.Image]
// for details. A value of -6 is a good default for URLs.
func QRCodeB64(src string, color color.Color, size int) string {
qr, err := qrcode.New(src, qrcode.Medium)
if err != nil {
panic(err)
}
qr.ForegroundColor = color
qr.DisableBorder = true
data, err := qr.PNG(size)
if err != nil {
panic(err)
}
res := new(strings.Builder)
res.WriteString("data:image/png;base64,")
enc := base64.NewEncoder(base64.StdEncoding, res)
enc.Write(data) //nolint:errcheck
return res.String()
}
+1
View File
@@ -57,6 +57,7 @@ require (
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4 // indirect
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/a-h/templ v0.3.1001 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
+2
View File
@@ -15,6 +15,8 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
@@ -133,6 +133,11 @@ func New(user *users.User, session *sessions.Session) *Preferences {
return p
}
// IsLoaded returns whether the user's preferences has been loaded (a user is attached).
func (p *Preferences) IsLoaded() bool {
return p.user != nil
}
// WidthList returns the list of available widths.
func (p *Preferences) WidthList() []string {
return readerWidthList
+9 -5
View File
@@ -25,8 +25,10 @@ type (
ctxUnauthorizedKey struct{}
)
// Context setters.
// nolint:revive
var (
withCSPNonce, getCSPNonce = ctxr.WithChecker[string](ctxCSPNonceKey{})
withCSPNonce, GetCSPNonce = ctxr.WithChecker[string](ctxCSPNonceKey{})
withCSP, getCSP = ctxr.WithChecker[csp.Policy](ctxCSPKey{})
withUnauthorized, getUnauthorized = ctxr.WithChecker[int](ctxUnauthorizedKey{})
)
@@ -137,10 +139,12 @@ func unauthorizedHandler(w http.ResponseWriter, r *http.Request) {
redir := urls.AbsoluteURL(r, "/login")
// Add the current path as a redirect query parameter
// to the login route
q := redir.Query()
q.Add("r", urls.CurrentPath(r))
redir.RawQuery = q.Encode()
// to the login route.
if r.Method == http.MethodGet {
q := redir.Query()
q.Add("r", urls.CurrentPath(r))
redir.RawQuery = q.Encode()
}
w.Header().Set("Location", redir.String())
w.WriteHeader(http.StatusSeeOther)
+87 -1
View File
@@ -5,6 +5,7 @@
package server
import (
"context"
"fmt"
"html"
"log/slog"
@@ -14,16 +15,39 @@ import (
"strings"
"github.com/CloudyKit/jet/v6"
"github.com/a-h/templ"
"codeberg.org/readeck/readeck/configs"
"codeberg.org/readeck/readeck/internal/auth"
"codeberg.org/readeck/readeck/internal/auth/users"
"codeberg.org/readeck/readeck/internal/profile/preferences"
"codeberg.org/readeck/readeck/internal/server/urls"
"codeberg.org/readeck/readeck/internal/templates"
"codeberg.org/readeck/readeck/pkg/ctxr"
"codeberg.org/readeck/readeck/pkg/glob"
"codeberg.org/readeck/readeck/pkg/libjet"
)
type (
ctxRequestKey struct{}
ctxUserKey struct{}
ctxPreferencesKey struct{}
)
var (
withRequest = ctxr.Setter[*http.Request](ctxRequestKey{})
// GetRequest returns the request.
GetRequest = ctxr.Getter[*http.Request](ctxRequestKey{})
withUser = ctxr.Setter[*users.User](ctxUserKey{})
// GetUser returns a [users.User].
GetUser = ctxr.Getter[*users.User](ctxUserKey{})
withPreferences = ctxr.Setter[*preferences.Preferences](ctxPreferencesKey{})
// GetPreferences returns the user's [preferences.Preferences].
GetPreferences = ctxr.Getter[*preferences.Preferences](ctxPreferencesKey{})
)
// TC is a simple type to carry template context.
type TC map[string]any
@@ -143,7 +167,7 @@ func initTemplates() {
// TemplateVars returns the default variables set for a template
// in the request's context.
func TemplateVars(r *http.Request) jet.VarMap {
cspNonce, _ := getCSPNonce(r.Context())
cspNonce, _ := GetCSPNonce(r.Context())
tr := Locale(r)
user := auth.GetRequestUser(r)
@@ -164,3 +188,65 @@ func TemplateVars(r *http.Request) jet.VarMap {
Set("pgettext", tr.Pgettext).
Set("npgettext", tr.Npgettext)
}
// RenderComponent renders a [templ.Component] in the response's writer.
func RenderComponent(
w http.ResponseWriter, r *http.Request,
status int, component templ.Component,
) {
if w.Header().Get("content-type") == "" {
w.Header().Set("content-type", "text/html; charset=utf-8")
}
w.WriteHeader(status)
// Set some context information
if err := component.Render(componentContext(r), w); err != nil {
Err(w, r, err)
return
}
}
// RenderTurboStreamComponent yields an HTML response with turbo-stream content-type using the
// given component. The template result is enclosed in a turbo-stream tag
// with action and target as specified.
// You can call this method as many times as needed to output several turbo-stream tags
// in the same HTTP response.
func RenderTurboStreamComponent(
w http.ResponseWriter, r *http.Request,
component templ.Component,
action, target string,
attrs map[string]string,
) {
extraAttrs := new(strings.Builder)
for k, v := range attrs {
extraAttrs.WriteString(k + `="` + html.EscapeString(v) + `" `)
}
log := Log(r).With(
slog.String("action", action),
slog.String("target", target),
slog.Any("attrs", attrs),
)
w.Header().Set("Content-Type", "text/vnd.turbo-stream.html; charset=utf-8")
fmt.Fprintf(w, `<turbo-stream action="%s" %starget="%s"><template>%s`, action, extraAttrs, target, "\n")
err := component.Render(componentContext(r), w)
fmt.Fprint(w, "</template></turbo-stream>\n\n")
if err != nil {
log.Error("turbo stream", slog.Any("err", err))
return
}
log.Debug("turbo stream")
}
// componentContext returns a [context.Context] with some values
// needed for component rendering.
// The resulting context always contains the request and its user.
func componentContext(r *http.Request) context.Context {
ctx := r.Context()
ctx = withUser(ctx, auth.GetRequestUser(r))
ctx = withRequest(ctx, r)
ctx = withPreferences(ctx, &preferences.Preferences{}) // lazy loaded
return ctx
}