diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..39a41c45 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*_templ.go linguist-generated +*_templ.go binary \ No newline at end of file diff --git a/Makefile b/Makefile index 308b2418..9285176c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/REUSE.toml b/REUSE.toml index f143c69d..c77612f7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -22,6 +22,7 @@ SPDX-License-Identifier = "CC-BY-4.0" [[annotations]] path = [ ".dockerignore", + ".gitattributes", ".gitignore", ".go-version", ".golangci.yml", diff --git a/components/README.md b/components/README.md new file mode 100644 index 00000000..1ad0cc64 --- /dev/null +++ b/components/README.md @@ -0,0 +1,84 @@ +--- +SPDX-FileCopyrightText: © 2026 Olivier Meunier +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 items... + +} + +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" +) +``` \ No newline at end of file diff --git a/components/breadcrumbs.go b/components/breadcrumbs.go new file mode 100644 index 00000000..affcd9bb --- /dev/null +++ b/components/breadcrumbs.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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{}) diff --git a/components/breadcrumbs.templ b/components/breadcrumbs.templ new file mode 100644 index 00000000..27433991 --- /dev/null +++ b/components/breadcrumbs.templ @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// SPDX-License-Identifier: AGPL-3.0-only + +package components + +templ Breadcrumbs() { + {{ items, _ := checkBreadcrumb(ctx) }} + if len(items) > 0 { + + } +} diff --git a/components/breadcrumbs_templ.go b/components/breadcrumbs_templ.go new file mode 100644 index 00000000..49c6edd8 --- /dev/null +++ b/components/breadcrumbs_templ.go @@ -0,0 +1,139 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +// SPDX-FileCopyrightText: © 2026 Olivier Meunier + +// + +// 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/components/components.go b/components/components.go new file mode 100644 index 00000000..769aca10 --- /dev/null +++ b/components/components.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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) + }) +} diff --git a/components/forms/forms.go b/components/forms/forms.go new file mode 100644 index 00000000..6d1458ab --- /dev/null +++ b/components/forms/forms.go @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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 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() +} diff --git a/components/forms/forms.templ b/components/forms/forms.templ new file mode 100644 index 00000000..fde1b2e8 --- /dev/null +++ b/components/forms/forms.templ @@ -0,0 +1,406 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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() { + + } +} + +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") + } + }} +
+ if f.label != "" { + + } +
+ { children... } + if errors := f.Errors(); len(errors) > 0 { +
    + for _, e := range errors { +
  • { e.Error() }
  • + } +
+ } + if f.help != "" { + { f.help } + } +
+
+} + +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() { + + { 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() { + + } +} + +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" + } + }} +
+ +
+ + // end with false so the field is bound + + + if errors := f.Errors(); len(errors) > 0 { +
    + for _, e := range errors { +
  • { e.Error() }
  • + } +
+ } + if f.help != "" { + { f.help } + } +
+
+} + +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() { + + } +} + +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() + } + } + }} +
+ +
+ // null value to bind the field + +
    + {{ + 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) + } + }} +
  • + + +
  • + } +
+ if errors := f.Errors(); len(errors) > 0 { +
    + for _, e := range errors { +
  • { e.Error() }
  • + } +
+ } + if f.help != "" { + { f.help } + } +
+
+} + +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() { +
+ + + + +
+ } +} + +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() { +
+ + + +
+ } +} + +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() { +

+ + + +

+ } +} diff --git a/components/forms/forms_templ.go b/components/forms/forms_templ.go new file mode 100644 index 00000000..b00ecb9b --- /dev/null +++ b/components/forms/forms_templ.go @@ -0,0 +1,1728 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +// SPDX-FileCopyrightText: © 2026 Olivier Meunier + +// + +// SPDX-License-Identifier: AGPL-3.0-only + +package forms + +//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 ( + "maps" + "slices" + + "codeberg.org/readeck/readeck/pkg/forms" + + . "codeberg.org/readeck/readeck/components" +) + +func Errors(f forms.Binder) 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 !f.IsValid() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var2 := 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, 2, "") + 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("Errors")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 20, Col: 59} + } + _, 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 + } + if errors := f.Errors(); len(errors) > 0 { + for _, err := range errors { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(err.Error()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 23, Col: 22} + } + _, 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, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(L(ctx).Gettext("Please check your form for errors.")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 26, Col: 62} + } + _, 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, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Message(WithMessageType("error")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func (f *fieldWidget) 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_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + f.classes = slices.Insert(f.classes, 0, "field") + if len(f.Errors()) > 0 { + f.classes = append(f.classes, "field--err with-errors") + } + var templ_7745c5c3_Var7 = []any{f.classes} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if f.label != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + 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 + } + templ_7745c5c3_Err = templ_7745c5c3_Var6.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errors := f.Errors(); len(errors) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range errors { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Error()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 59, Col: 21} + } + _, 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, 23, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if f.help != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(f.help) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 64, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (f *textField) 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_Var17 := templ.GetChildren(ctx) + if templ_7745c5c3_Var17 == nil { + templ_7745c5c3_Var17 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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 + templ_7745c5c3_Var18 := 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) + var templ_7745c5c3_Var19 = []any{f.inputClasses} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var17.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = parent().Render(templ.WithChildren(ctx, templ_7745c5c3_Var18), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (f *textAreaField) 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_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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 + templ_7745c5c3_Var25 := 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) + var templ_7745c5c3_Var26 = []any{f.inputClasses} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = parent().Render(templ.WithChildren(ctx, templ_7745c5c3_Var25), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (f *checkboxField) 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_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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" + } + var templ_7745c5c3_Var32 = []any{f.classes} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errors := f.Errors(); len(errors) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range errors { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(e.Error()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 160, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if f.help != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(f.help) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 165, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (f *selectField[T]) 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_Var43 := templ.GetChildren(ctx) + if templ_7745c5c3_Var43 == nil { + templ_7745c5c3_Var43 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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 + templ_7745c5c3_Var44 := 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) + var templ_7745c5c3_Var45 = []any{f.inputClasses} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var45...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = parent().Render(templ.WithChildren(ctx, templ_7745c5c3_Var44), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (f *multiSelectField[T]) 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_Var51 := templ.GetChildren(ctx) + if templ_7745c5c3_Var51 == nil { + templ_7745c5c3_Var51 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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() + } + } + var templ_7745c5c3_Var52 = []any{f.classes} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var52...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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) + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 = []any{inputClasses} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var56...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errors := f.Errors(); len(errors) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range errors { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(e.Error()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 268, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if f.help != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(f.help) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/forms/forms.templ`, Line: 273, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (f *passwordField) 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_Var69 := templ.GetChildren(ctx) + if templ_7745c5c3_Var69 == nil { + templ_7745c5c3_Var69 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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 + templ_7745c5c3_Var70 := 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) + var templ_7745c5c3_Var71 = []any{f.inputClasses} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var71...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = parent().Render(templ.WithChildren(ctx, templ_7745c5c3_Var70), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (f *timeTokenField) 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_Var75 := templ.GetChildren(ctx) + if templ_7745c5c3_Var75 == nil { + templ_7745c5c3_Var75 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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 + templ_7745c5c3_Var76 := 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) + var templ_7745c5c3_Var77 = []any{f.inputClasses} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = parent().Render(templ.WithChildren(ctx, templ_7745c5c3_Var76), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func FiledDropField(field forms.Field, options ...FieldOption) 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_Var88 := templ.GetChildren(ctx) + if templ_7745c5c3_Var88 == nil { + templ_7745c5c3_Var88 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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 + templ_7745c5c3_Var89 := 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, 115, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = parent().Render(templ.WithChildren(ctx, templ_7745c5c3_Var89), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/components/icon.go b/components/icon.go new file mode 100644 index 00000000..748606e8 --- /dev/null +++ b/components/icon.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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) + } +} diff --git a/components/icon.templ b/components/icon.templ new file mode 100644 index 00000000..85301bcd --- /dev/null +++ b/components/icon.templ @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// SPDX-License-Identifier: AGPL-3.0-only + +package components + +templ (i *icon) component(name string) { + + + + + +} + +// Spinner renders a spinner icon. +templ Spinner() { + @Icon("o-spinner", WithIconSvgClass("animate-spin stroke-current")) +} diff --git a/components/icon_templ.go b/components/icon_templ.go new file mode 100644 index 00000000..85f148c1 --- /dev/null +++ b/components/icon_templ.go @@ -0,0 +1,176 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +// SPDX-FileCopyrightText: © 2026 Olivier Meunier + +// + +// 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, "") + 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, "") + 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 diff --git a/components/layout.go b/components/layout.go new file mode 100644 index 00000000..501c9e80 --- /dev/null +++ b/components/layout.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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, + } +} diff --git a/components/layout.templ b/components/layout.templ new file mode 100644 index 00000000..a3a93543 --- /dev/null +++ b/components/layout.templ @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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) { + + + + + { title } - Readeck + + + + + + + + + + + + + @c.Head + + + + { children... } + + +} + +templ (c *Layout) Menu(menuBtn templ.Component) { + +} + +templ (c *Layout) menuButton(name string) { + +} + +templ (c *Layout) mainMenuItem(name, path, icon string, current bool) { +
  • + + @Icon(icon) + { name } + +
  • +} + +// QuickAccessMenu renders an a11y menu. +templ QuickAccessMenu(items [][2]string) { + if len(items) > 0 { + + } +} + +// SideMenuTitle renders a title used in side menus. +templ SideMenuTitle() { +

    + { children... } +

    +} + +// 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")}, + }) +
    + @c.Menu(c.menuButton(L(ctx).Gettext("Open Menu"))) + + @contentWrapper { + { children... } + } +
    + } +} + +templ (c *Layout) SideMenuWrapper() { +
    + @Flashes() + { children... } +
    +} + +templ (c *Layout) SideMenuStdWrapper() { +
    + @Flashes() + @Breadcrumbs() + { children... } +
    +} diff --git a/components/layout_templ.go b/components/layout_templ.go new file mode 100644 index 00000000..4eb5e0c9 --- /dev/null +++ b/components/layout_templ.go @@ -0,0 +1,890 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +// SPDX-FileCopyrightText: © 2026 Olivier Meunier + +// + +// 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, "") + 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") + 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, "") + 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, "") + 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, "
    ") + 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, "
    ") + 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, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !IsAnonymous(ctx) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
  • ") + 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, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
    ") + 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, "") + 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, "
  • ") + 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, "") + 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, "
  • ") + 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, "") + 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, "

    ") + 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, "

    ") + 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, "
    ") + 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, "") + 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, "
    ") + 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, "
    ") + 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, "
    ") + 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, "
    ") + 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, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/components/list.templ b/components/list.templ new file mode 100644 index 00000000..be907f1c --- /dev/null +++ b/components/list.templ @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// SPDX-License-Identifier: AGPL-3.0-only + +package components + +templ List(class ...string) { +
    + { children... } +
    +} + +templ ListItem(class ...string) { + {{ + if len(class) == 0 { + class = []string{"p-4"} + } + }} +
    + { children... } +
    +} diff --git a/components/list_templ.go b/components/list_templ.go new file mode 100644 index 00000000..0180239c --- /dev/null +++ b/components/list_templ.go @@ -0,0 +1,130 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +// SPDX-FileCopyrightText: © 2026 Olivier Meunier + +// + +// 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, "
    ") + 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, "
    ") + 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, "
    ") + 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, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/components/message.go b/components/message.go new file mode 100644 index 00000000..d0107fe1 --- /dev/null +++ b/components/message.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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) + } +} diff --git a/components/message.templ b/components/message.templ new file mode 100644 index 00000000..ce42ad19 --- /dev/null +++ b/components/message.templ @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// SPDX-License-Identifier: AGPL-3.0-only + +package components + +import ( + "strings" + + "codeberg.org/readeck/readeck/internal/server" +) + +templ (mc *message) component() { +
    + if mc.icon != "" { +
    + @Icon(mc.icon) +
    + } +
    + { children... } +
    + if mc.removable { +
    + +
    + } +
    +} + +// Flashes shows the flash messages extracted from the user's session. +templ Flashes() { + if flashes := server.Flashes(server.GetRequest(ctx)); len(flashes) > 0 { +
    + 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" + } + }} +
    + @msg.component() { + { f.Message } + } +
    + } + } +
    + } +} diff --git a/components/message_templ.go b/components/message_templ.go new file mode 100644 index 00000000..57aaa313 --- /dev/null +++ b/components/message_templ.go @@ -0,0 +1,216 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +// SPDX-FileCopyrightText: © 2026 Olivier Meunier + +// + +// 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, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if mc.icon != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
    ") + 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, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
    ") + 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, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if mc.removable { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
    ") + 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, "
    ") + 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, "
    ") + 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, "") + 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, "") + 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, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/components/pagination.templ b/components/pagination.templ new file mode 100644 index 00000000..939d218b --- /dev/null +++ b/components/pagination.templ @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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 { + + } +} diff --git a/components/pagination_templ.go b/components/pagination_templ.go new file mode 100644 index 00000000..97af5c48 --- /dev/null +++ b/components/pagination_templ.go @@ -0,0 +1,278 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +// SPDX-FileCopyrightText: © 2026 Olivier Meunier + +// + +// 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/components/qrcode.go b/components/qrcode.go new file mode 100644 index 00000000..1b48c799 --- /dev/null +++ b/components/qrcode.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: © 2026 Olivier Meunier +// +// 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() +} diff --git a/go.mod b/go.mod index ab292937..3bc8faa8 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 50a3cfcf..201b1f7a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/profile/preferences/preferences.go b/internal/profile/preferences/preferences.go index af84debf..2940e202 100644 --- a/internal/profile/preferences/preferences.go +++ b/internal/profile/preferences/preferences.go @@ -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 diff --git a/internal/server/security.go b/internal/server/security.go index e2f12d33..516563e8 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -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) diff --git a/internal/server/template.go b/internal/server/template.go index c1c56d3e..c869afe9 100644 --- a/internal/server/template.go +++ b/internal/server/template.go @@ -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, `\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 +}