diff --git a/.air.toml b/.air.toml
index aa66c5e..c2953af 100644
--- a/.air.toml
+++ b/.air.toml
@@ -5,16 +5,16 @@ tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
- cmd = "go build -o ./tmp/main ./cmd/web"
+ cmd = "make build"
delay = 1000
- exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads", "dbs"]
+ exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads", "dbs", "public"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
- include_ext = ["go", "tpl", "tmpl", "html"]
+ include_ext = ["go", "tpl", "tmpl", "html", "css"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
diff --git a/.gitignore b/.gitignore
index 747fcd5..b335d69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
.idea
dbs
uploads
-tmp
\ No newline at end of file
+tmp
+tailwindcss
+daisyui*
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 7d046af..57fc115 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,20 @@
+# The Tailwind CSS CLI package to install (pick the one that matches your OS: https://github.com/tailwindlabs/tailwindcss/releases/latest)
+TAILWIND_PACKAGE = tailwindcss-linux-x64
+
.PHONY: help
help: ## Print make targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+.PHONY: install
+install: ent-install air-install tailwind-install ## Install all dependencies
+
+.PHONY: tailwind-install
+tailwind-install: ## Install the Tailwind CSS CLI
+ curl -sLo tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/latest/download/$(TAILWIND_PACKAGE)
+ chmod +x tailwindcss
+ curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
+ curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
+
.PHONY: ent-install
ent-install: ## Install Ent code-generation module
go get entgo.io/ent/cmd/ent
@@ -39,3 +52,11 @@ test: ## Run all tests
.PHONY: check-updates
check-updates: ## Check for direct dependency updates
go list -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all | grep "\["
+
+.PHONY: css
+css: ## Build and minify Tailwind CSS
+ ./tailwindcss -i tailwind.css -o public/static/main.css -m
+
+.PHONY: build
+build: css ## Build CSS and compile the application binary
+ go build -o ./tmp/main ./cmd/web
\ No newline at end of file
diff --git a/README.md b/README.md
index 6b1b1fb..dc6a837 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
* [Getting started](#getting-started)
* [Dependencies](#dependencies)
* [Getting the code](#getting-the-code)
+ * [Installing tools](#installing-tools)
* [Create an admin account](#create-an-admin-account)
* [Start the application](#start-the-application)
* [Live reloading](#live-reloading)
@@ -65,6 +66,7 @@
* [Header management](#header-management)
* [Conditional and partial rendering](#conditional-and-partial-rendering)
* [CSRF token](#csrf-token)
+ * [CSS](#css)
* [Request](#request)
* [Title and metatags](#title-and-metatags)
* [URL generation](#url-generation)
@@ -77,6 +79,7 @@
* [Inline validation](#inline-validation)
* [CSRF](#csrf)
* [Models](#models)
+ * [Icons](#icons)
* [Node caching](#node-caching)
* [Flash messaging](#flash-messaging)
* [Pager](#pager)
@@ -91,9 +94,11 @@
* [Monitoring tasks and queues](#monitoring-tasks-and-queues)
* [Cron](#cron)
* [Files](#files)
-* [Static files](#static-files)
+ * [Uploads](#uploads)
+ * [Static files](#static-files)
+ * [Cache-buster](#cache-buster)
+ * [Public files](#public-files)
* [Cache control headers](#cache-control-headers)
- * [Cache-buster](#cache-buster)
* [Email](#email)
* [HTTPS](#https)
* [Logging](#logging)
@@ -125,7 +130,7 @@ Go server-side rendered HTML combined with the projects below enable you to crea
- [HTMX](https://htmx.org/): Access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
- [Alpine.js](https://alpinejs.dev/): Rugged, minimal tool for composing behavior directly in your markup. Think of it like jQuery for the modern web. Plop in a script tag and get going.
-- [Bulma](https://bulma.io/): Provides ready-to-use frontend components that you can easily combine to build responsive web interfaces. No JavaScript dependencies.
+- [DaisyUI](https://daisyui.com/): The [Tailwind CSS](https://tailwindcss.com/) plugin you will love! It provides useful component class names to help you write less code and build faster. No JavaScript dependencies.
#### Storage
@@ -174,9 +179,17 @@ git clone git@github.com:mikestefanello/pagoda.git
cd pagoda
```
+### Installing tools
+
+Several optional tools are available to make development easier for you. This includes [Ent](#orm) code-generator, for generating ORM code, [Air](https://github.com/air-verse/air) CLI, to provide [live reloading](#live-reloading), and [Tailwind CSS](https://tailwindcss.com/docs/installation/tailwind-cli) CLI, to generate CSS.
+
+If you wish to use Tailwind (with or without [Daisy UI](https://daisyui.com/)), modify the [Makefile](https://github.com/mikestefanello/pagoda/blob/main/Makefile), and adjust the `TAILWIND_PACKAGE` variable to reference the proper package for your operating system. By default, it's set to work with Linux x64. If you want to use Tailwind but don't want to use the standalone CLI, ie `npm`, modify the `tailwind-install` and `css` _make targets_ based on your preferences.
+
+To easily install all tools, run `make install` from the root of the repo. There are also separate _make targets_ for each tool (run `make help` to list all targets).
+
### Create an admin account
-In order to access the [admin panel](#admin-panel), you must log in with an admin user and in order to create your first admin user account, you must use the command-line. Execute `make admin email=your@email.com` from the root of the codebase, and an admin account will be generated using that email address. The console will print the randomly-generated password for the account.
+To access the [admin panel](#admin-panel), you must log in with an admin user and to create your first admin user account, you must use the command-line. Execute `make admin email=your@email.com` from the root of the codebase, and an admin account will be generated using that email address. The console will print the randomly-generated password for the account.
Once you have one admin account, you can use that account to manage other users and admins from within the UI.
@@ -190,7 +203,9 @@ These settings, and many others, can be changed via the [configuration](#configu
### Live reloading
-Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and runs whenever you save code changes, start by installing [air](https://github.com/air-verse/air) by running `make air-install`, then use `make watch` to start the application with automatic live reloading.
+Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and runs whenever you save code changes, start by installing [Air](https://github.com/air-verse/air), if you haven't already, by running `make air-install` (or `make install` to install all tools), then use `make watch` to start the application with automatic live reloading.
+
+When code changes are detected, `make css` will run to re-compile Tailwind styles automatically before restarting the app. If you choose not to use Tailwind, either modify `make css` to run whatever commands you require, or remove this from the `make build` target.
## Service container
@@ -623,6 +638,14 @@ if htmx.GetRequest(ctx).Target == "search" {
If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `JS()` [component](#components) by leveraging HTMX [events](https://htmx.org/reference/#events).
+### CSS
+
+[DaisyUI](https://daisyui.com/), which is a component library for [Tailwind CSS](https://tailwindcss.com/), was chosen as the default CSS solution. You are not required to use either of these and removing what has been provided should be quite simple. Both of these tools are very mature, have huge communities, and endless resources, making them ideal for rapid, simple frontend development
+
+Review the [installing tools](#installing-tools) section to ensure you have everything installed, and you understand how the `make` commands handle building your CSS styles and how live reloading automatically handles executing the Tailwind CLI. By default, the compiled CSS is written to `public/static/main.css` when `make css` is executed.
+
+Tailwind configuration is stored in [tailwind.css](https://github.com/mikestefanello/pagoda/blob/main/tailwind.css) and is configured to check for classes within `pkg/ui`. Remember, full class names must be present in the Go files in order for Tailwind to find them; you cannot dynamically build classes.
+
### Request
The `Request` type in the `ui` package is a foundational helper that provides useful data from the current request as well as resources and methods that make rendering UI components much easier. Using the `echo.Context`, a `Request` can be generated by executing `ui.NewRequest(ctx)`. As you develop and expand your application, you will likely want to expand this type to include additional data and methods that your frontend requires.
@@ -676,7 +699,40 @@ func ProfileLink(r *ui.Request, userName string, userID int64) gomponents.Node {
The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation).
-Your components can also make using utility-based CSS libraries, such as [Tailwind CSS](https://tailwindcss.com/), much easier by avoiding excessive duplication of classes across elements.
+Your components can also make using utility-based CSS libraries, such as [Tailwind CSS](https://tailwindcss.com/), much easier by avoiding excessive duplication of classes across elements. A number of [DaisyUI](https://daisyui.com/) components are provided.
+
+Here are some examples:
+
+```go
+Badge(ColorSuccess, "Hello")
+```
+
+```go
+Card(CardParams{
+ Title: "Hello world",
+ Body: Group{
+ Span(Text("This is a card.")),
+ },
+ Footer: Group{
+ ButtonLink(ColorNeutral, "https://daisyui.com", "Learn more"),
+ },
+ Color: ColorInfo,
+ Size: SizeMedium,
+})
+```
+
+```go
+Tabs([]Tab{
+ {
+ Title: "Tab 1",
+ Body: "Here is tab 1.",
+ },
+ {
+ Title: "Tab 2",
+ Body: "Check out tab 2.",
+ },
+})
+```
### Layouts
@@ -740,7 +796,7 @@ func (f *Guestbook) Render(r *ui.Request) Node {
Value: f.Message,
}),
ControlGroup(
- FormButton("is-link", "Submit"),
+ FormButton(ColorPrimary, "Submit"),
),
CSRF(r),
)
@@ -834,6 +890,19 @@ The `Request` automatically extracts the CSRF token from the context, but you mu
Models are objects built and provided by your _routes_ that can be rendered by your _ui_. Though not required, they reside in the [models package](https://github.com/mikestefanello/pagoda/tree/main/pkg/ui/models) and each has a `Render()` method, making them easy to render within your [pages](#pages). Please see example routes such as the homepage and search for an example.
+### Icons
+
+A starting SVG icon library is provided in `pkg/ui/icons` with icons from [heroicons](https://heroicons.com/). You are free to use any icons you want and in any manner.
+
+Example:
+```go
+A(
+ Href("/user/123"),
+ icons.UserCircle,
+ Text("Profile")
+)
+```
+
### Node caching
While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponents.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change.
@@ -868,11 +937,11 @@ There are four types of messages, and each can be created as follows:
- Success: `msg.Success(ctx echo.Context, message string)`
- Info: `msg.Info(ctx echo.Context, message string)`
- Warning: `msg.Warning(ctx echo.Context, message string)`
-- Danger: `msg.Danger(ctx echo.Context, message string)`
+- Error: `msg.Error(ctx echo.Context, message string)`
#### Rendering messages
-When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again.
+When a flash message is retrieved from storage to be rendered, it is deleted from storage so that it cannot be rendered again.
A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI.
@@ -1007,36 +1076,44 @@ By default, no cron solution is provided because it's very easy to add yourself
## Files
+Providing a generic approach to files that works for all use-cases is nearly impossible, so the defaults provided here is more of an illustration of what features are available. It's likely you will want to use a CDN and/or cloud storage for all or some of your file management.
+
+### Uploads
+
To handle file management functionality such as file uploads, an abstracted file system interface is provided as a _Service_ on the `Container` powered by [afero](https://github.com/spf13/afero). This allows you to easily change the file system backend (ie, local, GCS, SFTP, in-memory) without having to change any of the application code other than the initialization on the `Container`. By default, the local OS is used with a directory specified in the application configuration (which defaults to `uploads`). When running tests, an in-memory file system backend is automatically used.
A simple file upload form example is provided at `/files` which also dynamically lists all files previously uploaded. No database entities or entries are created or provided for files and uploaded files are not available to be served. You will have to implement whatever functionality your application needs.
-## Static files
+### Static files
-Static files are currently configured in the router (`pkg/handler/router.go`) to be served from the `static` directory. If you wish to change the directory, alter the constant `config.StaticDir`. The URL prefix for static files is `/files` which is controlled via the `config.StaticPrefix` constant.
+Static files, which are intended to be CSS, JS, UI images, etc, are currently configured in the router (`pkg/handler/router.go`) to be served from the `public/static` directory and are available via the URL prefix `/static`. By default, `make css` will write your css file here.
-### Cache control headers
+#### Cache-buster
-Static files are grouped separately so you can apply middleware only to them. Included is a custom middleware to set cache control headers (`middleware.CacheControl`) which has been added to the static files router group.
+While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. To do this, a function, `StaticFile()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string is generated using the timestamp of when the app started and persists until the application restarts.
-The cache max-life is controlled by the configuration at `Config.Cache.Expiration.StaticFile` and defaults to 6 months.
-
-### Cache-buster
-
-While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function, `File()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string is generated using the timestamp of when the app started and persists until the application restarts.
-
-For example, to render a file located in `static/picture.png`, you would use:
+For example, to render a file located in `public/static/picture.png`, you would use:
```go
-return Img(Src(ui.File("picture.png")))
+return Img(Src(ui.StaticFile("picture.png")))
```
Which would result in:
```html
-
+
```
Where `1741053493` is the cache-buster.
+### Public files
+
+An additional option for public files that do not fit in to the previously mentioned categories is provided as an example. Defined in the router, files located in `public/files` are available via the URL prefix `/files`. `ui.PublicFile()` can be used to generate URLs for files within this directory.
+
+### Cache control headers
+
+Static and public files are grouped separately so you can apply middleware only to them. Included is a custom middleware to set cache control headers (`middleware.CacheControl`) which has been added to the static files router group.
+
+The cache max-life is controlled by the configuration at `Config.Cache.Expiration.PublicFile` and defaults to 6 months.
+
## Email
An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is that there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications.
@@ -1137,7 +1214,7 @@ Thank you to all the following amazing projects for making this possible.
- [air](https://github.com/air-verse/air)
- [alpinejs](https://github.com/alpinejs/alpine)
- [backlite](https://github.com/mikestefanello/backlite)
-- [bulma](https://github.com/jgthms/bulma)
+- [daisyui](https://github.com/saadeghi/daisyui)
- [echo](https://github.com/labstack/echo)
- [ent](https://github.com/ent/ent)
- [go](https://go.dev/)
@@ -1149,6 +1226,7 @@ Thank you to all the following amazing projects for making this possible.
- [otter](https://github.com/maypok86/otter)
- [sessions](https://github.com/gorilla/sessions)
- [sqlite](https://sqlite.org/)
+- [tailwindcss](https://github.com/tailwindlabs/tailwindcss)
- [testify](https://github.com/stretchr/testify)
- [validator](https://github.com/go-playground/validator)
- [viper](https://github.com/spf13/viper)
\ No newline at end of file
diff --git a/config/config.go b/config/config.go
index d62183c..c74607d 100644
--- a/config/config.go
+++ b/config/config.go
@@ -8,14 +8,6 @@ import (
"github.com/spf13/viper"
)
-const (
- // StaticDir stores the name of the directory that will serve static files.
- StaticDir = "static"
-
- // StaticPrefix stores the URL prefix used when serving static files.
- StaticPrefix = "files"
-)
-
type environment string
const (
@@ -25,8 +17,8 @@ const (
// EnvTest represents the test environment.
EnvTest environment = "test"
- // EnvDevelop represents the development environment.
- EnvDevelop environment = "dev"
+ // EnvDevelopment represents the development environment.
+ EnvDevelopment environment = "dev"
// EnvStaging represents the staging environment.
EnvStaging environment = "staging"
@@ -92,7 +84,7 @@ type (
CacheConfig struct {
Capacity int
Expiration struct {
- StaticFile time.Duration
+ PublicFile time.Duration
}
}
diff --git a/config/config.yaml b/config/config.yaml
index f87c556..06ce5e4 100644
--- a/config/config.yaml
+++ b/config/config.yaml
@@ -27,7 +27,7 @@ app:
cache:
capacity: 100000
expiration:
- staticFile: "4380h"
+ publicFile: "4380h"
database:
driver: "sqlite3"
diff --git a/pkg/handlers/admin.go b/pkg/handlers/admin.go
index e87e883..fd92683 100644
--- a/pkg/handlers/admin.go
+++ b/pkg/handlers/admin.go
@@ -128,7 +128,7 @@ func (h *Admin) EntityAddSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
err := h.admin.Create(ctx, n.Name)
if err != nil {
- msg.Danger(ctx, err.Error())
+ msg.Error(ctx, err.Error())
return h.EntityAdd(n)(ctx)
}
@@ -154,7 +154,7 @@ func (h *Admin) EntityEditSubmit(n *gen.Type) echo.HandlerFunc {
id := ctx.Get(context.AdminEntityIDKey).(int)
err := h.admin.Update(ctx, n.Name, id)
if err != nil {
- msg.Danger(ctx, err.Error())
+ msg.Error(ctx, err.Error())
return h.EntityEdit(n)(ctx)
}
@@ -178,7 +178,7 @@ func (h *Admin) EntityDeleteSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
id := ctx.Get(context.AdminEntityIDKey).(int)
if err := h.admin.Delete(ctx, n.Name, id); err != nil {
- msg.Danger(ctx, err.Error())
+ msg.Error(ctx, err.Error())
return h.EntityDelete(n)(ctx)
}
diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go
index 52fca58..3bde378 100644
--- a/pkg/handlers/auth.go
+++ b/pkg/handlers/auth.go
@@ -134,7 +134,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
authFailed := func() error {
input.SetFieldError("Email", "")
input.SetFieldError("Password", "")
- msg.Danger(ctx, "Invalid credentials. Please try again.")
+ msg.Error(ctx, "Invalid credentials. Please try again.")
return h.LoginPage(ctx)
}
@@ -185,7 +185,7 @@ func (h *Auth) Logout(ctx echo.Context) error {
if err := h.auth.Logout(ctx); err == nil {
msg.Success(ctx, "You have been logged out successfully.")
} else {
- msg.Danger(ctx, "An error occurred. Please try again.")
+ msg.Error(ctx, "An error occurred. Please try again.")
}
return redirect.New(ctx).
Route(routenames.Home).
diff --git a/pkg/handlers/files.go b/pkg/handlers/files.go
index 5c77e84..d2d5cd3 100644
--- a/pkg/handlers/files.go
+++ b/pkg/handlers/files.go
@@ -54,7 +54,7 @@ func (h *Files) Page(ctx echo.Context) error {
func (h *Files) Submit(ctx echo.Context) error {
file, err := ctx.FormFile("file")
if err != nil {
- msg.Danger(ctx, "A file is required.")
+ msg.Error(ctx, "A file is required.")
return h.Page(ctx)
}
diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go
index b5937df..832d70f 100644
--- a/pkg/handlers/pages.go
+++ b/pkg/handlers/pages.go
@@ -42,6 +42,7 @@ func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post {
for k := range posts {
posts[k] = models.Post{
+ ID: k + 1,
Title: fmt.Sprintf("Post example #%d", k+1),
Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1),
}
diff --git a/pkg/handlers/pages_test.go b/pkg/handlers/pages_test.go
index 0cd8ff3..5013793 100644
--- a/pkg/handlers/pages_test.go
+++ b/pkg/handlers/pages_test.go
@@ -18,7 +18,7 @@ func TestPages__About(t *testing.T) {
toDoc()
// Goquery is an excellent package to use for testing HTML markup
- h1 := doc.Find("h1.title")
+ h1 := doc.Find("h1")
assert.Len(t, h1.Nodes, 1)
assert.Equal(t, "About", h1.Text())
}
diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go
index 21ecdbd..a58f802 100644
--- a/pkg/handlers/router.go
+++ b/pkg/handlers/router.go
@@ -2,31 +2,52 @@ package handlers
import (
"net/http"
+ "strings"
"github.com/gorilla/sessions"
+ "github.com/labstack/echo/v4"
echomw "github.com/labstack/echo/v4/middleware"
- "github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/services"
+ files "github.com/mikestefanello/pagoda/public"
)
// BuildRouter builds the router.
func BuildRouter(c *services.Container) error {
- // Static files with proper cache control.
- // ui.File() should be used in ui components to append a cache key to the URL in order to break cache
- // after each server restart.
- c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)).
- Static(config.StaticPrefix, config.StaticDir)
+ // Force HTTPS, if enabled.
+ if c.Config.HTTP.TLS.Enabled {
+ c.Web.Use(echomw.HTTPSRedirect())
+ }
+
+ // Serve public files with cache control.
+ c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.PublicFile)).
+ Static("files", "public/files")
+
+ // Serve static files.
+ // ui.StaticFile() should be used in ui components to append a cache key to the URL to break cache
+ // after each server reboot.
+ c.Web.Group(
+ "",
+ echomw.GzipWithConfig(echomw.GzipConfig{
+ Skipper: func(c echo.Context) bool {
+ for _, ext := range []string{
+ ".js",
+ ".css",
+ } {
+ if strings.HasSuffix(c.Request().URL.Path, ext) {
+ return false
+ }
+ }
+ return true
+ },
+ }),
+ middleware.CacheControl(c.Config.Cache.Expiration.PublicFile),
+ ).StaticFS("static", echo.MustSubFS(files.Static, "static"))
// Non-static file route group.
g := c.Web.Group("")
- // Force HTTPS, if enabled.
- if c.Config.HTTP.TLS.Enabled {
- g.Use(echomw.HTTPSRedirect())
- }
-
// Create a cookie store for session data.
cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
cookieStore.Options.HttpOnly = true
diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go
index 724ecdb..86dcbe6 100644
--- a/pkg/msg/msg.go
+++ b/pkg/msg/msg.go
@@ -20,8 +20,8 @@ const (
// TypeWarning represents a warning message type.
TypeWarning Type = "warning"
- // TypeDanger represents a danger message type.
- TypeDanger Type = "danger"
+ // TypeError represents an error message type.
+ TypeError Type = "error"
)
const (
@@ -44,9 +44,9 @@ func Warning(ctx echo.Context, message string) {
Set(ctx, TypeWarning, message)
}
-// Danger sets a danger flash message.
-func Danger(ctx echo.Context, message string) {
- Set(ctx, TypeDanger, message)
+// Error sets an error flash message.
+func Error(ctx echo.Context, message string) {
+ Set(ctx, TypeError, message)
}
// Set adds a new flash message of a given type into the session storage.
@@ -61,19 +61,19 @@ func Set(ctx echo.Context, typ Type, message string) {
// Get gets flash messages of a given type from the session storage.
// Errors will be logged and not returned.
func Get(ctx echo.Context, typ Type) []string {
- var msgs []string
-
if sess, err := getSession(ctx); err == nil {
if flash := sess.Flashes(string(typ)); len(flash) > 0 {
save(ctx, sess)
+ msgs := make([]string, 0, len(flash))
for _, m := range flash {
msgs = append(msgs, m.(string))
}
+ return msgs
}
}
- return msgs
+ return nil
}
// getSession gets the flash message session.
diff --git a/pkg/msg/msg_test.go b/pkg/msg/msg_test.go
index 38da0ce..8131fdb 100644
--- a/pkg/msg/msg_test.go
+++ b/pkg/msg/msg_test.go
@@ -33,8 +33,8 @@ func TestMsg(t *testing.T) {
assertMsg(TypeInfo, text)
text = "ccc"
- Danger(ctx, text)
- assertMsg(TypeDanger, text)
+ Error(ctx, text)
+ assertMsg(TypeError, text)
text = "ddd"
Warning(ctx, text)
diff --git a/pkg/services/container.go b/pkg/services/container.go
index 0c0c631..c5f8f60 100644
--- a/pkg/services/container.go
+++ b/pkg/services/container.go
@@ -221,7 +221,7 @@ func (c *Container) initMail() {
// initTasks initializes the task client.
func (c *Container) initTasks() {
var err error
- // You could use a separate database for tasks, if you'd like. but using one
+ // You could use a separate database for tasks, if you'd like, but using one
// makes transaction support easier.
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
DB: c.Database,
diff --git a/pkg/ui/components/alerts.go b/pkg/ui/components/alerts.go
index 3e2f821..ae381be 100644
--- a/pkg/ui/components/alerts.go
+++ b/pkg/ui/components/alerts.go
@@ -3,62 +3,64 @@ package components
import (
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/ui"
+ "github.com/mikestefanello/pagoda/pkg/ui/icons"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func FlashMessages(r *ui.Request) Node {
var g Group
+ var color Color
+
for _, typ := range []msg.Type{
msg.TypeSuccess,
msg.TypeInfo,
msg.TypeWarning,
- msg.TypeDanger,
+ msg.TypeError,
} {
for _, str := range msg.Get(r.Context, typ) {
- g = append(g, Notification(typ, str))
+ switch typ {
+ case msg.TypeSuccess:
+ color = ColorSuccess
+ case msg.TypeInfo:
+ color = ColorInfo
+ case msg.TypeWarning:
+ color = ColorWarning
+ case msg.TypeError:
+ color = ColorError
+ }
+
+ g = append(g, Alert(color, str))
}
}
return g
}
-func Notification(typ msg.Type, text string) Node {
+func Alert(color Color, text string) Node {
var class string
- switch typ {
- case msg.TypeSuccess:
- class = "success"
- case msg.TypeInfo:
- class = "info"
- case msg.TypeWarning:
- class = "warning"
- case msg.TypeDanger:
- class = "danger"
+ switch color {
+ case ColorSuccess:
+ class = "alert-success"
+ case ColorInfo:
+ class = "alert-info"
+ case ColorWarning:
+ class = "alert-warning"
+ case ColorError:
+ class = "alert-error"
}
return Div(
- Class("notification is-"+class),
+ Role("alert"),
+ Class("alert mb-2 "+class),
Attr("x-data", "{show: true}"),
Attr("x-show", "show"),
- Button(
- Class("delete"),
+ Span(
Attr("@click", "show = false"),
+ Class("cursor-pointer"),
+ icons.XCircle(),
),
- Text(text),
- )
-}
-
-func Message(class, header string, body Node) Node {
- return Article(
- Class("message "+class),
- If(header != "", Div(
- Class("message-header"),
- P(Text(header)),
- )),
- Div(
- Class("message-body"),
- body,
- ),
+ Span(Text(text)),
)
}
diff --git a/pkg/ui/components/data.go b/pkg/ui/components/data.go
new file mode 100644
index 0000000..95b4d25
--- /dev/null
+++ b/pkg/ui/components/data.go
@@ -0,0 +1,121 @@
+package components
+
+import (
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/html"
+)
+
+type (
+ CardParams struct {
+ Title string
+ Body Group
+ Footer Group
+ Color Color
+ Size Size
+ }
+
+ Stat struct {
+ Title string
+ Value string
+ Description string
+ Icon Node
+ }
+)
+
+func Badge(color Color, text string) Node {
+ var class string
+
+ switch color {
+ case ColorSuccess:
+ class = "badge-success"
+ case ColorWarning:
+ class = "badge-warning"
+ }
+
+ return Div(
+ Class("badge "+class),
+ Text(text),
+ )
+}
+
+func Divider(text string) Node {
+ return Div(
+ Class("divider"),
+ Text(text),
+ )
+}
+
+func Card(params CardParams) Node {
+ var colorClass, sizeClass string
+
+ switch params.Color {
+ case ColorSuccess:
+ colorClass = "bg-success text-success-content"
+ case ColorPrimary:
+ colorClass = "bg-primary text-primary-content"
+ case ColorAccent:
+ colorClass = "bg-accent text-accent-content"
+ case ColorNeutral:
+ colorClass = "bg-neutral text-neutral-content"
+ case ColorWarning:
+ colorClass = "bg-warning text-warning-content"
+ case ColorInfo:
+ colorClass = "bg-info text-info-content"
+ }
+
+ switch params.Size {
+ case SizeSmall:
+ sizeClass = "card-sm"
+ case SizeMedium:
+ sizeClass = "card-md"
+ case SizeLarge:
+ sizeClass = "card-lg"
+ }
+
+ return Div(
+ Class("cards mb-2 "+colorClass+" "+sizeClass),
+ Div(
+ Class("card-body"),
+ If(len(params.Title) > 0, Span(
+ Class("card-title"),
+ Text(params.Title),
+ )),
+ params.Body,
+ If(params.Footer != nil, Div(
+ Class("card-actions justify-end"),
+ params.Footer,
+ )),
+ ),
+ )
+}
+
+func Stats(stats ...Stat) Node {
+ g := make(Group, 0, len(stats))
+ for _, stat := range stats {
+ g = append(g, Div(
+ Class("stat"),
+ Iff(stat.Icon != nil, func() Node {
+ return Div(
+ Class("stat-figure text-secondary"),
+ stat.Icon,
+ )
+ }),
+ Div(
+ Class("stat-title"),
+ Text(stat.Title),
+ ),
+ Div(
+ Class("stat-value"),
+ Text(stat.Value),
+ ),
+ Div(
+ Class("stat-desc"),
+ Text(stat.Description),
+ ),
+ ))
+ }
+ return Div(
+ Class("stats shadow"),
+ g,
+ )
+}
diff --git a/pkg/ui/components/form.go b/pkg/ui/components/form.go
index 6149583..5faca8e 100644
--- a/pkg/ui/components/form.go
+++ b/pkg/ui/components/form.go
@@ -19,6 +19,12 @@ type (
Help string
}
+ FileFieldParams struct {
+ Name string
+ Label string
+ Help string
+ }
+
OptionsParams struct {
Form form.Form
FormField string
@@ -26,6 +32,7 @@ type (
Label string
Value string
Options []Choice
+ Help string
}
Choice struct {
@@ -52,38 +59,22 @@ type (
)
func ControlGroup(controls ...Node) Node {
- g := make(Group, len(controls))
- for i, control := range controls {
- g[i] = Div(
- Class("control"),
- control,
- )
- }
-
return Div(
- Class("field is-grouped"),
- g,
+ Class("mt-2 flex gap-2"),
+ Group(controls),
)
}
func TextareaField(el TextareaFieldParams) Node {
- return Div(
- Class("field"),
- Label(
- For("name"),
- Class("label"),
- Text(el.Label),
+ return Fieldset(
+ el.Label,
+ Textarea(
+ Class("textarea h-24 w-2/3 "+formFieldStatusClass(el.Form, el.FormField)),
+ ID(el.Name),
+ Name(el.Name),
+ Text(el.Value),
),
- Div(
- Class("control"),
- Textarea(
- ID(el.Name),
- Name(el.Name),
- Class("textarea "+formFieldStatusClass(el.Form, el.FormField)),
- Text(el.Value),
- ),
- ),
- If(el.Help != "", P(Class("help"), Text(el.Help))),
+ Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
@@ -91,25 +82,27 @@ func TextareaField(el TextareaFieldParams) Node {
func Radios(el OptionsParams) Node {
buttons := make(Group, len(el.Options))
for i, opt := range el.Options {
- buttons[i] = Label(
- Class("radio"),
+ id := "radio-" + el.Name + "-" + opt.Value
+ buttons[i] = Div(
+ Class("mb-2"),
Input(
+ ID(id),
Type("radio"),
Name(el.Name),
Value(opt.Value),
+ Class("radio mr-1 "+formFieldStatusClass(el.Form, el.FormField)),
If(el.Value == opt.Value, Checked()),
),
- Text(" "+opt.Label),
+ Label(
+ Text(opt.Label),
+ For(id),
+ ),
)
}
- return Div(
- Class("control field"),
- Label(Class("label"), Text(el.Label)),
- Div(
- Class("radios"),
- buttons,
- ),
+ return Fieldset(
+ el.Label,
+ buttons,
formFieldErrors(el.Form, el.FormField),
)
}
@@ -124,82 +117,77 @@ func SelectList(el OptionsParams) Node {
)
}
- return Div(
- Class("control field"),
- Label(Class("label"), Text(el.Label)),
- Div(
- Class("select"),
- Select(
- Name(el.Name),
- buttons,
- ),
+ return Fieldset(
+ el.Label,
+ Select(
+ Class("select "+formFieldStatusClass(el.Form, el.FormField)),
+ buttons,
),
+ Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
func Checkbox(el CheckboxParams) Node {
return Div(
- Class("field"),
- Div(
- Class("control"),
- Label(
+ Label(
+ Class("label"),
+ Input(
Class("checkbox"),
- Input(
- Type("checkbox"),
- Name(el.Name),
- If(el.Checked, Checked()),
- Value("true"),
- ),
- Text(" "+el.Label),
+ Type("checkbox"),
+ Name(el.Name),
+ If(el.Checked, Checked()),
+ Value("true"),
),
+ Text(" "+el.Label),
),
formFieldErrors(el.Form, el.FormField),
)
}
func InputField(el InputFieldParams) Node {
- return Div(
- Class("field"),
- Label(
- Class("label"),
- For(el.Name),
- Text(el.Label),
+ return Fieldset(
+ el.Label,
+ Input(
+ ID(el.Name),
+ Name(el.Name),
+ Type(el.InputType),
+ Class("input "+formFieldStatusClass(el.Form, el.FormField)),
+ Value(el.Value),
+ If(el.Placeholder != "", Placeholder(el.Placeholder)),
),
- Div(
- Class("control"),
- Input(
- ID(el.Name),
- Name(el.Name),
- Type(el.InputType),
- If(el.Placeholder != "", Placeholder(el.Placeholder)),
- Class("input "+formFieldStatusClass(el.Form, el.FormField)),
- Value(el.Value),
- ),
- ),
- If(el.Help != "", P(Class("help"), Text(el.Help))),
+ Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
-func FileField(name, label string) Node {
- return Div(
- Class("field file"),
- Label(
- Class("file-label"),
- Input(
- Class("file-input"),
- Type("file"),
- Name(name),
- ),
- Span(
- Class("file-cta"),
- Span(
- Class("file-label"),
- Text(label),
- ),
- ),
+func Help(text string) Node {
+ return If(len(text) > 0, Div(
+ Class("label"),
+ Text(text),
+ ))
+}
+
+func Fieldset(label string, els ...Node) Node {
+ return FieldSet(
+ Class("fieldset"),
+ If(len(label) > 0, Legend(
+ Class("fieldset-legend"),
+ Text(label),
+ )),
+ Group(els),
+ )
+}
+
+func FileField(el FileFieldParams) Node {
+ return Fieldset(
+ el.Label,
+ Input(
+ Type("file"),
+ Class("file-input"),
+ Name(el.Name),
),
+ Help(el.Help),
)
}
@@ -210,9 +198,9 @@ func formFieldStatusClass(fm form.Form, formField string) string {
case !fm.IsSubmitted():
return ""
case fm.FieldHasErrors(formField):
- return "is-danger"
+ return "input-error"
default:
- return "is-success"
+ return "input-success"
}
}
@@ -228,8 +216,8 @@ func formFieldErrors(fm form.Form, field string) Node {
g := make(Group, len(errs))
for i, err := range errs {
- g[i] = P(
- Class("help is-danger"),
+ g[i] = Div(
+ Class("text-error"),
Text(err),
)
}
@@ -245,17 +233,35 @@ func CSRF(r *ui.Request) Node {
)
}
-func FormButton(class, label string) Node {
+func FormButton(color Color, label string) Node {
return Button(
- Class("button "+class),
+ Class("btn "+buttonColor(color)),
Text(label),
)
}
-func ButtonLink(href, class, label string) Node {
+func ButtonLink(color Color, href, label string) Node {
return A(
Href(href),
- Class("button "+class),
+ Class("btn "+buttonColor(color)),
Text(label),
)
}
+
+func buttonColor(color Color) string {
+ // Only colors being used are included so unused styles are not compiled.
+ switch color {
+ case ColorPrimary:
+ return "btn-primary"
+ case ColorInfo:
+ return "btn-info"
+ case ColorAccent:
+ return "btn-accent"
+ case ColorError:
+ return "btn-error"
+ case ColorLink:
+ return "btn-link"
+ default:
+ return ""
+ }
+}
diff --git a/pkg/ui/components/head.go b/pkg/ui/components/head.go
index e4016b4..879a986 100644
--- a/pkg/ui/components/head.go
+++ b/pkg/ui/components/head.go
@@ -8,17 +8,18 @@ import (
. "maragu.dev/gomponents/html"
)
-func JS(r *ui.Request) Node {
+func JS() Node {
return Group{
- Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")),
+ Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js"), Defer()),
Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()),
}
}
func CSS() Node {
return Link(
- Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"),
+ Href(ui.StaticFile("main.css")),
Rel("stylesheet"),
+ Type("text/css"),
)
}
@@ -26,7 +27,7 @@ func Metatags(r *ui.Request) Node {
return Group{
Meta(Charset("utf-8")),
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
- Link(Rel("icon"), Href(ui.File("favicon.png"))),
+ Link(Rel("icon"), Href(ui.StaticFile("favicon.png"))),
TitleEl(Text(r.Config.App.Name), If(r.Title != "", Text(" | "+r.Title))),
If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))),
If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))),
diff --git a/pkg/ui/components/nav.go b/pkg/ui/components/nav.go
index 385069f..04fd627 100644
--- a/pkg/ui/components/nav.go
+++ b/pkg/ui/components/nav.go
@@ -1,19 +1,72 @@
package components
import (
+ "fmt"
+
+ "github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
-func MenuLink(r *ui.Request, title, routeName string, routeParams ...any) Node {
+func MenuLink(r *ui.Request, icon Node, title, routeName string, routeParams ...any) Node {
href := r.Path(routeName, routeParams...)
return Li(
+ Class("ml-2"),
A(
Href(href),
+ icon,
Text(title),
- If(href == r.CurrentPath, Class("is-active")),
+ Classes{
+ "menu-active": href == r.CurrentPath,
+ "p-2": true,
+ },
+ ),
+ )
+}
+
+func Pager(page int, path string, hasNext bool, hxTarget string) Node {
+ href := func(page int) string {
+ return fmt.Sprintf("%s?%s=%d",
+ path,
+ pager.QueryKey,
+ page,
+ )
+ }
+
+ return Div(
+ Class("join"),
+ A(
+ Class("join-item btn"),
+ Text("«"),
+ If(page <= 1, Disabled()),
+ Href(href(page-1)),
+ Iff(len(hxTarget) > 0, func() Node {
+ return Group{
+ Attr("hx-get", href(page-1)),
+ Attr("hx-swap", "outerHTML"),
+ Attr("hx-target", hxTarget),
+ }
+ }),
+ ),
+ Button(
+ Class("join-item btn"),
+ Textf("Page %d", page),
+ ),
+ A(
+ Class("join-item btn"),
+ Text("»"),
+ If(!hasNext, Disabled()),
+ Href(href(page+1)),
+ Iff(len(hxTarget) > 0, func() Node {
+ return Group{
+ Attr("hx-get", href(page+1)),
+ Attr("hx-swap", "outerHTML"),
+ Attr("hx-target", hxTarget),
+ }
+ }),
),
)
}
diff --git a/pkg/ui/components/styles.go b/pkg/ui/components/styles.go
new file mode 100644
index 0000000..5a8696e
--- /dev/null
+++ b/pkg/ui/components/styles.go
@@ -0,0 +1,27 @@
+package components
+
+type (
+ Color int
+ Size int
+)
+
+const (
+ ColorNone Color = iota
+ ColorNeutral
+ ColorPrimary
+ ColorSecondary
+ ColorAccent
+ ColorInfo
+ ColorSuccess
+ ColorWarning
+ ColorError
+ ColorLink
+)
+
+const (
+ SizeExtraSmall Size = iota
+ SizeSmall
+ SizeMedium
+ SizeLarge
+ SizeExtraLarge
+)
diff --git a/pkg/ui/components/tabs.go b/pkg/ui/components/tabs.go
index 5c8857b..d1efdc0 100644
--- a/pkg/ui/components/tabs.go
+++ b/pkg/ui/components/tabs.go
@@ -2,6 +2,7 @@ package components
import (
"fmt"
+ "math/rand"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
@@ -11,46 +12,27 @@ type Tab struct {
Title, Body string
}
-func Tabs(heading, description string, items []Tab) Node {
- renderTitles := func() Node {
- g := make(Group, len(items))
- for i, item := range items {
- g[i] = Li(
- Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)),
- Attr("@click", fmt.Sprintf("tab = %d", i)),
- A(Text(item.Title)),
- )
- }
- return g
- }
+func Tabs(tabs []Tab) Node {
+ g := make(Group, 0, len(tabs)*2)
+ id := fmt.Sprintf("tabs-%d", rand.Int())
- renderBodies := func() Node {
- g := make(Group, len(items))
- for i, item := range items {
- g[i] = Div(
- Attr("x-show", fmt.Sprintf("tab == %d", i)),
- P(Raw(" "+item.Body)),
- )
- }
- return g
+ for i, tab := range tabs {
+ g = append(g,
+ Input(
+ Type("radio"),
+ Name(id),
+ Class("tab"),
+ Aria("label", tab.Title),
+ If(i == 0, Checked()),
+ ),
+ Div(
+ Class("tab-content bg-base-100 border-base-300 p-6"),
+ Raw(tab.Body),
+ ))
}
return Div(
- P(
- Class("subtitle mt-5"),
- Text(heading),
- ),
- P(
- Class("mb-4"),
- Text(description),
- ),
- Div(
- Attr("x-data", "{tab: 0}"),
- Div(
- Class("tabs"),
- Ul(renderTitles()),
- ),
- renderBodies(),
- ),
+ Class("tabs tabs-lift"),
+ g,
)
}
diff --git a/pkg/ui/forms/admin_entity.go b/pkg/ui/forms/admin_entity.go
index 0f81a4d..5f05b78 100644
--- a/pkg/ui/forms/admin_entity.go
+++ b/pkg/ui/forms/admin_entity.go
@@ -113,10 +113,10 @@ func AdminEntity(r *ui.Request, schema *load.Schema, values url.Values) Node {
Method(http.MethodPost),
nodes,
ControlGroup(
- FormButton("is-primary", "Submit"),
+ FormButton(ColorPrimary, "Submit"),
ButtonLink(
+ ColorNone,
r.Path(routenames.AdminEntityList(schema.Name)),
- "is-secondary",
"Cancel",
),
),
diff --git a/pkg/ui/forms/admin_entity_delete.go b/pkg/ui/forms/admin_entity_delete.go
index ec52af3..82f9b16 100644
--- a/pkg/ui/forms/admin_entity_delete.go
+++ b/pkg/ui/forms/admin_entity_delete.go
@@ -14,14 +14,13 @@ func AdminEntityDelete(r *ui.Request, entityTypeName string) Node {
return Form(
Method(http.MethodPost),
P(
- Class("subtitle"),
Textf("Are you sure you want to delete this %s?", entityTypeName),
),
ControlGroup(
- FormButton("is-link", "Delete"),
+ FormButton(ColorError, "Delete"),
ButtonLink(
+ ColorNone,
r.Path(routenames.AdminEntityList(entityTypeName)),
- "is-secondary",
"Cancel",
),
),
diff --git a/pkg/ui/forms/cache.go b/pkg/ui/forms/cache.go
index f843213..c2d77dc 100644
--- a/pkg/ui/forms/cache.go
+++ b/pkg/ui/forms/cache.go
@@ -22,21 +22,22 @@ func (f *Cache) Render(r *ui.Request) Node {
ID("cache"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.CacheSubmit)),
- Message(
- "is-info",
- "Test the cache",
- Group{
- P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
- P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
+ Card(CardParams{
+ Title: "Test the cache",
+ Body: Group{
+ Span(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
+ Span(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
},
- ),
+ Color: ColorInfo,
+ Size: SizeMedium,
+ }),
Label(
For("value"),
Class("value"),
Text("Value in cache: "),
),
- If(f.CurrentValue != "", Span(Class("tag is-success"), Text(f.CurrentValue))),
- If(f.CurrentValue == "", I(Text("(empty)"))),
+ If(f.CurrentValue != "", Badge(ColorSuccess, f.CurrentValue)),
+ If(f.CurrentValue == "", Badge(ColorWarning, "empty")),
InputField(InputFieldParams{
Form: f,
FormField: "Value",
@@ -46,7 +47,7 @@ func (f *Cache) Render(r *ui.Request) Node {
Value: f.Value,
}),
ControlGroup(
- FormButton("is-link", "Update cache"),
+ FormButton(ColorPrimary, "Update cache"),
),
CSRF(r),
)
diff --git a/pkg/ui/forms/contact.go b/pkg/ui/forms/contact.go
index 121a6b6..44def5e 100644
--- a/pkg/ui/forms/contact.go
+++ b/pkg/ui/forms/contact.go
@@ -51,7 +51,7 @@ func (f *Contact) Render(r *ui.Request) Node {
Value: f.Message,
}),
ControlGroup(
- FormButton("is-link", "Submit"),
+ FormButton(ColorPrimary, "Submit"),
),
CSRF(r),
)
diff --git a/pkg/ui/forms/file.go b/pkg/ui/forms/file.go
index ad447b7..1b15cc2 100644
--- a/pkg/ui/forms/file.go
+++ b/pkg/ui/forms/file.go
@@ -18,9 +18,13 @@ func (f File) Render(r *ui.Request) Node {
Method(http.MethodPost),
Action(r.Path(routenames.FilesSubmit)),
EncType("multipart/form-data"),
- FileField("file", "Choose a file.. "),
+ FileField(FileFieldParams{
+ Name: "file",
+ Label: "Test file",
+ Help: "Pick a file to upload.",
+ }),
ControlGroup(
- FormButton("is-link", "Upload"),
+ FormButton(ColorPrimary, "Upload"),
),
CSRF(r),
)
diff --git a/pkg/ui/forms/forgot_password.go b/pkg/ui/forms/forgot_password.go
index 511e36b..91f06cc 100644
--- a/pkg/ui/forms/forgot_password.go
+++ b/pkg/ui/forms/forgot_password.go
@@ -31,8 +31,8 @@ func (f *ForgotPassword) Render(r *ui.Request) Node {
Value: f.Email,
}),
ControlGroup(
- FormButton("is-primary", "Reset password"),
- ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
+ FormButton(ColorPrimary, "Reset password"),
+ ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
),
CSRF(r),
)
diff --git a/pkg/ui/forms/login.go b/pkg/ui/forms/login.go
index b3024f7..c5921c0 100644
--- a/pkg/ui/forms/login.go
+++ b/pkg/ui/forms/login.go
@@ -40,10 +40,25 @@ func (f *Login) Render(r *ui.Request) Node {
Label: "Password",
Placeholder: "******",
}),
+ Div(
+ Class("text-right text-primary mt-2"),
+ A(
+ Href(r.Path(routenames.ForgotPassword)),
+ Text("Forgot password?"),
+ ),
+ ),
ControlGroup(
- FormButton("is-link", "Login"),
- ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
+ FormButton(ColorPrimary, "Login"),
+ ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
),
CSRF(r),
+ Div(
+ Class("text-center text-base-content/50 mt-4"),
+ Text("Don't have an account? "),
+ A(
+ Href(r.Path(routenames.Register)),
+ Text("Register"),
+ ),
+ ),
)
}
diff --git a/pkg/ui/forms/register.go b/pkg/ui/forms/register.go
index 2d58325..473c8f2 100644
--- a/pkg/ui/forms/register.go
+++ b/pkg/ui/forms/register.go
@@ -51,16 +51,24 @@ func (f *Register) Render(r *ui.Request) Node {
}),
InputField(InputFieldParams{
Form: f,
- FormField: "PasswordConfirm",
+ FormField: "ConfirmPassword",
Name: "password-confirm",
InputType: "password",
Label: "Confirm password",
Placeholder: "******",
}),
ControlGroup(
- FormButton("is-primary", "Register"),
- ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
+ FormButton(ColorPrimary, "Register"),
+ ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
),
CSRF(r),
+ Div(
+ Class("text-center text-base-content/50 mt-4"),
+ Text("Already have an account? "),
+ A(
+ Href(r.Path(routenames.Login)),
+ Text("Login"),
+ ),
+ ),
)
}
diff --git a/pkg/ui/forms/reset_password.go b/pkg/ui/forms/reset_password.go
index 03abcad..2fa2390 100644
--- a/pkg/ui/forms/reset_password.go
+++ b/pkg/ui/forms/reset_password.go
@@ -39,7 +39,7 @@ func (f *ResetPassword) Render(r *ui.Request) Node {
Placeholder: "******",
}),
ControlGroup(
- FormButton("is-primary", "Update password"),
+ FormButton(ColorPrimary, "Update password"),
),
CSRF(r),
)
diff --git a/pkg/ui/forms/task.go b/pkg/ui/forms/task.go
index 2c4c2b7..ade3694 100644
--- a/pkg/ui/forms/task.go
+++ b/pkg/ui/forms/task.go
@@ -42,7 +42,7 @@ func (f *Task) Render(r *ui.Request) Node {
Help: "The message the task will output to the log",
}),
ControlGroup(
- FormButton("is-link", "Add task to queue"),
+ FormButton(ColorPrimary, "Add task to queue"),
),
CSRF(r),
)
diff --git a/pkg/ui/icons/icons.go b/pkg/ui/icons/icons.go
new file mode 100644
index 0000000..c5ddf78
--- /dev/null
+++ b/pkg/ui/icons/icons.go
@@ -0,0 +1,208 @@
+package icons
+
+import (
+ "fmt"
+
+ "github.com/mikestefanello/pagoda/pkg/ui/cache"
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/html"
+)
+
+func CircleStack() Node {
+ return icon("CircleStack",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"),
+ ),
+ )
+}
+
+func Eyes() Node {
+ return icon("Eyes",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"),
+ ),
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
+ ),
+ )
+}
+
+func UserCircle() Node {
+ return icon("UserCircle",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
+ ),
+ )
+}
+
+func Globe() Node {
+ return icon("Globe",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"),
+ ),
+ )
+}
+
+func Home() Node {
+ return icon("Home",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"),
+ ),
+ )
+}
+
+func Info() Node {
+ return icon("Info",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"),
+ ),
+ )
+}
+
+func Mail() Node {
+ return icon("Mail",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"),
+ ),
+ )
+}
+
+func Archive() Node {
+ return icon("Archive",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z"),
+ ),
+ )
+}
+
+func PencilSquare() Node {
+ return icon("PencilSquare",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"),
+ ),
+ )
+}
+
+func Document() Node {
+ return icon("Document",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"),
+ ),
+ )
+}
+
+func Exit() Node {
+ return icon("Exit",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15"),
+ ),
+ )
+}
+
+func Enter() Node {
+ return icon("Enter",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75"),
+ ),
+ )
+}
+
+func UserPlus() Node {
+ return icon("UserPlus",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"),
+ ),
+ )
+}
+
+func QuestionCircle() Node {
+ return icon("QuestionCircle",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"),
+ ),
+ )
+}
+
+func XCircle() Node {
+ return icon("XCircle",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"),
+ ),
+ )
+}
+
+func MagnifyingGlass() Node {
+ return icon("MagnifyingGlass",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"),
+ ),
+ )
+}
+
+func LockClosed() Node {
+ return icon("LockClosed",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"),
+ ),
+ )
+}
+
+func Star() Node {
+ return icon("Star",
+ El("path",
+ Attr("stroke-linecap", "round"),
+ Attr("stroke-linejoin", "round"),
+ Attr("d", "M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"),
+ ),
+ )
+}
+
+func icon(id string, els ...Node) Node {
+ return cache.SetIfNotExists(fmt.Sprintf("icon.%s", id), func() Node {
+ return SVG(
+ Attr("xmlns", "http://www.w3.org/2000/svg"),
+ Attr("fill", "none"),
+ Attr("viewBox", "0 0 24 24"),
+ Attr("stroke-width", "1.5"),
+ Attr("stroke", "currentColor"),
+ Class("w-5 h-5"),
+ Group(els),
+ )
+ })
+}
diff --git a/pkg/ui/layouts/auth.go b/pkg/ui/layouts/auth.go
index 8f7e7c5..f8491e7 100644
--- a/pkg/ui/layouts/auth.go
+++ b/pkg/ui/layouts/auth.go
@@ -1,9 +1,7 @@
package layouts
import (
- "github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
- "github.com/mikestefanello/pagoda/pkg/ui/cache"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
@@ -13,31 +11,24 @@ func Auth(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
- Data("theme", "light"),
+ Data("theme", "dark"),
Head(
Metatags(r),
CSS(),
- JS(r),
+ JS(),
),
Body(
- Section(
- Class("hero is-fullheight"),
+ Div(
+ Class("hero flex items-center justify-center min-h-screen"),
Div(
- Class("hero-body"),
+ Class("flex-col hero-content"),
Div(
- Class("container"),
+ Class("card shadow-md bg-base-200 w-96"),
Div(
- Class("columns is-centered"),
- Div(
- Class("column is-half"),
- If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
- Div(
- Class("notification"),
- FlashMessages(r),
- content,
- authNavBar(r),
- ),
- ),
+ Class("card-body"),
+ If(len(r.Title) > 0, H1(Class("text-2xl font-bold"), Text(r.Title))),
+ FlashMessages(r),
+ content,
),
),
),
@@ -47,20 +38,3 @@ func Auth(r *ui.Request, content Node) Node {
),
)
}
-
-func authNavBar(r *ui.Request) Node {
- return cache.SetIfNotExists("authNavBar", func() Node {
- return Nav(
- Class("navbar"),
- Div(
- Class("navbar-menu"),
- Div(
- Class("navbar-start"),
- A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")),
- A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")),
- A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")),
- ),
- ),
- )
- })
-}
diff --git a/pkg/ui/layouts/primary.go b/pkg/ui/layouts/primary.go
index a4d78a2..30de8c3 100644
--- a/pkg/ui/layouts/primary.go
+++ b/pkg/ui/layouts/primary.go
@@ -6,6 +6,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/cache"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
+ "github.com/mikestefanello/pagoda/pkg/ui/icons"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
@@ -14,118 +15,97 @@ func Primary(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
- Data("theme", "light"),
+ Data("theme", "dark"),
Head(
Metatags(r),
CSS(),
- JS(r),
+ JS(),
),
Body(
- headerNavBar(r),
Div(
- Class("container mt-5"),
+ Class("drawer lg:drawer-open"),
+ Input(
+ ID("sidebar"),
+ Type("checkbox"),
+ Class("drawer-toggle"),
+ ),
Div(
- Class("columns"),
- Div(
- Class("column is-2"),
- sidebarMenu(r),
- ),
- Div(
- Class("column is-10"),
- Div(
- Class("box"),
- If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
- FlashMessages(r),
- content,
- ),
+ Class("drawer-content flex flex-col p-7 prose-base"),
+ If(len(r.Title) > 0, H1(Text(r.Title))),
+ FlashMessages(r),
+ content,
+ Label(
+ For("sidebar"),
+ Class("btn btn-primary drawer-button lg:hidden"),
+ Text("Open drawer"),
),
),
+ sidebarMenu(r),
),
+ searchModal(r),
HtmxListeners(r),
),
),
)
}
-func headerNavBar(r *ui.Request) Node {
- return cache.SetIfNotExists("layout.headerNavBar", func() Node {
- return Nav(
- Class("navbar is-dark"),
- Div(
- Class("container"),
- Div(
- Class("navbar-brand"),
- HxBoost(),
- A(
- Href(r.Path(routenames.Home)),
- Class("navbar-item"),
- Text("Pagoda"),
- ),
- ),
- Div(
- ID("navbarMenu"),
- Class("navbar-menu"),
- Div(
- Class("navbar-end"),
- search(r),
- ),
+func search() Node {
+ return cache.SetIfNotExists("layout.search", func() Node {
+ return Div(
+ Class("ml-2"),
+ Attr("x-data", ""),
+ Label(
+ Class("input"),
+ icons.MagnifyingGlass(),
+ Input(
+ Type("search"),
+ Class("grow"),
+ Placeholder("Search"),
+ Attr("@click", "search_modal.showModal();"),
),
),
+
)
})
}
-func search(r *ui.Request) Node {
- return cache.SetIfNotExists("layout.search", func() Node {
- return Div(
- Class("search mr-2 mt-1"),
- Attr("x-data", "{modal:false}"),
- Input(
- Class("input"),
- Type("search"),
- Placeholder("Search..."),
- Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"),
- ),
+func searchModal(r *ui.Request) Node {
+ return cache.SetIfNotExists("layout.searchModal", func() Node {
+ return Dialog(
+ ID("search_modal"),
+ Class("modal"),
Div(
- Class("modal"),
- Attr(":class", "modal ? 'is-active' : ''"),
- Attr("x-show", "modal == true"),
- Div(
- Class("modal-background"),
- ),
- Div(
- Class("modal-content"),
- Attr("@click.outside", "modal = false;"),
- Div(
- Class("box"),
- H2(
- Class("subtitle"),
- Text("Search"),
- ),
- P(
- Class("control"),
- Input(
- Attr("hx-get", r.Path(routenames.Search)),
- Attr("hx-trigger", "keyup changed delay:500ms"),
- Attr("hx-target", "#results"),
- Name("query"),
- Class("input"),
- Type("search"),
- Placeholder("Search..."),
- Attr("x-ref", "input"),
- ),
- ),
- Div(
- Class("block"),
- ),
- Div(
- ID("results"),
- ),
+ Class("modal-box"),
+ Form(
+ Method("dialog"),
+ Button(
+ Class("btn btn-sm btn-circle btn-ghost absolute right-2 top-2"),
+ Text("✕"),
),
),
+ H3(
+ Class("text-lg font-bold mb-2"),
+ Text("Search"),
+ ),
+ Input(
+ Attr("hx-get", r.Path(routenames.Search)),
+ Attr("hx-trigger", "keyup changed delay:500ms"),
+ Attr("hx-target", "#results"),
+ Name("query"),
+ Class("input w-full"),
+ Type("search"),
+ Placeholder("Search..."),
+ ),
+ Ul(
+ ID("results"),
+ Class("list"),
+ ),
+ ),
+ Form(
+ Method("dialog"),
+ Class("modal-backdrop"),
Button(
- Class("modal-close is-large"),
- Aria("label", "close"),
+ Text("close"),
),
),
)
@@ -133,66 +113,67 @@ func search(r *ui.Request) Node {
}
func sidebarMenu(r *ui.Request) Node {
+ header := func(text string) Node {
+ return Li(
+ Class("menu-title mt-3 uppercase"),
+ Span(Text(text)),
+ )
+ }
+
adminSubMenu := func() Node {
entityTypeNames := admin.GetEntityTypeNames()
entityTypeLinks := make(Group, len(entityTypeNames))
for _, n := range entityTypeNames {
- entityTypeLinks = append(entityTypeLinks, MenuLink(r, n, routenames.AdminEntityList(n)))
+ entityTypeLinks = append(entityTypeLinks, MenuLink(r, icons.PencilSquare(), n, routenames.AdminEntityList(n)))
}
return Group{
- P(
- Class("menu-label"),
- Text("Entities"),
- ),
- Ul(
- Class("menu-list"),
- entityTypeLinks,
- ),
- P(
- Class("menu-label"),
- Text("Monitoring"),
- ),
- Ul(
- Class("menu-list"),
- Li(
- A(
- Href(r.Path(routenames.AdminTasks)),
- Text("Tasks"),
- Target("_blank"),
- ),
+ header("Entities"),
+ entityTypeLinks,
+ header("Monitoring"),
+ Li(
+ A(
+ icons.CircleStack(),
+ Href(r.Path(routenames.AdminTasks)),
+ Text("Tasks"),
+ Target("_blank"),
),
),
}
}
- return Aside(
- Class("menu"),
- HxBoost(),
- P(
- Class("menu-label"),
- Text("General"),
+ return Div(
+ Class("drawer-side"),
+ Label(
+ For("sidebar"),
+ Aria("label", "close sidebar"),
+ Class("drawer-overlay"),
),
- Ul(
- Class("menu-list"),
- MenuLink(r, "Dashboard", routenames.Home),
- MenuLink(r, "About", routenames.About),
- MenuLink(r, "Contact", routenames.Contact),
- MenuLink(r, "Cache", routenames.Cache),
- MenuLink(r, "Task", routenames.Task),
- MenuLink(r, "Files", routenames.Files),
+ Div(
+ Class("menu bg-base-200 text-base-content min-h-full w-80 p-4"),
+ Div(
+ Class("w-2/3 mx-auto mt-3 mb-10"),
+ Img(
+ Src(ui.StaticFile("logo.png")),
+ ),
+ ),
+ search(),
+ Ul(
+ HxBoost(),
+ header("General"),
+ MenuLink(r, icons.Home(), "Dashboard", routenames.Home),
+ MenuLink(r, icons.Info(), "About", routenames.About),
+ MenuLink(r, icons.Mail(), "Contact", routenames.Contact),
+ MenuLink(r, icons.Archive(), "Cache", routenames.Cache),
+ MenuLink(r, icons.CircleStack(), "Task", routenames.Task),
+ MenuLink(r, icons.Document(), "Files", routenames.Files),
+ header("Account"),
+ If(r.IsAuth, MenuLink(r, icons.Exit(), "Logout", routenames.Logout)),
+ If(!r.IsAuth, MenuLink(r, icons.Enter(), "Login", routenames.Login)),
+ If(!r.IsAuth, MenuLink(r, icons.UserPlus(), "Register", routenames.Register)),
+ If(!r.IsAuth, MenuLink(r, icons.QuestionCircle(), "Forgot password", routenames.ForgotPasswordSubmit)),
+ Iff(r.IsAdmin, adminSubMenu),
+ ),
),
- P(
- Class("menu-label"),
- Text("Account"),
- ),
- Ul(
- Class("menu-list"),
- If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)),
- If(!r.IsAuth, MenuLink(r, "Login", routenames.Login)),
- If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)),
- If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)),
- ),
- Iff(r.IsAdmin, adminSubMenu),
)
}
diff --git a/pkg/ui/models/post.go b/pkg/ui/models/post.go
index b66ef67..67e0b2b 100644
--- a/pkg/ui/models/post.go
+++ b/pkg/ui/models/post.go
@@ -5,6 +5,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/ui"
+ . "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
@@ -16,6 +17,7 @@ type (
}
Post struct {
+ ID int
Title, Body string
}
)
@@ -28,57 +30,37 @@ func (p *Posts) Render(path string) Node {
return Div(
ID("posts"),
- g,
- Div(
- Class("field is-grouped is-grouped-centered"),
- If(!p.Pager.IsBeginning(), P(
- Class("control"),
- Button(
- Class("button is-primary"),
- Attr("hx-swap", "outerHTML"),
- Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page-1)),
- Attr("hx-target", "#posts"),
- Text("Previous page"),
- ),
- )),
- If(!p.Pager.IsEnd(), P(
- Class("control"),
- Button(
- Class("button is-primary"),
- Attr("hx-swap", "outerHTML"),
- Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page+1)),
- Attr("hx-target", "#posts"),
- Text("Next page"),
- ),
- )),
+ Ul(
+ Class("list bg-base-100 rounded-box shadow-md not-prose"),
+ g,
),
+ Div(Class("mb-4")),
+ Pager(p.Pager.Page, path, !p.Pager.IsEnd(), "#posts"),
)
}
func (p *Post) Render() Node {
- return Article(
- Class("media"),
- Figure(
- Class("media-left"),
- P(
- Class("image is-64x64"),
- Img(
- Src(ui.File("gopher.png")),
- Alt("Gopher"),
- ),
+ return Li(
+ Class("list-row"),
+ Div(
+ Class("text-4xl font-thin opacity-30 tabular-nums"),
+ Text(fmt.Sprintf("%02d", p.ID)),
+ ),
+ Div(
+ Img(
+ Class("size-10 rounded-box"),
+ Src(ui.StaticFile("gopher.png")),
+ Alt("Gopher"),
),
),
Div(
- Class("media-content"),
+ Class("list-col-grow"),
Div(
- Class("content"),
- P(
- Strong(
- Text(p.Title),
- ),
- Br(),
- Text(p.Body),
- ),
+ Text(p.Title),
+ ),
+ Div(
+ Class("text-xs font-semibold opacity-60"),
+ Text(p.Body),
),
),
)
diff --git a/pkg/ui/models/search_result.go b/pkg/ui/models/search_result.go
index f1a07af..0a831b4 100644
--- a/pkg/ui/models/search_result.go
+++ b/pkg/ui/models/search_result.go
@@ -11,9 +11,11 @@ type SearchResult struct {
}
func (s *SearchResult) Render() Node {
- return A(
- Class("panel-block"),
- Href(s.URL),
- Text(s.Title),
+ return Li(
+ Class("list-row"),
+ A(
+ Href(s.URL),
+ Text(s.Title),
+ ),
)
}
diff --git a/pkg/ui/pages/about.go b/pkg/ui/pages/about.go
index 6518423..eab53aa 100644
--- a/pkg/ui/pages/about.go
+++ b/pkg/ui/pages/about.go
@@ -15,12 +15,12 @@ func About(ctx echo.Context) error {
r.Title = "About"
r.Metatags.Description = "Learn a little about what's included in Pagoda."
- // The tabs are static so we can render and cache them.
+ // The tabs are static, so we can render and cache them.
tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
return Group{
+ H2(Text("Frontend")),
+ P(Text("The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.")),
Tabs(
- "Frontend",
- "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.",
[]Tab{
{
Title: "HTMX",
@@ -31,15 +31,14 @@ func About(ctx echo.Context) error {
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev to learn more.",
},
{
- Title: "Bulma",
- Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit bulma.io to learn more.",
+ Title: "DaisyUI",
+ Body: "DaisyUI is the Tailwind CSS plugin you will love! It provides useful component class names to help you write less code and build faster. No JavaScript requirements. Visit daisyui.com to learn more.",
},
},
),
- Div(Class("mb-4")),
+ H2(Text("Backend")),
+ P(Text("The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.")),
Tabs(
- "Backend",
- "The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.",
[]Tab{
{
Title: "Echo",
diff --git a/pkg/ui/pages/admin_entity.go b/pkg/ui/pages/admin_entity.go
index 44ccae4..2cd61ab 100644
--- a/pkg/ui/pages/admin_entity.go
+++ b/pkg/ui/pages/admin_entity.go
@@ -7,14 +7,12 @@ import (
"entgo.io/ent/entc/load"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent/admin"
- "github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
- . "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
@@ -51,12 +49,12 @@ func AdminEntityList(
r.Title = entityTypeName
genHeader := func() Node {
- g := make(Group, 0, len(entityList.Columns)+3)
+ g := make(Group, 0, len(entityList.Columns)+2)
g = append(g, Th(Text("ID")))
for _, h := range entityList.Columns {
g = append(g, Th(Text(h)))
}
- g = append(g, Th(), Th())
+ g = append(g, Th())
return g
}
@@ -69,14 +67,14 @@ func AdminEntityList(
g = append(g,
Td(
ButtonLink(
+ ColorInfo,
r.Path(routenames.AdminEntityEdit(entityTypeName), row.ID),
- "is-link",
"Edit",
),
- ),
- Td(
- ButtonLink(r.Path(routenames.AdminEntityDelete(entityTypeName), row.ID),
- "is-danger",
+ Span(Class("mr-2")),
+ ButtonLink(
+ ColorError,
+ r.Path(routenames.AdminEntityDelete(entityTypeName), row.ID),
"Delete",
),
),
@@ -92,45 +90,27 @@ func AdminEntityList(
return g
}
- pagedHref := func(page int) string {
- return fmt.Sprintf("%s?%s=%d",
- r.Path(routenames.AdminEntityList(entityTypeName)),
- pager.QueryKey,
- page,
- )
- }
-
return r.Render(layouts.Primary, Group{
- ButtonLink(
- r.Path(routenames.AdminEntityAdd(entityTypeName)),
- "is-primary",
- fmt.Sprintf("Add %s", entityTypeName),
+ Div(
+ Class("form-control mb-2"),
+ ButtonLink(
+ ColorAccent,
+ r.Path(routenames.AdminEntityAdd(entityTypeName)),
+ fmt.Sprintf("Add %s", entityTypeName),
+ ),
),
Table(
- Class("table"),
+ Class("table table-zebra mb-2"),
THead(
Tr(genHeader()),
),
TBody(genRows()),
),
- Nav(
- Class("pagination"),
- A(
- Classes{
- "pagination-previous": true,
- "is-disabled": entityList.Page == 1,
- },
- If(entityList.Page != 1, Href(pagedHref(entityList.Page-1))),
- Text("Previous page"),
- ),
- A(
- Classes{
- "pagination-previous": true,
- "is-disabled": !entityList.HasNextPage,
- },
- If(entityList.HasNextPage, Href(pagedHref(entityList.Page+1))),
- Text("Next page"),
- ),
+ Pager(
+ entityList.Page,
+ r.Path(routenames.AdminEntityAdd(entityTypeName)),
+ entityList.HasNextPage,
+ "",
),
})
}
diff --git a/pkg/ui/pages/contact.go b/pkg/ui/pages/contact.go
index 915a087..66bb37a 100644
--- a/pkg/ui/pages/contact.go
+++ b/pkg/ui/pages/contact.go
@@ -3,7 +3,7 @@ package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
- "github.com/mikestefanello/pagoda/pkg/ui/components"
+ . "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
@@ -17,21 +17,25 @@ func ContactUs(ctx echo.Context, form *forms.Contact) error {
g := Group{
Iff(r.Htmx.Target != "contact", func() Node {
- return components.Message(
- "is-link",
- "",
- Group{
- P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
- P(Text("Only the form below will update async upon submission.")),
+ return Card(CardParams{
+ Title: "Card component",
+ Body: Group{
+ Span(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
+ Span(Text("Only the form below will update async upon submission.")),
},
- )
+ Color: ColorWarning,
+ Size: SizeMedium,
+ })
}),
Iff(form.IsDone(), func() Node {
- return components.Message(
- "is-large is-success",
- "Thank you!",
- Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."),
- )
+ return Card(CardParams{
+ Title: "Thank you!",
+ Body: Group{
+ Span(Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled.")),
+ },
+ Color: ColorSuccess,
+ Size: SizeLarge,
+ })
}),
Iff(!form.IsDone(), func() Node {
return form.Render(r)
diff --git a/pkg/ui/pages/file.go b/pkg/ui/pages/file.go
index 506946d..e7029b2 100644
--- a/pkg/ui/pages/file.go
+++ b/pkg/ui/pages/file.go
@@ -21,19 +21,19 @@ func UploadFile(ctx echo.Context, files []*models.File) error {
}
n := Group{
- Message(
- "is-link",
- "",
- P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
- ),
- Hr(),
+ P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
+ Divider(""),
forms.File{}.Render(r),
- Hr(),
+ Divider(""),
H3(
Class("title"),
Text("Uploaded files"),
),
- Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))),
+ Card(CardParams{
+ Body: Group{Text("Below are all files in the configured upload directory.")},
+ Color: ColorWarning,
+ Size: SizeMedium,
+ }),
Table(
Class("table"),
THead(
diff --git a/pkg/ui/pages/home.go b/pkg/ui/pages/home.go
index aea8433..22a7fd3 100644
--- a/pkg/ui/pages/home.go
+++ b/pkg/ui/pages/home.go
@@ -1,16 +1,14 @@
package pages
import (
- "fmt"
-
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
+ "github.com/mikestefanello/pagoda/pkg/ui/icons"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
"github.com/mikestefanello/pagoda/pkg/ui/models"
. "maragu.dev/gomponents"
- . "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
@@ -38,69 +36,71 @@ func Home(ctx echo.Context, posts *models.Posts) error {
headerMsg := func() Node {
return Group{
- Section(
- Class("hero is-info welcome is-small mb-3"),
- Div(
- Class("hero-body"),
- Div(
- Class("container"),
- H1(
- Class("title"),
- Iff(r.IsAuth, func() Node {
- return Text(fmt.Sprintf("Hello, %s", r.AuthUser.Name))
- }),
- If(!r.IsAuth, Text("Hello")),
- ),
- H2(
- Class("subtitle"),
- If(!r.IsAuth, Text("Please login in to your account.")),
- If(r.IsAuth, Text("Welcome back!")),
- ),
- ),
- ),
+ Stats(
+ Stat{
+ Title: "User name",
+ Value: func() string {
+ if r.IsAuth {
+ return r.AuthUser.Name
+ }
+ return "(not logged in)"
+ }(),
+ Description: "The logged in user's name",
+ Icon: icons.UserCircle(),
+ },
+ Stat{
+ Title: "Admin status",
+ Value: func() string {
+ if r.IsAdmin {
+ return "Administrator"
+ }
+ return "Non-administrator"
+ }(),
+ Description: "Use `make admin` to create an admin account",
+ Icon: icons.LockClosed(),
+ },
+ Stat{
+ Title: "GitHub Stars",
+ Value: "2,500+",
+ Description: "Star if you like Pagoda",
+ Icon: icons.Star(),
+ },
),
- Section(
- Class("hero is-light is-small mb-5"),
- Div(
- Class("hero-body"),
- Div(
- Class("container"),
- B(Text("Admin status: ")),
- Span(
- Classes{
- "tag": true,
- "is-success": r.IsAdmin,
- "is-danger": !r.IsAdmin,
- },
- Text(fmt.Sprint(r.IsAdmin)),
- ),
- If(!r.IsAdmin, Span(
- Class("is-size-7 ml-3"),
- Raw(`(click here for instructions to make an admin account)`),
- )),
- ),
- ),
- ),
- H2(Class("title"), Text("Recent posts")),
- H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")),
+ H2(Text("Recent posts")),
+ Span(Text("Below is an example of both paging and AJAX fetching using HTMX")),
}
}
- filesMsg := func() Node {
- return Message(
- "is-small is-warning mt-5",
- "Serving files",
- Group{
- Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
- Text("Static files also contain cache-control headers which are configured via middleware."),
- },
+ cards := func() Node {
+ return Div(
+ Class("flex w-full gap-2 mt-5"),
+ Card(CardParams{
+ Title: "Serving files",
+ Body: Group{
+ Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
+ Text("Static files also contain cache-control headers which are configured via middleware."),
+ },
+ Color: ColorWarning,
+ Size: SizeSmall,
+ }),
+ Card(CardParams{
+ Title: "Documentation",
+ Body: Group{
+ Text("Have you read through the entire documentation? If not, you may be missing functionality or have questions. "),
+ },
+ Footer: Group{
+ ButtonLink(ColorNeutral, "https://github.com/mikestefanello/pagoda?tab=readme-ov-file#table-of-contents", "Learn more"),
+ },
+ Color: ColorNeutral,
+ Size: SizeSmall,
+ }),
)
}
g := Group{
Iff(r.Htmx.Target != "posts", headerMsg),
posts.Render(r.Path(routenames.Home)),
- Iff(r.Htmx.Target != "posts", filesMsg),
+ Iff(r.Htmx.Target != "posts", cards),
}
return r.Render(layouts.Primary, g)
diff --git a/pkg/ui/pages/task.go b/pkg/ui/pages/task.go
index 94bcb95..cbe5cff 100644
--- a/pkg/ui/pages/task.go
+++ b/pkg/ui/pages/task.go
@@ -3,7 +3,7 @@ package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
- "github.com/mikestefanello/pagoda/pkg/ui/components"
+ . "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
@@ -17,23 +17,23 @@ func AddTask(ctx echo.Context, form *forms.Task) error {
g := Group{
Iff(r.Htmx.Target != "task", func() Node {
- return components.Message(
- "is-link",
- "",
- Group{
- P(Raw("Submitting this form will create an ExampleTask in the task queue. After the specified delay, the message will be logged by the queue processor.")),
- P(Raw("See pkg/tasks and the README for more information.")),
- })
+ return Group{
+ P(Raw("Submitting this form will create an ExampleTask in the task queue. After the specified delay, the message will be logged by the queue processor.")),
+ P(Raw("See pkg/tasks and the README for more information.")),
+ }
}),
form.Render(r),
Iff(r.Htmx.Target != "task", func() Node {
- return components.Message(
- "is-warning",
- "",
- Group{
- If(!r.IsAdmin, P(Text("Log in as an admin in order to access the task and queue monitoring UI."))),
- If(r.IsAdmin, P(Text("View all queued tasks by clicking on the Tasks link in the sidebar."))),
- })
+ var text string
+ if r.IsAdmin {
+ text = "View all queued tasks by clicking on the Tasks link in the sidebar."
+ } else {
+ text = "Log in as an admin in order to access the task and queue monitoring UI."
+ }
+ return Group{
+ Div(Class("mt-5")),
+ Alert(ColorWarning, text),
+ }
}),
}
diff --git a/pkg/ui/request.go b/pkg/ui/request.go
index c5d5c8f..db62898 100644
--- a/pkg/ui/request.go
+++ b/pkg/ui/request.go
@@ -57,7 +57,7 @@ type (
}
// LayoutFunc is a callback function intended to render your page node within a given layout.
- // This is handled as a callback in order to automatically support HTMX requests so that you can respond
+ // This is handled as a callback to automatically support HTMX requests so that you can respond
// with only the page content and not the entire layout.
// See Request.Render().
LayoutFunc func(*Request, gomponents.Node) gomponents.Node
diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go
index e982a9b..fed8e07 100644
--- a/pkg/ui/ui.go
+++ b/pkg/ui/ui.go
@@ -3,8 +3,6 @@ package ui
import (
"fmt"
"time"
-
- "github.com/mikestefanello/pagoda/config"
)
var (
@@ -12,7 +10,12 @@ var (
cacheBuster = fmt.Sprint(time.Now().Unix())
)
-// File generates a relative URL to a static file including a cache-buster query parameter.
-func File(filepath string) string {
- return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster)
+// PublicFile generates a relative URL to a public file.
+func PublicFile(filepath string) string {
+ return fmt.Sprintf("/%s/%s", "files", filepath)
+}
+
+// StaticFile generates a relative URL to a static file including a cache-buster query parameter.
+func StaticFile(filepath string) string {
+ return fmt.Sprintf("/%s/%s?v=%s", "static", filepath, cacheBuster)
}
diff --git a/pkg/ui/ui_test.go b/pkg/ui/ui_test.go
index bffeead..8cecce8 100644
--- a/pkg/ui/ui_test.go
+++ b/pkg/ui/ui_test.go
@@ -4,13 +4,19 @@ import (
"fmt"
"testing"
- "github.com/mikestefanello/pagoda/config"
"github.com/stretchr/testify/assert"
)
-func TestFile(t *testing.T) {
+func TestPublicFile(t *testing.T) {
path := "abc.txt"
- got := File(path)
- expected := fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, path, cacheBuster)
+ got := PublicFile(path)
+ expected := fmt.Sprintf("/%s/%s", "files", path)
+ assert.Equal(t, expected, got)
+}
+
+func TestStaticFile(t *testing.T) {
+ path := "abc.txt"
+ got := StaticFile(path)
+ expected := fmt.Sprintf("/%s/%s?v=%s", "static", path, cacheBuster)
assert.Equal(t, expected, got)
}
diff --git a/public/files/.gitkeep b/public/files/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/public/fs.go b/public/fs.go
new file mode 100644
index 0000000..63d9025
--- /dev/null
+++ b/public/fs.go
@@ -0,0 +1,8 @@
+package files
+
+import (
+ "embed"
+)
+
+//go:embed static
+var Static embed.FS
diff --git a/static/favicon.png b/public/static/favicon.png
similarity index 100%
rename from static/favicon.png
rename to public/static/favicon.png
diff --git a/static/gopher.png b/public/static/gopher.png
similarity index 100%
rename from static/gopher.png
rename to public/static/gopher.png
diff --git a/public/static/logo.png b/public/static/logo.png
new file mode 100644
index 0000000..77d08bf
Binary files /dev/null and b/public/static/logo.png differ
diff --git a/public/static/main.css b/public/static/main.css
new file mode 100644
index 0000000..cec6d1e
--- /dev/null
+++ b/public/static/main.css
@@ -0,0 +1,2 @@
+/*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */
+@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-thin:100;--font-weight-semibold:600;--font-weight-bold:700;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}@property --radialprogress{syntax: ""; inherits: true; initial-value: 0%;}:root{scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch,currentColor 35%,#0000)#0000}}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E")}:root:has(.modal-open,.modal[open],.modal:target,.modal-toggle:checked,.drawer:not([class*=drawer-open])>.drawer-toggle:checked){overflow:hidden}:where(:root:has(.modal-open,.modal[open],.modal:target,.modal-toggle:checked,.drawer:not(.drawer-open)>.drawer-toggle:checked)){scrollbar-gutter:stable;background-image:linear-gradient(var(--color-base-100),var(--color-base-100));--root-bg:var(--color-base-100)}@supports (color:color-mix(in lab, red, red)){:where(:root:has(.modal-open,.modal[open],.modal:target,.modal-toggle:checked,.drawer:not(.drawer-open)>.drawer-toggle:checked)){--root-bg:color-mix(in srgb,var(--color-base-100),oklch(0% 0 0) 40%)}}:where(.modal[open],.modal-open,.modal-toggle:checked+.modal):not(.modal-start,.modal-end){scrollbar-gutter:stable}:root,[data-theme]{background-color:var(--root-bg,var(--color-base-100));color:var(--color-base-content)}:root:has(input.theme-controller[value=custom-theme]:checked),[data-theme=custom-theme]{color-scheme:normal}}@layer components;@layer utilities{.modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:translate .3s ease-out,visibility .3s allow-discrete,background-color .3s ease-out,opacity .1s ease-out;overscroll-behavior:contain;z-index:999;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:hidden}.modal::backdrop{display:none}.modal.modal-open,.modal[open],.modal:target{pointer-events:auto;visibility:visible;opacity:1;background-color:oklch(0% 0 0/.4)}:is(.modal.modal-open,.modal[open],.modal:target) .modal-box{opacity:1;translate:0;scale:1}@starting-style{.modal.modal-open,.modal[open],.modal:target{visibility:hidden;opacity:0}}.drawer-side{pointer-events:none;visibility:hidden;z-index:1;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete,visibility .3s ease-out .1s allow-discrete;inset-inline-start:0;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0;overflow:hidden}.drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.drawer-side>*{grid-row-start:1;grid-column-start:1}.drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out;translate:-100%}[dir=rtl] :is(.drawer-side>:not(.drawer-overlay)){translate:100%}.drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}.drawer-toggle:checked~.drawer-side{pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto}.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.tab{cursor:pointer;appearance:none;text-align:center;webkit-user-select:none;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative}@media (hover:hover){.tab:hover{color:var(--color-base-content)}}.tab{--tab-p:1rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));order:var(--tab-order);height:var(--tab-height);border-color:#0000;padding-inline-start:var(--tab-p);padding-inline-end:var(--tab-p);font-size:.875rem}.tab:is(input[type=radio]){min-width:fit-content}.tab:is(input[type=radio]):after{content:attr(aria-label)}.tab:is(label){position:relative}.tab:is(label) input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}:is(.tab:checked,.tab:is(label:has(:checked)),.tab:is(.tab-active,[aria-selected=true]))+.tab-content{height:calc(100% - var(--tab-height) + var(--border));display:block}.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true]){color:color-mix(in oklab,var(--color-base-content)50%,transparent)}}.tab:not(input):empty{cursor:default;flex-grow:1}.tab:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.tab:focus{outline-offset:2px;outline:2px solid #0000}}.tab:focus-visible,.tab:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}.tab[disabled]{pointer-events:none;opacity:.4}.menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.menu :where(li ul){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative}.menu :where(li ul):before{background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";inset-inline-start:0;position:absolute;top:.75rem;bottom:.75rem}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;text-wrap:balance;-webkit-user-select:none;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.menu :where(li>details>summary){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li>details>summary){outline-offset:2px;outline:2px solid #0000}}.menu :where(li>details>summary)::-webkit-details-marker{display:none}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.menu :where(li>details[open]>summary):after,.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{background-color:color-mix(in oklab,var(--color-base-content)10%,transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{color:var(--color-base-content);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){background-color:color-mix(in oklab,var(--color-base-content)10%,transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative}.menu :where(li) .badge{justify-self:flex-end}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{outline-offset:2px;outline:2px solid #0000}}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto,calc(var(--noise)*100%);background-image:none,var(--fx-noise)}:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):not(:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):active){box-shadow:0 2px calc(var(--depth)*3px)-2px var(--menu-active-bg)}.menu :where(li).menu-disabled{pointer-events:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li).menu-disabled{color:color-mix(in oklab,var(--color-base-content)20%,transparent)}}.menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.menu .dropdown-content{margin-top:.5rem;padding:.5rem}.menu .dropdown-content:before{display:none}:where(.btn){width:unset}.btn{cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;webkit-user-select:none;-webkit-user-select:none;user-select:none;padding-inline:var(--btn-p);color:var(--btn-fg);--tw-prose-links:var(--btn-fg);height:var(--size);font-size:var(--fontsize,.875rem);outline-color:var(--btn-color,var(--color-base-content));background-color:var(--btn-bg);background-size:auto,calc(var(--noise)*100%);background-image:none,var(--btn-noise);border-width:var(--border);border-style:solid;border-color:var(--btn-border);text-shadow:0 .5px oklch(100% 0 0/calc(var(--depth)*.15));touch-action:manipulation;box-shadow:0 .5px 0 .5px oklch(100% 0 0/calc(var(--depth)*6%))inset,var(--btn-shadow);--size:calc(var(--size-field,.25rem)*10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-p:1rem;--btn-border:var(--btn-bg);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;font-weight:600;transition-property:color,background-color,border-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab,var(--btn-bg),#000 calc(var(--depth)*5%))}}.btn{--btn-shadow:0 3px 2px -2px var(--btn-bg),0 4px 3px -2px var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0 3px 2px -2px color-mix(in oklab,var(--btn-bg)calc(var(--depth)*30%),#0000),0 4px 3px -2px color-mix(in oklab,var(--btn-bg)calc(var(--depth)*30%),#0000)}}.btn{--btn-noise:var(--fx-noise)}.prose .btn{text-decoration-line:none}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab,var(--btn-color,var(--color-base-200)),#000 7%)}}}.btn:focus-visible{isolation:isolate;outline-width:2px;outline-style:solid}.btn:active:not(.btn-active){--btn-bg:var(--btn-color,var(--color-base-200));translate:0 .5px}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-bg:color-mix(in oklab,var(--btn-color,var(--color-base-200)),#000 5%)}}.btn:active:not(.btn-active){--btn-border:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-border:color-mix(in oklab,var(--btn-color,var(--color-base-200)),#000 7%)}}.btn:active:not(.btn-active){--btn-shadow:0 0 0 0 oklch(0% 0 0/0),0 0 0 0 oklch(0% 0 0/0)}.btn:is(:disabled,[disabled],.btn-disabled):not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:is(:disabled,[disabled],.btn-disabled):not(.btn-link,.btn-ghost){background-color:color-mix(in oklab,var(--color-base-content)10%,transparent)}}.btn:is(:disabled,[disabled],.btn-disabled):not(.btn-link,.btn-ghost){box-shadow:none}.btn:is(:disabled,[disabled],.btn-disabled){pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:is(:disabled,[disabled],.btn-disabled){--btn-fg:color-mix(in oklch,var(--color-base-content)20%,#0000)}}@media (hover:hover){.btn:is(:disabled,[disabled],.btn-disabled):hover{pointer-events:none;background-color:var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.btn:is(:disabled,[disabled],.btn-disabled):hover{background-color:color-mix(in oklab,var(--color-neutral)20%,transparent)}}.btn:is(:disabled,[disabled],.btn-disabled):hover{--btn-border:#0000;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:is(:disabled,[disabled],.btn-disabled):hover{--btn-fg:color-mix(in oklch,var(--color-base-content)20%,#0000)}}}.btn:is(input[type=checkbox],input[type=radio]){appearance:none}.btn:is(input[type=checkbox],input[type=radio]):after{content:attr(aria-label)}.btn:where(input:checked:not(.filter .btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);isolation:isolate}.tabs-lift{--tabs-height:auto;--tabs-direction:row}.tabs-lift>.tab{--tab-border:0 0 var(--border)0;--tab-radius-ss:min(var(--radius-field),var(--tab-radius-min));--tab-radius-se:min(var(--radius-field),var(--tab-radius-min));--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border)var(--tab-p)0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color)#0000;--tab-corner-width:calc(100% + min(var(--radius-field),var(--tab-radius-min))*2);--tab-corner-height:min(var(--radius-field),var(--tab-radius-min));--tab-corner-position:top left,top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es)}.tabs-lift>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked)){--tab-border:var(--border)var(--border)0 var(--border);--tab-border-colors:var(--tab-border-color)var(--tab-border-color)#0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border))var(--border)calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--tab-grad:calc(69% - var(--border));--radius-start:radial-gradient(circle at top left,#0000 var(--tab-grad),var(--tab-border-color)calc(var(--tab-grad) + .25px),var(--tab-border-color)calc(var(--tab-grad) + var(--border)),var(--tab-bg)calc(var(--tab-grad) + var(--border) + .25px));--radius-end:radial-gradient(circle at top right,#0000 var(--tab-grad),var(--tab-border-color)calc(var(--tab-grad) + .25px),var(--tab-border-color)calc(var(--tab-grad) + var(--border)),var(--tab-bg)calc(var(--tab-grad) + var(--border) + .25px));background-color:var(--tab-bg)}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start),var(--radius-end);background-size:min(var(--radius-field),var(--tab-radius-min))min(var(--radius-field),var(--tab-radius-min));inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):first-child:before{--radius-start:none}[dir=rtl] :is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):first-child:before{transform:rotateY(180deg)}:is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):last-child:before{--radius-end:none}[dir=rtl] :is(.tabs-lift>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled,[disabled]),.tabs-lift>.tab:is(input:checked,label:has(:checked))):last-child:before{transform:rotateY(180deg)}.tabs-lift:has(.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true]){--tab-border-colors:var(--tab-border-color)var(--tab-border-color)#0000 var(--tab-border-color)}.tabs-lift .tab-content{--tabcontent-margin:calc(-1*var(--border))0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.list{flex-direction:column;font-size:.875rem;display:flex}.list :where(.list-row){--list-grid-cols:minmax(0,auto)1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}.list :where(.list-row):has(.list-col-grow:first-child){--list-grid-cols:1fr}.list :where(.list-row):has(.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0,auto)1fr}.list :where(.list-row):has(.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0,auto)minmax(0,auto)1fr}.list :where(.list-row):has(.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0,auto)minmax(0,auto)minmax(0,auto)1fr}.list :where(.list-row):has(.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0,auto)minmax(0,auto)minmax(0,auto)minmax(0,auto)1fr}.list :where(.list-row):has(.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0,auto)minmax(0,auto)minmax(0,auto)minmax(0,auto)minmax(0,auto)1fr}.list :where(.list-row) :not(.list-col-wrap){grid-row-start:1}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border)solid;inset-inline:var(--radius-box);border-color:var(--color-base-content);position:absolute;bottom:0}@supports (color:color-mix(in lab, red, red)){:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{border-color:color-mix(in oklab,var(--color-base-content)5%,transparent)}}.input{cursor:text;border:var(--border)solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;width:clamp(3rem,20rem,100%);height:var(--size);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;font-size:.875rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.input{box-shadow:0 1px color-mix(in oklab,var(--input-color)calc(var(--depth)*10%),#0000)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset}}.input{--size:calc(var(--size-field,.25rem)*10);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab,var(--color-base-content)20%,#0000)}}.input:where(input){display:inline-flex}.input :where(input){appearance:none;background-color:#0000;border:none;width:100%;height:100%;display:inline-flex}.input :where(input):focus,.input :where(input):focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.input :where(input):focus,.input :where(input):focus-within{outline-offset:2px;outline:2px solid #0000}}.input :where(input[type=url]),.input :where(input[type=email]){direction:ltr}.input :where(input[type=date]){display:inline-block}.input:focus,.input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{box-shadow:0 1px color-mix(in oklab,var(--input-color)calc(var(--depth)*10%),#0000)}}.input:focus,.input:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate;z-index:1}.input:has(>input[disabled]),.input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]){color:color-mix(in oklab,var(--color-base-content)40%,transparent)}}:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab,var(--color-base-content)20%,transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]){box-shadow:none}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input::-webkit-calendar-picker-indicator{position:absolute;inset-inline-end:.75em}.table{border-radius:var(--radius-box);text-align:left;width:100%;font-size:.875rem;position:relative}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.table :where(thead,tfoot){white-space:nowrap;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead,tfoot){color:color-mix(in oklab,var(--color-base-content)60%,transparent)}}.table :where(thead,tfoot){font-size:.875rem;font-weight:600}.table :where(tfoot){border-top:var(--border)solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(tfoot){border-top:var(--border)solid color-mix(in oklch,var(--color-base-content)5%,#0000)}}.table :where(.table-pin-rows thead tr){z-index:1;background-color:var(--color-base-100);position:sticky;top:0}.table :where(.table-pin-rows tfoot tr){z-index:1;background-color:var(--color-base-100);position:sticky;bottom:0}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.table :where(thead tr,tbody tr:not(:last-child)){border-bottom:var(--border)solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead tr,tbody tr:not(:last-child)){border-bottom:var(--border)solid color-mix(in oklch,var(--color-base-content)5%,#0000)}}.range{appearance:none;webkit-appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem)*6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:currentColor}@supports (color:color-mix(in lab, red, red)){.range{--range-bg:color-mix(in oklab,currentColor 10%,#0000)}}.range{cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p),var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden}[dir=rtl] .range{--range-dir:-1}.range:focus{outline:none}.range:focus-visible{outline-offset:2px;outline:2px solid}.range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size)*.5)}@media (forced-colors:active){.range::-webkit-slider-runnable-track{border:1px solid}.range::-moz-range-track{border:1px solid}}.range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p),var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p)solid;appearance:none;webkit-appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0/calc(var(--depth)*.1))inset,0 8px 0 -4px oklch(100% 0 0/calc(var(--depth)*.1))inset,0 1px currentColor,0 0 0 2rem var(--range-thumb)inset,calc((var(--range-dir,1)*-100rem) - (var(--range-dir,1)*var(--range-thumb-size)/2))0 0 calc(100rem*var(--range-fill));background-color:currentColor;position:relative;top:50%;transform:translateY(-50%)}@supports (color:color-mix(in lab, red, red)){.range::-webkit-slider-thumb{box-shadow:0 -1px oklch(0% 0 0/calc(var(--depth)*.1))inset,0 8px 0 -4px oklch(100% 0 0/calc(var(--depth)*.1))inset,0 1px color-mix(in oklab,currentColor calc(var(--depth)*10%),#0000),0 0 0 2rem var(--range-thumb)inset,calc((var(--range-dir,1)*-100rem) - (var(--range-dir,1)*var(--range-thumb-size)/2))0 0 calc(100rem*var(--range-fill))}}.range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size)*.5)}.range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p),var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p)solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0/calc(var(--depth)*.1))inset,0 8px 0 -4px oklch(100% 0 0/calc(var(--depth)*.1))inset,0 1px currentColor,0 0 0 2rem var(--range-thumb)inset,calc((var(--range-dir,1)*-100rem) - (var(--range-dir,1)*var(--range-thumb-size)/2))0 0 calc(100rem*var(--range-fill));background-color:currentColor;position:relative;top:50%}@supports (color:color-mix(in lab, red, red)){.range::-moz-range-thumb{box-shadow:0 -1px oklch(0% 0 0/calc(var(--depth)*.1))inset,0 8px 0 -4px oklch(100% 0 0/calc(var(--depth)*.1))inset,0 1px color-mix(in oklab,currentColor calc(var(--depth)*10%),#0000),0 0 0 2rem var(--range-thumb)inset,calc((var(--range-dir,1)*-100rem) - (var(--range-dir,1)*var(--range-thumb-size)/2))0 0 calc(100rem*var(--range-fill))}}.range:disabled{cursor:not-allowed;opacity:.3}.select{border:var(--border)solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);height:var(--size);touch-action:manipulation;text-overflow:ellipsis;box-shadow:0 1px var(--input-color)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:1rem 1.75rem;font-size:.875rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.select{box-shadow:0 1px color-mix(in oklab,var(--input-color)calc(var(--depth)*10%),#0000)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset}}.select{border-color:var(--input-color);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab,var(--color-base-content)20%,#0000)}}.select{--size:calc(var(--size-field,.25rem)*10)}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}.select select{appearance:none;background:inherit;border-radius:inherit;border-style:none;width:calc(100% + 2.75rem);height:calc(100% - 2px);margin-inline:-1rem -1.75rem;padding-inline:1rem 1.75rem}.select select:focus,.select select:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.select select:focus,.select select:focus-within{outline-offset:2px;outline:2px solid #0000}}.select select:not(:last-child){background-image:none;margin-inline-end:-1.375rem}.select:focus,.select:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{box-shadow:0 1px color-mix(in oklab,var(--input-color)calc(var(--depth)*10%),#0000)}}.select:focus,.select:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate;z-index:1}.select:has(>select[disabled]),.select:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]){color:color-mix(in oklab,var(--color-base-content)40%,transparent)}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab,var(--color-base-content)20%,transparent)}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.card{border-radius:var(--radius-box);outline-offset:2px;outline:0 solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.card:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.card:focus{outline-offset:2px;outline:2px solid #0000}}.card:focus-visible{outline-color:currentColor}.card :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.card :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.card:where(.card-border){border:var(--border)solid var(--color-base-200)}.card:where(.card-dash){border:var(--border)dashed var(--color-base-200)}.card.image-full{display:grid}.card.image-full>*{grid-row-start:1;grid-column-start:1}.card.image-full>.card-body{color:var(--color-neutral-content);position:relative}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.card.image-full>figure img{object-fit:cover;filter:brightness(28%);height:100%}.card figure{justify-content:center;align-items:center;display:flex}.card:has(>input:is(input[type=checkbox],input[type=radio])){cursor:pointer;-webkit-user-select:none;user-select:none}.card:has(>:checked){outline:2px solid}.checkbox{border:var(--border)solid var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border)solid var(--input-color,color-mix(in oklab,var(--color-base-content)20%,#0000))}}.checkbox{cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0/calc(var(--depth)*.1))inset,0 0 #0000 inset,0 0 #0000;--size:calc(var(--size-selector,.25rem)*6);width:var(--size);height:var(--size);background-size:auto,calc(var(--noise)*100%);background-image:none,var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0/calc(var(--depth)*.1))inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset,0 8px 0 -4px oklch(100% 0 0/calc(var(--depth)*.1))inset,0 1px oklch(0% 0 0/calc(var(--depth)*.1))}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}.checkbox:indeterminate:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -35%;rotate:none}.checkbox:disabled{cursor:not-allowed;opacity:.2}.radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border)solid var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}@supports (color:color-mix(in lab, red, red)){.radio{border:var(--border)solid var(--input-color,color-mix(in srgb,currentColor 20%,#0000))}}.radio{box-shadow:0 1px oklch(0% 0 0/calc(var(--depth)*.1))inset;--size:calc(var(--size-selector,.25rem)*6);width:var(--size);height:var(--size);color:var(--input-color,currentColor)}.radio:before{--tw-content:"";content:var(--tw-content);background-size:auto,calc(var(--noise)*100%);background-image:none,var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.radio:focus-visible{outline:2px solid}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor;animation:.2s ease-out radio}:is(.radio:checked,.radio[aria-checked=true]):before{box-shadow:0 -1px oklch(0% 0 0/calc(var(--depth)*.1))inset,0 8px 0 -4px oklch(100% 0 0/calc(var(--depth)*.1))inset,0 1px oklch(0% 0 0/calc(var(--depth)*.1));background-color:currentColor}@media (forced-colors:active){:is(.radio:checked,.radio[aria-checked=true]):before{outline-style:var(--tw-outline-style);outline-offset:calc(1px*-1);outline-width:1px}}@media print{:is(.radio:checked,.radio[aria-checked=true]):before{outline-offset:-1rem;outline:.25rem solid}}.radio:disabled{cursor:not-allowed;opacity:.2}.drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.absolute{position:absolute}.relative{position:relative}.static{position:static}.top-2{top:calc(var(--spacing)*2)}.right-2{right:calc(var(--spacing)*2)}.file-input{cursor:pointer;cursor:pointer;border:var(--border)solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;width:clamp(3rem,20rem,100%);height:var(--size);border-color:var(--input-color);box-shadow:0 1px var(--input-color)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));align-items:center;padding-inline-end:.75rem;font-size:.875rem;line-height:2;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.file-input{box-shadow:0 1px color-mix(in oklab,var(--input-color)calc(var(--depth)*10%),#0000)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset}}.file-input{--size:calc(var(--size-field,.25rem)*10);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.file-input{--input-color:color-mix(in oklab,var(--color-base-content)20%,#0000)}}.file-input::file-selector-button{cursor:pointer;webkit-user-select:none;-webkit-user-select:none;user-select:none;height:calc(100% + var(--border)*2);margin-inline-end:1rem;margin-block:calc(var(--border)*-1);color:var(--btn-fg);border-width:var(--border);border-style:solid;border-color:var(--btn-border);background-color:var(--btn-bg);background-size:calc(var(--noise)*100%);background-image:var(--btn-noise);text-shadow:0 .5px oklch(1 0 0/calc(var(--depth)*.15));box-shadow:0 .5px 0 .5px white inset,var(--btn-shadow);border-start-start-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-start-radius:calc(var(--join-es,var(--radius-field) - var(--border)));margin-inline-start:calc(var(--border)*-1);padding-inline:1rem;font-size:.875rem;font-weight:600}@supports (color:color-mix(in lab, red, red)){.file-input::file-selector-button{box-shadow:0 .5px 0 .5px color-mix(in oklab,color-mix(in oklab,white 30%,var(--btn-bg))calc(var(--depth)*20%),#0000)inset,var(--btn-shadow)}}.file-input::file-selector-button{--size:calc(var(--size-field,.25rem)*10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-border:var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.file-input::file-selector-button{--btn-border:color-mix(in oklab,var(--btn-bg),#000 5%)}}.file-input::file-selector-button{--btn-shadow:0 3px 2px -2px var(--btn-bg),0 4px 3px -2px var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.file-input::file-selector-button{--btn-shadow:0 3px 2px -2px color-mix(in oklab,var(--btn-bg)30%,#0000),0 4px 3px -2px color-mix(in oklab,var(--btn-bg)30%,#0000)}}.file-input::file-selector-button{--btn-noise:var(--fx-noise)}.file-input:focus{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.file-input:focus{box-shadow:0 1px color-mix(in oklab,var(--input-color)10%,#0000)}}.file-input:focus{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200)}:is(.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab,var(--color-base-content)20%,transparent)}}.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]){box-shadow:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]){color:color-mix(in oklch,var(--color-base-content)20%,#0000)}}:is(.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]))::file-selector-button{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]))::file-selector-button{--btn-fg:color-mix(in oklch,var(--color-base-content)20%,#0000)}}.hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}.textarea{border:var(--border)solid #0000;appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;touch-action:manipulation;border-color:var(--input-color);width:clamp(3rem,20rem,100%);min-height:5rem;box-shadow:0 1px var(--input-color)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem;font-size:.875rem}@supports (color:color-mix(in lab, red, red)){.textarea{box-shadow:0 1px color-mix(in oklab,var(--input-color)calc(var(--depth)*10%),#0000)inset,0 -1px oklch(100% 0 0/calc(var(--depth)*.1))inset}}.textarea{--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea{--input-color:color-mix(in oklab,var(--color-base-content)20%,#0000)}}.textarea textarea{appearance:none;background-color:#0000;border:none}.textarea textarea:focus,.textarea textarea:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.textarea textarea:focus,.textarea textarea:focus-within{outline-offset:2px;outline:2px solid #0000}}.textarea:focus,.textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.textarea:focus,.textarea:focus-within{box-shadow:0 1px color-mix(in oklab,var(--input-color)calc(var(--depth)*10%),#0000)}}.textarea:focus,.textarea:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){color:color-mix(in oklab,var(--color-base-content)40%,transparent)}}:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab,var(--color-base-content)20%,transparent)}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){box-shadow:none}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.modal-backdrop button{cursor:pointer}.tab-content{order:var(--tabcontent-order);--tabcontent-radius-ss:0;--tabcontent-radius-se:0;--tabcontent-radius-es:0;--tabcontent-radius-ee:0;--tabcontent-order:1;width:100%;margin:var(--tabcontent-margin);border-color:#0000;border-width:var(--border);border-start-start-radius:var(--tabcontent-radius-ss);border-start-end-radius:var(--tabcontent-radius-se);border-end-end-radius:var(--tabcontent-radius-ee);border-end-start-radius:var(--tabcontent-radius-es);display:none}.stat-figure{grid-row:1/span 3;grid-column-start:2;place-self:center flex-end}.hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.hero>*{grid-row-start:1;grid-column-start:1}.modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:95%;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}.drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}.stat-value{white-space:nowrap;grid-column-start:1;font-size:2rem;font-weight:800}.stat-desc{white-space:nowrap;color:var(--color-base-content);grid-column-start:1}@supports (color:color-mix(in lab, red, red)){.stat-desc{color:color-mix(in oklab,var(--color-base-content)60%,transparent)}}.stat-desc{font-size:.75rem}.stat-title{white-space:nowrap;color:var(--color-base-content);grid-column-start:1}@supports (color:color-mix(in lab, red, red)){.stat-title{color:color-mix(in oklab,var(--color-base-content)60%,transparent)}}.stat-title{font-size:.75rem}.divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:var(--color-base-content);flex-direction:row;align-self:stretch;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.divider{--divider-color:color-mix(in oklab,var(--color-base-content)10%,transparent)}}.divider:before,.divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.divider:before,.divider:after{border:.5px solid}}.divider:not(:empty){gap:1rem}.mx-auto{margin-inline:auto}.label{white-space:nowrap;color:currentColor;align-items:center;gap:.375rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.label{color:color-mix(in oklab,currentColor 60%,transparent)}}.label:has(input){cursor:pointer}.label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex}.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border)solid currentColor;margin-inline:-.75rem .75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border)solid color-mix(in oklab,currentColor 10%,#0000)}}.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border)solid currentColor;margin-inline:.75rem -.75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border)solid color-mix(in oklab,currentColor 10%,#0000)}}.join-item:where(:not(:first-child,:disabled,[disabled],.btn-disabled)){margin-block-start:0;margin-inline-start:calc(var(--border,1px)*-1)}.join-item:where(:is(:disabled,[disabled],.btn-disabled)){border-width:var(--border,1px)0 var(--border,1px)var(--border,1px)}.prose-base{font-size:1rem;line-height:1.75}.prose-base :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose-base :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em;margin-bottom:1.2em;font-size:1.25em;line-height:1.6}.prose-base :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose-base :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:.888889em;font-size:2.25em;line-height:1.11111}.prose-base :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:1em;font-size:1.5em;line-height:1.33333}.prose-base :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.6em;margin-bottom:.6em;font-size:1.25em;line-height:1.6}.prose-base :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose-base :where(img):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-base :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose-base :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-base :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose-base :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;border-radius:.3125rem;padding-inline-start:.375em;font-size:.875em}.prose-base :where(code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-base :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em}.prose-base :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em}.prose-base :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.857143em;padding-inline-end:1.14286em;padding-bottom:.857143em;border-radius:.375rem;margin-top:1.71429em;margin-bottom:1.71429em;padding-inline-start:1.14286em;font-size:.875em;line-height:1.71429}.prose-base :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-base :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose-base :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose-base :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-base :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose-base :where(.prose-base>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose-base :where(.prose-base>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose-base :where(.prose-base>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose-base :where(.prose-base>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose-base :where(.prose-base>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose-base :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose-base :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose-base :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose-base :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose-base :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:3em;margin-bottom:3em}.prose-base :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-base :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-base :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-base :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-base :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em;line-height:1.71429}.prose-base :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em}.prose-base :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-base :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-base :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.571429em;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em}.prose-base :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-base :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-base :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose-base :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-base :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.857143em;font-size:.875em;line-height:1.42857}.prose-base :where(.prose-base>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-base :where(.prose-base>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-5{margin-top:calc(var(--spacing)*5)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.fieldset-legend{color:var(--color-base-content);justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:-.25rem;padding-block:.5rem;font-weight:600;display:flex}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-10{margin-bottom:calc(var(--spacing)*10)}.ml-2{margin-left:calc(var(--spacing)*2)}.status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:var(--color-base-content);width:.5rem;height:.5rem;display:inline-block}@supports (color:color-mix(in lab, red, red)){.status{background-color:color-mix(in oklab,var(--color-base-content)20%,transparent)}}.status{vertical-align:middle;color:#0000004d;background-position:50%;background-repeat:no-repeat}@supports (color:color-mix(in lab, red, red)){.status{color:#0000004d}@supports (color:color-mix(in lab, red, red)){.status{color:color-mix(in oklab,var(--color-black)30%,transparent)}}}.status{background-image:radial-gradient(circle at 35% 30%,oklch(1 0 0/calc(var(--depth)*.5)),#0000);box-shadow:0 2px 3px -1px}@supports (color:color-mix(in lab, red, red)){.status{box-shadow:0 2px 3px -1px color-mix(in oklab,currentColor calc(var(--depth)*100%),#0000)}}.badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border)solid var(--badge-color,var(--color-base-200));width:fit-content;padding-inline:calc(.25rem*3 - var(--border));background-size:auto,calc(var(--noise)*100%);background-image:none,var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem)*6);height:var(--size);justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}.tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem)*10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.stat:not(:last-child){border-inline-end:var(--border)dashed currentColor}@supports (color:color-mix(in lab, red, red)){.stat:not(:last-child){border-inline-end:var(--border)dashed color-mix(in oklab,currentColor 10%,#0000)}}.stat:not(:last-child){border-block-end:none}.card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.card-body :where(p){flex-grow:1}.alert{border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;border:var(--border)solid var(--color-base-200);background-size:auto,calc(var(--noise)*100%);background-image:none,var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0/calc(var(--depth)*.08))inset,0 1px #000,0 4px 3px -2px oklch(0% 0 0/calc(var(--depth)*.08));grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid}@supports (color:color-mix(in lab, red, red)){.alert{box-shadow:0 3px 0 -2px oklch(100% 0 0/calc(var(--depth)*.08))inset,0 1px color-mix(in oklab,color-mix(in oklab,#000 20%,var(--alert-color,var(--color-base-200)))calc(var(--depth)*20%),#0000),0 4px 3px -2px oklch(0% 0 0/calc(var(--depth)*.08))}}.alert:has(:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}.alert.alert-outline{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none}.alert.alert-dash{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none;border-style:dashed}.alert.alert-soft{color:var(--alert-color,var(--color-base-content));background:var(--alert-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.alert.alert-soft{background:color-mix(in oklab,var(--alert-color,var(--color-base-content))8%,var(--color-base-100))}}.alert.alert-soft{border-color:var(--alert-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.alert.alert-soft{border-color:color-mix(in oklab,var(--alert-color,var(--color-base-content))10%,var(--color-base-100))}}.alert.alert-soft{box-shadow:none;background-image:none}.fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.card-actions{flex-wrap:wrap;align-items:flex-start;gap:.5rem;display:flex}.card-title{font-size:var(--cardtitle-fs,1.125rem);align-items:center;gap:.5rem;font-weight:600;display:flex}.join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;align-items:stretch;display:inline-flex}.join :where(.join-item){border-start-start-radius:var(--join-ss,0);border-start-end-radius:var(--join-se,0);border-end-end-radius:var(--join-ee,0);border-end-start-radius:var(--join-es,0)}.join :where(.join-item) *{--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.join>.join-item:where(:first-child),.join :first-child:not(:last-child) :where(.join-item){--join-ss:var(--radius-field);--join-se:0;--join-es:var(--radius-field);--join-ee:0}.join>.join-item:where(:last-child),.join :last-child:not(:first-child) :where(.join-item){--join-ss:0;--join-se:var(--radius-field);--join-es:0;--join-ee:var(--radius-field)}.join>.join-item:where(:only-child),.join :only-child :where(.join-item){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.flex{display:flex}.hidden{display:none}.inline{display:inline}.table{display:table}.btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}.size-10{width:calc(var(--spacing)*10);height:calc(var(--spacing)*10)}.h-5{height:calc(var(--spacing)*5)}.h-24{height:calc(var(--spacing)*24)}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-2\/3{width:66.6667%}.w-5{width:calc(var(--spacing)*5)}.w-80{width:calc(var(--spacing)*80)}.w-96{width:calc(var(--spacing)*96)}.w-full{width:100%}.grow{flex-grow:1}.link{cursor:pointer;text-decoration-line:underline}.link:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.link:focus{outline-offset:2px;outline:2px solid #0000}}.link:focus-visible{outline-offset:2px;outline:2px solid}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-2{gap:calc(var(--spacing)*2)}.rounded-box{border-radius:var(--radius-box);border-radius:var(--radius-box)}.alert-error{border-color:var(--color-error);color:var(--color-error-content);--alert-color:var(--color-error)}.alert-info{border-color:var(--color-info);color:var(--color-info-content);--alert-color:var(--color-info)}.alert-success{border-color:var(--color-success);color:var(--color-success-content);--alert-color:var(--color-success)}.alert-warning{border-color:var(--color-warning);color:var(--color-warning-content);--alert-color:var(--color-warning)}.border-base-300{border-color:var(--color-base-300)}.table-zebra tbody tr:where(:nth-child(2n)),.table-zebra tbody tr:where(:nth-child(2n)) :where(.table-pin-cols tr th){background-color:var(--color-base-200)}@media (hover:hover){:is(.table-zebra tbody tr.row-hover,.table-zebra tbody tr.row-hover:where(:nth-child(2n))):hover{background-color:var(--color-base-300)}}.bg-accent{background-color:var(--color-accent)}.bg-base-100{background-color:var(--color-base-100)}.bg-base-200{background-color:var(--color-base-200)}.bg-info{background-color:var(--color-info)}.bg-neutral{background-color:var(--color-neutral)}.bg-primary{background-color:var(--color-primary)}.bg-success{background-color:var(--color-success)}.bg-warning{background-color:var(--color-warning)}.p-2{padding:calc(var(--spacing)*2)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-7{padding:calc(var(--spacing)*7)}.menu-title{color:var(--color-base-content);padding-block:.5rem;padding-inline:.75rem}@supports (color:color-mix(in lab, red, red)){.menu-title{color:color-mix(in oklab,var(--color-base-content)40%,transparent)}}.menu-title{font-size:.875rem;font-weight:600}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.font-thin{--tw-font-weight:var(--font-weight-thin);font-weight:var(--font-weight-thin)}.text-accent-content{color:var(--color-accent-content)}.text-base-content,.text-base-content\/50{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/50{color:color-mix(in oklab,var(--color-base-content)50%,transparent)}}.text-error{color:var(--color-error)}.text-info-content{color:var(--color-info-content)}.text-neutral-content{color:var(--color-neutral-content)}.text-primary{color:var(--color-primary)}.text-primary-content{color:var(--color-primary-content)}.text-secondary{color:var(--color-secondary)}.text-success-content{color:var(--color-success-content)}.text-warning-content{color:var(--color-warning-content)}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.btn-link{--btn-border:#0000;--btn-bg:#0000;--btn-fg:var(--color-primary);--btn-noise:none;--btn-shadow:"";outline-color:currentColor;text-decoration-line:underline}.btn-link:is(.btn-active,:hover,:active:focus,:focus-visible){--btn-border:#0000;--btn-bg:#0000;text-decoration-line:underline}@media (hover:none){.btn-link:hover:not(.btn-active,:active,:focus-visible,:disabled,[disabled],.btn-disabled){text-decoration-line:none}}.opacity-30{opacity:.3}.opacity-60{opacity:.6}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible){--btn-shadow:"";--btn-bg:#0000;--btn-border:#0000;--btn-noise:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible):not(:disabled,[disabled],.btn-disabled){--btn-fg:currentColor;outline-color:currentColor}@media (hover:none){.btn-ghost:hover:not(.btn-active,:active,:focus-visible,:disabled,[disabled],.btn-disabled){--btn-shadow:"";--btn-bg:#0000;--btn-border:#0000;--btn-noise:none;--btn-fg:currentColor}}.btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem)*8)}.card-lg .card-body{--card-p:2rem;--card-fs:1rem}.card-lg .card-title{--cardtitle-fs:1.25rem}.card-md .card-body{--card-p:1.5rem;--card-fs:.875rem}.card-md .card-title{--cardtitle-fs:1.125rem}.card-sm .card-body{--card-p:1rem;--card-fs:.75rem}.card-sm .card-title{--cardtitle-fs:1rem}.badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.btn-accent{--btn-color:var(--color-accent);--btn-fg:var(--color-accent-content)}.btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content)}.btn-info{--btn-color:var(--color-info);--btn-fg:var(--color-info-content)}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.input-error,.input-error:focus,.input-error:focus-within{--input-color:var(--color-error)}.input-success,.input-success:focus,.input-success:focus-within{--input-color:var(--color-success)}@media (min-width:64rem){.lg\:drawer-open>.drawer-side{overflow-y:auto}.lg\:drawer-open>.drawer-toggle{display:none}.lg\:drawer-open>.drawer-toggle~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky}.lg\:drawer-open>.drawer-toggle~.drawer-side>.drawer-overlay{cursor:default;background-color:#0000}.lg\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay),[dir=rtl] :is(.lg\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay)){translate:0%}.lg\:drawer-open>.drawer-toggle:checked~.drawer-side{pointer-events:auto;visibility:visible}.lg\:hidden{display:none}}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@keyframes progress{50%{background-position-x:-115%}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@keyframes dropdown{0%{opacity:0}}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
\ No newline at end of file
diff --git a/tailwind.css b/tailwind.css
new file mode 100644
index 0000000..fd80716
--- /dev/null
+++ b/tailwind.css
@@ -0,0 +1,9 @@
+@import "tailwindcss" source(none);
+@plugin "@tailwindcss/typography";
+@source "./pkg/ui/**/*.{go}";
+@plugin "./daisyui.js";
+
+/* Optional for custom themes – Docs: https://daisyui.com/docs/themes/#how-to-add-a-new-custom-theme */
+@plugin "./daisyui-theme.js"{
+ /* custom theme here */
+}
\ No newline at end of file