detached a1d9a53

wip

commit
a1d9a53ec2dc701265d5d76b2d637d9bcc27f0c4
author
bamen · · 2025-03-23 18:00 UTC
parent
dbdbc7c
signature
unsigned
101 files · +15924 -2963

this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.

M .goreleaser.yaml +2 -0
@@ -3,6 +3,8 @@ version: 2
33 before:
44 hooks:
55 - go mod tidy
6+ - templ generate
7+ - bun run build:css
68
79 release:
810 draft: true
A .templ.config.json +4 -0
@@ -0,0 +1,4 @@
1+{
2+ "root": "internal/templating/models/templ",
3+ "paths": ["internal/templating/models/templ"]
4+}⏎̸
A CLAUDE.md +28 -0
@@ -0,0 +1,28 @@
1+# Gordon Project Guidelines
2+
3+## Build Commands
4+- Build project: `make build`
5+- Build CSS: `bun run build:css` or `make build-css`
6+- Watch CSS for changes: `bun run dev:css`
7+- Clean build artifacts: `make clean`
8+- Run rate limit test: `go run pkg/scripts/rate_limit_test/main.go --url URL`
9+
10+## Code Style
11+
12+### Go Guidelines
13+- Use tabs for indentation (4-space equivalent)
14+- Organize imports: standard lib first, then third-party packages
15+- Error handling: `if err != nil { return nil, fmt.Errorf("context: %w", err) }`
16+- Use structured logging with levels: `logger.Debug/Info/Warn/Error`
17+- Variable naming: camelCase for variables, PascalCase for exported items
18+- Functions should be focused and documented with comments
19+
20+### JavaScript/CSS
21+- Use Prettier for formatting
22+- CSS is built with Tailwind
23+- Run Tailwind build with `bun run build:css`
24+
25+## File Structure
26+- Package declaration first, followed by imports
27+- Type definitions before function implementations
28+- Methods for types should be grouped together⏎̸
M Dockerfile +17 -1
@@ -1,8 +1,24 @@
11 FROM --platform=$TARGETPLATFORM alpine:latest
22
3+# Install diagnostic utilities
4+RUN apk add --no-cache \
5+ curl \
6+ wget \
7+ net-tools \
8+ bind-tools \
9+ busybox-extras
10+
311 COPY gordon /gordon
12+COPY pkg/scripts/container_diagnostic/diagnostic.sh /diagnostic.sh
13+RUN chmod +x /diagnostic.sh
414
515 RUN touch /.iscontainer
616
17+# Ensure we're running as root to bind to privileged ports
18+USER root
19+
20+# Expose the ports the server needs to listen on
21+EXPOSE 80 443 8080
22+
723 ENTRYPOINT ["/gordon"]
8-CMD ["serve"]
24+CMD ["serve", "--use-fallback-binding", "--proxy-debug"]
M Makefile +8 -2
@@ -20,13 +20,13 @@ LDFLAGS := -s -w \
2020 ARCHS := amd64 arm64
2121
2222 # Phony targets
23-.PHONY: all build build-push clean build-css
23+.PHONY: all build build-push clean build-css build-templ
2424
2525 # Default target
2626 all: build
2727
2828 # Build binaries
29-build: build-css
29+build: build-css build-templ
3030 @echo "Building Go binaries..."
3131 @mkdir -p $(DIST_DIR)
3232 @rm -f $(DIST_DIR)/*
@@ -41,6 +41,12 @@ build-css:
4141 @bun run build:css
4242 @echo "CSS built successfully"
4343
44+# Generate Go code from templ templates
45+build-templ:
46+ @echo "Generating Go code from templ templates..."
47+ @templ generate
48+ @echo "templ generation completed successfully"
49+
4450 # Build and push Docker images
4551 build-push: build-css build
4652 @echo "Cleaning up dangling images..."
A TEMPL_MIGRATION.md +58 -0
@@ -0,0 +1,58 @@
1+# Gordon Templ Migration
2+
3+This project is being migrated from using Go's html/template to [templ](https://github.com/a-h/templ) for improved type safety, maintainability, and IDE support.
4+
5+## Migration Status
6+
7+The following templates have been migrated:
8+
9+- [x] Login page (index.gohtml → login/index.templ)
10+- [x] Header component (header.gohtml → components/header.templ)
11+- [x] Footer component (footer.gohtml → components/footer.templ)
12+- [x] Menu component (menu.gohtml → components/menu.templ)
13+- [x] Container list component (containerlist.gohtml → components/containerlist.templ)
14+- [x] Success component (success.gohtml → components/success.templ)
15+- [x] Admin dashboard (dashboard.gohtml → pages/admin/dashboard.templ)
16+- [x] Admin manager (manager.gohtml → pages/admin/manager.templ)
17+- [x] Image list component (imagelist.gohtml → components/imagelist.templ)
18+- [x] Upload image form (uploadimage.gohtml → components/uploadimage.templ)
19+- [x] Create container form (createcontainer.gohtml → components/createcontainer.templ)
20+- [x] Install page (install.gohtml → pages/admin/install.templ)
21+- [x] Edit container form (editcontainer.gohtml → components/editcontainer.templ)
22+- [x] Full container creation (createcontainerfull.gohtml → pages/admin/createcontainerfull.templ)
23+- [x] Index page (index.gohtml → pages/admin/index.templ)
24+- [x] User page (user.gohtml → pages/admin/user.templ)
25+
26+All templates have been successfully migrated! 🎉
27+
28+## Build Process
29+
30+The build process has been updated to include templ generation:
31+
32+1. Makefile includes a `build-templ` target that runs `templ generate`
33+2. GoReleaser configuration includes `templ generate` in the before hooks
34+
35+## Directory Structure
36+
37+- `internal/templating/models/templ/` - Root directory for templ templates
38+ - `components/` - Reusable UI components
39+ - `layouts/` - Page layouts like AdminLayout
40+ - `pages/` - Full page templates
41+ - `admin/` - Admin pages
42+ - `login/` - Login pages
43+
44+## Theme Configuration
45+
46+The default theme has been set to `gordon-dark`, which is defined in `internal/webui/public/assets/css/custom.css`.
47+
48+## Handlers
49+
50+New handlers have been created in `internal/httpserve/handlers/templ_handlers.go` that use the templ renderer to render the migrated templates.
51+
52+## Further Work
53+
54+1. ✅ Complete the migration of all templates
55+2. Update all handlers to use the new templ templates
56+3. Add error handling and loading states to components
57+4. Create more reusable components for forms, buttons, cards, etc.
58+5. Consider integrating with Go's generics for more type safety⏎̸
M docker-compose.yml.example +6 -1
@@ -44,9 +44,11 @@ services:
4444 # Reverse Proxy Configuration
4545 - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
4646 - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
47+ - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
4748 - GORDON_PROXY_CERT_DIR=/certs
4849 - GORDON_PROXY_EMAIL=admin@example.com
49- - GORDON_PROXY_ENABLE_LOGS=true # Enable HTTP request logging (set to false to disable)
50+ - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
51+ - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
5052
5153 # Session Secret for security
5254 - SESSION_SECRET=replace_with_secure_random_string
@@ -97,8 +99,11 @@ networks:
9799 # # Reverse Proxy Configuration
98100 # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
99101 # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
102+# - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
100103 # - GORDON_PROXY_CERT_DIR=/certs
101104 # - GORDON_PROXY_EMAIL=admin@example.com
105+# - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
106+# - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
102107 #
103108 # # Session Secret for security
104109 # - SESSION_SECRET=replace_with_secure_random_string
M go.mod +9 -0
@@ -6,6 +6,7 @@ require (
66 github.com/AlecAivazis/survey/v2 v2.3.7
77 github.com/Masterminds/semver/v3 v3.3.1
88 github.com/PuerkitoBio/goquery v1.10.2
9+ github.com/a-h/templ v0.3.833
910 github.com/charmbracelet/bubbles v0.20.0
1011 github.com/charmbracelet/lipgloss v1.0.0
1112 github.com/charmbracelet/log v0.4.0
@@ -24,12 +25,20 @@ require (
2425 )
2526
2627 require (
28+ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
29+ github.com/andybalholm/brotli v1.1.0 // indirect
30+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
2731 github.com/cespare/xxhash/v2 v2.3.0 // indirect
32+ github.com/cli/browser v1.3.0 // indirect
33+ github.com/fsnotify/fsnotify v1.7.0 // indirect
2834 github.com/klauspost/compress v1.18.0 // indirect
2935 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
36+ github.com/natefinch/atomic v1.0.1 // indirect
3037 github.com/zeebo/xxh3 v1.0.2 // indirect
3138 go.mongodb.org/mongo-driver v1.17.3 // indirect
3239 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
40+ golang.org/x/mod v0.24.0 // indirect
41+ golang.org/x/tools v0.31.0 // indirect
3342 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
3443 )
3544
M go.sum +14 -2 generated file: collapsed
M internal/cli/cmd/serve.go +68 -3
@@ -4,6 +4,7 @@ package cmd
44
55 import (
66 "fmt"
7+ "os"
78
89 "github.com/bnema/gordon/internal/cli/handler"
910 "github.com/bnema/gordon/internal/common"
@@ -17,6 +18,13 @@ import (
1718 func NewServeCommand(a *server.App) *cobra.Command {
1819 defaultport := "1323"
1920 var port string
21+ var proxyEnabled bool
22+ var proxyPort string
23+ var proxyHttpPort string
24+ var skipCertificates bool
25+ var proxyDebug bool
26+ var useFallbackBinding bool
27+ var detectUpstreamProxy bool
2028
2129 // if the server is running in a docker container, the default port is 8080
2230 // (changed from 80 to avoid conflict with the reverse proxy)
@@ -36,9 +44,56 @@ func NewServeCommand(a *server.App) *cobra.Command {
3644 fmt.Println("Continuing with Docker/Podman functionality disabled")
3745 }
3846
39- // handler.StartServer will use the value of port, which will be the default
40- // value if no flag is specified
41- a.Config.Http.Port = port
47+ // Only use the port from flag if it was explicitly set by the user
48+ // otherwise keep the one from configuration (which has env vars applied)
49+ if cmd.Flags().Changed("port") {
50+ logger.Info("Using port from command line flag", "port", port)
51+ a.Config.Http.Port = port
52+ } else {
53+ // Use the port from the config (which would have env vars applied)
54+ port = a.Config.Http.Port
55+ logger.Info("Using port from configuration", "port", port)
56+ }
57+
58+ // Apply proxy flags if set
59+ if cmd.Flags().Changed("proxy-enabled") {
60+ logger.Info("Setting proxy enabled flag from command line", "enabled", proxyEnabled)
61+ a.Config.ReverseProxy.Enabled = proxyEnabled
62+ }
63+
64+ if cmd.Flags().Changed("proxy-port") {
65+ logger.Info("Setting proxy port from command line", "port", proxyPort)
66+ a.Config.ReverseProxy.Port = proxyPort
67+ }
68+
69+ if cmd.Flags().Changed("proxy-http-port") {
70+ logger.Info("Setting proxy HTTP port from command line", "port", proxyHttpPort)
71+ a.Config.ReverseProxy.HttpPort = proxyHttpPort
72+ }
73+
74+ if cmd.Flags().Changed("skip-certificates") {
75+ logger.Info("Setting skip certificates flag from command line", "skip", skipCertificates)
76+ a.Config.ReverseProxy.SkipCertificates = skipCertificates
77+ }
78+
79+ if cmd.Flags().Changed("proxy-debug") && proxyDebug {
80+ logger.Info("Enabling proxy debug mode")
81+ // Set log level to debug for proxy-related components
82+ logger.GetLogger().SetLogLevel("debug")
83+ }
84+
85+ if cmd.Flags().Changed("use-fallback-binding") {
86+ logger.Info("Setting use fallback binding flag", "enabled", useFallbackBinding)
87+ if useFallbackBinding {
88+ os.Setenv("GORDON_USE_FALLBACK_BINDING", "true")
89+ }
90+ }
91+
92+ if cmd.Flags().Changed("detect-upstream-proxy") {
93+ logger.Info("Setting detect upstream proxy flag", "enabled", detectUpstreamProxy)
94+ a.Config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy
95+ }
96+
4297 err := handler.StartServer(a, port)
4398 if err != nil {
4499 logger.Error("Server error", "error", err)
@@ -53,5 +108,15 @@ func NewServeCommand(a *server.App) *cobra.Command {
53108
54109 // Attach the flag to serveCmd and store its value in the variable port
55110 serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on")
111+
112+ // Add proxy-related flags
113+ serveCmd.Flags().BoolVar(&proxyEnabled, "proxy-enabled", true, "enable or disable the reverse proxy")
114+ serveCmd.Flags().StringVar(&proxyPort, "proxy-port", "443", "port for the reverse proxy HTTPS server")
115+ serveCmd.Flags().StringVar(&proxyHttpPort, "proxy-http-port", "80", "port for the reverse proxy HTTP server")
116+ serveCmd.Flags().BoolVar(&skipCertificates, "skip-certificates", false, "skip Let's Encrypt certificate acquisition")
117+ serveCmd.Flags().BoolVar(&proxyDebug, "proxy-debug", false, "enable detailed debug logging for the proxy")
118+ serveCmd.Flags().BoolVar(&useFallbackBinding, "use-fallback-binding", false, "use fallback binding method for HTTPS ports")
119+ serveCmd.Flags().BoolVar(&detectUpstreamProxy, "detect-upstream-proxy", false, "detect and handle upstream TLS termination proxies")
120+
56121 return serveCmd
57122 }
M internal/cli/handler/start_server.go +55 -15
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/bnema/gordon/internal/httpserve"
13+ "github.com/bnema/gordon/internal/proxy"
1314 "github.com/bnema/gordon/internal/server"
1415 "github.com/bnema/gordon/pkg/logger"
1516 "github.com/labstack/echo/v4"
@@ -19,6 +20,8 @@ import (
1920 var (
2021 globalServer *server.App
2122 globalServerLock sync.Mutex
23+ // Add a lock for server initialization
24+ serverInitLock sync.Mutex
2225 )
2326
2427 // SetGlobalServer sets the global server instance for shutdown handling
@@ -38,6 +41,10 @@ func GetGlobalServer() *server.App {
3841 // execute cmd/srv/main.go main function
3942
4043 func StartServer(a *server.App, port string) error {
44+ // Use lock to ensure only one server initialization happens at a time
45+ serverInitLock.Lock()
46+ defer serverInitLock.Unlock()
47+
4148 // Set the global server instance for shutdown handling
4249 SetGlobalServer(a)
4350
@@ -54,27 +61,38 @@ func StartServer(a *server.App, port string) error {
5461 logger.Error("Token initialization error", "error", err)
5562 }
5663
57- // Initialize and start the reverse proxy using the already loaded configuration
58- p, err := httpserve.InitializeProxy(a)
59- if err != nil {
60- logger.Error("Failed to initialize reverse proxy", "error", err)
61- // Continue even if proxy fails, as the main server should still work
62- }
64+ // Create a channel to signal when the main server is ready
65+ serverReady := make(chan struct{})
6366
67+ logger.Info("Initializing main HTTP server...")
6468 e := echo.New()
6569 e.Server.ReadTimeout = 10 * time.Minute
6670 e.Server.WriteTimeout = 10 * time.Minute
6771 e.HideBanner = true
6872 e.HidePort = true
73+
74+ logger.Debug("About to register routes to main HTTP server")
6975 e = httpserve.RegisterRoutes(e, a)
76+ logger.Info("Routes registered to main HTTP server")
7077
7178 // Setup a channel to capture termination signals
7279 sigs := make(chan os.Signal, 1)
7380 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
7481
75- // Start server in a goroutine
82+ // Start the server in a goroutine
7683 go func() {
77- logger.Info("Starting server", "port", port)
84+ logger.Info("Starting main server on port", "port", a.Config.Http.Port)
85+
86+ // Create a separate goroutine that will signal after a short delay
87+ // This ensures the server has time to initialize and bind to the port
88+ go func() {
89+ // Wait for server to start binding
90+ time.Sleep(1 * time.Second)
91+ logger.Debug("Signaling that main server is ready")
92+ close(serverReady)
93+ }()
94+
95+ // Attempt to start the server
7896 if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
7997 if err.Error() != "http: Server closed" {
8098 logger.Error("Server error", "error", err)
@@ -82,13 +100,35 @@ func StartServer(a *server.App, port string) error {
82100 }
83101 }()
84102
85- // Test admin connections after the server has started (in a separate goroutine)
86- if p != nil {
87- go func() {
88- // Add a delay to ensure the server has fully started
89- time.Sleep(2 * time.Second)
90- p.TestAdminConnectionLater()
91- }()
103+ // Log information about accessing the admin WebUI
104+ adminPath := a.Config.Admin.Path
105+ if adminPath == "" {
106+ adminPath = "/admin" // Default if not set
107+ }
108+
109+ // Log access information
110+ protocol := "https"
111+ if !a.Config.Http.Https {
112+ protocol = "http"
113+ }
114+
115+ fullDomain := a.Config.Http.FullDomain()
116+ logger.Info("Access the Admin WebUI at", "url", fmt.Sprintf("%s://%s%s", protocol, fullDomain, adminPath))
117+
118+ // Wait for the main server to be ready before initializing the proxy
119+ <-serverReady
120+
121+ // Initialize and start the reverse proxy after the main server is ready
122+ var p *proxy.Proxy
123+ logger.Info("Starting reverse proxy initialization...")
124+ p, err = httpserve.InitializeProxy(a)
125+ if err != nil {
126+ logger.Error("Failed to initialize reverse proxy", "error", err, "solution", "Check logs for specific HTTPS binding errors and container capabilities")
127+ // Alert the user that this is a critical issue
128+ fmt.Println("[gordon] | ⚠️ WARNING: Reverse proxy failed to initialize. HTTPS server will not be available!")
129+ fmt.Println("[gordon] | ⚠️ Check logs for details and verify container has correct permissions.")
130+ } else {
131+ logger.Info("Reverse proxy initialization complete")
92132 }
93133
94134 // Wait for interrupt signal
M internal/common/init_config.go +170 -61
@@ -47,14 +47,15 @@ type BuildConfig struct {
4747 }
4848
4949 type AdminConfig struct {
50- Path string `yaml:"path"`
50+ Path string `yaml:"path"`
51+ AdminWebUI bool `yaml:"-"`
5152 }
5253
5354 type GeneralConfig struct {
5455 StorageDir string `yaml:"storageDir"`
5556 Token string `yaml:"token"`
5657 LogLevel string `yaml:"logLevel"`
57- GordonContainerID string `yaml:"gordonContainerID"`
58+ GordonContainerID string `yaml:"-"`
5859 }
5960
6061 type HttpConfig struct {
@@ -73,33 +74,39 @@ type ContainerEngineConfig struct {
7374 }
7475
7576 type ReverseProxyConfig struct {
76- Port string `yaml:"port"` // Port for the reverse proxy to listen on
77- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
78- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
79- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
80- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
81- LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
82- Email string `yaml:"email"` // Email for Let's Encrypt
83- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
84- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
85- EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true)
86- DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false)
77+ Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled
78+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
79+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
80+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
81+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
82+ RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
83+ LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
84+ Email string `yaml:"email"` // Email for Let's Encrypt
85+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
86+ GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
87+ EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging (default: true)
88+ EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware (default: false)
89+ DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies (default: false)
90+ SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy (default: false)
8791 }
8892
8993 // Default values
9094 var (
91- sock = "/var/run/docker.sock"
92- podmansock = "/run/podman/podman.sock"
93- reverseProxyPort = "443" // Changed to 443 for standard HTTPS
94- httpPort = "80" // Standard HTTP port
95- certDir = "/certs"
96- letsEncryptMode = "staging"
97- autoRenew = true
98- renewBefore = 30 // days
99- cacheSize = 1000 // entries
100- gracePeriod = 30 // seconds
101- defaultLogLevel = "info" // Default log level
102- disableRateLimit = false
95+ sock = "/var/run/docker.sock"
96+ podmansock = "/run/podman/podman.sock"
97+ reverseProxyPort = "443" // Changed to 443 for standard HTTPS
98+ httpPort = "80" // Standard HTTP port
99+ certDir = "/certs"
100+ letsEncryptMode = "staging"
101+ autoRenew = true
102+ proxyEnabled = true // Default to enabled for backward compatibility
103+ renewBefore = 30 // days
104+ cacheSize = 1000 // entries
105+ gracePeriod = 30 // seconds
106+ defaultLogLevel = "info" // Default log level
107+ disableRateLimit = false
108+ detectUpstreamProxy = false // Default to disabled
109+ skipCertificates = false // Default to disabled
103110 )
104111
105112 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -115,6 +122,12 @@ func applyDefaultsToConfig(config *Config) bool {
115122 }
116123
117124 // Apply defaults to ReverseProxy config
125+ // Set Enabled to true by default if not specified
126+ if !config.ReverseProxy.Enabled {
127+ config.ReverseProxy.Enabled = proxyEnabled
128+ logger.Debug("Applied default value for ReverseProxy.Enabled", "value", proxyEnabled)
129+ defaultsApplied = true
130+ }
118131 if config.ReverseProxy.Port == "" {
119132 config.ReverseProxy.Port = reverseProxyPort
120133 logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)
@@ -150,16 +163,30 @@ func applyDefaultsToConfig(config *Config) bool {
150163 logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)
151164 defaultsApplied = true
152165 }
153- // Set EnableLogs to true by default if not specified
154- if !config.ReverseProxy.EnableLogs {
155- config.ReverseProxy.EnableLogs = true
156- logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true)
166+ // Set EnableHttpLogs to true by default if not specified
167+ if !config.ReverseProxy.EnableHttpLogs {
168+ config.ReverseProxy.EnableHttpLogs = true
169+ logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true)
170+ defaultsApplied = true
171+ }
172+ // Set EnableRateLimit to false by default if not specified
173+ if !config.ReverseProxy.EnableRateLimit {
174+ config.ReverseProxy.EnableRateLimit = false
175+ logger.Debug("Applied default value for ReverseProxy.EnableRateLimit", "value", false)
176+ defaultsApplied = true
177+ }
178+
179+ // Set DetectUpstreamProxy to false by default if not specified
180+ if !config.ReverseProxy.DetectUpstreamProxy {
181+ config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy
182+ logger.Debug("Applied default value for ReverseProxy.DetectUpstreamProxy", "value", detectUpstreamProxy)
157183 defaultsApplied = true
158184 }
159- // Set DisableRateLimit to false by default if not specified
160- if !config.ReverseProxy.DisableRateLimit {
161- config.ReverseProxy.DisableRateLimit = false
162- logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false)
185+
186+ // Set SkipCertificates to false by default if not specified
187+ if !config.ReverseProxy.SkipCertificates {
188+ config.ReverseProxy.SkipCertificates = skipCertificates
189+ logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates)
163190 defaultsApplied = true
164191 }
165192
@@ -476,6 +503,25 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
476503 logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)
477504 }
478505 }
506+
507+ // New environment variables for reverse proxy port control
508+ if val := os.Getenv("GORDON_USE_FALLBACK_BINDING"); val != "" {
509+ // This is handled at runtime in the proxy server but we log it here
510+ if printLogs {
511+ logger.Info("Found environment variable GORDON_USE_FALLBACK_BINDING", "value", val)
512+ }
513+ }
514+
515+ // Handle GORDON_PROXY_ENABLED environment variable
516+ if val := os.Getenv("GORDON_PROXY_ENABLED"); val != "" {
517+ enabled, err := strconv.ParseBool(val)
518+ if err == nil {
519+ config.ReverseProxy.Enabled = enabled
520+ }
521+ if printLogs {
522+ logger.Info("Using environment variable GORDON_PROXY_ENABLED", "value", config.ReverseProxy.Enabled)
523+ }
524+ }
479525 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {
480526 config.ReverseProxy.CertDir = val
481527 if printLogs {
@@ -524,22 +570,59 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
524570 logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)
525571 }
526572
527- // Handle GORDON_PROXY_ENABLE_LOGS environment variable
528- if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {
573+ // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable
574+ if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" {
529575 enableLogs, err := strconv.ParseBool(val)
530576 if err == nil {
531- config.ReverseProxy.EnableLogs = enableLogs
577+ config.ReverseProxy.EnableHttpLogs = enableLogs
532578 }
533- logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs)
579+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_HTTP_LOGS", "value", config.ReverseProxy.EnableHttpLogs)
580+ } else if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {
581+ // For backward compatibility
582+ enableLogs, err := strconv.ParseBool(val)
583+ if err == nil {
584+ config.ReverseProxy.EnableHttpLogs = enableLogs
585+ }
586+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS (deprecated, use GORDON_PROXY_ENABLE_HTTP_LOGS)", "value", config.ReverseProxy.EnableHttpLogs)
534587 }
535588
536- // Handle GORDON_PROXY_DISABLE_RATE_LIMIT environment variable
537- if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {
589+ // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable
590+ if val := os.Getenv("GORDON_PROXY_ENABLE_RATE_LIMIT"); val != "" {
591+ enableRateLimit, err := strconv.ParseBool(val)
592+ if err == nil {
593+ config.ReverseProxy.EnableRateLimit = enableRateLimit
594+ }
595+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_RATE_LIMIT", "value", config.ReverseProxy.EnableRateLimit)
596+ } else if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {
597+ // For backward compatibility
538598 disableRateLimit, err := strconv.ParseBool(val)
539599 if err == nil {
540- config.ReverseProxy.DisableRateLimit = disableRateLimit
600+ // Invert the logic
601+ config.ReverseProxy.EnableRateLimit = !disableRateLimit
602+ }
603+ logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT (deprecated, use GORDON_PROXY_ENABLE_RATE_LIMIT)", "value", !config.ReverseProxy.EnableRateLimit)
604+ }
605+
606+ // Handle GORDON_PROXY_DETECT_UPSTREAM environment variable
607+ if val := os.Getenv("GORDON_PROXY_DETECT_UPSTREAM"); val != "" {
608+ detectUpstream, err := strconv.ParseBool(val)
609+ if err == nil {
610+ config.ReverseProxy.DetectUpstreamProxy = detectUpstream
611+ }
612+ if printLogs {
613+ logger.Info("Using environment variable GORDON_PROXY_DETECT_UPSTREAM", "value", config.ReverseProxy.DetectUpstreamProxy)
614+ }
615+ }
616+
617+ // Handle GORDON_PROXY_SKIP_CERTIFICATES environment variable
618+ if val := os.Getenv("GORDON_PROXY_SKIP_CERTIFICATES"); val != "" {
619+ skipCerts, err := strconv.ParseBool(val)
620+ if err == nil {
621+ config.ReverseProxy.SkipCertificates = skipCerts
622+ }
623+ if printLogs {
624+ logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates)
541625 }
542- logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT", "value", config.ReverseProxy.DisableRateLimit)
543626 }
544627 }
545628
@@ -587,16 +670,16 @@ func (config *Config) LoadConfig() (*Config, error) {
587670 Network: "gordon",
588671 },
589672 ReverseProxy: ReverseProxyConfig{
590- Port: reverseProxyPort,
591- HttpPort: httpPort,
592- CertDir: certDir,
593- AutoRenew: autoRenew,
594- RenewBefore: renewBefore,
595- LetsEncryptMode: letsEncryptMode,
596- CacheSize: cacheSize,
597- GracePeriod: gracePeriod,
598- EnableLogs: true,
599- DisableRateLimit: false,
673+ Port: reverseProxyPort,
674+ HttpPort: httpPort,
675+ CertDir: certDir,
676+ AutoRenew: autoRenew,
677+ RenewBefore: renewBefore,
678+ LetsEncryptMode: letsEncryptMode,
679+ CacheSize: cacheSize,
680+ GracePeriod: gracePeriod,
681+ EnableHttpLogs: true,
682+ EnableRateLimit: false,
600683 },
601684 }
602685
@@ -726,16 +809,16 @@ func (config *Config) LoadConfig() (*Config, error) {
726809 Network: "gordon",
727810 },
728811 ReverseProxy: ReverseProxyConfig{
729- Port: reverseProxyPort,
730- HttpPort: httpPort,
731- CertDir: certDir,
732- AutoRenew: autoRenew,
733- RenewBefore: renewBefore,
734- LetsEncryptMode: letsEncryptMode,
735- CacheSize: cacheSize,
736- GracePeriod: gracePeriod,
737- EnableLogs: true,
738- DisableRateLimit: false,
812+ Port: reverseProxyPort,
813+ HttpPort: httpPort,
814+ CertDir: certDir,
815+ AutoRenew: autoRenew,
816+ RenewBefore: renewBefore,
817+ LetsEncryptMode: letsEncryptMode,
818+ CacheSize: cacheSize,
819+ GracePeriod: gracePeriod,
820+ EnableHttpLogs: true,
821+ EnableRateLimit: false,
739822 },
740823 }
741824
@@ -808,6 +891,27 @@ func (config *Config) LoadConfig() (*Config, error) {
808891 logger.Info("Loaded configuration from", "path", configFilePath)
809892 }
810893
894+ // After loading from file or environment, check if Admin.Path is empty
895+ // This should be placed before returning the config
896+ if config.Admin.Path == "" {
897+ logger.Warn("Admin path is empty or not set, admin dashboard webui will be disabled")
898+ config.Admin.AdminWebUI = false
899+ } else {
900+ config.Admin.AdminWebUI = true
901+ }
902+
903+ // Save config file if running in container and env vars were applied
904+ // This ensures config.yml always reflects the environment variables
905+ if docker.IsRunningInContainer() && configLogsPrinted {
906+ logger.Info("Environment variables were applied, saving configuration to reflect current values...")
907+ err = config.SaveConfig()
908+ if err != nil {
909+ logger.Error("Failed to save configuration with environment variables", "error", err)
910+ } else {
911+ logger.Info("Successfully updated config.yml with environment variable values")
912+ }
913+ }
914+
811915 // Apply the log level from configuration
812916 if config.General.LogLevel != "" {
813917 // Set the log level using our centralized logger
@@ -930,3 +1034,8 @@ func (c *HttpConfig) FullDomain() string {
9301034 func (c *Config) GetVersion() string {
9311035 return c.Build.BuildVersion
9321036 }
1037+
1038+// IsAdminWebUIEnabled returns whether the admin dashboard is enabled
1039+func (c *AdminConfig) IsAdminWebUIEnabled() bool {
1040+ return c.AdminWebUI && c.Path != ""
1041+}
M internal/common/init_dockerclient.go +155 -41
@@ -19,62 +19,176 @@ func DockerInit(cc *ContainerEngineConfig) error {
1919 cc.PodmanSock = cc.Sock
2020 }
2121
22- config := NewDockerConfig(cc)
23- err := docker.InitializeClient(config)
24- if err != nil {
25- // Check if it's a "socket doesn't exist" error
26- if strings.Contains(err.Error(), "no such file or directory") {
27- // Log the error
28- log.Printf("Error initializing Docker client: %s", err)
29-
30- // Try alternative socket paths
31- socketPaths := []string{
32- "/run/podman/podman.sock", // Standard path
33- "/var/run/podman/podman.sock", // Alternative standard path
34- os.Getenv("HOME") + "/.local/podman/podman.sock", // Some distros
35- "/run/user/1000/podman/podman.sock", // User-specific socket
36- }
22+ // If socket is empty, try to detect it automatically
23+ if cc.Sock == "" {
24+ // Try standard paths first
25+ standardPaths := []string{
26+ "/var/run/docker.sock", // Standard Docker socket in containers
27+ "/run/podman/podman.sock", // Standard Podman path
28+ "/var/run/podman/podman.sock", // Alternative standard path
29+ }
3730
38- // Add Docker socket path as a fallback
39- if !isPodmanSocket {
40- socketPaths = append(socketPaths, "/var/run/docker.sock")
41- }
31+ // Add home directory based path
32+ homePath := os.Getenv("HOME")
33+ if homePath != "" {
34+ standardPaths = append(standardPaths, homePath+"/.local/podman/podman.sock")
35+ }
36+
37+ // Check standard paths first
38+ for _, path := range standardPaths {
39+ if _, err := os.Stat(path); err == nil {
40+ log.Printf("Auto-detected container engine socket at %s", path)
4241
43- for _, path := range socketPaths {
44- // Skip if it's the path we already tried
45- if path == cc.Sock {
46- continue
42+ // Detect if this is a Podman socket
43+ isPodmanSocket = detectPodmanSocket(path)
44+ if isPodmanSocket {
45+ cc.Podman = true
46+ cc.PodmanSock = path
4747 }
4848
49- // Check if this socket exists
50- if _, err := os.Stat(path); err == nil {
51- log.Printf("Found alternative socket at %s", path)
49+ cc.Sock = path
50+ break
51+ } else {
52+ log.Printf("Socket not found at: %s", path)
53+ }
54+ }
5255
53- // Detect if this is a Podman socket
54- isPodmanSocket = detectPodmanSocket(path)
55- if isPodmanSocket {
56+ // If not found in standard paths, try common user IDs
57+ if cc.Sock == "" {
58+ log.Printf("Scanning for user-specific Podman sockets in ID range 1000-1100...")
59+
60+ // Try common user IDs from 1000 to 1100 (typical range for first users on Linux)
61+ for uid := 1000; uid <= 1100; uid++ {
62+ uidStr := fmt.Sprintf("%d", uid)
63+ podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
64+ dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
65+
66+ // Check the Podman socket exists
67+ if _, err := os.Stat(podmanSocketPath); err == nil {
68+ // Check if the socket is accessible for reading/writing
69+ if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
70+ file.Close()
71+ log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
5672 cc.Podman = true
57- cc.PodmanSock = path
73+ cc.PodmanSock = podmanSocketPath
74+ cc.Sock = podmanSocketPath
75+ break
76+ } else {
77+ log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
5878 }
79+ }
5980
60- cc.Sock = path
61-
62- // Try again with the new path
63- newConfig := NewDockerConfig(cc)
64- if err := docker.InitializeClient(newConfig); err == nil {
65- return nil
81+ // Check Docker socket for this UID
82+ if _, err := os.Stat(dockerSocketPath); err == nil {
83+ if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil {
84+ file.Close()
85+ log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath)
86+ cc.Sock = dockerSocketPath
87+ break
6688 }
6789 }
6890 }
91+ }
92+ }
6993
70- // Return without an error, as we want to continue even if Docker is not available
71- log.Printf("Docker/Podman functionality will be limited or unavailable")
72- return fmt.Errorf("could not connect to container engine: %w", err)
94+ // If we found a socket, try to initialize Docker client
95+ if cc.Sock != "" {
96+ config := NewDockerConfig(cc)
97+ err := docker.InitializeClient(config)
98+ if err == nil {
99+ log.Printf("Successfully initialized Docker client with socket: %s", cc.Sock)
100+ return nil
73101 }
74102
75- return err
103+ // Log the error but continue with fallback strategy
104+ log.Printf("Error initializing Docker client: %s", err)
105+ }
106+
107+ // If we failed to find or initialize a socket, try a comprehensive fallback scan
108+ // across all common socket locations and user IDs
109+ socketPaths := []string{
110+ "/var/run/docker.sock",
111+ "/run/podman/podman.sock",
112+ "/var/run/podman/podman.sock",
113+ }
114+
115+ // Add home directory based path
116+ homePath := os.Getenv("HOME")
117+ if homePath != "" {
118+ socketPaths = append(socketPaths, homePath+"/.local/podman/podman.sock")
119+ }
120+
121+ // Skip if we already tried this path
122+ if cc.Sock != "" {
123+ skipPath := cc.Sock
124+ for i, path := range socketPaths {
125+ if path == skipPath {
126+ socketPaths = append(socketPaths[:i], socketPaths[i+1:]...)
127+ break
128+ }
129+ }
76130 }
77- return nil
131+
132+ // Try common user IDs from 1000 to 1100
133+ for uid := 1000; uid <= 1100; uid++ {
134+ uidStr := fmt.Sprintf("%d", uid)
135+ podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
136+ dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
137+
138+ // Check the Podman socket exists
139+ if _, err := os.Stat(podmanSocketPath); err == nil {
140+ // Check if the socket is accessible for reading/writing
141+ if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
142+ file.Close()
143+ log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
144+ socketPaths = append([]string{podmanSocketPath}, socketPaths...) // Prioritize this path
145+ } else {
146+ log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
147+ }
148+ }
149+
150+ // Also check Docker socket for this UID
151+ if _, err := os.Stat(dockerSocketPath); err == nil {
152+ socketPaths = append(socketPaths, dockerSocketPath)
153+ }
154+ }
155+
156+ log.Printf("Comprehensive socket scan across %d potential paths...", len(socketPaths))
157+
158+ for _, path := range socketPaths {
159+ // Skip if it's the path we already tried
160+ if path == cc.Sock {
161+ continue
162+ }
163+
164+ // Check if this socket exists
165+ if _, err := os.Stat(path); err == nil {
166+ log.Printf("Found alternative socket at %s", path)
167+
168+ // Detect if this is a Podman socket
169+ isPodmanSocket = detectPodmanSocket(path)
170+ if isPodmanSocket {
171+ cc.Podman = true
172+ cc.PodmanSock = path
173+ }
174+
175+ cc.Sock = path
176+
177+ // Try again with the new path
178+ newConfig := NewDockerConfig(cc)
179+ if err := docker.InitializeClient(newConfig); err == nil {
180+ log.Printf("Successfully initialized Docker client with socket: %s", path)
181+ return nil
182+ } else {
183+ log.Printf("Socket found but client initialization failed: %s", err)
184+ }
185+ }
186+ }
187+
188+ // If we still haven't found a valid socket, give up but don't fail the application
189+ log.Printf("No working container engine socket found after comprehensive scan")
190+ log.Printf("Docker/Podman functionality will be limited or unavailable")
191+ return fmt.Errorf("could not find or connect to any container engine socket")
78192 }
79193
80194 // detectPodmanSocket checks if a socket path is likely a Podman socket
M internal/httpserve/handlers/container_crud.go +35 -37
@@ -7,6 +7,8 @@ import (
77
88 "github.com/bnema/gordon/internal/server"
99 "github.com/bnema/gordon/internal/templating/cmdparams"
10+ templcomponents "github.com/bnema/gordon/internal/templating/models/templ/components"
11+ templadmin "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
1012 "github.com/bnema/gordon/internal/templating/render"
1113 "github.com/bnema/gordon/pkg/docker"
1214 "github.com/bnema/gordon/pkg/sanitize"
@@ -34,7 +36,7 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
3436 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
3537 if !exists {
3638 log.Debug("No mapping found for short ID: %s", shortID)
37- return c.String(http.StatusNotFound, "Image ID not found")
39+ return RenderNotFoundPage(c, a)
3840 }
3941
4042 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -49,30 +51,23 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
4951 var imageName string
5052 if len(imageInfo.RepoTags) > 0 {
5153 imageName = imageInfo.RepoTags[0]
54+ } else {
55+ imageName = imageInfo.ID
5256 }
5357
54- data := map[string]interface{}{
55- "Title": "Create a new container",
56- "ShortID": shortID,
57- "ImageID": fullID,
58- "ImageName": imageName,
58+ // Setup form data
59+ formData := templcomponents.ContainerFormData{
60+ ImageName: imageName,
61+ ImageID: fullID,
62+ ShortID: shortID,
63+ ErrorMessage: "",
5964 }
6065
61- // Render the create container page
62- rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a)
63-
64- if err != nil {
65- return sendError(c, err)
66- }
67-
68- renderedHTML, err := rendererData.Render(data, a)
69-
70- if err != nil {
71- return sendError(c, err)
72- }
73-
74- return c.HTML(200, renderedHTML)
66+ // Get the renderer
67+ renderer := render.NewTemplRenderer(a)
7568
69+ // Render the component
70+ return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData))
7671 }
7772
7873 // CreateContainerFullGET handles the full HTML page for creating a new container
@@ -84,7 +79,7 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
8479 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
8580 if !exists {
8681 log.Debug("No mapping found for short ID: %s", shortID)
87- return c.String(http.StatusNotFound, "Image ID not found")
82+ return RenderNotFoundPage(c, a)
8883 }
8984
9085 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -99,29 +94,32 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
9994 var imageName string
10095 if len(imageInfo.RepoTags) > 0 {
10196 imageName = imageInfo.RepoTags[0]
97+ } else {
98+ imageName = imageInfo.ID
10299 }
103100
104- data := map[string]interface{}{
105- "Title": "Create a new container",
106- "ShortID": shortID,
107- "ImageID": fullID,
108- "ImageName": imageName,
109- "AdminPath": a.Config.Admin.Path,
101+ // Setup form data
102+ formData := templcomponents.ContainerFormData{
103+ ImageName: imageName,
104+ ImageID: fullID,
105+ ShortID: shortID,
106+ ErrorMessage: "",
110107 }
111108
112- // Specify both admin and fragments directories
113- rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments")
114- if err != nil {
115- return sendError(c, err)
116- }
109+ // Get the renderer
110+ renderer := render.NewTemplRenderer(a)
117111
118- renderedHTML, err := rendererData.Render(data, a)
119- if err != nil {
120- log.Debug("Error rendering template: %v", err)
121- return sendError(c, err)
112+ // Render the createcontainerfull template
113+ pageData := templadmin.CreateContainerFullData{
114+ Title: "Create Container | Gordon",
115+ BuildVersion: a.Config.Build.BuildVersion,
116+ AdminPath: a.Config.Admin.Path,
117+ UserSettings: "/user",
118+ LogoutURL: a.Config.Admin.Path + "/logout",
119+ ContainerData: formData,
122120 }
123121
124- return c.HTML(200, renderedHTML)
122+ return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData))
125123 }
126124
127125 // CreateContainerPOST handles the create container form submission
M internal/httpserve/handlers/container_management.go +58 -2
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/gordon/internal/templating/render"
1313 "github.com/bnema/gordon/pkg/docker"
1414 "github.com/bnema/gordon/pkg/humanize"
15+ "github.com/bnema/gordon/pkg/logger"
1516 "github.com/docker/docker/api/types"
1617 "github.com/labstack/echo/v4"
1718 )
@@ -58,6 +59,10 @@ type HumanReadableContainerImage struct {
5859 SizeStr string
5960 RepoDigests []string
6061 RepoTags []string
62+ Repository string
63+ Tag string
64+ Size string
65+ Created string
6166 }
6267
6368 type HumanReadableContainer struct {
@@ -85,17 +90,33 @@ type ContainerDisplay struct {
8590
8691 // renderHTML is a generalized function to render HTML
8792 func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {
93+ logger.Debug("Rendering HTML", "path", path, "template", templateName, "data_keys", getKeysFromMap(data))
94+
8895 rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
8996 if err != nil {
97+ logger.Error("Error getting HTML renderer", "error", err)
9098 return err
9199 }
100+
92101 renderedHTML, err := rendererData.Render(data, a)
93102 if err != nil {
103+ logger.Error("Error rendering HTML", "error", err)
94104 return err
95105 }
106+
107+ logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML))
96108 return c.HTML(http.StatusOK, renderedHTML)
97109 }
98110
111+// getKeysFromMap returns a slice of keys from a map
112+func getKeysFromMap(data map[string]interface{}) []string {
113+ keys := make([]string, 0, len(data))
114+ for k := range data {
115+ keys = append(keys, k)
116+ }
117+ return keys
118+}
119+
99120 // ActionSuccess returns the success HTML fragment
100121 func ActionSuccess(a *server.App) string {
101122 // Create a data structure to pass to the template
@@ -114,11 +135,19 @@ func ActionSuccess(a *server.App) string {
114135
115136 // ImageManagerComponent handles the /image-manager route (HTMX route)
116137 func ImageManagerComponent(c echo.Context, a *server.App) error {
138+ logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String())
139+
117140 images, err := docker.ListContainerImages()
118141 if err != nil {
142+ logger.Error("Error listing container images:", "error", err)
119143 return sendError(c, err)
120144 }
121145
146+ logger.Debug("Images found", "count", len(images))
147+ for i, img := range images {
148+ logger.Debug("Image details", "index", i, "id", img.ID, "repoTags", img.RepoTags)
149+ }
150+
122151 var humanReadableImages []HumanReadableContainerImage
123152
124153 for _, img := range images {
@@ -129,6 +158,14 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
129158 sizeStr := humanize.BytesToReadableSize(img.Size)
130159
131160 for _, repoTag := range img.RepoTags {
161+ // Split the repo tag into repository and tag
162+ parts := strings.Split(repoTag, ":")
163+ repository := parts[0]
164+ tag := "latest"
165+ if len(parts) > 1 {
166+ tag = parts[1]
167+ }
168+
132169 humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
133170 ID: img.ID,
134171 ShortID: shortID,
@@ -137,6 +174,11 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
137174 Name: repoTag,
138175 RepoDigests: img.RepoDigests,
139176 RepoTags: img.RepoTags,
177+ // Add fields that match template expectations
178+ Repository: repository,
179+ Tag: tag,
180+ Size: sizeStr,
181+ Created: createdStr,
140182 })
141183 }
142184 }
@@ -145,6 +187,7 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
145187 "Images": humanReadableImages,
146188 }
147189
190+ logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
148191 return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)
149192 }
150193
@@ -170,11 +213,19 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
170213
171214 // ContainerManagerComponent handles the /container-manager route
172215 func ContainerManagerComponent(c echo.Context, a *server.App) error {
216+ logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
217+
173218 containers, err := docker.ListRunningContainers()
174219 if err != nil {
220+ logger.Error("Error listing containers", "error", err)
175221 return sendError(c, err)
176222 }
177223
224+ logger.Debug("Containers found", "count", len(containers))
225+ for i, container := range containers {
226+ logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State)
227+ }
228+
178229 var humanReadableContainers []HumanReadableContainer
179230
180231 for _, container := range containers {
@@ -212,10 +263,15 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
212263 return err
213264 }
214265 data := map[string]interface{}{
215- "Lang": yamlData["Lang"],
216- "containers": humanReadableContainers,
266+ "Lang": yamlData["Lang"],
267+ "Containers": humanReadableContainers,
268+ "NoContainer": len(humanReadableContainers) == 0,
217269 }
218270
271+ logger.Debug("Rendering containerlist template",
272+ "humanReadableContainers", len(humanReadableContainers),
273+ "noContainer", len(humanReadableContainers) == 0)
274+
219275 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
220276 }
221277
M internal/httpserve/handlers/container_transactions.go +3 -3
@@ -2,13 +2,13 @@ package handlers
22
33 import (
44 "fmt"
5- "log"
65 "strings"
76
87 "github.com/bnema/gordon/internal/server"
98 "github.com/bnema/gordon/internal/templating/cmdparams"
109 "github.com/bnema/gordon/internal/templating/render"
1110 "github.com/bnema/gordon/pkg/docker"
11+ "github.com/bnema/gordon/pkg/logger"
1212 "github.com/labstack/echo/v4"
1313 "gopkg.in/yaml.v3"
1414 )
@@ -132,13 +132,13 @@ func (tq *TransactionQueue) Execute() error {
132132 for i, step := range tq.steps {
133133 err := step()
134134 if err != nil {
135- log.Printf("Error occurred at step index %d: %v", i, err)
135+ logger.Error(fmt.Sprintf("Error occurred at step index %d: %v", i, err))
136136 transactionErrors = append(transactionErrors, err)
137137 for j := i - 1; j >= 0; j-- {
138138 rollback := tq.rollbacks[j]
139139 if rollback != nil {
140140 if rollbackErr := rollback(); rollbackErr != nil {
141- log.Printf("Failed to execute rollback at index %d: %v", j, rollbackErr)
141+ logger.Error(fmt.Sprintf("Failed to execute rollback at index %d: %v", j, rollbackErr))
142142 rollbackErrors = append(rollbackErrors, rollbackErr)
143143 }
144144 }
M internal/httpserve/handlers/endpoint_deploy.go +34 -34
@@ -17,7 +17,7 @@ import (
1717 "github.com/bnema/gordon/internal/proxy"
1818 "github.com/bnema/gordon/internal/server"
1919 "github.com/bnema/gordon/pkg/docker"
20- "github.com/charmbracelet/log"
20+ "github.com/bnema/gordon/pkg/logger"
2121 "github.com/labstack/echo/v4"
2222 )
2323
@@ -26,7 +26,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
2626 payload, err := validateAndPrepareDeployPayload(c)
2727 if err != nil {
2828 // Debug
29- log.Error("Failed to validate and prepare payload", "error", err)
29+ logger.Error("Failed to validate and prepare payload", "error", err)
3030 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
3131 Success: false,
3232 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -37,7 +37,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
3737 _, err = saveAndImportImage(c, a, payload)
3838 if err != nil {
3939 // Debug
40- log.Error("Failed to save and import image", "error", err)
40+ logger.Error("Failed to save and import image", "error", err)
4141 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
4242 Success: false,
4343 Message: fmt.Sprintf("Failed to save or import image: %v", err),
@@ -48,14 +48,14 @@ func PostDeploy(c echo.Context, a *server.App) error {
4848 containerID, containerName, err := createAndStartContainer(a, payload)
4949 if err != nil {
5050 if strings.Contains(err.Error(), "is already in use") {
51- log.Warn("Container already exists", "error", err)
51+ logger.Warn("Container already exists", "error", err)
5252 existingContainerID := extractContainerID(err.Error())
5353 existingContainerName, _ := docker.GetContainerName(existingContainerID)
5454 // Remove the / from the container name
5555 existingContainerName = strings.TrimLeft(existingContainerName, "/")
5656
5757 if existingContainerID == "" || existingContainerName == "" {
58- log.Error("Failed to extract container ID or Name from error message")
58+ logger.Error("Failed to extract container ID or Name from error message")
5959 return sendJSONResponse(c, http.StatusConflict, DeployResponse{
6060 Success: false,
6161 Message: "A container for this deploy already exists but could not be identified.",
@@ -71,7 +71,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
7171 StatusCode: http.StatusConflict,
7272 })
7373 }
74- log.Error("Failed to create or start container", "error", err)
74+ logger.Error("Failed to create or start container", "error", err)
7575 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
7676 Success: false,
7777 Message: fmt.Sprintf("Failed to create or start container: %v", err),
@@ -90,11 +90,11 @@ func PostDeploy(c echo.Context, a *server.App) error {
9090 containerIP := GetContainerIP(a, containerID, containerName)
9191 err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
9292 if err != nil {
93- log.Error("Failed to add proxy route", "error", err)
93+ logger.Error("Failed to add proxy route", "error", err)
9494 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
9595 }
9696 } else {
97- log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
97+ logger.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
9898 }
9999
100100 // Arrived here means deployment was successful
@@ -124,7 +124,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
124124
125125 payload, err := validateAndPrepareDeployPayload(c)
126126 if err != nil {
127- log.Error("Failed to validate and prepare payload", "error", err)
127+ logger.Error("Failed to validate and prepare payload", "error", err)
128128 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
129129 Success: false,
130130 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -196,7 +196,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
196196 // Chunks validation steps
197197 //
198198 if len(chunkData) == 0 {
199- log.Error("Received empty chunk")
199+ logger.Error("Received empty chunk")
200200 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
201201 Success: false,
202202 Message: "Received empty chunk",
@@ -206,7 +206,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
206206
207207 // Verify chunk size matches metadata
208208 if int64(len(chunkData)) != metadata.ChunkSize {
209- log.Error("Chunk size mismatch",
209+ logger.Error("Chunk size mismatch",
210210 "expected", metadata.ChunkSize,
211211 "received", len(chunkData))
212212 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
@@ -236,7 +236,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
236236 }
237237
238238 func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error {
239- log.Info("Starting complete deploy transfer processing",
239+ logger.Info("Starting complete deploy transfer processing",
240240 "transferID", transferID)
241241
242242 // Get transfer data
@@ -296,21 +296,21 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
296296 // Check for existing container
297297 existingContainerID := docker.GetContainerIDByName(containerName)
298298 if existingContainerID != "" {
299- log.Warn("Container already exists",
299+ logger.Warn("Container already exists",
300300 "name", containerName,
301301 "id", existingContainerID)
302302 return sendDeployErrorResponse(c, "Container already exists", nil)
303303 }
304304
305305 // Import the image
306- log.Info("Importing image from file",
306+ logger.Info("Importing image from file",
307307 "path", tmpFile.Name(),
308308 "imageName", imageName)
309309
310310 imageID, err := docker.ImportImageToEngine(tmpFile.Name())
311311 if err != nil {
312312 if strings.Contains(err.Error(), "platform compatibility check failed") {
313- log.Error("Platform compatibility check failed",
313+ logger.Error("Platform compatibility check failed",
314314 "error", err,
315315 "imageName", imageName)
316316 return sendDeployErrorResponse(c, "Platform compatibility check failed", err)
@@ -343,7 +343,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
343343 // Cleanup transfer data
344344 cleanupTransfer(transferID)
345345
346- log.Info("Deployment completed successfully",
346+ logger.Info("Deployment completed successfully",
347347 "containerID", containerID,
348348 "containerName", containerName,
349349 "domain", targetDomain)
@@ -352,7 +352,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
352352 containerIP := GetContainerIP(a, containerID, containerName)
353353 err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)
354354 if err != nil {
355- log.Error("Failed to add proxy route", "error", err)
355+ logger.Error("Failed to add proxy route", "error", err)
356356 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
357357 }
358358
@@ -422,7 +422,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
422422
423423 // Double check that we have a valid container IP - critical for recreated containers
424424 if containerIP == "" || containerIP == containerID || containerIP == "localhost" {
425- log.Warn("Invalid container IP detected, attempting to get correct IP",
425+ logger.Warn("Invalid container IP detected, attempting to get correct IP",
426426 "containerID", containerID,
427427 "invalid_ip", containerIP)
428428
@@ -437,7 +437,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
437437 // Get the correct container IP with retry logic
438438 newContainerIP := GetContainerIP(a, containerID, containerName)
439439 if newContainerIP != containerIP && newContainerIP != containerName {
440- log.Info("Updated container IP for proxy route",
440+ logger.Info("Updated container IP for proxy route",
441441 "containerID", containerID,
442442 "old_ip", containerIP,
443443 "new_ip", newContainerIP)
@@ -448,12 +448,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
448448 // Get container info to check labels
449449 containerInfo, err := docker.GetContainerInfo(containerID)
450450 if err != nil {
451- log.Warn("Failed to get container info", "error", err)
451+ logger.Warn("Failed to get container info", "error", err)
452452 } else {
453453 // Check if the container has a gordon.proxy.port label that might override containerPort
454454 if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
455455 if portLabel != containerPort {
456- log.Info("Using container label port instead of provided port",
456+ logger.Info("Using container label port instead of provided port",
457457 "label_port", portLabel,
458458 "provided_port", containerPort)
459459 containerPort = portLabel
@@ -467,7 +467,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
467467 // Check if the target domain specifies HTTPS
468468 if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
469469 protocol = "https"
470- log.Debug("Using HTTPS protocol for proxy route based on target domain",
470+ logger.Debug("Using HTTPS protocol for proxy route based on target domain",
471471 "domain", targetDomain,
472472 "protocol", protocol)
473473 } else {
@@ -477,7 +477,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
477477 if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
478478 (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
479479 protocol = "https"
480- log.Debug("Using HTTPS protocol for proxy route based on container labels",
480+ logger.Debug("Using HTTPS protocol for proxy route based on container labels",
481481 "domain", targetDomain,
482482 "containerID", containerID,
483483 "label_value", sslValue,
@@ -496,16 +496,16 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
496496 cleanDomain = strings.TrimPrefix(cleanDomain, ".")
497497
498498 // Log that we're adding the domain for Let's Encrypt
499- log.Debug("Adding domain for Let's Encrypt verification",
499+ logger.Debug("Adding domain for Let's Encrypt verification",
500500 "domain", cleanDomain)
501501
502502 // Get container name for better logging
503503 containerName, err := docker.GetContainerName(containerID)
504504 if err != nil {
505- log.Warn("Unable to get container name", "containerID", containerID, "error", err)
505+ logger.Warn("Unable to get container name", "containerID", containerID, "error", err)
506506 } else {
507507 containerName = strings.TrimPrefix(containerName, "/")
508- log.Debug("Container name for proxy route", "name", containerName)
508+ logger.Debug("Container name for proxy route", "name", containerName)
509509 }
510510
511511 // Check the database for any existing routes with this domain, regardless of container ID
@@ -526,7 +526,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
526526
527527 if err == nil {
528528 // Domain exists in database, check if this is a recreated container
529- log.Info("Found existing proxy route for domain",
529+ logger.Info("Found existing proxy route for domain",
530530 "domain", cleanDomain,
531531 "old_container_id", existingContainerID,
532532 "new_container_id", containerID)
@@ -538,7 +538,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
538538 networkName := a.Config.ContainerEngine.Network
539539 if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&
540540 networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {
541- log.Info("Updating container IP before database update",
541+ logger.Info("Updating container IP before database update",
542542 "domain", cleanDomain,
543543 "old_ip", containerIP,
544544 "new_ip", networkSettings.IPAddress)
@@ -559,7 +559,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
559559 return fmt.Errorf("failed to update proxy route: %w", err)
560560 }
561561
562- log.Info("Updated proxy route for recreated container",
562+ logger.Info("Updated proxy route for recreated container",
563563 "domain", cleanDomain,
564564 "old_container_id", existingContainerID,
565565 "new_container_id", containerID,
@@ -579,11 +579,11 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
579579 WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)
580580
581581 if err == nil && updatedContainerIP != containerIP {
582- log.Warn("Database verification failed: container IP mismatch",
582+ logger.Warn("Database verification failed: container IP mismatch",
583583 "expected", containerIP,
584584 "actual", updatedContainerIP)
585585 } else {
586- log.Debug("Database verification passed: container IP updated correctly",
586+ logger.Debug("Database verification passed: container IP updated correctly",
587587 "container_ip", containerIP)
588588 }
589589
@@ -596,12 +596,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
596596 routes := p.GetRoutes()
597597 if route, exists := routes[cleanDomain]; exists {
598598 if route.ContainerIP != containerIP {
599- log.Warn("Route reload verification failed: container IP mismatch",
599+ logger.Warn("Route reload verification failed: container IP mismatch",
600600 "domain", cleanDomain,
601601 "expected", containerIP,
602602 "actual", route.ContainerIP)
603603 } else {
604- log.Debug("Route reload verification passed: container IP loaded correctly",
604+ logger.Debug("Route reload verification passed: container IP loaded correctly",
605605 "domain", cleanDomain,
606606 "container_ip", containerIP)
607607 }
@@ -623,7 +623,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
623623 // and avoid database locking conflicts
624624 p.RegisterNewlyCreatedContainer(containerID)
625625
626- log.Debug("Added proxy route",
626+ logger.Debug("Added proxy route",
627627 "domain", targetDomain,
628628 "containerIP", containerIP,
629629 "containerPort", containerPort,
M internal/httpserve/handlers/endpoint_helpers.go +24 -24
@@ -18,7 +18,7 @@ import (
1818 "github.com/bnema/gordon/internal/templating/cmdparams"
1919 "github.com/bnema/gordon/pkg/docker"
2020 "github.com/bnema/gordon/pkg/filestore"
21- "github.com/charmbracelet/log"
21+ "github.com/bnema/gordon/pkg/logger"
2222 "github.com/labstack/echo/v4"
2323 )
2424
@@ -71,7 +71,7 @@ func (info *InfoResponse) Populate(a *server.App) {
7171 func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
7272 err := c.JSON(statusCode, response)
7373 if err != nil {
74- log.Error("Failed to send JSON response",
74+ logger.Error("Failed to send JSON response",
7575 "error", err,
7676 "statusCode", statusCode)
7777 }
@@ -152,7 +152,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
152152 imageID, err = docker.ImportImageToEngine(imagePath)
153153 if err != nil {
154154 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
155- log.Error("Failed to remove temporary image file after import failure",
155+ logger.Error("Failed to remove temporary image file after import failure",
156156 "error", removeErr,
157157 "path", imagePath)
158158 }
@@ -161,7 +161,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
161161
162162 if imageID == "" {
163163 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
164- log.Error("Failed to remove temporary image file after empty ID",
164+ logger.Error("Failed to remove temporary image file after empty ID",
165165 "error", removeErr,
166166 "path", imagePath)
167167 }
@@ -170,7 +170,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
170170
171171 err = filestore.RemoveFromStorage(imagePath)
172172 if err != nil {
173- log.Error("Failed to remove temporary image file after successful import",
173+ logger.Error("Failed to remove temporary image file after successful import",
174174 "error", err,
175175 "path", imagePath)
176176 return "", fmt.Errorf("failed to remove temporary image file: %v", err)
@@ -181,7 +181,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
181181
182182 // createAndStartContainer creates a container from the image, starts it and returns the container ID
183183 func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
184- log.Info("Starting container creation process",
184+ logger.Info("Starting container creation process",
185185 "imageName", payload.ImageName,
186186 "imageID", payload.ImageID,
187187 "port", payload.Port)
@@ -191,21 +191,21 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
191191 return "", "", fmt.Errorf("failed to create command parameters: %v", err)
192192 }
193193
194- log.Debug("Container parameters prepared", "params", params)
194+ logger.Debug("Container parameters prepared", "params", params)
195195
196196 containerID, err := docker.CreateContainer(params)
197197 if err != nil {
198198 return "", "", fmt.Errorf("failed to create container: %v", err)
199199 }
200200
201- log.Info("Container created successfully", "containerID", containerID)
201+ logger.Info("Container created successfully", "containerID", containerID)
202202
203203 containerName, err := docker.GetContainerName(containerID)
204204 if err != nil {
205205 return "", "", fmt.Errorf("failed to get container name: %v", err)
206206 }
207207
208- log.Info("Starting container",
208+ logger.Info("Starting container",
209209 "containerID", containerID,
210210 "name", containerName)
211211
@@ -215,7 +215,7 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
215215 // Get container logs if start failed
216216 logs, logErr := docker.GetContainerLogs(containerID)
217217 if logErr == nil {
218- log.Error("Container start failed - container logs",
218+ logger.Error("Container start failed - container logs",
219219 "logs", logs,
220220 "containerID", containerID)
221221 }
@@ -223,14 +223,14 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
223223 // Cleanup the failed container
224224 cleanupErr := docker.RemoveContainer(containerID)
225225 if cleanupErr != nil {
226- log.Error("Failed to cleanup container after start failure",
226+ logger.Error("Failed to cleanup container after start failure",
227227 "containerID", containerID,
228228 "error", cleanupErr)
229229 }
230230 return "", "", fmt.Errorf("failed to start container: %v", err)
231231 }
232232
233- log.Info("Container started successfully",
233+ logger.Info("Container started successfully",
234234 "containerID", containerID,
235235 "name", containerName)
236236
@@ -292,14 +292,14 @@ func isTransferComplete(transferID string) bool {
292292
293293 metadata := chunkStore.metadata[transferID]
294294 if metadata == nil {
295- log.Warn("No metadata found for transfer", "transferID", transferID)
295+ logger.Warn("No metadata found for transfer", "transferID", transferID)
296296 return false
297297 }
298298
299299 chunksReceived := len(chunkStore.chunks[transferID])
300300 isComplete := chunksReceived == metadata.TotalChunks
301301
302- log.Debug("Transfer completion check",
302+ logger.Debug("Transfer completion check",
303303 "transferID", transferID,
304304 "chunksReceived", chunksReceived,
305305 "totalExpected", metadata.TotalChunks,
@@ -328,7 +328,7 @@ func cleanupOldTransfers() {
328328 chunkStore.mu.Lock()
329329 for id, startTime := range chunkStore.started {
330330 if now.Sub(startTime) > transferTimeout {
331- log.Debug("Cleaning up stale transfer",
331+ logger.Debug("Cleaning up stale transfer",
332332 "transferID", id,
333333 "age", now.Sub(startTime))
334334 cleanupTransfer(id)
@@ -376,7 +376,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
376376 totalSize += int64(len(chunk))
377377 }
378378
379- log.Debug("Starting chunk assembly",
379+ logger.Debug("Starting chunk assembly",
380380 "totalChunks", totalChunks,
381381 "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))
382382
@@ -435,7 +435,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
435435 return fmt.Errorf("failed to reset file pointer: %w", err)
436436 }
437437
438- log.Debug("Chunk assembly completed successfully",
438+ logger.Debug("Chunk assembly completed successfully",
439439 "totalBytesWritten", written,
440440 "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))
441441
@@ -464,7 +464,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
464464 // If this is a retry, add an increasing delay
465465 if retry > 0 {
466466 sleepTime := time.Duration(500*retry) * time.Millisecond
467- log.Debug("Retrying to get container IP",
467+ logger.Debug("Retrying to get container IP",
468468 "attempt", retry+1,
469469 "containerID", containerID,
470470 "delay", sleepTime)
@@ -486,7 +486,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
486486 refreshedInfo, err := docker.GetContainerInfo(containerID)
487487 if err == nil {
488488 containerInfo = refreshedInfo
489- log.Debug("Refreshed container info for IP retrieval",
489+ logger.Debug("Refreshed container info for IP retrieval",
490490 "containerName", containerName,
491491 "containerID", containerID)
492492 } else {
@@ -501,7 +501,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
501501 networkName := a.Config.ContainerEngine.Network
502502 if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
503503 containerIP = networkSettings.IPAddress
504- log.Debug("Found container IP",
504+ logger.Debug("Found container IP",
505505 "containerName", containerName,
506506 "containerID", containerID,
507507 "containerIP", containerIP,
@@ -513,7 +513,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
513513 for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {
514514 if networkSettings.IPAddress != "" {
515515 containerIP = networkSettings.IPAddress
516- log.Debug("Found container IP in alternative network",
516+ logger.Debug("Found container IP in alternative network",
517517 "containerName", containerName,
518518 "containerID", containerID,
519519 "containerIP", containerIP,
@@ -533,7 +533,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
533533 // Extract just the IP address from the CIDR format
534534 ipCIDR := endpoint.IPv4Address
535535 containerIP = strings.Split(ipCIDR, "/")[0]
536- log.Debug("Found container IP via network inspection",
536+ logger.Debug("Found container IP via network inspection",
537537 "containerName", containerName,
538538 "containerID", containerID,
539539 "containerIP", containerIP,
@@ -546,12 +546,12 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
546546
547547 // If we've tried multiple times and couldn't get an IP
548548 if lastErr != nil {
549- log.Warn("Failed to get container info after multiple attempts",
549+ logger.Warn("Failed to get container info after multiple attempts",
550550 "error", lastErr,
551551 "containerID", containerID)
552552 }
553553
554- log.Warn("Could not find container IP, using container name as fallback",
554+ logger.Warn("Could not find container IP, using container name as fallback",
555555 "containerName", containerName,
556556 "containerID", containerID)
557557
A internal/httpserve/handlers/error_handlers.go +36 -0
@@ -0,0 +1,36 @@
1+package handlers
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/server"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/errors"
8+ "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/labstack/echo/v4"
10+)
11+
12+// RenderNotFoundPage renders a 404 error page using templ
13+func RenderNotFoundPage(c echo.Context, a *server.App) error {
14+ data := errors.ErrorPageData{
15+ Title: "Page Not Found | Gordon",
16+ AdminPath: a.Config.Admin.Path,
17+ StatusCode: http.StatusNotFound,
18+ Message: "Sorry, the page you are looking for does not exist.",
19+ }
20+
21+ renderer := render.NewTemplRenderer(a)
22+ return renderer.RenderTempl(c, errors.NotFoundPage(data))
23+}
24+
25+// RenderForbiddenPage renders a 403 error page using templ
26+func RenderForbiddenPage(c echo.Context, a *server.App) error {
27+ data := errors.ErrorPageData{
28+ Title: "Access Forbidden | Gordon",
29+ AdminPath: a.Config.Admin.Path,
30+ StatusCode: http.StatusForbidden,
31+ Message: "You don't have permission to access this resource.",
32+ }
33+
34+ renderer := render.NewTemplRenderer(a)
35+ return renderer.RenderTempl(c, errors.ForbiddenPage(data))
36+} ⏎̸
A internal/httpserve/handlers/templ_handlers.go +212 -0
@@ -0,0 +1,212 @@
1+package handlers
2+
3+import (
4+ "github.com/bnema/gordon/internal/server"
5+ "github.com/bnema/gordon/internal/templating/models/templ/components"
6+ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
8+ "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/bnema/gordon/pkg/docker"
10+ "github.com/docker/docker/api/types"
11+ "github.com/labstack/echo/v4"
12+ "strings"
13+ "time"
14+ "github.com/bnema/gordon/pkg/humanize"
15+ "net/http"
16+)
17+
18+// RenderTemplLoginPage renders the login page using templ
19+func RenderTemplLoginPage(c echo.Context, a *server.App) error {
20+ // Check for token in query param for backward compatibility
21+ urlToken := c.QueryParam("token")
22+ _ = urlToken // Not using this yet, but kept for compatibility
23+
24+ // Check for error message
25+ errorMsg := c.QueryParam("error")
26+
27+ // Create a templ renderer
28+ renderer := render.NewTemplRenderer(a)
29+
30+ // Render the login page
31+ return renderer.RenderTempl(c, login.LoginPage("Login", a.Config.Build.BuildVersion, errorMsg))
32+}
33+
34+// RenderTemplDashboardPage renders the dashboard page using templ
35+func RenderTemplDashboardPage(c echo.Context, a *server.App) error {
36+ // Create a templ renderer
37+ renderer := render.NewTemplRenderer(a)
38+
39+ // Get user-specific URLs
40+ adminPath := a.Config.Admin.Path
41+ userSettingsURL := adminPath + "/settings"
42+ logoutURL := adminPath + "/logout"
43+
44+ // Render the dashboard page
45+ return renderer.RenderTempl(c, admin.DashboardPage(
46+ "Dashboard",
47+ a.Config.Build.BuildVersion,
48+ adminPath,
49+ userSettingsURL,
50+ logoutURL,
51+ ))
52+}
53+
54+// RenderTemplManagerPage renders the manager page using templ
55+func RenderTemplManagerPage(c echo.Context, a *server.App) error {
56+ // Create a templ renderer
57+ renderer := render.NewTemplRenderer(a)
58+
59+ // Get container and image counts
60+ containerCount, _ := GetContainerCount(a)
61+ imageCount, _ := GetImageCount(a)
62+
63+ // Get user-specific URLs
64+ adminPath := a.Config.Admin.Path
65+ userSettingsURL := adminPath + "/settings"
66+ logoutURL := adminPath + "/logout"
67+
68+ // Render the manager page
69+ return renderer.RenderTempl(c, admin.ManagerPage(
70+ "Container Manager",
71+ a.Config.Build.BuildVersion,
72+ adminPath,
73+ userSettingsURL,
74+ logoutURL,
75+ containerCount,
76+ imageCount,
77+ ))
78+}
79+
80+// RenderTemplContainerList renders the container list component
81+func RenderTemplContainerList(c echo.Context, a *server.App) error {
82+ // Create a templ renderer
83+ renderer := render.NewTemplRenderer(a)
84+
85+ // Get the containers
86+ containers, noContainer, err := GetContainers(a)
87+ if err != nil {
88+ return err
89+ }
90+
91+ // Render the container list component
92+ return renderer.RenderTempl(c, components.ContainerList(containers, noContainer))
93+}
94+
95+// RenderTemplImageList renders the image list component
96+func RenderTemplImageList(c echo.Context, a *server.App) error {
97+ // Create a templ renderer
98+ renderer := render.NewTemplRenderer(a)
99+
100+ // Get the images
101+ images, err := docker.ListContainerImages()
102+ if err != nil {
103+ return err
104+ }
105+
106+ // Convert to human-readable format
107+ var humanReadableImages []components.HumanReadableImage
108+ for _, img := range images {
109+ shortID := img.ID[7:19]
110+
111+ for _, repoTag := range img.RepoTags {
112+ // Split the repo tag into repository and tag
113+ parts := strings.Split(repoTag, ":")
114+ repository := parts[0]
115+ tag := "latest"
116+ if len(parts) > 1 {
117+ tag = parts[1]
118+ }
119+
120+ humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
121+ ID: img.ID,
122+ ShortID: shortID,
123+ Name: repoTag,
124+ Repository: repository,
125+ Tag: tag,
126+ Size: humanize.BytesToReadableSize(img.Size),
127+ Created: humanize.TimeAgo(time.Unix(img.Created, 0)),
128+ RepoDigests: img.RepoDigests,
129+ RepoTags: img.RepoTags,
130+ })
131+ }
132+ }
133+
134+ // Render the image list component
135+ return renderer.RenderTempl(c, components.ImageList(humanReadableImages))
136+}
137+
138+// RenderTemplUploadImageForm renders the upload image form component
139+func RenderTemplUploadImageForm(c echo.Context, a *server.App) error {
140+ // Create a templ renderer
141+ renderer := render.NewTemplRenderer(a)
142+
143+ // Render the upload image form component
144+ return renderer.RenderTempl(c, components.UploadImageForm())
145+}
146+
147+// RenderTemplCreateContainerForm renders the create container form component
148+func RenderTemplCreateContainerForm(c echo.Context, a *server.App) error {
149+ // Create a templ renderer
150+ renderer := render.NewTemplRenderer(a)
151+
152+ // Get the image ID from the URL parameter
153+ shortID := c.Param("ID")
154+
155+ // Try to fetch the image info
156+ imageID, exists := safelyInteractWithIDMap(Fetch, shortID)
157+ if !exists {
158+ return c.String(http.StatusBadRequest, "Invalid image ID")
159+ }
160+
161+ // Get image info
162+ image, err := docker.GetImageInfo(imageID)
163+ if err != nil {
164+ return err
165+ }
166+
167+ // Prepare image name from repo tags
168+ imageName := "unknown"
169+ if len(image.RepoTags) > 0 {
170+ imageName = image.RepoTags[0]
171+ }
172+
173+ // Prepare form data
174+ formData := components.ContainerFormData{
175+ ImageName: imageName,
176+ ImageID: imageID,
177+ ShortID: shortID,
178+ // Default values can be set here if needed
179+ ProxyPort: "80", // Default proxy port
180+ }
181+
182+ // Render the create container form component
183+ return renderer.RenderTempl(c, components.CreateContainerForm(formData))
184+}
185+
186+// GetContainerCount returns the count of containers
187+func GetContainerCount(a *server.App) (int, error) {
188+ containers, err := docker.ListRunningContainers()
189+ if err != nil {
190+ return 0, err
191+ }
192+ return len(containers), nil
193+}
194+
195+// GetImageCount returns the count of images
196+func GetImageCount(a *server.App) (int, error) {
197+ images, err := docker.ListContainerImages()
198+ if err != nil {
199+ return 0, err
200+ }
201+ return len(images), nil
202+}
203+
204+// GetContainers returns the list of containers
205+func GetContainers(a *server.App) ([]types.Container, bool, error) {
206+ containers, err := docker.ListRunningContainers()
207+ if err != nil {
208+ return nil, false, err
209+ }
210+ noContainer := len(containers) == 0
211+ return containers, noContainer, nil
212+}⏎̸
A internal/httpserve/middleware/error_middleware.go +36 -0
@@ -0,0 +1,36 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/httpserve/handlers"
7+ "github.com/bnema/gordon/internal/server"
8+ "github.com/labstack/echo/v4"
9+)
10+
11+// CustomHTTPErrorHandler is a custom error handler that uses templ to render error pages
12+func CustomHTTPErrorHandler(a *server.App) echo.HTTPErrorHandler {
13+ return func(err error, c echo.Context) {
14+ code := http.StatusInternalServerError
15+ if he, ok := err.(*echo.HTTPError); ok {
16+ code = he.Code
17+ }
18+
19+ // Handle specific error codes with our templates
20+ switch code {
21+ case http.StatusNotFound:
22+ if err := handlers.RenderNotFoundPage(c, a); err != nil {
23+ c.Logger().Error(err)
24+ }
25+ return
26+ case http.StatusForbidden:
27+ if err := handlers.RenderForbiddenPage(c, a); err != nil {
28+ c.Logger().Error(err)
29+ }
30+ return
31+ default:
32+ // For other errors, use Echo's default error handler
33+ c.Echo().DefaultHTTPErrorHandler(err, c)
34+ }
35+ }
36+} ⏎̸
M internal/httpserve/proxy_init.go +6 -0
@@ -1,6 +1,8 @@
11 package httpserve
22
33 import (
4+ "time"
5+
46 "github.com/bnema/gordon/internal/proxy"
57 "github.com/bnema/gordon/internal/server"
68 "github.com/charmbracelet/log"
@@ -21,6 +23,10 @@ func InitializeProxy(a *server.App) (*proxy.Proxy, error) {
2123 return nil, err
2224 }
2325
26+ // Add a small delay to ensure router initialization is complete
27+ // This helps prevent race conditions during startup
28+ time.Sleep(500 * time.Millisecond)
29+
2430 log.Info("Reverse proxy initialized and started")
2531 return p, nil
2632 }
M internal/httpserve/router.go +47 -8
@@ -12,9 +12,13 @@ import (
1212
1313 // RegisterRoutes registers all routes and middlewares
1414 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
15- // Add default Echo logger middleware
16- e.Use(echomid.Logger())
1715 e.Use(echomid.Recover())
16+
17+ // Log admin status
18+ log.Info("Admin WebUI status",
19+ "enabled", a.Config.Admin.IsAdminWebUIEnabled(),
20+ "path", a.Config.Admin.Path)
21+
1822 // Initiate the session middleware
1923 e.Use(middleware.InitSessionMiddleware(a))
2024
@@ -22,7 +26,10 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
2226 e.Use(middleware.LanguageDetection)
2327
2428 AdminPath := a.Config.Admin.Path
29+ log.Debug("Binding login routes with AdminPath", "path", AdminPath)
2530 bindLoginRoute(e, a, AdminPath)
31+
32+ log.Debug("Binding API endpoints")
2633 bindAPIEndpoints(e, a)
2734
2835 // SetCommonDataMiddleware will pass data to the renderer
@@ -37,15 +44,29 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
3744 // Color scheme detection for dark/light mode
3845 e.Use(middleware.ColorSchemeDetection)
3946
47+ // Set custom error handler
48+ e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a)
49+
50+ log.Debug("Binding admin routes with AdminPath", "path", AdminPath)
4051 bindAdminRoute(e, a, AdminPath)
52+
53+ log.Debug("Binding static routes")
4154 bindStaticRoute(e, a, "/*")
42- bindHTMXEndpoints(e, a)
55+
56+ // Only bind HTMX endpoints if Admin WebUI is enabled
57+ if a.Config.Admin.IsAdminWebUIEnabled() {
58+ log.Info("Admin WebUI is enabled, binding HTMX endpoints")
59+ bindHTMXEndpoints(e, a)
60+ } else {
61+ log.Info("Admin WebUI is disabled, skipping HTMX endpoints")
62+ }
4363
4464 // Protect the root path with a 403
4565 e.GET("/", func(c echo.Context) error {
46- return c.String(403, "403 Forbidden")
66+ return handlers.RenderForbiddenPage(c, a)
4767 })
4868
69+ log.Info("All routes registered successfully")
4970 return e
5071 }
5172
@@ -101,8 +122,14 @@ func bindStaticRoute(e *echo.Echo, a *server.App, path string) {
101122
102123 // bindLoginRoute binds all login routes
103124 func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
125+ // Skip binding admin login routes if admin webUI is disabled
126+ if !a.Config.Admin.IsAdminWebUIEnabled() {
127+ log.Debug("Admin WebUI is disabled, skipping admin login routes")
128+ return
129+ }
130+
104131 e.GET(adminPath+"/login", func(c echo.Context) error {
105- return handlers.RenderLoginPage(c, a)
132+ return handlers.RenderTemplLoginPage(c, a)
106133 })
107134 e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {
108135 return handlers.HandleTokenSubmission(c, a)
@@ -120,6 +147,12 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
120147
121148 // bindAdminRoute binds all admin routes
122149 func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
150+ // Skip binding admin routes if admin webUI is disabled
151+ if !a.Config.Admin.IsAdminWebUIEnabled() {
152+ log.Debug("Admin WebUI is disabled, skipping admin routes")
153+ return
154+ }
155+
123156 adminGroup := e.Group(adminPath)
124157 // Since login is behind the admin path, we cannot use group middleware
125158 adminGroup.GET("", func(c echo.Context) error {
@@ -127,7 +160,7 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
127160 }, middleware.RequireLogin(a))
128161
129162 adminGroup.GET("/manager", func(c echo.Context) error {
130- return handlers.AdminManagerRoute(c, a)
163+ return handlers.RenderTemplManagerPage(c, a)
131164 }, middleware.RequireLogin(a))
132165 adminGroup.GET("/cc/:ID", func(c echo.Context) error {
133166 return handlers.CreateContainerFullGET(c, a)
@@ -146,7 +179,13 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
146179
147180 // bindHTMXEndpoints binds all HTMX endpoints
148181 func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
149- // Create a group for /htmx endpoints
182+ // Skip if AdminWebUI is disabled
183+ if !a.Config.Admin.IsAdminWebUIEnabled() {
184+ log.Debug("Admin WebUI is disabled, skipping HTMX endpoints")
185+ return
186+ }
187+
188+ // Create a group for /htmx endpoints
150189 htmxGroup := e.Group("/htmx")
151190
152191 // Apply middleware to the group, so all endpoints are protected
@@ -163,7 +202,7 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
163202
164203 // List all containers
165204 htmxGroup.GET("/container-manager", func(c echo.Context) error {
166- return handlers.ContainerManagerComponent(c, a)
205+ return handlers.RenderTemplContainerList(c, a)
167206 })
168207 // Stop a container
169208 htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {
M internal/proxy/certificates.go +126 -0
@@ -24,8 +24,116 @@ import (
2424 "golang.org/x/crypto/acme/autocert"
2525 )
2626
27+// detectUpstreamProxy checks for signs of an upstream TLS-terminating proxy
28+func (p *Proxy) detectUpstreamProxy() bool {
29+ // Check if detection is explicitly disabled
30+ if !p.config.DetectUpstreamProxy {
31+ return false
32+ }
33+
34+ // First check our cached detection status - if we already detected a proxy previously
35+ p.upstreamProxyMu.RLock()
36+ alreadyDetected := p.upstreamProxyDetected
37+ p.upstreamProxyMu.RUnlock()
38+
39+ if alreadyDetected {
40+ return true
41+ }
42+
43+ // Check for known HTTP headers that indicate a TLS termination proxy
44+ // This is a best-effort approach since we're analyzing HTTP headers from
45+ // previous requests stored in our context (if available)
46+
47+ // Look for common proxy headers in our recent requests
48+ // This is a static check at startup; ideally we would check incoming
49+ // requests dynamically, but for this implementation we're using
50+ // known proxy environment indicators
51+
52+ // Most common proxy headers: X-Forwarded-Proto, X-Forwarded-SSL, X-Forwarded-For
53+ // Environment variables: HTTPS=on, HTTP_X_FORWARDED_PROTO=https
54+
55+ // Check for common environment variables set by upstream proxies
56+ if os.Getenv("HTTPS") == "on" ||
57+ os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" ||
58+ os.Getenv("HTTP_X_FORWARDED_SSL") == "on" {
59+ logger.Info("Upstream TLS-terminating proxy detected via environment variables",
60+ "detection_method", "environment_variables",
61+ "https", os.Getenv("HTTPS"),
62+ "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"),
63+ "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL"))
64+
65+ // Cache this result
66+ p.upstreamProxyMu.Lock()
67+ p.upstreamProxyDetected = true
68+ p.upstreamProxyMu.Unlock()
69+
70+ return true
71+ }
72+
73+ // Check if Cloudflare headers are present in environment
74+ // Cloudflare sets environment variables like HTTP_CF_VISITOR={"scheme":"https"}
75+ if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" {
76+ if strings.Contains(cfVisitor, "\"scheme\":\"https\"") {
77+ logger.Info("Cloudflare proxy detected via environment variables",
78+ "detection_method", "cloudflare_headers",
79+ "cf_visitor", cfVisitor)
80+
81+ // Cache this result
82+ p.upstreamProxyMu.Lock()
83+ p.upstreamProxyDetected = true
84+ p.upstreamProxyMu.Unlock()
85+
86+ return true
87+ }
88+ }
89+
90+ // Not detected, but log if explicit configuration is set
91+ if p.config.SkipCertificates {
92+ logger.Info("No upstream proxy detected automatically, but certificate acquisition will be skipped due to configuration",
93+ "detection_result", "false",
94+ "skip_certificates", "true (forced via config)")
95+ }
96+
97+ return false
98+}
99+
27100 // setupCertManager configures the autocert manager for Let's Encrypt
28101 func (p *Proxy) setupCertManager() {
102+ // First check for upstream TLS-terminating proxies if detection is enabled
103+ isProxied := p.detectUpstreamProxy()
104+
105+ // Skip certificate acquisition if we're behind a TLS-terminating proxy
106+ // and the skip certificates option is enabled
107+ if isProxied && p.config.SkipCertificates {
108+ logger.Info("Upstream TLS-terminating proxy detected, skipping Let's Encrypt certificate acquisition",
109+ "upstream_proxy", "detected",
110+ "certificate_acquisition", "disabled")
111+ // We still setup a fallback cert for the admin domain
112+ adminDomain := p.app.GetConfig().Http.FullDomain()
113+ rootDomain := p.app.GetConfig().Http.Domain
114+
115+ var fallbackDomains []string
116+ fallbackDomains = append(fallbackDomains, adminDomain)
117+
118+ // Include root domain in fallback certificate if it's different from admin domain
119+ if rootDomain != "" && rootDomain != adminDomain {
120+ fallbackDomains = append(fallbackDomains, rootDomain)
121+ }
122+
123+ fallbackCert, err := generateFallbackCertificates(fallbackDomains)
124+ if err != nil {
125+ logger.Warn("Failed to generate fallback certificate", "error", err)
126+ } else {
127+ logger.Info("Generated fallback self-signed certificate for admin domain behind proxy",
128+ "domain", adminDomain,
129+ "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05"))
130+ // Store the fallback certificate
131+ p.fallbackCert = fallbackCert
132+ }
133+
134+ return
135+ }
136+
29137 // Create a cache directory if it doesn't exist
30138 dir := p.config.CertDir
31139 if dir == "" {
@@ -384,6 +492,15 @@ func (p *Proxy) requestAdminCertificate() {
384492 logger.Debug("HTTPS is disabled, skipping admin certificate request")
385493 return
386494 }
495+
496+ // Check for upstream proxy with certificate skipping enabled
497+ isProxied := p.detectUpstreamProxy()
498+ if isProxied && p.config.SkipCertificates {
499+ logger.Info("Upstream TLS-terminating proxy detected, skipping admin certificate request",
500+ "domain", adminDomain,
501+ "skip_reason", "behind TLS-terminating proxy")
502+ return
503+ }
387504
388505 // Extract hostname from admin domain, resolving it to see if it's publicly accessible
389506 host := adminDomain
@@ -539,6 +656,15 @@ func (p *Proxy) requestDomainCertificate(domain string) {
539656 logger.Warn("Empty domain provided to requestDomainCertificate")
540657 return
541658 }
659+
660+ // Check for upstream proxy with certificate skipping enabled
661+ isProxied := p.detectUpstreamProxy()
662+ if isProxied && p.config.SkipCertificates {
663+ logger.Info("Upstream TLS-terminating proxy detected, skipping domain certificate request",
664+ "domain", domain,
665+ "skip_reason", "behind TLS-terminating proxy")
666+ return
667+ }
542668
543669 // Extract hostname from domain, resolving it to see if it's publicly accessible
544670 ips, err := net.LookupIP(domain)
M internal/proxy/containers.go +7 -5
@@ -1,7 +1,6 @@
11 package proxy
22
33 import (
4- "fmt"
54 "os"
65 "strings"
76
@@ -12,11 +11,11 @@ import (
1211 // scanContainersAndCheckCertificates scans all containers from the Gordon network
1312 // and checks if we have certificates for their domains and subdomains
1413 func (p *Proxy) scanContainersAndCheckCertificates() error {
15- // Get the network name from app config
1614 networkName := p.app.GetConfig().ContainerEngine.Network
17-
1815 if networkName == "" {
19- return fmt.Errorf("container network name is not configured")
16+ // If no network is defined, skip container scanning
17+ logger.Info("No container network defined, skipping certificate checks")
18+ return nil
2019 }
2120
2221 logger.Info("Scanning containers in network for certificate checks",
@@ -25,7 +24,10 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
2524 // Try to get network info using the docker client
2625 networkInfo, err := docker.GetNetworkInfo(networkName)
2726 if err != nil {
28- return fmt.Errorf("failed to get network info: %w", err)
27+ logger.Warn("Failed to get network info, skipping certificate checks",
28+ "error", err,
29+ "network", networkName)
30+ return nil // Return nil instead of error to continue execution
2931 }
3032
3133 // Skip admin cert check if environment variable is set
M internal/proxy/core.go +34 -6
@@ -22,7 +22,6 @@ import (
2222 // - certificates.go - Certificate management functions
2323 // - routes.go - Route management functions
2424 // - middleware.go - HTTP middleware functions
25-// - blacklist.go - IP blacklisting functionality
2625 // - server.go - Server startup and shutdown
2726 // - utils.go - Utility functions
2827
@@ -58,6 +57,10 @@ type Proxy struct {
5857 recentContainersMu sync.RWMutex
5958 containerCooldownPeriod time.Duration
6059
60+ // Upstream proxy detection
61+ upstreamProxyDetected bool
62+ upstreamProxyMu sync.RWMutex
63+
6164 // SQL queries
6265 queries *queries.ProxyQueries
6366 }
@@ -66,8 +69,36 @@ type Proxy struct {
6669 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
6770 logger.Debug("Initializing reverse proxy")
6871
69- // Log important configuration information
70- logger.Debug("All internal connections to containers will use HTTP protocol regardless of external protocol")
72+ // Set up the config
73+ config := app.GetConfig().ReverseProxy
74+
75+ // Check if proxy is disabled through configuration
76+ if !config.Enabled {
77+ logger.Info("Reverse proxy is disabled via configuration")
78+ // Return a non-active proxy instance
79+ return &Proxy{
80+ config: config,
81+ app: app,
82+ serverStarted: false,
83+ shutdown: make(chan struct{}),
84+ }, nil
85+ }
86+
87+ // Check if running inside a container
88+ isContainer := docker.IsRunningInContainer()
89+ logger.Debug("Container environment check", "is_running_in_container", isContainer)
90+
91+ if !isContainer {
92+ logger.Info("Gordon is not running in a container, proxy will be disabled automatically")
93+ // Override the enabled setting to false
94+ config.Enabled = false
95+ return &Proxy{
96+ config: config,
97+ app: app,
98+ serverStarted: false,
99+ shutdown: make(chan struct{}),
100+ }, nil
101+ }
71102
72103 // Set up the echo server for HTTPS traffic
73104 httpsServer := echo.New()
@@ -77,9 +108,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
77108 httpServer := echo.New()
78109 httpServer.HideBanner = true
79110
80- // Set up the config
81- config := app.GetConfig().ReverseProxy
82-
83111 // Detect and store our container ID (if we're running in a container)
84112 var ourContainerID string
85113 if docker.IsRunningInContainer() {
M internal/proxy/middleware.go +90 -8
@@ -2,6 +2,7 @@ package proxy
22
33 import (
44 "net/http"
5+ "strings"
56 "time"
67
78 "github.com/bnema/gordon/pkg/logger"
@@ -30,8 +31,8 @@ func (p *Proxy) setupMiddleware() {
3031 p.httpsServer.Use(p.createRequestIDMiddleware())
3132 p.httpServer.Use(p.createRequestIDMiddleware())
3233
33- // Check if rate limiting is disabled via configuration
34- if !p.config.DisableRateLimit {
34+ // Check if rate limiting is enabled via configuration
35+ if p.config.EnableRateLimit {
3536 // Create a new Starskey rate limiter store for HTTPS server
3637 httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(
3738 "./data/ratelimiter/https", // Path to store rate limit data
@@ -98,12 +99,12 @@ func (p *Proxy) setupMiddleware() {
9899 }
99100
100101 // Add custom logger middleware with request ID if enabled in config
101- if p.config.EnableLogs {
102+ if p.config.EnableHttpLogs {
102103 p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
103104 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
104105 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
105106 Skipper: func(c echo.Context) bool {
106- return !p.config.EnableLogs
107+ return !p.config.EnableHttpLogs
107108 },
108109 }))
109110 logger.Debug("HTTP request logging enabled for HTTPS server")
@@ -111,8 +112,8 @@ func (p *Proxy) setupMiddleware() {
111112 logger.Debug("HTTP request logging disabled for HTTPS server")
112113 }
113114
114- // Check if rate limiting is disabled via configuration
115- if !p.config.DisableRateLimit {
115+ // Check if rate limiting is enabled via configuration
116+ if p.config.EnableRateLimit {
116117 // Create a new Starskey rate limiter store for HTTP server
117118 httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(
118119 "./data/ratelimiter/http", // Path to store rate limit data
@@ -179,12 +180,12 @@ func (p *Proxy) setupMiddleware() {
179180 }
180181
181182 // Add custom logger middleware with request ID for HTTP server if enabled in config
182- if p.config.EnableLogs {
183+ if p.config.EnableHttpLogs {
183184 p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
184185 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
185186 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
186187 Skipper: func(c echo.Context) bool {
187- return !p.config.EnableLogs
188+ return !p.config.EnableHttpLogs
188189 },
189190 }))
190191 logger.Debug("HTTP request logging enabled for HTTP server")
@@ -206,8 +207,89 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc {
206207 // Add it as a response header
207208 c.Response().Header().Set(echo.HeaderXRequestID, requestID)
208209
210+ // If proxy detection is enabled, check for upstream proxy headers
211+ if p.config.DetectUpstreamProxy {
212+ p.detectUpstreamProxyFromRequest(c)
213+ }
214+
209215 // Continue processing
210216 return next(c)
211217 }
212218 }
213219 }
220+
221+// detectUpstreamProxyFromRequest checks for headers that indicate TLS termination upstream
222+// This allows real-time detection as requests come in
223+func (p *Proxy) detectUpstreamProxyFromRequest(c echo.Context) {
224+ // Only check if detection is enabled
225+ if !p.config.DetectUpstreamProxy {
226+ return
227+ }
228+
229+ // First check our cached detection status
230+ p.upstreamProxyMu.RLock()
231+ alreadyDetected := p.upstreamProxyDetected
232+ p.upstreamProxyMu.RUnlock()
233+
234+ // Skip if we already detected a proxy previously
235+ if alreadyDetected {
236+ return
237+ }
238+
239+ req := c.Request()
240+ headers := req.Header
241+ proxyDetected := false
242+ detectionMethod := ""
243+ headerName := ""
244+ headerValue := ""
245+
246+ // Check for common TLS termination proxy headers
247+ // X-Forwarded-Proto: https indicates an upstream proxy terminated TLS
248+ if proto := headers.Get("X-Forwarded-Proto"); proto == "https" {
249+ proxyDetected = true
250+ detectionMethod = "request_header"
251+ headerName = "X-Forwarded-Proto"
252+ headerValue = proto
253+ }
254+
255+ // X-Forwarded-SSL: on indicates an upstream proxy terminated TLS
256+ if !proxyDetected {
257+ if ssl := headers.Get("X-Forwarded-SSL"); ssl == "on" {
258+ proxyDetected = true
259+ detectionMethod = "request_header"
260+ headerName = "X-Forwarded-SSL"
261+ headerValue = ssl
262+ }
263+ }
264+
265+ // Cloudflare specific headers
266+ if !proxyDetected {
267+ if cf := headers.Get("CF-Visitor"); cf != "" && strings.Contains(cf, "\"scheme\":\"https\"") {
268+ proxyDetected = true
269+ detectionMethod = "cloudflare_header"
270+ headerName = "CF-Visitor"
271+ headerValue = cf
272+ }
273+ }
274+
275+ // If we detected a proxy, store the detection and log it
276+ if proxyDetected {
277+ // Store the detection in our state with lock protection
278+ p.upstreamProxyMu.Lock()
279+ p.upstreamProxyDetected = true
280+ p.upstreamProxyMu.Unlock()
281+
282+ logger.Info("Upstream TLS-terminating proxy detected from request headers",
283+ "detection_method", detectionMethod,
284+ "header", headerName,
285+ "value", headerValue,
286+ "host", req.Host)
287+
288+ // If certificate skipping is enabled, log it
289+ if p.config.SkipCertificates {
290+ logger.Info("Upstream TLS-terminating proxy detected during request, future certificate requests will be skipped",
291+ "host", req.Host,
292+ "certificate_acquisition", "will be skipped for new domains")
293+ }
294+ }
295+}
M internal/proxy/routes.go +105 -732
@@ -3,15 +3,12 @@ package proxy
33 import (
44 "database/sql"
55 "fmt"
6- "net"
7- "net/http"
8- "net/http/httputil"
96 "net/url"
107 "os"
118 "strings"
129 "time"
1310
14- "github.com/bnema/gordon/pkg/docker"
11+ docker "github.com/bnema/gordon/pkg/docker"
1512 "github.com/bnema/gordon/pkg/logger"
1613 "github.com/labstack/echo/v4"
1714 )
@@ -42,12 +39,7 @@ func (p *Proxy) loadRoutes() error {
4239 }
4340
4441 // Save the admin domain route if it exists
45- adminDomain := p.app.GetConfig().Http.FullDomain()
46- var adminRoute *ProxyRouteInfo
47- if route, exists := p.routes[adminDomain]; exists {
48- adminRoute = route
49- logger.Debug("Preserving admin domain route", "domain", adminDomain)
50- }
42+ adminRoute := p.saveAdminRoute()
5143
5244 // Query the database for active proxy routes using retry mechanism
5345 rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes)
@@ -58,8 +50,8 @@ func (p *Proxy) loadRoutes() error {
5850 // Restore admin route if it was saved
5951 if adminRoute != nil {
6052 p.routes = make(map[string]*ProxyRouteInfo)
61- p.routes[adminDomain] = adminRoute
62- logger.Debug("Restored admin domain route after empty database query", "domain", adminDomain)
53+ p.restoreAdminRoute(adminRoute, p.routes)
54+ logger.Debug("Restored admin domain route after empty database query", "domain", p.app.GetConfig().Http.FullDomain())
6355 }
6456
6557 return nil
@@ -72,10 +64,7 @@ func (p *Proxy) loadRoutes() error {
7264 p.routes = make(map[string]*ProxyRouteInfo)
7365
7466 // Restore admin route if it was saved
75- if adminRoute != nil {
76- p.routes[adminDomain] = adminRoute
77- logger.Debug("Restored admin domain route after clearing routes map", "domain", adminDomain)
78- }
67+ p.restoreAdminRoute(adminRoute, p.routes)
7968
8069 // Populate the routes map
8170 for rows.Next() {
@@ -85,19 +74,11 @@ func (p *Proxy) loadRoutes() error {
8574 return fmt.Errorf("failed to scan row: %w", err)
8675 }
8776
88- // Normalize domain (lowercase, no trailing dot)
89- domain = strings.TrimSuffix(strings.ToLower(domain), ".")
77+ // Normalize domain
78+ domain = normalizeDomain(domain)
9079
9180 // Add the route to the map
92- p.routes[domain] = &ProxyRouteInfo{
93- Domain: domain,
94- ContainerID: containerID,
95- ContainerIP: containerIP,
96- ContainerPort: containerPort,
97- Protocol: protocol,
98- Path: path,
99- Active: active,
100- }
81+ p.routes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active)
10182
10283 logger.Debug("Loaded proxy route",
10384 "domain", domain,
@@ -112,114 +93,29 @@ func (p *Proxy) loadRoutes() error {
11293
11394 // configureRoutes sets up the HTTP and HTTPS routes
11495 func (p *Proxy) configureRoutes() {
96+ // Ensure only one goroutine can configure routes at a time
97+ p.mu.Lock()
98+ defer p.mu.Unlock()
99+
115100 // Get the admin domain from the config
116101 adminDomain := p.app.GetConfig().Http.FullDomain()
117102 logger.Debug("Configuring proxy routes", "admin_domain", adminDomain)
118103
119104 // Verify admin domain is in routes - if not, we need to add it
120- p.mu.RLock()
121105 _, adminRouteExists := p.routes[adminDomain]
122- p.mu.RUnlock()
123106
124107 if !adminRouteExists {
125- logger.Warn("Admin domain route is missing, attempting to recreate it")
126- p.mu.Lock()
127- // Auto-detect the Gordon container name
128- containerName := p.detectGordonContainer()
129-
130- // In a container environment, we need to use the host container IP
131- // instead of 127.0.0.1 because each container has its own localhost
132- containerIP := "localhost" // Default to localhost for most reliable connectivity
133-
134- // Fall back options for container IP
135- if os.Getenv("GORDON_ADMIN_HOST") != "" {
136- // Allow explicit configuration via env var
137- containerIP = os.Getenv("GORDON_ADMIN_HOST")
138- } else if os.Getenv("HOSTNAME") != "" {
139- // Use container's own hostname as they're on the same network
140- containerIP = os.Getenv("HOSTNAME")
141- }
142-
143- p.routes[adminDomain] = &ProxyRouteInfo{
144- Domain: adminDomain,
145- ContainerIP: containerIP,
146- ContainerPort: p.app.GetConfig().Http.Port,
147- ContainerID: containerName,
148- Protocol: "http", // Gordon server uses HTTP internally
149- Path: "/",
150- Active: true,
151- }
108+ // Create new admin route
109+ p.routes[adminDomain] = p.createAdminRoute()
152110 logger.Info("Recreated admin domain route",
153111 "domain", adminDomain,
154- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
155- p.mu.Unlock()
112+ "target", fmt.Sprintf("http://%s:%s", p.routes[adminDomain].ContainerIP, p.routes[adminDomain].ContainerPort))
156113 } else {
157114 logger.Debug("Admin domain route exists", "domain", adminDomain)
158115 }
159116
160- // Handler function - will be assigned to both HTTP and HTTPS servers
161- handler := echo.HandlerFunc(func(c echo.Context) error {
162- host := c.Request().Host
163-
164- // Strip port if present in the host
165- if hostParts := strings.Split(host, ":"); len(hostParts) > 1 {
166- host = hostParts[0]
167- }
168-
169- // Normalize host (trim trailing dot, convert to lowercase)
170- host = strings.TrimSuffix(strings.ToLower(host), ".")
171-
172- // Check if this is the admin domain
173- adminDomain := p.app.GetConfig().Http.FullDomain()
174- if strings.EqualFold(host, adminDomain) {
175- // Always prioritize admin domain
176- p.mu.RLock()
177- adminRoute, adminOk := p.routes[adminDomain]
178- p.mu.RUnlock()
179-
180- if adminOk {
181- return p.proxyRequest(c, adminRoute)
182- }
183- }
184-
185- // Important: Get the route freshly from the map for every request
186- // This ensures we always use the most up-to-date IP
187- p.mu.RLock()
188- route, ok := p.routes[host]
189- p.mu.RUnlock()
190-
191- if !ok {
192- // Check if the host is an IP address - silently handle without logging warnings
193- if net.ParseIP(host) != nil {
194- // For IP-based requests, just return a 404 without logging warnings
195- return c.String(http.StatusNotFound, "Domain not found")
196- }
197-
198- // Create list of available domains for debugging
199- availableDomains := make([]string, 0, len(p.routes))
200- p.mu.RLock()
201- for d := range p.routes {
202- availableDomains = append(availableDomains, d)
203- }
204- p.mu.RUnlock()
205-
206- // Log warning for non-IP hosts that aren't configured
207- logger.Warn("Request with unknown host",
208- "requested_host", host,
209- "client_ip", c.RealIP(),
210- "available_domains", strings.Join(availableDomains, ", "))
211- return c.String(http.StatusNotFound, "Domain not found")
212- }
213-
214- // Check if the route is active
215- if !route.Active {
216- logger.Warn("Route is not active",
217- "domain", host,
218- "client_ip", c.RealIP())
219- return c.String(http.StatusServiceUnavailable, "Route is not active")
220- }
221- return p.proxyRequest(c, route)
222- })
117+ // Create handler function for both HTTP and HTTPS servers
118+ handler := p.createProxyHandler()
223119
224120 // Add the handler to the HTTPS server
225121 if p.httpsServer != nil {
@@ -237,8 +133,8 @@ func (p *Proxy) configureRoutes() {
237133 // Note: Regardless of the external protocol, all internal connections to containers use HTTP.
238134 // The HTTPS protocol is only used for external connections and certificates.
239135 func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error {
240- // Normalize domain name (lowercase, no trailing dot)
241- domainName = strings.TrimSuffix(strings.ToLower(domainName), ".")
136+ // Normalize domain name
137+ domainName = normalizeDomain(domainName)
242138
243139 logger.Debug("Adding proxy route",
244140 "domain", domainName,
@@ -256,15 +152,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
256152
257153 // Add the route to memory immediately for better UX
258154 p.mu.Lock()
259- p.routes[domainName] = &ProxyRouteInfo{
260- Domain: domainName,
261- ContainerID: containerID,
262- ContainerIP: containerIP,
263- ContainerPort: containerPort,
264- Protocol: protocol,
265- Path: path,
266- Active: true,
267- }
155+ p.routes[domainName] = createRouteInfo(domainName, containerID, containerIP, containerPort, protocol, path, true)
268156 p.mu.Unlock()
269157
270158 // Return success - the event listener will handle the database update
@@ -324,37 +212,8 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
324212 logger.Debug("Added domain to database for Let's Encrypt",
325213 "domain", hostname)
326214
327- // Also add the subdomain.domain format to the database if this is a domain with components
328- hostParts := strings.Split(hostname, ".")
329- if len(hostParts) >= 3 {
330- // This appears to be in format subdomain.domain.tld
331- // Extract subdomain and domain parts
332- subdomain := hostParts[0]
333- domainPart := strings.Join(hostParts[1:], ".")
334-
335- // Check if the domain part already exists
336- var domainPartID string
337- err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID)
338- if err != nil {
339- if err == sql.ErrNoRows {
340- // Domain part doesn't exist, create it
341- domainPartID = proxyGenerateUUID()
342- _, err = txExecWithRetry(tx,
343- p.queries.InsertDomain,
344- domainPartID, domainPart, accountID, now, now,
345- )
346- if err != nil {
347- logger.Warn("Failed to insert domain part, continuing anyway",
348- "domain_part", domainPart,
349- "error", err)
350- } else {
351- logger.Debug("Added domain part to database for Let's Encrypt",
352- "domain_part", domainPart,
353- "subdomain", subdomain)
354- }
355- }
356- }
357- }
215+ // Process subdomain if needed
216+ p.processNewSubdomain(tx, hostname, accountID, now)
358217 } else {
359218 logger.Error("Failed to query domain",
360219 "domain", hostname,
@@ -439,27 +298,23 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
439298 // Register this container to prevent duplicate processing
440299 p.RegisterNewlyCreatedContainer(containerID)
441300
442- // Update the in-memory routes map immediately for better UX
301+ // Lock to update the in-memory routes map and reload routes
443302 p.mu.Lock()
444- p.routes[hostname] = &ProxyRouteInfo{
445- Domain: hostname,
446- ContainerID: containerID,
447- ContainerIP: containerIP,
448- ContainerPort: containerPort,
449- Protocol: protocol,
450- Path: path,
451- Active: true,
303+
304+ // Update the in-memory routes map
305+ hostname = domainName
306+ p.routes[hostname] = createRouteInfo(hostname, containerID, containerIP, containerPort, protocol, path, true)
307+
308+ // Reload the routes directly while holding the lock
309+ // This avoids the race condition from concurrent reload operations
310+ if err := p.Reload(); err != nil {
311+ logger.Error("Failed to reload routes after adding route",
312+ "domain", hostname,
313+ "error", err)
452314 }
453- p.mu.Unlock()
454315
455- // Reload the routes in the background to avoid blocking
456- go func() {
457- if err := p.Reload(); err != nil {
458- logger.Error("Failed to reload routes after adding route",
459- "domain", hostname,
460- "error", err)
461- }
462- }()
316+ // Unlock before continuing with certificate operations
317+ p.mu.Unlock()
463318
464319 logger.Info("Added proxy route",
465320 "domain", hostname,
@@ -488,10 +343,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
488343 // RemoveRoute removes a route from the database and reloads the proxy
489344 func (p *Proxy) RemoveRoute(domainName string) error {
490345 // Prevent removing the admin domain
491- adminDomain := p.app.GetConfig().Http.FullDomain()
492- if domainName == adminDomain {
493- logger.Warn("Attempt to remove admin domain route prevented",
494- "domain", domainName)
346+ if err := p.checkAdminDomainProtection(domainName); err != nil {
495347 return fmt.Errorf("cannot remove admin domain route: %s", domainName)
496348 }
497349
@@ -512,16 +364,9 @@ func (p *Proxy) RemoveRoute(domainName string) error {
512364 return fmt.Errorf("failed to query domain: %w", err)
513365 }
514366
515- // Delete the route with retry
516- _, err = txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID)
517- if err != nil {
518- return fmt.Errorf("failed to delete route: %w", err)
519- }
520-
521- // Delete the domain with retry
522- _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID)
523- if err != nil {
524- return fmt.Errorf("failed to delete domain: %w", err)
367+ // Delete the route and domain from the database
368+ if err := p.deleteRouteWithTransaction(tx, domainID); err != nil {
369+ return err
525370 }
526371
527372 // Commit the transaction
@@ -556,27 +401,17 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo {
556401 func (p *Proxy) Reload() error {
557402 logger.Info("Reloading proxy routes from database")
558403
559- // Get admin domain - this should be the full subdomain.domain format
404+ // Get admin domain
560405 adminDomain := p.app.GetConfig().Http.FullDomain()
561- // Normalize the admin domain (lowercase, no trailing dot)
562- adminDomain = strings.TrimSuffix(strings.ToLower(adminDomain), ".")
406+ // Normalize the admin domain
407+ adminDomain = normalizeDomain(adminDomain)
408+
409+ // Lock for the entire reload operation to prevent concurrent modifications
410+ p.mu.Lock()
411+ defer p.mu.Unlock()
563412
564413 // Save existing admin route if it exists
565- p.mu.RLock()
566- var adminRoute *ProxyRouteInfo
567- if route, exists := p.routes[adminDomain]; exists {
568- adminRoute = &ProxyRouteInfo{
569- Domain: route.Domain,
570- ContainerID: route.ContainerID,
571- ContainerIP: route.ContainerIP,
572- ContainerPort: route.ContainerPort,
573- Protocol: route.Protocol,
574- Path: route.Path,
575- Active: route.Active,
576- }
577- logger.Debug("Preserved admin domain route during reload", "domain", adminDomain)
578- }
579- p.mu.RUnlock()
414+ adminRoute := p.saveAdminRoute()
580415
581416 // Create a new route map instead of modifying the existing one
582417 // This ensures a clean reload without any potential race conditions
@@ -602,19 +437,11 @@ func (p *Proxy) Reload() error {
602437 return fmt.Errorf("failed to scan row during reload: %w", err)
603438 }
604439
605- // Normalize domain (lowercase, no trailing dot)
606- domain = strings.TrimSuffix(strings.ToLower(domain), ".")
440+ // Normalize domain
441+ domain = normalizeDomain(domain)
607442
608443 // Add the route to the new map
609- newRoutes[domain] = &ProxyRouteInfo{
610- Domain: domain,
611- ContainerID: containerID,
612- ContainerIP: containerIP,
613- ContainerPort: containerPort,
614- Protocol: protocol,
615- Path: path,
616- Active: active,
617- }
444+ newRoutes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active)
618445
619446 logger.Debug("Loaded route during reload",
620447 "domain", domain,
@@ -654,38 +481,18 @@ func (p *Proxy) Reload() error {
654481
655482 // If admin domain is still missing, recreate it
656483 if _, exists := newRoutes[adminDomain]; !exists {
657- logger.Warn("Admin domain route is missing, recreating it", "domain", adminDomain)
658-
659- // Auto-detect the Gordon container information
660- containerName := p.detectGordonContainer()
661- containerIP := "localhost" // Default to localhost
662-
663- // Fall back options for container IP
664- if os.Getenv("GORDON_ADMIN_HOST") != "" {
665- containerIP = os.Getenv("GORDON_ADMIN_HOST")
666- } else if os.Getenv("HOSTNAME") != "" {
667- containerIP = os.Getenv("HOSTNAME")
668- }
669-
670- // Add to in-memory routes
671- newRoutes[adminDomain] = &ProxyRouteInfo{
672- Domain: adminDomain,
673- ContainerID: containerName,
674- ContainerIP: containerIP,
675- ContainerPort: p.app.GetConfig().Http.Port,
676- Protocol: "http", // Gordon server uses HTTP internally
677- Path: "/",
678- Active: true,
679- }
484+ // Create a new admin route
485+ adminRoute := p.createAdminRoute()
486+ newRoutes[adminDomain] = adminRoute
680487
681488 // Add to database
682489 err := p.AddRoute(
683490 adminDomain,
684- containerName,
685- containerIP,
686- p.app.GetConfig().Http.Port,
687- "http",
688- "/",
491+ adminRoute.ContainerID,
492+ adminRoute.ContainerIP,
493+ adminRoute.ContainerPort,
494+ adminRoute.Protocol,
495+ adminRoute.Path,
689496 )
690497
691498 if err != nil {
@@ -697,24 +504,22 @@ func (p *Proxy) Reload() error {
697504 }
698505 }
699506
700- // Atomically swap the new routes map with the old one
701- // This ensures that no requests use a partially updated map
702- p.mu.Lock()
507+ // Update the routes map with the new one
703508 p.routes = newRoutes
704- p.mu.Unlock()
509+ // Note: We don't need to call configureRoutes() here because it would acquire the same lock
510+ // that we already hold. Instead, we do the work directly.
705511
706- // Rebuild route configuration
707- p.configureRoutes()
512+ // Define the handler using our helper function
513+ handler := p.createProxyHandler()
708514
709- // Force recreation of the route handlers if servers are initialized
710- if p.httpServer != nil {
711- logger.Debug("Refreshing HTTP routes")
712- p.httpServer.Routes()
515+ // Add the handler to the HTTPS server
516+ if p.httpsServer != nil {
517+ p.httpsServer.Any("/*", handler)
713518 }
714519
715- if p.httpsServer != nil {
716- logger.Debug("Refreshing HTTPS routes")
717- p.httpsServer.Routes()
520+ // Also add the handler to the HTTP server for HTTP-01 challenges
521+ if p.httpServer != nil {
522+ p.httpServer.Any("/*", handler)
718523 }
719524
720525 return nil
@@ -743,91 +548,14 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error {
743548 "old_ip", route.ContainerIP,
744549 "new_ip", newIP)
745550
746- // Double-check that the container exists and is running
747- containerInfo, err := docker.GetContainerInfo(containerID)
748- if err != nil {
749- logger.Warn("Container not found when updating route IP, might have been recreated",
750- "domain", domain,
751- "container_id", containerID,
752- "error", err)
753- // Continue with the update anyway, as the container ID might still be valid
754- } else {
755- logger.Debug("Container exists when updating route IP",
756- "domain", domain,
757- "container_id", containerID,
758- "container_state", containerInfo.State.Status)
759-
760- // Verify the new IP matches what's in the container
761- networkName := p.app.GetConfig().ContainerEngine.Network
762- if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil {
763- if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists &&
764- networkSettings.IPAddress != "" && networkSettings.IPAddress != newIP {
765- logger.Warn("New IP doesn't match container network IP, using container's actual IP",
766- "domain", domain,
767- "provided_ip", newIP,
768- "container_actual_ip", networkSettings.IPAddress)
769- newIP = networkSettings.IPAddress
770- }
771- }
772- }
551+ // Verify and normalize the container IP
552+ newIP = p.verifyAndNormalizeContainerIP(containerID, newIP)
773553
774554 // First, update the in-memory route immediately
775- p.mu.Lock()
776- if r, ok := p.routes[domain]; ok {
777- oldIP := r.ContainerIP
778- r.ContainerIP = newIP
779- // Make sure the route is also marked as active
780- r.Active = true
781- logger.Info("Force updated route IP in-memory",
782- "domain", domain,
783- "old_ip", oldIP,
784- "new_ip", newIP)
785- }
786- p.mu.Unlock()
555+ p.forceUpdateRouteIPInMemory(domain, newIP)
787556
788557 // Next, update the database
789- // Get the domain ID with retry
790- var domainID string
791- rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain)
792- if err != nil {
793- logger.Error("Failed to query domain for IP update", "domain", domain, "error", err)
794- return fmt.Errorf("failed to query domain: %w", err)
795- }
796-
797- if !rows.Next() {
798- rows.Close()
799- logger.Error("Domain not found in database when updating IP", "domain", domain)
800- return fmt.Errorf("domain not found: %s", domain)
801- }
802-
803- if err := rows.Scan(&domainID); err != nil {
804- rows.Close()
805- logger.Error("Failed to scan domain ID for IP update", "domain", domain, "error", err)
806- return fmt.Errorf("failed to scan domain ID: %w", err)
807- }
808- rows.Close()
809-
810- // Update the proxy_route record with retry and ensure it's marked as active
811- now := time.Now().Format(time.RFC3339)
812- result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID)
813-
814- if err != nil {
815- logger.Error("Failed to update IP in database", "domain", domain, "error", err)
816- return fmt.Errorf("failed to update database: %w", err)
817- }
818-
819- rowsAffected, _ := result.RowsAffected()
820- if rowsAffected == 0 {
821- logger.Warn("No rows affected when updating route IP in database", "domain", domain)
822- return fmt.Errorf("no rows affected when updating route in database")
823- }
824-
825- logger.Info("Force updated route IP in database",
826- "domain", domain,
827- "container_id", containerID,
828- "new_ip", newIP)
829-
830- return nil
558+ return p.updateRouteIPInDatabase(domain, containerID, newIP)
831559 }
832560
833561 // FindRoutesByContainerName finds routes associated with a specific container name
@@ -992,8 +720,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
992720
993721 // First approach: check if there are any domains that should point to this container based on labels
994722 if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" {
995- // Normalize domain (lowercase, no trailing dot)
996- domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".")
723+ // Normalize domain
724+ domainLabel = normalizeDomain(domainLabel)
997725
998726 // Check if there's already a route for this domain
999727 p.mu.RLock()
@@ -1008,28 +736,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1008736 "old_container_id", route.ContainerID,
1009737 "new_container_id", containerID)
1010738
1011- // Get the port to use for the proxy from the container labels
1012- containerPort := "80" // Default
1013- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1014- containerPort = portLabel
1015- }
1016-
1017- // Get the protocol to use
1018- protocol := "http" // Default
1019- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
1020- (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
1021- protocol = "https"
1022- }
1023-
1024- // Update the route with the new container ID and IP
1025- err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, route.Path)
739+ // Update route for this container
740+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, route.Path)
1026741 if err != nil {
1027742 logger.Error("Failed to update route for recreated container", "error", err)
1028- } else {
1029- logger.Info("Updated route for recreated container",
1030- "domain", domainLabel,
1031- "container_id", containerID,
1032- "container_ip", containerIP)
1033743 }
1034744 } else {
1035745 // Same container ID but maybe IP changed
@@ -1053,27 +763,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1053763 "domain", domainLabel,
1054764 "container_id", containerID)
1055765
1056- // Get the port to use for the proxy from the container labels
1057- containerPort := "80" // Default
1058- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1059- containerPort = portLabel
1060- }
1061-
1062- // Get the protocol to use
1063- protocol := "http" // Default
1064- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
1065- (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
1066- protocol = "https"
1067- }
1068-
1069766 // Add a new route for this container
1070- logger.Info("Adding missing route for Gordon container",
1071- "domain", domainLabel,
1072- "container_id", containerID,
1073- "container_ip", containerIP,
1074- "container_port", containerPort)
1075-
1076- err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/")
767+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "")
1077768 if err != nil {
1078769 logger.Error("Failed to add missing route", "error", err)
1079770 }
@@ -1093,33 +784,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1093784 "old_container_id", oldRoute.ContainerID,
1094785 "new_container_id", containerID)
1095786
1096- // Get the port to use for the proxy from the container labels
1097- containerPort := oldRoute.ContainerPort // Use the existing port by default
1098- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1099- // But prefer the container label if present
1100- containerPort = portLabel
1101- }
1102-
1103- // Get the protocol to use
1104- protocol := oldRoute.Protocol // Use existing protocol by default
1105- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists {
1106- // But prefer the container label if present
1107- if sslLabel == "true" || sslLabel == "1" || sslLabel == "yes" {
1108- protocol = "https"
1109- } else {
1110- protocol = "http"
1111- }
1112- }
1113-
1114- // Update the route
1115- err = p.AddRoute(domain, containerID, containerIP, containerPort, protocol, oldRoute.Path)
787+ // Update route for this container, preserving the old path
788+ err = p.updateRouteForContainer(domain, containerID, containerIP, containerInfo, oldRoute.Path)
1116789 if err != nil {
1117790 logger.Error("Failed to update route for recreated container", "error", err)
1118- } else {
1119- logger.Info("Updated route for recreated container",
1120- "domain", domain,
1121- "container_id", containerID,
1122- "container_ip", containerIP)
1123791 }
1124792 }
1125793 }
@@ -1143,150 +811,13 @@ func (p *Proxy) StartContainerEventListener() error {
1143811 "container_name", containerName,
1144812 "container_ip", containerIP)
1145813
1146- // Skip processing if container is in cooldown period
1147- if p.IsContainerInCooldownPeriod(containerID) {
1148- logger.Debug("Skipping container event processing - container in cooldown period",
1149- "container_id", containerID,
1150- "container_name", containerName)
1151- return
1152- }
1153-
1154- // Register this container as recently created to prevent duplicate processing
1155- p.RegisterNewlyCreatedContainer(containerID)
1156-
1157814 // Use a goroutine to handle the event asynchronously to avoid blocking the event listener
1158815 go func() {
1159816 // Add a small delay to allow any direct API calls to complete first
1160817 time.Sleep(500 * time.Millisecond)
1161818
1162- // Check if this is the Gordon container (admin domain)
1163- isAdminContainer := false
1164- adminDomain := p.app.GetConfig().Http.FullDomain()
1165-
1166- p.mu.RLock()
1167- if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID {
1168- isAdminContainer = true
1169- logger.Debug("Admin container event detected", "container_id", containerID)
1170- }
1171- p.mu.RUnlock()
1172-
1173- // First, check if there are any existing routes for this container ID
1174- var existingRoutes = make(map[string]*ProxyRouteInfo)
1175- p.mu.RLock()
1176- for domain, route := range p.routes {
1177- if route.ContainerID == containerID {
1178- existingRoutes[domain] = route
1179- }
1180- }
1181- p.mu.RUnlock()
1182-
1183- // If we found existing routes, check if the IP has changed
1184- if len(existingRoutes) > 0 {
1185- logger.Debug("Found existing routes for container",
1186- "container_id", containerID,
1187- "route_count", len(existingRoutes))
1188-
1189- // Verify the container IP with an additional check
1190- verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName)
1191- if err == nil && verifiedIP != "" && verifiedIP != containerIP {
1192- logger.Warn("Container IP mismatch from event vs. direct network check",
1193- "container_id", containerID,
1194- "event_ip", containerIP,
1195- "network_ip", verifiedIP)
1196-
1197- // Use the directly checked IP as it's more reliable
1198- containerIP = verifiedIP
1199- }
1200-
1201- // Check and update each route if IP has changed
1202- for domain, route := range existingRoutes {
1203- // Handle admin domain specially to avoid path issues
1204- if domain == adminDomain && isAdminContainer {
1205- // For admin domain, only update the IP if it has changed
1206- if route.ContainerIP != containerIP {
1207- logger.Info("Admin container restarted with new IP - updating route",
1208- "container_id", containerID,
1209- "container_name", containerName,
1210- "domain", domain,
1211- "old_ip", route.ContainerIP,
1212- "new_ip", containerIP)
1213-
1214- // Update IP in memory only first
1215- p.mu.Lock()
1216- if r, ok := p.routes[domain]; ok {
1217- r.ContainerIP = containerIP
1218- r.Active = true
1219- }
1220- p.mu.Unlock()
1221-
1222- // Update DB in background
1223- go func(domain, containerID, containerIP string) {
1224- // Get the domain ID
1225- var domainID string
1226- rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain)
1227- if err != nil {
1228- logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err)
1229- return
1230- }
1231-
1232- if !rows.Next() {
1233- rows.Close()
1234- logger.Error("Admin domain not found in database", "domain", domain)
1235- return
1236- }
1237-
1238- if err := rows.Scan(&domainID); err != nil {
1239- rows.Close()
1240- logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err)
1241- return
1242- }
1243- rows.Close()
1244-
1245- // Update the IP in the database directly
1246- now := time.Now().Format(time.RFC3339)
1247- _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP,
1248- containerIP, now, domainID, containerID)
1249-
1250- if err != nil {
1251- logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err)
1252- } else {
1253- logger.Info("Updated admin container IP in database",
1254- "domain", domain,
1255- "container_id", containerID,
1256- "new_ip", containerIP)
1257- }
1258- }(domain, containerID, containerIP)
1259- }
1260- } else if route.ContainerIP != containerIP {
1261- // For non-admin domains, use the standard route update
1262- logger.Info("Container restarted with new IP - updating route",
1263- "container_id", containerID,
1264- "container_name", containerName,
1265- "domain", domain,
1266- "old_ip", route.ContainerIP,
1267- "new_ip", containerIP)
1268-
1269- // Update the route IP
1270- err = p.ForceUpdateRouteIP(domain, containerIP)
1271- if err != nil {
1272- logger.Error("Failed to update IP for restarted container",
1273- "domain", domain,
1274- "error", err)
1275- }
1276- } else {
1277- // Even if IP is the same, make sure the route is active
1278- if !route.Active {
1279- logger.Info("Reactivating route for restarted container",
1280- "container_id", containerID,
1281- "container_name", containerName,
1282- "domain", domain)
1283-
1284- // Just reuse ForceUpdateRouteIP with the same IP to activate the route
1285- p.ForceUpdateRouteIP(domain, containerIP)
1286- }
1287- }
1288- }
1289- }
819+ // Process the container event
820+ p.processContainerEvent(containerID, containerName, containerIP)
1290821
1291822 // Call the standard recreation handler for additional checks
1292823 p.HandleContainerRecreation(containerID, containerName, containerIP)
@@ -1339,60 +870,15 @@ func (p *Proxy) StartContainerEventListener() error {
1339870
1340871 // checkRoutesForIPConsistency verifies that all active routes have the correct container IP
1341872 func (p *Proxy) checkRoutesForIPConsistency() {
873+ // Get the network name from app config
1342874 networkName := p.app.GetConfig().ContainerEngine.Network
1343875
1344- // Get a copy of the current routes
1345- p.mu.RLock()
1346- routesToCheck := make(map[string]*ProxyRouteInfo)
1347- for domain, route := range p.routes {
1348- if route.Active {
1349- routesToCheck[domain] = &ProxyRouteInfo{
1350- Domain: route.Domain,
1351- ContainerID: route.ContainerID,
1352- ContainerIP: route.ContainerIP,
1353- ContainerPort: route.ContainerPort,
1354- Protocol: route.Protocol,
1355- Path: route.Path,
1356- Active: route.Active,
1357- }
1358- }
1359- }
1360- p.mu.RUnlock()
876+ // Get a copy of all active routes
877+ routesToCheck := p.getActiveRoutes()
1361878
1362- // Check each active route
879+ // Check each active route for IP consistency
1363880 for domain, route := range routesToCheck {
1364- // Skip checking admin domain
1365- if domain == p.app.GetConfig().Http.FullDomain() {
1366- continue
1367- }
1368-
1369- // Verify the current container IP
1370- currentIP, err := docker.GetContainerIPFromNetwork(route.ContainerID, networkName)
1371- if err != nil {
1372- // Container might not exist anymore
1373- logger.Warn("Failed to get current IP for container during consistency check",
1374- "domain", domain,
1375- "container_id", route.ContainerID,
1376- "error", err)
1377- continue
1378- }
1379-
1380- // If IP has changed, update the route
1381- if currentIP != "" && currentIP != route.ContainerIP {
1382- logger.Info("Detected IP mismatch during consistency check",
1383- "domain", domain,
1384- "container_id", route.ContainerID,
1385- "old_ip", route.ContainerIP,
1386- "current_ip", currentIP)
1387-
1388- // Update the route
1389- err = p.ForceUpdateRouteIP(domain, currentIP)
1390- if err != nil {
1391- logger.Error("Failed to update IP during consistency check",
1392- "domain", domain,
1393- "error", err)
1394- }
1395- }
881+ p.checkAndUpdateIPIfNeeded(domain, route, networkName)
1396882 }
1397883 }
1398884
@@ -1402,28 +888,14 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1402888 return
1403889 }
1404890
1405- // Get the admin domain to protect it
1406- adminDomain := p.app.GetConfig().Http.FullDomain()
1407-
1408891 // Filter out admin domain from domains to mark inactive
1409- filteredDomains := make([]string, 0, len(domains))
1410- for _, domain := range domains {
1411- if domain != adminDomain {
1412- filteredDomains = append(filteredDomains, domain)
1413- } else {
1414- logger.Warn("Prevented admin domain from being marked inactive",
1415- "domain", adminDomain)
1416- }
1417- }
892+ filteredDomains := p.filterOutAdminDomain(domains)
1418893
1419894 // If all domains were filtered out, nothing to do
1420895 if len(filteredDomains) == 0 {
1421896 return
1422897 }
1423898
1424- // Use the filtered domains from here on
1425- domains = filteredDomains
1426-
1427899 // Begin a transaction
1428900 tx, err := p.app.GetDB().Begin()
1429901 if err != nil {
@@ -1432,31 +904,17 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1432904 }
1433905 defer tx.Rollback()
1434906
1435- // Update each route in the database
1436- for _, domain := range domains {
1437- // First, get the domain ID
1438- var domainID string
1439- err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID)
1440- if err != nil {
1441- logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err)
1442- continue
1443- }
1444-
1445- // Update the route to mark it as inactive
1446- now := time.Now().Format(time.RFC3339)
1447- _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID)
907+ // Update each route in the database and memory
908+ for _, domain := range filteredDomains {
909+ // Update in database
910+ err := p.markRouteInactiveInDatabase(tx, domain)
1448911 if err != nil {
1449- logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err)
912+ // Skip to next domain if there was an error
1450913 continue
1451914 }
1452915
1453- // Also update the in-memory route
1454- p.mu.Lock()
1455- if route, exists := p.routes[domain]; exists {
1456- route.Active = false
1457- logger.Info("Marked route as inactive", "domain", domain)
1458- }
1459- p.mu.Unlock()
916+ // Update in memory
917+ p.markRouteInactiveInMemory(domain)
1460918 }
1461919
1462920 // Commit the transaction
@@ -1465,7 +923,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1465923 return
1466924 }
1467925
1468- logger.Info("Successfully marked orphaned routes as inactive", "count", len(domains))
926+ logger.Info("Successfully marked orphaned routes as inactive", "count", len(filteredDomains))
1469927 }
1470928
1471929 // DiscoverMissingRoutes finds Gordon-managed containers that don't have routes yet
@@ -1531,8 +989,8 @@ func (p *Proxy) DiscoverMissingRoutes() {
1531989 continue
1532990 }
1533991
1534- // Normalize domain (lowercase, no trailing dot)
1535- domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".")
992+ // Normalize domain
993+ domainLabel = normalizeDomain(domainLabel)
1536994
1537995 // Skip if this is the admin domain
1538996 if domainLabel == p.app.GetConfig().Http.FullDomain() {
@@ -1559,27 +1017,13 @@ func (p *Proxy) DiscoverMissingRoutes() {
15591017 // Extract just the IP part from the CIDR notation (e.g., "192.168.1.2/24" → "192.168.1.2")
15601018 containerIP = strings.Split(containerIP, "/")[0]
15611019
1562- // Get the port to use for the proxy from the container labels
1563- containerPort := "80" // Default
1564- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1565- containerPort = portLabel
1566- }
1567-
1568- // Get the protocol to use
1569- protocol := "http" // Default
1570- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
1571- (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
1572- protocol = "https"
1573- }
1574-
15751020 // Add a new route for this container
15761021 logger.Info("Adding missing route for Gordon container",
15771022 "domain", domainLabel,
15781023 "container_id", containerID,
1579- "container_ip", containerIP,
1580- "container_port", containerPort)
1024+ "container_ip", containerIP)
15811025
1582- err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/")
1026+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "")
15831027 if err != nil {
15841028 logger.Error("Failed to add missing route", "error", err)
15851029 }
@@ -1737,6 +1181,7 @@ func (p *Proxy) LogGordonIdentity() {
17371181 }
17381182 }
17391183
1184+// detectGordonContainer attempts to find the Gordon container automatically
17401185 // detectGordonContainer attempts to find the Gordon container automatically
17411186 func (p *Proxy) detectGordonContainer() string {
17421187 logger.Debug("Starting Gordon container detection")
@@ -1880,83 +1325,11 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error {
18801325 // Create the target URL
18811326 targetURL := &url.URL{
18821327 Scheme: targetProtocol,
1328+ Host: fmt.Sprintf("%s:%s", formatIPAddress(containerIP), containerPort),
18831329 }
18841330
1885- // Format host properly for both IPv4 and IPv6
1886- if strings.Contains(containerIP, ":") {
1887- // IPv6 address needs to be wrapped in brackets
1888- containerIP = "[" + containerIP + "]"
1889- }
1890- targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort)
1891-
1892- // Create a reverse proxy
1893- proxy := httputil.NewSingleHostReverseProxy(targetURL)
1894-
1895- // Use our custom transport with longer timeouts
1896- if p.reverseProxyClient != nil {
1897- proxy.Transport = p.reverseProxyClient.Transport
1898- }
1899-
1900- // Set up request director to modify the request before it's sent
1901- originalDirector := proxy.Director
1902- proxy.Director = func(req *http.Request) {
1903- // Call the original director first
1904- originalDirector(req)
1905-
1906- // Keep the original host in the Host header
1907- req.Host = host
1908-
1909- // Set X-Forwarded headers to preserve client information
1910- if clientIP := c.RealIP(); clientIP != "" {
1911- req.Header.Set("X-Forwarded-For", clientIP)
1912- req.Header.Set("X-Real-IP", clientIP)
1913- }
1914-
1915- // Set X-Forwarded-Proto to the original protocol
1916- req.Header.Set("X-Forwarded-Proto", c.Scheme())
1917-
1918- // Set X-Forwarded-Host to the original host
1919- req.Header.Set("X-Forwarded-Host", host)
1920- }
1921-
1922- // Log errors that occur during proxying
1923- proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
1924- logger.Error("Proxy error",
1925- "host", host,
1926- "target", targetURL.String(),
1927- "error", err)
1928-
1929- // Skip container down detection for admin domain
1930- if !isAdminDomain {
1931- // Check if this is a connection refused error
1932- if strings.Contains(err.Error(), "connection refused") ||
1933- strings.Contains(err.Error(), "no route to host") ||
1934- strings.Contains(err.Error(), "i/o timeout") {
1935- // Container might be down or unreachable - mark the route as inactive
1936- logger.Warn("Container appears to be down, marking route as inactive",
1937- "domain", host,
1938- "container_id", containerID)
1939-
1940- // Find domains for this container and mark them inactive
1941- var domains []string
1942- p.mu.RLock()
1943- for d, r := range p.routes {
1944- if r.ContainerID == containerID {
1945- domains = append(domains, d)
1946- }
1947- }
1948- p.mu.RUnlock()
1949-
1950- if len(domains) > 0 {
1951- go p.markRoutesInactive(domains)
1952- }
1953- }
1954- }
1955-
1956- // Return a 502 Bad Gateway error
1957- c.Response().WriteHeader(http.StatusBadGateway)
1958- c.Response().Write([]byte("Bad Gateway: Container is unreachable"))
1959- }
1331+ // Create a reverse proxy using the helper function
1332+ proxy := p.createReverseProxy(targetURL, host, containerID, isAdminDomain)
19601333
19611334 // Serve the request using the reverse proxy
19621335 proxy.ServeHTTP(c.Response(), c.Request())
A internal/proxy/routes_helpers.go +723 -0
@@ -0,0 +1,723 @@
1+package proxy
2+
3+import (
4+ "crypto/rand"
5+ "database/sql"
6+ "fmt"
7+ "net"
8+ "net/http"
9+ "net/http/httputil"
10+ "net/url"
11+ "os"
12+ "strings"
13+ "time"
14+
15+ "github.com/bnema/gordon/pkg/docker"
16+ "github.com/bnema/gordon/pkg/logger"
17+ "github.com/docker/docker/api/types"
18+ "github.com/google/uuid"
19+ "github.com/labstack/echo/v4"
20+)
21+
22+// proxyGenerateUUID generates a random UUID for use in various identifiers
23+// This is a renamed version to avoid conflicts until we clean up proxy.go
24+func proxyGenerateUUID() string {
25+ id, err := uuid.NewRandom()
26+ if err != nil {
27+ // If we can't generate a UUID using the standard library,
28+ // fall back to our own implementation
29+ b := make([]byte, 16)
30+ _, err := rand.Read(b)
31+ if err != nil {
32+ return fmt.Sprintf("%d", time.Now().UnixNano())
33+ }
34+ return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
35+ }
36+ return id.String()
37+}
38+
39+// normalizeDomain converts a domain to lowercase and removes trailing dot
40+func normalizeDomain(domain string) string {
41+ return strings.TrimSuffix(strings.ToLower(domain), ".")
42+}
43+
44+// createProxyHandler creates a standard Echo handler function for both HTTP and HTTPS servers
45+func (p *Proxy) createProxyHandler() echo.HandlerFunc {
46+ return func(c echo.Context) error {
47+ host := c.Request().Host
48+
49+ // Strip port if present in the host
50+ if hostParts := strings.Split(host, ":"); len(hostParts) > 1 {
51+ host = hostParts[0]
52+ }
53+
54+ // Normalize host (trim trailing dot, convert to lowercase)
55+ host = normalizeDomain(host)
56+
57+ // Check if this is the admin domain
58+ adminDomain := p.app.GetConfig().Http.FullDomain()
59+ if strings.EqualFold(host, adminDomain) {
60+ // Always prioritize admin domain
61+ adminRoute, adminOk := p.routes[adminDomain]
62+
63+ if adminOk {
64+ return p.proxyRequest(c, adminRoute)
65+ }
66+ }
67+
68+ // Important: Get the route freshly from the map for every request
69+ // This ensures we always use the most up-to-date IP
70+ route, ok := p.routes[host]
71+
72+ if !ok {
73+ // Check if the host is an IP address - silently handle without logging warnings
74+ if net.ParseIP(host) != nil {
75+ // For IP-based requests, just return a 404 without logging warnings
76+ return c.String(http.StatusNotFound, "Domain not found")
77+ }
78+
79+ // Create list of available domains for debugging
80+ availableDomains := make([]string, 0, len(p.routes))
81+ for d := range p.routes {
82+ availableDomains = append(availableDomains, d)
83+ }
84+
85+ // Log warning for non-IP hosts that aren't configured
86+ logger.Warn("Request with unknown host",
87+ "requested_host", host,
88+ "client_ip", c.RealIP(),
89+ "available_domains", strings.Join(availableDomains, ", "))
90+ return c.String(http.StatusNotFound, "Domain not found")
91+ }
92+
93+ // Check if the route is active
94+ if !route.Active {
95+ logger.Warn("Route is not active",
96+ "domain", host,
97+ "client_ip", c.RealIP())
98+ return c.String(http.StatusServiceUnavailable, "Route is not active")
99+ }
100+ return p.proxyRequest(c, route)
101+ }
102+}
103+
104+// createReverseProxy creates a reverse proxy for a given target URL
105+func (p *Proxy) createReverseProxy(targetURL *url.URL, host string, containerID string, isAdminDomain bool) *httputil.ReverseProxy {
106+ proxy := httputil.NewSingleHostReverseProxy(targetURL)
107+
108+ // Use our custom transport with longer timeouts
109+ if p.reverseProxyClient != nil {
110+ proxy.Transport = p.reverseProxyClient.Transport
111+ }
112+
113+ // Set up request director to modify the request before it's sent
114+ originalDirector := proxy.Director
115+ proxy.Director = func(req *http.Request) {
116+ // Call the original director first
117+ originalDirector(req)
118+
119+ // Keep the original host in the Host header
120+ req.Host = host
121+
122+ // Set X-Forwarded headers to preserve client information
123+ if clientIP := req.Header.Get("X-Real-IP"); clientIP != "" {
124+ req.Header.Set("X-Forwarded-For", clientIP)
125+ }
126+
127+ // Set X-Forwarded-Proto based on the request scheme
128+ if req.TLS != nil {
129+ req.Header.Set("X-Forwarded-Proto", "https")
130+ } else {
131+ req.Header.Set("X-Forwarded-Proto", "http")
132+ }
133+
134+ // Set X-Forwarded-Host to the original host
135+ req.Header.Set("X-Forwarded-Host", host)
136+ }
137+
138+ // Create error handler for proxy errors
139+ proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
140+ logger.Error("Proxy error",
141+ "host", host,
142+ "target", targetURL.String(),
143+ "error", err)
144+
145+ // Skip container down detection for admin domain
146+ if !isAdminDomain {
147+ // Check if this is a connection refused error
148+ if strings.Contains(err.Error(), "connection refused") ||
149+ strings.Contains(err.Error(), "no route to host") ||
150+ strings.Contains(err.Error(), "i/o timeout") {
151+ // Container might be down or unreachable - mark the route as inactive
152+ logger.Warn("Container appears to be down, marking route as inactive",
153+ "domain", host,
154+ "container_id", containerID)
155+
156+ // Find domains for this container and mark them inactive
157+ var domains []string
158+ p.mu.RLock()
159+ for d, r := range p.routes {
160+ if r.ContainerID == containerID {
161+ domains = append(domains, d)
162+ }
163+ }
164+ p.mu.RUnlock()
165+
166+ if len(domains) > 0 {
167+ go p.markRoutesInactive(domains)
168+ }
169+ }
170+ }
171+
172+ // Return a 502 Bad Gateway error
173+ rw.WriteHeader(http.StatusBadGateway)
174+ rw.Write([]byte("Bad Gateway: Container is unreachable"))
175+ }
176+
177+ return proxy
178+}
179+
180+// saveAdminRoute preserves the admin route during route operations
181+func (p *Proxy) saveAdminRoute() *ProxyRouteInfo {
182+ adminDomain := p.app.GetConfig().Http.FullDomain()
183+ if route, exists := p.routes[adminDomain]; exists {
184+ return &ProxyRouteInfo{
185+ Domain: route.Domain,
186+ ContainerID: route.ContainerID,
187+ ContainerIP: route.ContainerIP,
188+ ContainerPort: route.ContainerPort,
189+ Protocol: route.Protocol,
190+ Path: route.Path,
191+ Active: route.Active,
192+ }
193+ }
194+ return nil
195+}
196+
197+// restoreAdminRoute adds back the admin route to the routes map
198+func (p *Proxy) restoreAdminRoute(adminRoute *ProxyRouteInfo, routes map[string]*ProxyRouteInfo) {
199+ if adminRoute != nil {
200+ adminDomain := p.app.GetConfig().Http.FullDomain()
201+ routes[adminDomain] = adminRoute
202+ logger.Debug("Restored admin domain route", "domain", adminDomain)
203+ }
204+}
205+
206+// createAdminRoute creates a new admin route with default settings
207+func (p *Proxy) createAdminRoute() *ProxyRouteInfo {
208+ adminDomain := p.app.GetConfig().Http.FullDomain()
209+ logger.Warn("Admin domain route is missing, recreating it", "domain", adminDomain)
210+
211+ // Auto-detect the Gordon container information
212+ containerName := p.detectGordonContainer()
213+ containerIP := "localhost" // Default to localhost
214+
215+ // Fall back options for container IP
216+ if os.Getenv("GORDON_ADMIN_HOST") != "" {
217+ containerIP = os.Getenv("GORDON_ADMIN_HOST")
218+ } else if os.Getenv("HOSTNAME") != "" {
219+ containerIP = os.Getenv("HOSTNAME")
220+ }
221+
222+ return &ProxyRouteInfo{
223+ Domain: adminDomain,
224+ ContainerID: containerName,
225+ ContainerIP: containerIP,
226+ ContainerPort: p.app.GetConfig().Http.Port,
227+ Protocol: "http", // Gordon server uses HTTP internally
228+ Path: "/",
229+ Active: true,
230+ }
231+}
232+
233+// processNewSubdomain extracts subdomain and parent domain and adds them to the database
234+func (p *Proxy) processNewSubdomain(tx *sql.Tx, hostname, accountID string, now string) error {
235+ hostParts := strings.Split(hostname, ".")
236+ if len(hostParts) >= 3 {
237+ // This appears to be in format subdomain.domain.tld
238+ // Extract subdomain and domain parts
239+ subdomain := hostParts[0]
240+ domainPart := strings.Join(hostParts[1:], ".")
241+
242+ // Check if the domain part already exists
243+ var domainPartID string
244+ err := tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID)
245+ if err != nil {
246+ if err == sql.ErrNoRows {
247+ // Domain part doesn't exist, create it
248+ domainPartID = proxyGenerateUUID()
249+ _, err = txExecWithRetry(tx,
250+ p.queries.InsertDomain,
251+ domainPartID, domainPart, accountID, now, now,
252+ )
253+ if err != nil {
254+ logger.Warn("Failed to insert domain part, continuing anyway",
255+ "domain_part", domainPart,
256+ "error", err)
257+ } else {
258+ logger.Debug("Added domain part to database for Let's Encrypt",
259+ "domain_part", domainPart,
260+ "subdomain", subdomain)
261+ }
262+ }
263+ }
264+ }
265+ return nil
266+}
267+
268+// getContainerInfoFromLabels extracts container configuration from Docker labels
269+func getContainerInfoFromLabels(containerInfo types.ContainerJSON) (string, string, string) {
270+ // Get the port to use for the proxy from the container labels
271+ containerPort := "80" // Default
272+ if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
273+ containerPort = portLabel
274+ }
275+
276+ // Get the protocol to use
277+ protocol := "http" // Default
278+ if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
279+ (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
280+ protocol = "https"
281+ }
282+
283+ // Get path if specified
284+ path := "/" // Default
285+ if pathLabel, exists := containerInfo.Config.Labels["gordon.proxy.path"]; exists && pathLabel != "" {
286+ path = pathLabel
287+ }
288+
289+ return containerPort, protocol, path
290+}
291+
292+// updateRouteForContainer updates or creates a route for a container using label information
293+func (p *Proxy) updateRouteForContainer(
294+ domainLabel, containerID, containerIP string,
295+ containerInfo types.ContainerJSON,
296+ existingPath string) error {
297+
298+ // Get container configuration from labels
299+ containerPort, protocol, path := getContainerInfoFromLabels(containerInfo)
300+
301+ // Use existing path if provided and path from labels is default
302+ if existingPath != "" && path == "/" {
303+ path = existingPath
304+ }
305+
306+ // Add or update the route
307+ logger.Info("Updating route for container",
308+ "domain", domainLabel,
309+ "container_id", containerID,
310+ "container_ip", containerIP)
311+
312+ return p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, path)
313+}
314+
315+// processContainerEvent handles a container start/restart event
316+func (p *Proxy) processContainerEvent(containerID, containerName, containerIP string) {
317+ logger.Debug("Processing container event",
318+ "container_id", containerID,
319+ "container_name", containerName,
320+ "container_ip", containerIP)
321+
322+ // Skip processing if container is in cooldown period
323+ if p.IsContainerInCooldownPeriod(containerID) {
324+ logger.Debug("Skipping container event processing - container in cooldown period",
325+ "container_id", containerID,
326+ "container_name", containerName)
327+ return
328+ }
329+
330+ // Register this container as recently created to prevent duplicate processing
331+ p.RegisterNewlyCreatedContainer(containerID)
332+
333+ // Check if this is the admin domain
334+ isAdminContainer := false
335+ adminDomain := p.app.GetConfig().Http.FullDomain()
336+
337+ p.mu.RLock()
338+ if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID {
339+ isAdminContainer = true
340+ logger.Debug("Admin container event detected", "container_id", containerID)
341+ }
342+ p.mu.RUnlock()
343+
344+ // First, check if there are any existing routes for this container ID
345+ var existingRoutes = make(map[string]*ProxyRouteInfo)
346+ p.mu.RLock()
347+ for domain, route := range p.routes {
348+ if route.ContainerID == containerID {
349+ existingRoutes[domain] = route
350+ }
351+ }
352+ p.mu.RUnlock()
353+
354+ // If we found existing routes, check if the IP has changed
355+ if len(existingRoutes) > 0 {
356+ logger.Debug("Found existing routes for container",
357+ "container_id", containerID,
358+ "route_count", len(existingRoutes))
359+
360+ // Verify the container IP with an additional check
361+ networkName := p.app.GetConfig().ContainerEngine.Network
362+ verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName)
363+ if err == nil && verifiedIP != "" && verifiedIP != containerIP {
364+ logger.Warn("Container IP mismatch from event vs. direct network check",
365+ "container_id", containerID,
366+ "event_ip", containerIP,
367+ "network_ip", verifiedIP)
368+
369+ // Use the directly checked IP as it's more reliable
370+ containerIP = verifiedIP
371+ }
372+
373+ // Check and update each route if IP has changed
374+ for domain, route := range existingRoutes {
375+ // Handle admin domain specially to avoid path issues
376+ if domain == adminDomain && isAdminContainer {
377+ // For admin domain, only update the IP if it has changed
378+ if route.ContainerIP != containerIP {
379+ p.updateAdminContainerIP(domain, containerID, containerIP)
380+ }
381+ } else if route.ContainerIP != containerIP {
382+ // For non-admin domains, use the standard route update
383+ logger.Info("Container restarted with new IP - updating route",
384+ "container_id", containerID,
385+ "container_name", containerName,
386+ "domain", domain,
387+ "old_ip", route.ContainerIP,
388+ "new_ip", containerIP)
389+
390+ // Update the route IP
391+ err := p.ForceUpdateRouteIP(domain, containerIP)
392+ if err != nil {
393+ logger.Error("Failed to update IP for restarted container",
394+ "domain", domain,
395+ "error", err)
396+ }
397+ } else {
398+ // Even if IP is the same, make sure the route is active
399+ if !route.Active {
400+ logger.Info("Reactivating route for restarted container",
401+ "container_id", containerID,
402+ "container_name", containerName,
403+ "domain", domain)
404+
405+ // Just reuse ForceUpdateRouteIP with the same IP to activate the route
406+ p.ForceUpdateRouteIP(domain, containerIP)
407+ }
408+ }
409+ }
410+ }
411+}
412+
413+// updateAdminContainerIP updates the admin container's IP in memory and in the database
414+func (p *Proxy) updateAdminContainerIP(domain, containerID, containerIP string) {
415+ logger.Info("Admin container restarted with new IP - updating route",
416+ "container_id", containerID,
417+ "domain", domain,
418+ "new_ip", containerIP)
419+
420+ // Update IP in memory only first
421+ p.mu.Lock()
422+ if r, ok := p.routes[domain]; ok {
423+ r.ContainerIP = containerIP
424+ r.Active = true
425+ }
426+ p.mu.Unlock()
427+
428+ // Update DB in background
429+ go func(domain, containerID, containerIP string) {
430+ // Get the domain ID
431+ var domainID string
432+ rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain)
433+ if err != nil {
434+ logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err)
435+ return
436+ }
437+
438+ if !rows.Next() {
439+ rows.Close()
440+ logger.Error("Admin domain not found in database", "domain", domain)
441+ return
442+ }
443+
444+ if err := rows.Scan(&domainID); err != nil {
445+ rows.Close()
446+ logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err)
447+ return
448+ }
449+ rows.Close()
450+
451+ // Update the IP in the database directly
452+ now := time.Now().Format(time.RFC3339)
453+ _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP,
454+ containerIP, now, domainID, containerID)
455+
456+ if err != nil {
457+ logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err)
458+ } else {
459+ logger.Info("Updated admin container IP in database",
460+ "domain", domain,
461+ "container_id", containerID,
462+ "new_ip", containerIP)
463+ }
464+ }(domain, containerID, containerIP)
465+}
466+
467+// formatIPAddress handles proper formatting for both IPv4 and IPv6 addresses
468+func formatIPAddress(ip string) string {
469+ if strings.Contains(ip, ":") {
470+ // IPv6 address needs to be wrapped in brackets
471+ return "[" + ip + "]"
472+ }
473+ return ip
474+}
475+
476+// createRouteInfo creates a new ProxyRouteInfo struct from parameters
477+func createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path string, active bool) *ProxyRouteInfo {
478+ return &ProxyRouteInfo{
479+ Domain: domain,
480+ ContainerID: containerID,
481+ ContainerIP: containerIP,
482+ ContainerPort: containerPort,
483+ Protocol: protocol,
484+ Path: path,
485+ Active: active,
486+ }
487+}
488+
489+// forceUpdateRouteIPInMemory updates a route's IP address in the in-memory routes map
490+func (p *Proxy) forceUpdateRouteIPInMemory(domain, newIP string) {
491+ p.mu.Lock()
492+ defer p.mu.Unlock()
493+
494+ if r, ok := p.routes[domain]; ok {
495+ oldIP := r.ContainerIP
496+ r.ContainerIP = newIP
497+ // Make sure the route is also marked as active
498+ r.Active = true
499+ logger.Info("Force updated route IP in-memory",
500+ "domain", domain,
501+ "old_ip", oldIP,
502+ "new_ip", newIP)
503+ }
504+}
505+
506+// updateRouteIPInDatabase updates a route's IP address in the database
507+func (p *Proxy) updateRouteIPInDatabase(domain, containerID, newIP string) error {
508+ // Get the domain ID with retry
509+ var domainID string
510+ rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain)
511+ if err != nil {
512+ logger.Error("Failed to query domain for IP update", "domain", domain, "error", err)
513+ return fmt.Errorf("failed to query domain: %w", err)
514+ }
515+
516+ if !rows.Next() {
517+ rows.Close()
518+ logger.Error("Domain not found in database when updating IP", "domain", domain)
519+ return fmt.Errorf("domain not found: %s", domain)
520+ }
521+
522+ if err := rows.Scan(&domainID); err != nil {
523+ rows.Close()
524+ logger.Error("Failed to scan domain ID for IP update", "domain", domain, "error", err)
525+ return fmt.Errorf("failed to scan domain ID: %w", err)
526+ }
527+ rows.Close()
528+
529+ // Update the proxy_route record with retry and ensure it's marked as active
530+ now := time.Now().Format(time.RFC3339)
531+ result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID)
532+
533+ if err != nil {
534+ logger.Error("Failed to update IP in database", "domain", domain, "error", err)
535+ return fmt.Errorf("failed to update database: %w", err)
536+ }
537+
538+ rowsAffected, _ := result.RowsAffected()
539+ if rowsAffected == 0 {
540+ logger.Warn("No rows affected when updating route IP in database", "domain", domain)
541+ return fmt.Errorf("no rows affected when updating route in database")
542+ }
543+
544+ logger.Info("Force updated route IP in database",
545+ "domain", domain,
546+ "container_id", containerID,
547+ "new_ip", newIP)
548+
549+ return nil
550+}
551+
552+// verifyAndNormalizeContainerIP verifies that a container's IP address is correct
553+// and returns the normalized IP address
554+func (p *Proxy) verifyAndNormalizeContainerIP(containerID, providedIP string) string {
555+ // Double-check that the container exists and is running
556+ containerInfo, err := docker.GetContainerInfo(containerID)
557+ if err != nil {
558+ logger.Warn("Container not found when updating route IP, might have been recreated",
559+ "container_id", containerID,
560+ "error", err)
561+ // Continue with the update anyway, as the container ID might still be valid
562+ return providedIP
563+ }
564+
565+ logger.Debug("Container exists when updating route IP",
566+ "container_id", containerID,
567+ "container_state", containerInfo.State.Status)
568+
569+ // Verify the new IP matches what's in the container
570+ networkName := p.app.GetConfig().ContainerEngine.Network
571+ if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil {
572+ if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists &&
573+ networkSettings.IPAddress != "" && networkSettings.IPAddress != providedIP {
574+ logger.Warn("New IP doesn't match container network IP, using container's actual IP",
575+ "provided_ip", providedIP,
576+ "container_actual_ip", networkSettings.IPAddress)
577+ return networkSettings.IPAddress
578+ }
579+ }
580+
581+ return providedIP
582+}
583+
584+// filterOutAdminDomain removes the admin domain from a list of domains
585+func (p *Proxy) filterOutAdminDomain(domains []string) []string {
586+ if len(domains) == 0 {
587+ return domains
588+ }
589+
590+ // Get the admin domain to protect it
591+ adminDomain := p.app.GetConfig().Http.FullDomain()
592+
593+ // Filter out admin domain from domains
594+ filteredDomains := make([]string, 0, len(domains))
595+ for _, domain := range domains {
596+ if domain != adminDomain {
597+ filteredDomains = append(filteredDomains, domain)
598+ } else {
599+ logger.Warn("Prevented admin domain from being modified",
600+ "domain", adminDomain)
601+ }
602+ }
603+
604+ return filteredDomains
605+}
606+
607+// markRouteInactiveInDatabase marks a route as inactive in the database
608+func (p *Proxy) markRouteInactiveInDatabase(tx *sql.Tx, domain string) error {
609+ // Get the domain ID
610+ var domainID string
611+ err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID)
612+ if err != nil {
613+ logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err)
614+ return err
615+ }
616+
617+ // Update the route to mark it as inactive
618+ now := time.Now().Format(time.RFC3339)
619+ _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID)
620+ if err != nil {
621+ logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err)
622+ return err
623+ }
624+
625+ return nil
626+}
627+
628+// markRouteInactiveInMemory marks a route as inactive in the in-memory routes map
629+func (p *Proxy) markRouteInactiveInMemory(domain string) {
630+ p.mu.Lock()
631+ defer p.mu.Unlock()
632+
633+ if route, exists := p.routes[domain]; exists {
634+ route.Active = false
635+ logger.Info("Marked route as inactive", "domain", domain)
636+ }
637+}
638+
639+// getActiveRoutes returns a copy of all active routes in the routes map
640+func (p *Proxy) getActiveRoutes() map[string]*ProxyRouteInfo {
641+ p.mu.RLock()
642+ defer p.mu.RUnlock()
643+
644+ routesToCheck := make(map[string]*ProxyRouteInfo)
645+ for domain, route := range p.routes {
646+ if route.Active {
647+ routesToCheck[domain] = &ProxyRouteInfo{
648+ Domain: route.Domain,
649+ ContainerID: route.ContainerID,
650+ ContainerIP: route.ContainerIP,
651+ ContainerPort: route.ContainerPort,
652+ Protocol: route.Protocol,
653+ Path: route.Path,
654+ Active: route.Active,
655+ }
656+ }
657+ }
658+
659+ return routesToCheck
660+}
661+
662+// checkAndUpdateIPIfNeeded checks if a container's IP address has changed and updates it if needed
663+func (p *Proxy) checkAndUpdateIPIfNeeded(domain string, route *ProxyRouteInfo, networkName string) {
664+ // Skip checking admin domain
665+ if domain == p.app.GetConfig().Http.FullDomain() {
666+ return
667+ }
668+
669+ // Verify the current container IP
670+ currentIP, err := docker.GetContainerIPFromNetwork(route.ContainerID, networkName)
671+ if err != nil {
672+ // Container might not exist anymore
673+ logger.Warn("Failed to get current IP for container during consistency check",
674+ "domain", domain,
675+ "container_id", route.ContainerID,
676+ "error", err)
677+ return
678+ }
679+
680+ // If IP has changed, update the route
681+ if currentIP != "" && currentIP != route.ContainerIP {
682+ logger.Info("Detected IP mismatch during consistency check",
683+ "domain", domain,
684+ "container_id", route.ContainerID,
685+ "old_ip", route.ContainerIP,
686+ "current_ip", currentIP)
687+
688+ // Update the route
689+ err = p.ForceUpdateRouteIP(domain, currentIP)
690+ if err != nil {
691+ logger.Error("Failed to update IP during consistency check",
692+ "domain", domain,
693+ "error", err)
694+ }
695+ }
696+}
697+
698+// checkAdminDomainProtection checks if a domain is the admin domain and returns an error if it is
699+func (p *Proxy) checkAdminDomainProtection(domain string) error {
700+ adminDomain := p.app.GetConfig().Http.FullDomain()
701+ if domain == adminDomain {
702+ logger.Warn("Attempt to modify admin domain prevented", "domain", domain)
703+ return fmt.Errorf("cannot modify admin domain: %s", domain)
704+ }
705+ return nil
706+}
707+
708+// deleteRouteWithTransaction deletes a route and domain from the database within a transaction
709+func (p *Proxy) deleteRouteWithTransaction(tx *sql.Tx, domainID string) error {
710+ // Delete the route with retry
711+ _, err := txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID)
712+ if err != nil {
713+ return fmt.Errorf("failed to delete route: %w", err)
714+ }
715+
716+ // Delete the domain with retry
717+ _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID)
718+ if err != nil {
719+ return fmt.Errorf("failed to delete domain: %w", err)
720+ }
721+
722+ return nil
723+}
M internal/proxy/server.go +513 -186
@@ -4,82 +4,152 @@ import (
44 "context"
55 "crypto/tls"
66 "fmt"
7+ "log"
78 "net/http"
9+ "os"
10+ "os/exec"
11+ "strconv"
12+ "strings"
13+ "sync"
814 "time"
915
16+ "net"
17+
1018 "github.com/bnema/gordon/pkg/docker"
1119 "github.com/bnema/gordon/pkg/logger"
1220 "github.com/labstack/echo/v4"
1321 "golang.org/x/crypto/acme"
1422 )
1523
24+// Global mutex for admin connection testing to prevent concurrent executions
25+var adminTestMutex sync.Mutex
26+
1627 // This file contains server operations functions for the proxy.
1728 // These methods may need to be deleted from proxy.go after validation.
1829
1930 // Start loads the routes from the database and starts the proxy server
2031 func (p *Proxy) Start() error {
21- // First, load routes from the database
32+ // Check if proxy is disabled via configuration
33+ if !p.config.Enabled {
34+ logger.Info("Proxy server is disabled via configuration, not starting")
35+ return nil
36+ }
37+
38+ // Configure proxy ports based on environment
39+ p.configureProxyPorts()
40+
41+ // Load routes from the database
2242 if err := p.loadRoutes(); err != nil {
2343 return fmt.Errorf("failed to load routes: %w", err)
2444 }
2545
26- // Now log Gordon container identity for debugging (after routes are loaded)
46+ // Log Gordon container identity and prepare admin route
2747 p.LogGordonIdentity()
48+ p.setupAdminRoute()
2849
29- // Scan Gordon network containers and check for certificates
50+ // Scan containers for certificates
3051 if err := p.scanContainersAndCheckCertificates(); err != nil {
3152 logger.Warn("Failed to scan containers for certificate checks", "error", err)
3253 }
3354
34- // Run an immediate route verification to ensure all routes are valid on startup
35- logger.Info("Performing initial route verification on startup")
36-
37- // Discover any containers that should have routes but don't
55+ // Discover missing routes and log active containers
3856 p.DiscoverMissingRoutes()
39-
40- // Log all active containers and their routes for visibility
4157 p.LogActiveContainersAndRoutes()
4258
43- // Start the container event listener for real-time updates
59+ // Start container event listener
4460 if err := p.StartContainerEventListener(); err != nil {
4561 logger.Warn("Failed to start container event listener", "error", err)
46- // Continue anyway, this is non-critical
4762 }
4863
49- // Check if there might be port conflicts with the main server
50- mainServerPort := p.app.GetConfig().Http.Port
64+ // Start the admin route verification in a separate goroutine
65+ go p.StartAdminRouteVerification()
5166
52- // Check HTTP port conflict
53- if p.config.HttpPort == mainServerPort {
54- logger.Warn("HTTP port for reverse proxy conflicts with main server port",
55- "port", p.config.HttpPort,
56- "solution", "reverse proxy HTTP server will be disabled")
67+ // Check for port conflicts with the main server
68+ if p.checkForPortConflicts() {
5769 return nil
5870 }
5971
60- // Check HTTPS port conflict (less common, but still possible)
61- if p.config.Port == mainServerPort {
62- logger.Warn("HTTPS port for reverse proxy conflicts with main server port",
63- "port", p.config.Port,
64- "solution", "reverse proxy HTTPS server will be disabled")
65- return nil
72+ // Set up middleware and configure routes
73+ p.setupMiddleware()
74+ p.configureRoutes()
75+
76+ // Configure HTTP server for Let's Encrypt challenges
77+ p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil)))
78+
79+ // Create and start HTTP and HTTPS servers
80+ p.serverStarted = true
81+
82+ // Start servers in separate goroutines
83+ go p.startHTTPSServer()
84+ go p.startHTTPServer()
85+
86+ // Log details about the proxy server
87+ logger.Info("Proxy server started",
88+ "http_port", p.config.HttpPort,
89+ "https_port", p.config.Port,
90+ "admin_domain", p.app.GetConfig().Http.FullDomain(),
91+ "container_id", p.gordonContainerID,
92+ )
93+
94+ // Give servers time to start up
95+ logger.Info("Waiting for servers to complete startup...")
96+ time.Sleep(3 * time.Second)
97+
98+ // Test admin connection after server starts
99+ go p.TestAdminConnectionLater()
100+
101+ return nil
102+}
103+
104+// configureProxyPorts configures the proxy ports based on the environment
105+func (p *Proxy) configureProxyPorts() {
106+ // Detect container environment
107+ containerEnv := os.Getenv("container")
108+ _, isContainer := os.LookupEnv("container")
109+
110+ // Log container information if applicable
111+ if isContainer {
112+ logger.Info("Running in container environment", "container_type", containerEnv, "uid", os.Getuid())
113+ }
114+
115+ // When running as non-root user, check for privileged ports
116+ if !isContainer && os.Getuid() != 0 {
117+ // Non-root user - check for privileged ports
118+ httpPort, _ := strconv.Atoi(p.config.HttpPort)
119+ httpsPort, _ := strconv.Atoi(p.config.Port)
120+
121+ // Use non-privileged ports if trying to bind to privileged ports without root
122+ if httpPort < 1024 {
123+ logger.Warn("Using alternative HTTP port 8080 instead of privileged port", "original", p.config.HttpPort)
124+ p.config.HttpPort = "8080"
125+ }
126+
127+ if httpsPort < 1024 {
128+ logger.Warn("Using alternative HTTPS port 8443 instead of privileged port", "original", p.config.Port)
129+ p.config.Port = "8443"
130+ }
66131 }
67132
68- // Add a special route for the admin domain (Gordon itself)
133+ // Log final port configuration
134+ logger.Info("Final proxy port configuration",
135+ "http_port", p.config.HttpPort,
136+ "https_port", p.config.Port,
137+ "in_container", isContainer)
138+}
139+
140+// setupAdminRoute adds a special route for the admin domain (Gordon itself)
141+func (p *Proxy) setupAdminRoute() {
69142 adminDomain := p.app.GetConfig().Http.FullDomain()
143+ logger.Info("Setting up admin domain route", "domain", adminDomain)
70144
71145 // Auto-detect the Gordon container name and ID
72146 containerName := p.detectGordonContainer()
73-
74- // Get the network name from config
75147 networkName := p.app.GetConfig().ContainerEngine.Network
76148
77- // Initialize container IP
78- var containerIP string
79-
80- // Try to get the actual container IP from the network
149+ // Get the IP address from the container's network
81150 if p.gordonContainerID == "" {
82- return fmt.Errorf("failed to detect Gordon container ID, cannot add admin route")
151+ logger.Error("Failed to detect Gordon container ID, cannot add admin route")
152+ return
83153 }
84154
85155 // Get the IP address from the container's network
@@ -89,147 +159,171 @@ func (p *Proxy) Start() error {
89159 "container_id", p.gordonContainerID,
90160 "network", networkName,
91161 "error", err)
92- return fmt.Errorf("failed to get Gordon container IP from network %s: %w", networkName, err)
162+ return
93163 }
94164
95- containerIP = ip
96- logger.Info("Using Gordon container IP from network",
97- "container_id", p.gordonContainerID,
98- "network", networkName,
99- "ip", containerIP)
100-
101- // Check if admin route exists in database and create/update it
165+ // Add the admin route to the database
102166 err = p.AddRoute(
103167 adminDomain,
104168 containerName,
105- containerIP,
169+ ip,
106170 p.app.GetConfig().Http.Port,
107171 "http", // Gordon server uses HTTP internally
108172 "/",
109173 )
110174
111175 if err != nil {
112- logger.Error("Failed to add admin route to database",
113- "error", err,
114- "domain", adminDomain)
176+ logger.Error("Failed to add admin route to database", "error", err, "domain", adminDomain)
115177 } else {
116- logger.Info("Ensured admin domain route is in database",
117- "domain", adminDomain,
118- "container", containerName,
119- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
178+ logger.Info("Ensured admin domain route is in database", "domain", adminDomain)
120179 }
121180
181+ // Add to in-memory routes if it doesn't already exist
122182 p.mu.Lock()
123- // Only add if it doesn't already exist in memory
124183 if _, exists := p.routes[adminDomain]; !exists {
125184 p.routes[adminDomain] = &ProxyRouteInfo{
126185 Domain: adminDomain,
127- ContainerIP: containerIP,
186+ ContainerIP: ip,
128187 ContainerPort: p.app.GetConfig().Http.Port,
129- ContainerID: containerName, // Use auto-detected container name
130- Protocol: "http", // Gordon server uses HTTP internally
188+ ContainerID: containerName,
189+ Protocol: "http", // Gordon server uses HTTP internally
131190 Path: "/",
132191 Active: true,
133192 }
134- logger.Info("Added special route for admin domain",
135- "domain", adminDomain,
136- "container", containerName,
137- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
138- } else {
139- logger.Debug("Admin domain route already exists",
140- "domain", adminDomain,
141- "route", fmt.Sprintf("%s://%s:%s",
142- p.routes[adminDomain].Protocol,
143- p.routes[adminDomain].ContainerIP,
144- p.routes[adminDomain].ContainerPort))
193+ logger.Info("Added special route for admin domain", "domain", adminDomain)
145194 }
146195 p.mu.Unlock()
196+}
147197
148- // Set up middleware
149- p.setupMiddleware()
198+// checkForPortConflicts checks if there are port conflicts with the main server
199+// Returns true if conflicts are found and the proxy should not be started
200+func (p *Proxy) checkForPortConflicts() bool {
201+ mainServerPort := p.app.GetConfig().Http.Port
150202
151- // Configure routes
152- p.configureRoutes()
203+ // Check HTTP port conflict
204+ if p.config.HttpPort == mainServerPort {
205+ logger.Warn("HTTP port for reverse proxy conflicts with main server port",
206+ "port", p.config.HttpPort,
207+ "solution", "reverse proxy HTTP server will be disabled")
208+ return true
209+ }
153210
154- // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges
155- // and redirect everything else to HTTPS
156- p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil)))
211+ // Check HTTPS port conflict
212+ if p.config.Port == mainServerPort {
213+ logger.Warn("HTTPS port for reverse proxy conflicts with main server port",
214+ "port", p.config.Port,
215+ "solution", "reverse proxy HTTPS server will be disabled")
216+ return true
217+ }
218+
219+ return false
220+}
157221
158- // Create a custom HTTPS server with proper TLS config
222+// startHTTPSServer starts the HTTPS server
223+func (p *Proxy) startHTTPSServer() {
224+ // Create HTTPS server
159225 httpsServer := &http.Server{
160226 Addr: fmt.Sprintf(":%s", p.config.Port),
161227 Handler: p.httpsServer,
162228 TLSConfig: &tls.Config{
163229 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
164- // Log SNI information in debug mode
165- if hello.ServerName != "" {
166- logger.Debug("TLS handshake with SNI",
167- "server_name", hello.ServerName)
168- }
169-
170230 // Try to get the certificate from the autocert manager
171231 cert, err := p.certManager.GetCertificate(hello)
172232
173- // If we can't get a certificate and we have a fallback, use it for the admin domain
233+ // If no cert available and we have a fallback, use it for admin domain
234+ adminDomain := p.app.GetConfig().Http.FullDomain()
174235 if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil {
175- logger.Debug("Using fallback certificate for admin domain",
176- "domain", adminDomain,
177- "error", err)
236+ logger.Info("Using fallback certificate for admin domain", "domain", adminDomain)
178237 return p.fallbackCert, nil
179238 }
180239
181240 return cert, err
182241 },
183242 MinVersion: tls.VersionTLS12,
184- // Add server name to use when client doesn't send SNI
185243 ServerName: p.app.GetConfig().Http.FullDomain(),
186- // Add support for TLS-ALPN-01 challenges
187244 NextProtos: []string{acme.ALPNProto},
188245 },
189- // Add timeouts to prevent hanging connections
190246 ReadTimeout: 30 * time.Second,
191247 WriteTimeout: 30 * time.Second,
192248 IdleTimeout: 120 * time.Second,
249+ ErrorLog: log.New(os.Stderr, "[HTTPS] ", log.LstdFlags),
250+ }
251+
252+ // Try to start the server with different approaches depending on environment
253+ logger.Info("Starting HTTPS server", "port", p.config.Port)
254+
255+ // First try explicit IPv4 binding
256+ httpsAddr := fmt.Sprintf("0.0.0.0:%s", p.config.Port)
257+ tcpListener, err := net.Listen("tcp4", httpsAddr)
258+
259+ if err != nil {
260+ logger.Warn("Failed to create HTTPS listener with explicit IPv4 binding",
261+ "error", err, "port", p.config.Port)
262+
263+ // Try simple binding as fallback
264+ if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
265+ logger.Error("HTTPS server failed", "error", err, "port", p.config.Port)
266+ }
267+ return
268+ }
269+
270+ // Create TLS listener
271+ tlsListener := tls.NewListener(tcpListener, httpsServer.TLSConfig)
272+ logger.Info("HTTPS server is now accepting connections", "port", p.config.Port)
273+
274+ // Start server with explicit listener
275+ if err := httpsServer.Serve(tlsListener); err != nil && err != http.ErrServerClosed {
276+ logger.Error("HTTPS server failed", "error", err, "port", p.config.Port)
193277 }
278+}
194279
195- // Create a custom HTTP server
280+// startHTTPServer starts the HTTP server
281+func (p *Proxy) startHTTPServer() {
282+ // Create HTTP server
196283 httpServer := &http.Server{
197284 Addr: fmt.Sprintf(":%s", p.config.HttpPort),
198285 Handler: p.httpServer,
199286 ReadTimeout: 30 * time.Second,
200287 WriteTimeout: 30 * time.Second,
201288 IdleTimeout: 120 * time.Second,
289+ ErrorLog: log.New(os.Stderr, "[HTTP] ", log.LstdFlags),
202290 }
203291
204- p.serverStarted = true
292+ // Try to start the server with different approaches depending on environment
293+ logger.Info("Starting HTTP server", "port", p.config.HttpPort)
205294
206- // Start HTTPS server
207- go func() {
208- logger.Info("Starting HTTPS server", "address", httpsServer.Addr)
209- // Using empty strings for cert and key files since we're using GetCertificate
210- if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
211- logger.Error("HTTPS server failed", "error", err)
212- }
213- }()
295+ // First try explicit IPv4 binding
296+ httpAddr := fmt.Sprintf("0.0.0.0:%s", p.config.HttpPort)
297+ httpListener, err := net.Listen("tcp4", httpAddr)
214298
215- // Start HTTP server
216- go func() {
217- logger.Info("Starting HTTP server", "address", httpServer.Addr)
299+ if err != nil {
300+ logger.Warn("Failed to create HTTP listener with explicit IPv4 binding",
301+ "error", err, "port", p.config.HttpPort)
302+
303+ // Try simple binding as fallback
218304 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
219- logger.Error("HTTP server failed", "error", err)
305+ logger.Error("HTTP server failed", "error", err, "port", p.config.HttpPort)
220306 }
221- }()
222-
223- // Test admin connection after server starts
224- go p.TestAdminConnectionLater()
307+ return
308+ }
225309
226- return nil
310+ // Start server with explicit listener
311+ logger.Info("HTTP server is now accepting connections", "port", p.config.HttpPort)
312+ if err := httpServer.Serve(httpListener); err != nil && err != http.ErrServerClosed {
313+ logger.Error("HTTP server failed", "error", err, "port", p.config.HttpPort)
314+ }
227315 }
228316
229317 // Stop gracefully shuts down the proxy server
230318 func (p *Proxy) Stop() error {
231319 logger.Info("Stopping reverse proxy")
232320
321+ // Check if proxy is disabled via configuration
322+ if !p.config.Enabled {
323+ logger.Info("Proxy server is disabled via configuration, nothing to stop")
324+ return nil
325+ }
326+
233327 // Run any rate limiter cleanup functions
234328 for _, cleanup := range rateLimiterCleanup {
235329 cleanup()
@@ -325,21 +419,178 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
325419
326420 // TestAdminConnectionLater tests the admin connection after a short delay
327421 func (p *Proxy) TestAdminConnectionLater() {
328- // Wait for servers to start
329- time.Sleep(2 * time.Second)
422+ // Try to acquire the lock, return immediately if another test is in progress
423+ if !adminTestMutex.TryLock() {
424+ logger.Debug("Admin connection test already in progress, skipping")
425+ return
426+ }
427+ defer adminTestMutex.Unlock()
428+
429+ // Wait for servers to start - give plenty of time for binding to complete
430+ time.Sleep(10 * time.Second)
431+
432+ // Log that we're beginning the connection test
433+ logger.Info("Beginning admin connection test after server startup")
434+
435+ // First verify if ports 80 and 443 are actually bound locally
436+ verifyLocalPortBinding := func(port string) bool {
437+ // Try a local connection to the port
438+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%s", port), 2*time.Second)
439+ if err != nil {
440+ logger.Error(fmt.Sprintf("Failed to connect to localhost:%s - port may not be bound", port),
441+ "error", err)
442+ return false
443+ }
444+ conn.Close()
445+ logger.Info(fmt.Sprintf("Successfully verified port %s is bound locally", port))
446+ return true
447+ }
448+
449+ // Check if we can connect to ports 80 and 443 locally
450+ httpBound := verifyLocalPortBinding(p.config.HttpPort)
451+ httpsBound := verifyLocalPortBinding(p.config.Port)
452+
453+ if !httpBound || !httpsBound {
454+ logger.Error("One or more ports are not bound locally - proxy may not be functioning",
455+ "http_port_ok", httpBound,
456+ "https_port_ok", httpsBound)
457+ }
330458
331459 logger.Debug("Testing admin connections after server startup")
332460
333461 // Get domain and current route
334462 adminDomain := p.app.GetConfig().Http.FullDomain()
335463
464+ // First, check if the route exists in memory
336465 p.mu.RLock()
337466 route, exists := p.routes[adminDomain]
338467 p.mu.RUnlock()
339468
469+ // Add debug logging for admin route configuration
470+ if exists {
471+ logger.Debug("Current admin route configuration",
472+ "domain", adminDomain,
473+ "container_ip", route.ContainerIP,
474+ "container_port", route.ContainerPort,
475+ "protocol", route.Protocol)
476+
477+ // Get the container's own IP by checking network interfaces
478+ containerIP := p.getOwnContainerIP()
479+ adminPath := p.app.GetConfig().Admin.Path
480+ url := fmt.Sprintf("http://%s:%s%s/ping", containerIP, route.ContainerPort, adminPath)
481+
482+ logger.Debug("Testing direct IP connection", "url", url)
483+ client := &http.Client{
484+ Timeout: 5 * time.Second,
485+ }
486+
487+ resp, err := client.Get(url)
488+ if err != nil {
489+ logger.Error("Failed to connect to admin with direct IP",
490+ "url", url,
491+ "error", err)
492+ } else {
493+ resp.Body.Close()
494+ logger.Info("Successfully connected to admin with direct IP",
495+ "url", url,
496+ "status", resp.Status)
497+
498+ // If localhost is being used, update the route to use the container IP
499+ if route.ContainerIP == "localhost" || route.ContainerIP == "127.0.0.1" {
500+ logger.Info("Updating admin route from localhost to container IP",
501+ "old_ip", route.ContainerIP,
502+ "new_ip", containerIP)
503+
504+ p.mu.Lock()
505+ if r, ok := p.routes[adminDomain]; ok {
506+ r.ContainerIP = containerIP
507+ }
508+ p.mu.Unlock()
509+
510+ // Reconfigure routes with the new IP
511+ p.configureRoutes()
512+
513+ // Also update in database if possible
514+ err := p.ForceUpdateRouteIP(adminDomain, containerIP)
515+ if err != nil {
516+ logger.Warn("Failed to update admin route IP in database",
517+ "error", err,
518+ "domain", adminDomain)
519+ }
520+ }
521+ }
522+ }
523+
340524 if !exists {
341- logger.Warn("Admin route not found")
342- return
525+ logger.Warn("Admin route not found in memory, creating it now")
526+ // Auto-detect the Gordon container name and ID
527+ containerName := p.detectGordonContainer()
528+
529+ // Get the network name from config
530+ networkName := p.app.GetConfig().ContainerEngine.Network
531+
532+ // Try to get the actual container IP from the network
533+ if p.gordonContainerID == "" {
534+ logger.Error("Failed to detect Gordon container ID, cannot add admin route")
535+ return
536+ }
537+
538+ // Get the IP address from the container's network
539+ ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName)
540+ if err != nil || ip == "" {
541+ logger.Error("Failed to get Gordon container IP from network",
542+ "container_id", p.gordonContainerID,
543+ "network", networkName,
544+ "error", err)
545+ ip = "localhost" // Fallback to localhost
546+ }
547+
548+ // Create the route in the database
549+ err = p.AddRoute(
550+ adminDomain,
551+ containerName,
552+ ip,
553+ p.app.GetConfig().Http.Port,
554+ "http", // Gordon server uses HTTP internally
555+ "/",
556+ )
557+
558+ if err != nil {
559+ logger.Error("Failed to add admin route to database during recovery",
560+ "error", err,
561+ "domain", adminDomain)
562+ } else {
563+ logger.Info("Successfully recreated admin domain route in database",
564+ "domain", adminDomain,
565+ "container", containerName,
566+ "target", fmt.Sprintf("http://%s:%s", ip, p.app.GetConfig().Http.Port))
567+ }
568+
569+ // Add route to memory directly to ensure it's available immediately
570+ p.mu.Lock()
571+ p.routes[adminDomain] = &ProxyRouteInfo{
572+ Domain: adminDomain,
573+ ContainerIP: ip,
574+ ContainerPort: p.app.GetConfig().Http.Port,
575+ ContainerID: containerName,
576+ Protocol: "http", // Gordon server uses HTTP internally
577+ Path: "/",
578+ Active: true,
579+ }
580+ p.mu.Unlock()
581+
582+ // Reconfigure routes to apply the changes
583+ p.configureRoutes()
584+
585+ // Update route variable for the next steps
586+ p.mu.RLock()
587+ route, exists = p.routes[adminDomain]
588+ p.mu.RUnlock()
589+
590+ if !exists {
591+ logger.Error("Failed to create admin route, route still doesn't exist after recreation attempt")
592+ return
593+ }
343594 }
344595
345596 // Test connection and update if needed
@@ -351,14 +602,43 @@ func (p *Proxy) TestAdminConnectionLater() {
351602 logger.Info("Updated admin route with working connection",
352603 "domain", adminDomain,
353604 "ip", testedIP)
605+
606+ // Update the route in database as well
607+ err := p.ForceUpdateRouteIP(adminDomain, testedIP)
608+ if err != nil {
609+ logger.Warn("Failed to update admin route IP in database",
610+ "error", err,
611+ "domain", adminDomain)
612+ }
354613 }
355614 p.mu.Unlock()
615+
616+ // Reconfigure routes to apply the IP change
617+ p.configureRoutes()
356618 }
357619
358620 // Check external port access
359621 p.checkExternalPortAccess()
360622 }
361623
624+// checkPorts is a helper function to diagnose what might be using a port
625+func checkPorts(protocol, port string) {
626+ // This is just a debugging helper - errors are ignored
627+ // Try using netstat to check port usage
628+ output, err := exec.Command("netstat", "-tuln").CombinedOutput()
629+ if err == nil {
630+ logger.Debug("Network port status", "output", string(output))
631+ }
632+
633+ // Try using lsof to check port usage
634+ output, err = exec.Command("lsof", "-i", fmt.Sprintf("%s:%s", protocol, port)).CombinedOutput()
635+ if err == nil && len(output) > 0 {
636+ logger.Debug("Processes using port", "port", port, "output", string(output))
637+ } else {
638+ logger.Debug("No process found using port with lsof", "port", port)
639+ }
640+}
641+
362642 // checkExternalPortAccess verifies if ports 80 and 443 are accessible
363643 func (p *Proxy) checkExternalPortAccess() {
364644 adminDomain := p.app.GetConfig().Http.FullDomain()
@@ -406,82 +686,129 @@ func (p *Proxy) checkExternalPortAccess() {
406686 }
407687 }
408688
409-// // detectGordonContainer attempts to find the Gordon container automatically
410-// func (p *Proxy) detectGordonContainer() string {
411-// // Check if we're running inside a container
412-// if docker.IsRunningInContainer() {
413-// logger.Debug("Detected we're running in a container")
414-
415-// // Get our hostname which should be the container ID in Docker/Podman
416-// hostname := os.Getenv("HOSTNAME")
417-// if hostname != "" {
418-// // Check if there's a container with this hostname
419-// containers, err := docker.ListRunningContainers()
420-// if err == nil {
421-// for _, container := range containers {
422-// // Check if this container is us by comparing hostname with container ID
423-// if strings.HasPrefix(hostname, container.ID) {
424-// // This is our container
425-// containerName := strings.TrimLeft(container.Names[0], "/")
426-// logger.Debug("Auto-detected Gordon container name from hostname", "container_name", containerName)
427-// return containerName
428-// }
429-// }
430-// } else {
431-// logger.Debug("Could not list containers when checking hostname", "error", err)
432-// }
433-// }
434-// }
435-
436-// // Try to find Gordon by examining container processes
437-// containers, err := docker.ListRunningContainers()
438-// if err == nil {
439-// for _, container := range containers {
440-// containerID := container.ID
441-
442-// // Check container processes for Gordon
443-// containerLogs, err := docker.GetContainerLogs(containerID)
444-// if err == nil && (strings.Contains(containerLogs, "/gordon") || strings.Contains(containerLogs, "serve")) {
445-// containerName := strings.TrimLeft(container.Names[0], "/")
446-// logger.Debug("Auto-detected Gordon container by process", "container_name", containerName)
447-// return containerName
448-// } else if err != nil {
449-// logger.Debug("Could not get container logs", "container_id", containerID, "error", err)
450-// }
451-
452-// // Check container command for Gordon
453-// if len(container.Command) > 0 && strings.Contains(container.Command, "gordon") {
454-// containerName := strings.TrimLeft(container.Names[0], "/")
455-// logger.Debug("Auto-detected Gordon container by command", "container_name", containerName)
456-// return containerName
457-// }
458-// }
459-// } else {
460-// logger.Debug("Could not list containers when checking processes", "error", err)
461-// }
462-
463-// // If we couldn't detect from processes, try to find a container with gordon in the name or image
464-// if err == nil {
465-// for _, container := range containers {
466-// // Check container names for "gordon"
467-// for _, name := range container.Names {
468-// if strings.Contains(strings.ToLower(name), "gordon") {
469-// containerName := strings.TrimLeft(name, "/")
470-// logger.Debug("Auto-detected Gordon container by name", "container_name", containerName)
471-// return containerName
472-// }
473-// }
474-
475-// // Check image name for "gordon"
476-// if strings.Contains(strings.ToLower(container.Image), "gordon") {
477-// containerName := strings.TrimLeft(container.Names[0], "/")
478-// logger.Debug("Auto-detected Gordon container by image", "container_name", containerName, "image", container.Image)
479-// return containerName
480-// }
481-// }
482-// }
483-
484-// // If all else fails, default to "gordon"
485-// logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon")
486-// return "gordon"
487-// }
689+// getOwnContainerIP attempts to determine the container's own IP address
690+// from network interfaces, preferring non-loopback interfaces
691+func (p *Proxy) getOwnContainerIP() string {
692+ // Default fallback IP based on common container network range
693+ defaultIP := "10.89.0.118" // Updated to match the observed IP in logs
694+
695+ // Use environment variable if available (can be set in container config)
696+ if envIP := os.Getenv("GORDON_CONTAINER_IP"); envIP != "" {
697+ logger.Debug("Using container IP from environment variable", "ip", envIP)
698+ return envIP
699+ }
700+
701+ // Try to get the IP from the Gordon network - this is the most reliable method
702+ networkName := p.app.GetConfig().ContainerEngine.Network
703+ if p.gordonContainerID != "" {
704+ ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName)
705+ if err == nil && ip != "" {
706+ logger.Info("Using container IP from Docker network", "ip", ip, "network", networkName)
707+ return ip
708+ } else {
709+ logger.Error("Failed to get container IP from Docker network",
710+ "error", err,
711+ "container_id", p.gordonContainerID,
712+ "network", networkName)
713+ }
714+ } else {
715+ logger.Error("Gordon container ID not set, cannot get container IP from Docker network")
716+ }
717+
718+ // Try to detect by listing network interfaces
719+ ifaces, err := net.Interfaces()
720+ if err != nil {
721+ logger.Error("Failed to list network interfaces", "error", err)
722+ return defaultIP
723+ }
724+
725+ // First, look for eth0 or similar standard interfaces
726+ for _, iface := range ifaces {
727+ // Skip loopback
728+ if iface.Flags&net.FlagLoopback != 0 {
729+ continue
730+ }
731+
732+ // Prefer eth0 or similar
733+ if strings.HasPrefix(iface.Name, "eth") || strings.HasPrefix(iface.Name, "en") {
734+ addrs, err := iface.Addrs()
735+ if err != nil {
736+ continue
737+ }
738+
739+ for _, addr := range addrs {
740+ ip, _, err := net.ParseCIDR(addr.String())
741+ if err != nil {
742+ continue
743+ }
744+
745+ // Only use IPv4 addresses
746+ if ipv4 := ip.To4(); ipv4 != nil {
747+ logger.Debug("Found container IP from network interface",
748+ "interface", iface.Name,
749+ "ip", ipv4.String())
750+ return ipv4.String()
751+ }
752+ }
753+ }
754+ }
755+
756+ // If no preferred interface found, use any non-loopback interface
757+ for _, iface := range ifaces {
758+ if iface.Flags&net.FlagLoopback != 0 {
759+ continue
760+ }
761+
762+ addrs, err := iface.Addrs()
763+ if err != nil {
764+ continue
765+ }
766+
767+ for _, addr := range addrs {
768+ ip, _, err := net.ParseCIDR(addr.String())
769+ if err != nil {
770+ continue
771+ }
772+
773+ if ipv4 := ip.To4(); ipv4 != nil {
774+ logger.Debug("Found container IP from fallback interface",
775+ "interface", iface.Name,
776+ "ip", ipv4.String())
777+ return ipv4.String()
778+ }
779+ }
780+ }
781+
782+ logger.Warn("Could not determine container IP, using default", "ip", defaultIP)
783+ return defaultIP
784+}
785+
786+// StartAdminRouteVerification starts a periodic verification of admin routes
787+// to ensure they are correctly registered and functioning
788+func (p *Proxy) StartAdminRouteVerification() {
789+ ticker := time.NewTicker(30 * time.Second)
790+ defer ticker.Stop()
791+
792+ logger.Debug("Starting admin route verification service")
793+
794+ for {
795+ select {
796+ case <-ticker.C:
797+ adminDomain := p.app.GetConfig().Http.FullDomain()
798+
799+ // Check if the admin route exists in the routes map
800+ p.mu.RLock()
801+ _, exists := p.routes[adminDomain]
802+ p.mu.RUnlock()
803+
804+ if !exists {
805+ logger.Warn("Admin route missing during periodic check, recreating it")
806+ p.TestAdminConnectionLater()
807+ continue
808+ }
809+
810+ // Test the existing admin connection
811+ p.TestAdminConnectionLater()
812+ }
813+ }
814+}
D internal/proxy/utils.go +0 -26
@@ -1,26 +0,0 @@
1-package proxy
2-
3-import (
4- "crypto/rand"
5- "fmt"
6- "time"
7-
8- "github.com/google/uuid"
9-)
10-
11-// proxyGenerateUUID generates a random UUID for use in various identifiers
12-// This is a renamed version to avoid conflicts until we clean up proxy.go
13-func proxyGenerateUUID() string {
14- id, err := uuid.NewRandom()
15- if err != nil {
16- // If we can't generate a UUID using the standard library,
17- // fall back to our own implementation
18- b := make([]byte, 16)
19- _, err := rand.Read(b)
20- if err != nil {
21- return fmt.Sprintf("%d", time.Now().UnixNano())
22- }
23- return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
24- }
25- return id.String()
26-}
M internal/templating/models/html/admin/createcontainerfull.gohtml +3 -3
@@ -2,13 +2,13 @@
22 <html lang="en">
33 {{template "header.gohtml" .}}
44
5- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">
5+ <body class="bg-app flex flex-col lg:flex-row p-0">
66 {{template "menu.gohtml" .}}
77 <!-- Main content here -->
88 <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
99 <div class="flex flex-col md:flex-row w-full flex-grow">
10- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">
11- <h1 class="text-2xl font-bold mb-4">Edit Container</h1>
10+ <div class="card flex-grow">
11+ <h1 class="card-header mb-4">Edit Container</h1>
1212 <!-- Include the existing edit container fragment here -->
1313 {{template "createcontainer.gohtml" .}}
1414 </div>
M internal/templating/models/html/admin/dashboard.gohtml +1 -1
@@ -1,6 +1,6 @@
11 <div id="dashboard">
22 <h1
3- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
3+ class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
44 >
55 Hello World from dashboard.html
66 </h1>
M internal/templating/models/html/admin/index.gohtml +9 -9
@@ -2,23 +2,23 @@
22 <html lang="en">
33 {{template "header.gohtml" .}}
44
5- <body class="bg-zinc-200 flex flex-col md:flex-row p-4 md:p-0">
5+ <body class="bg-app flex flex-col md:flex-row p-4 md:p-0">
66 {{template "menu.gohtml" .}}
77 <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">
88 <div
99 class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
10- <div class="flex-grow bg-white p-4 rounded-md shadow mb-4">
11- <h2>{{index .Lang "dashboard" "content" "title"}}</h2>
10+ <div class="card flex-grow">
11+ <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2>
1212 </div>
1313 <div class="w-full md:w-2/6 flex flex-col space-y-4">
14- <div class="bg-white p-4 rounded-md shadow mb-4">
15- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
14+ <div class="card">
15+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
1616 </div>
17- <div class="bg-white p-4 rounded-md shadow mb-4">
18- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
17+ <div class="card">
18+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
1919 </div>
20- <div class="bg-white p-4 rounded-md shadow mb-4">
21- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
20+ <div class="card">
21+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
2222 </div>
2323 </div>
2424 </div>
M internal/templating/models/html/admin/install.gohtml +7 -7
@@ -21,7 +21,7 @@
2121 </head>
2222
2323 <body
24- class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center"
24+ class="IBMPlexSans bg-app md:p-8 flex flex-col items-center"
2525 hx-boost="true"
2626 >
2727 <!-- Logo and Title -->
@@ -38,28 +38,28 @@
3838 </div>
3939 <div class="flex justify-center mt-4">
4040 <h1
41- class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter"
41+ class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
4242 >
4343 Gordon
4444 </h1>
45- <span class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"
45+ <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2"
4646 >v0.0.1</span
4747 >
4848 </div>
4949 </div>
5050
5151 <!-- Main Content -->
52- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">
52+ <div class="card w-2/3">
5353 <!-- Built-in Proxy -->
5454 <div id="built-in-proxy" class="mt-8">
5555 <div class="flex items-center mb-4">
56- <div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
56+ <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
5757 <span class="text-white font-bold">✓</span>
5858 </div>
59- <h2 class="text-lg ml-2">Reverse Proxy</h2>
59+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
6060 </div>
6161
62- <div class="bg-gray-100 p-4 rounded-md">
62+ <div class="bg-indigo-50 p-4 rounded-md">
6363 <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
6464 <p>The proxy automatically:</p>
6565 <ul class="list-disc ml-6 mb-4">
M internal/templating/models/html/admin/manager.gohtml +65 -23
@@ -1,34 +1,76 @@
11 <!-- manager.gohtml -->
22 <!DOCTYPE html>
3-<html lang="en">
3+<html lang="en" data-theme="gordon">
44 {{template "header.gohtml" .}}
55
6- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">
6+ <body class="min-h-screen bg-base-200">
77 {{template "menu.gohtml" .}}
8- <!-- Main content here -->
9- <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
10- <div class="flex flex-col md:flex-row w-full flex-grow">
11- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">
12- <!-- List all the containers -->
13- <div
14- id="container-manager"
15- hx-get="/htmx/container-manager"
16- hx-trigger="load"
17- hx-swap="innerHTML"
18- hx-swap-oob="true"
19- ></div>
20- <!-- List all the images -->
21- <div
22- id="image-manager"
23- hx-get="/htmx/image-manager"
24- hx-trigger="load"
25- hx-swap="innerHTML"
26- hx-swap-oob="true"
27- ></div>
8+
9+ <!-- Main content -->
10+ <div class="container mx-auto py-6 px-4 mt-16">
11+ <div class="text-sm breadcrumbs mb-4">
12+ <ul>
13+ <li><a href="{{ .AdminPath }}">Gordon</a></li>
14+ <li>Manager</li>
15+ </ul>
2816 </div>
17+
18+ <!-- Stats overview -->
19+ <div class="stats shadow mb-6 w-full">
20+ <div class="stat">
21+ <div class="stat-figure text-primary">
22+ <span class="iconf text-3xl">󰡨</span>
23+ </div>
24+ <div class="stat-title">Containers</div>
25+ <div class="stat-value" id="container-count">{{.ContainerCount}}</div>
26+ <div class="stat-desc">Docker containers managed by Gordon</div>
27+ </div>
28+
29+ <div class="stat">
30+ <div class="stat-figure text-secondary">
31+ <span class="iconf text-3xl">󰋊</span>
32+ </div>
33+ <div class="stat-title">Images</div>
34+ <div class="stat-value" id="image-count">{{.ImageCount}}</div>
35+ <div class="stat-desc">Docker images available</div>
36+ </div>
37+ </div>
38+
39+ <!-- Tabs for containers and images -->
40+ <div role="tablist" class="tabs tabs-boxed mb-4">
41+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
42+ <div role="tabpanel" class="tab-content p-0">
43+ <!-- Container manager section -->
44+ <div id="container-manager"
45+ hx-get="/htmx/container-manager"
46+ hx-trigger="load"
47+ hx-swap="innerHTML">
48+ <!-- Container content loaded via HTMX -->
49+ <div class="text-center p-4">
50+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
51+ Loading containers...
52+ </div>
53+ </div>
54+ </div>
55+
56+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
57+ <div role="tabpanel" class="tab-content p-0">
58+ <!-- Image manager section -->
59+ <div id="image-manager"
60+ hx-get="/htmx/image-manager"
61+ hx-trigger="load"
62+ hx-swap="innerHTML">
63+ <!-- Image content loaded via HTMX -->
64+ <div class="text-center p-4">
65+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
66+ Loading images...
67+ </div>
68+ </div>
69+ </div>
70+ </div>
71+
2972 </div>
3073
31- <!-- footer -->
3274 {{template "footer.gohtml" .}}
3375 </body>
3476 </html>
M internal/templating/models/html/fragments/components.gohtml +55 -12
@@ -1,20 +1,24 @@
11 <!-- @format -->
22
3-<div id="header">
4- <h1
5- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
6- >
7- {{ .body.header.helloworld }}
8- </h1>
3+<div id="header" class="hero bg-base-200">
4+ <div class="hero-content text-center">
5+ <div class="max-w-md">
6+ <h1 class="text-5xl font-bold gordon-title">
7+ {{ .body.header.helloworld }}
8+ </h1>
9+ <p class="py-6 IBMPlexSans">Welcome to Gordon Container Management system.</p>
10+ <button class="btn btn-primary">Get Started</button>
11+ </div>
12+ </div>
913 </div>
1014
11-<div id="hello">
12- <h1
13- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
14- >
15- {{ .body.div.hello }}
16- </h1>
15+<div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
16+ <div>
17+ <span class="iconfb">󰋽</span>
18+ <span class="IBMPlexSans">{{ .body.div.hello }}</span>
19+ </div>
1720 </div>
21+
1822 <div
1923 id="header"
2024 hx-get="/htmx"
@@ -31,3 +35,42 @@
3135 hx-trigger="load"
3236 hx-swap="outerHTML"
3337 ></div>
38+
39+<!-- Stats Cards -->
40+<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
41+ <div class="stat bg-base-100 shadow">
42+ <div class="stat-figure text-primary">
43+ <span class="iconfb text-3xl">󰡨</span>
44+ </div>
45+ <div class="stat-title">Containers</div>
46+ <div class="stat-value">31</div>
47+ <div class="stat-desc">Running containers</div>
48+ </div>
49+
50+ <div class="stat bg-base-100 shadow">
51+ <div class="stat-figure text-secondary">
52+ <span class="iconfb text-3xl">󱧕</span>
53+ </div>
54+ <div class="stat-title">Images</div>
55+ <div class="stat-value">42</div>
56+ <div class="stat-desc">Available images</div>
57+ </div>
58+
59+ <div class="stat bg-base-100 shadow">
60+ <div class="stat-figure text-accent">
61+ <span class="iconfb text-3xl">󰙅</span>
62+ </div>
63+ <div class="stat-title">Networks</div>
64+ <div class="stat-value">12</div>
65+ <div class="stat-desc">Container networks</div>
66+ </div>
67+</div>
68+
69+<!-- Example of tabs -->
70+<div class="tabs tabs-boxed my-4">
71+ <a class="tab tab-active">Overview</a>
72+ <a class="tab">Containers</a>
73+ <a class="tab">Images</a>
74+ <a class="tab">Networks</a>
75+ <a class="tab">Volumes</a>
76+</div>
M internal/templating/models/html/fragments/containerlist.gohtml +120 -110
@@ -1,112 +1,122 @@
1-<div class="flex flex-col p-4" hx-ext="response-targets">
2- <div class="flex justify-between items-center mb-1">
3- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">
4- {{index .Lang "manager" "content" "h1-containers"}}
5- </h1>
6- <button
7- id="refresh-containers-button"
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
9- title="Refresh container list"
10- >
11- <span class="iconf text-xl align-middle">󰑐 </span>
12- Refresh
13- </button>
14- </div>
15- <table class="p-4 border-t-2 table-auto">
16- <thead>
17- <tr class="text-center text-gray-400">
18- <th>{{index .Lang "manager" "content" "thName"}}</th>
19- <th>{{index .Lang "manager" "content" "thPorts"}}</th>
20- <th>{{index .Lang "manager" "content" "thEntryPoint"}}</th>
21- <th>{{index .Lang "manager" "content" "thStatus"}}</th>
22- <th>{{index .Lang "manager" "content" "thState"}}</th>
23- <th>{{index .Lang "manager" "content" "thActions"}}</th>
24- </tr>
25- </thead>
26- <tbody>
27- {{ $Lang := .Lang }}
28- {{
29- range.containers
30- }}
31- <tr id="container-{{.ID}}" class="text-center hover:bg-slate-100">
32- <td>
33- <span
34- title="{{.Container.ID}}"
35- class="font-bold"
36- >{{.Name}}</span
37- >
38- </td>
39- <td class="port-container">{{ .Ports }}</td>
40- <td class="entrypoint-container">{{.ProxyPort}}</td>
41- <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td>
42- <td style="color: {{.StateColor}};">{{.State}}</td>
43- <td>
44- <button
45- id="start-button-{{.ID}}"
46- hx-post="/htmx/container-manager/start/{{.ID}}"
47- hx-target="#actionResponse-{{.ID}}"
48- hx-target-*="#error-message-{{.ID}}"
49- hx-swap="outerHTML"
50- class="py-2 px-4"
51- >
52- <span title="Start the container" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue"></span>
53- </button>
54- <!-- EDIT -->
55- <button
56- id="edit-button-{{.ID}}"
57- hx-get="/htmx/container-manager/edit/{{.ID}}"
58- hx-target="#actionResponse-{{.ID}}"
59- hx-target-*="#error-message-{{.ID}}"
60- hx-swap="innerHTML"
61- class="py-2 px-4"
62- data-active="false"
63- >
64- <span title="Edit the container" class="iconf text-2xl text-blue-600 hover:text-gordonmascot-hblue">󱩽</span>
65- </button>
66- <!-- stop -->
67- <button
68- id="stop-button-{{.ID}}"
69- hx-post="/htmx/container-manager/stop/{{.ID}}"
70- hx-target="#actionResponse-{{.ID}}"
71- hx-target-*="#error-message-{{.ID}}"
72- hx-swap="innerHTML"
73- hx-confirm="{{index $Lang "manager" "content" "confirm-container-stop"}}"
74- class="py-2 px-4"
75- >
76- <span title="Stop the container" class="iconf text-xl text-red-600 hover:text-red-900"></span>
77- </button>
78- <!-- REMOVE -->
79- <button
80- id="remove-button-img-{{.ID}}"
81- hx-delete="/htmx/container-manager/delete/{{.ID}}"
82- hx-target="#actionResponse-{{.ID}}"
83- hx-target-*="#error-message-{{.ID}}"
84- hx-swap="innerHTML"
85- hx-confirm="{{index $Lang "manager" "content" "confirm-delete"}}"
86- class="py-2 px-4"
87- >
88- <!-- iconoir trash -->
89- <span title="Remove the container" class="iconf text-xl text-red-600 hover:text-red-900"></span>
90- </button>
91- </td>
1+<!-- Container list using daisyUI components -->
2+<div class="card w-full bg-base-300">
3+ <div class="card-body p-4">
4+
5+ <!-- Action buttons -->
6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7+ <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3>
8+ <div class="btn-group">
9+ <button
10+ class="btn btn-sm btn-primary"
11+ hx-get="/htmx/container-manager"
12+ hx-target="#container-manager"
13+ hx-swap="innerHTML">
14+ <span class="iconf">󰑓</span> Refresh
15+ </button>
16+ </div>
17+ </div>
18+
19+ <!-- Container list -->
20+ <table class="table">
21+ <!-- head -->
22+ <thead>
23+ <tr>
24+ <th>Name</th>
25+ <th>Image</th>
26+ <th>Status</th>
27+ <th>Created</th>
28+ <th>Actions</th>
29+ </tr>
30+ </thead>
31+ <tbody>
32+ {{ if or .NoContainer (eq (len .Containers) 0) }}
33+ <tr>
34+ <td colspan="5" class="text-center py-4">
35+ <div class="alert alert-info">
36+ <span class="iconf">ℹ️</span>
37+ <span>No containers found. Upload an image first, then create a container.</span>
38+ </div>
39+ </td>
9240 </tr>
93- <tr>
94- <td colspan="7">
95- <div id="actionResponse-{{.ID}}"></div>
96- <div id="error-message-{{.ID}}" class="text-red-500"></div>
97- </td>
98- </tr>
99- {{ else }}
100- <tr>
101- <td colspan="7" class="mt-2">
102- <p class="text-center text-gray-400 text-xl">
103- {{index $Lang "manager" "content" "no-containers"}}
104- </p>
105- </td>
106- </tr>
107- {{
108- end
109- }}
110- </tbody>
111- </table>
41+ {{ else }}
42+ {{ range .Containers }}
43+ <tr class="hover">
44+ <td>
45+ <div class="flex items-center gap-3">
46+ <div>
47+ <div class="font-bold">{{ .Names }}</div>
48+ <div class="text-sm opacity-50">{{ .ID }}</div>
49+ </div>
50+ </div>
51+ </td>
52+ <td>{{ .Image }}</td>
53+ <td>
54+ {{ if eq .State "running" }}
55+ <div class="badge badge-success badge-sm">Running</div>
56+ {{ else if eq .State "exited" }}
57+ <div class="badge badge-warning badge-sm">Stopped</div>
58+ {{ else }}
59+ <div class="badge badge-ghost badge-sm">{{ .State }}</div>
60+ {{ end }}
61+ </td>
62+ <td>{{ .Created }}</td>
63+ <th>
64+ <div class="dropdown dropdown-end">
65+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
66+ <span class="iconf">󰅀</span>
67+ </div>
68+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
69+ {{ if eq .State "running" }}
70+ <li>
71+ <button
72+ class="text-warning flex items-center"
73+ hx-post="/htmx/container-manager/stop/{{ .ID }}"
74+ hx-target="#container-manager"
75+ hx-swap="innerHTML">
76+ <span class="iconf mr-2">󰓛</span> Stop
77+ </button>
78+ </li>
79+ {{ else if eq .State "exited" }}
80+ <li>
81+ <button
82+ class="text-success flex items-center"
83+ hx-post="/htmx/container-manager/start/{{ .ID }}"
84+ hx-target="#container-manager"
85+ hx-swap="innerHTML">
86+ <span class="iconf mr-2">󰐊</span> Start
87+ </button>
88+ </li>
89+ {{ end }}
90+ <li>
91+ <button
92+ class="text-info flex items-center"
93+ hx-get="/htmx/container-manager/edit/{{ .ID }}"
94+ hx-target="#container-manager"
95+ hx-swap="innerHTML">
96+ <span class="iconf mr-2">󰏫</span> Edit
97+ </button>
98+ </li>
99+ <li>
100+ <button
101+ class="text-error flex items-center"
102+ hx-delete="/htmx/container-manager/delete/{{ .ID }}"
103+ hx-target="#container-manager"
104+ hx-swap="innerHTML"
105+ hx-confirm="Are you sure you want to delete this container?">
106+ <span class="iconf mr-2">󰆴</span> Delete
107+ </button>
108+ </li>
109+ </ul>
110+ </div>
111+ </th>
112+ </tr>
113+ {{ end }}
114+ {{ end }}
115+ </tbody>
116+ </table>
117+ </div>
112118 </div>
119+
120+<script>
121+console.debug('Container list rendered');
122+</script>
M internal/templating/models/html/fragments/createcontainer.gohtml +79 -54
@@ -1,9 +1,12 @@
11 <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets">
22 <!-- Left Side: Docker Run Options -->
3- <div class="flex-grow bg-slate-100 p-4 rounded-md">
4- <h2 class="font-bold pb-4">Deploy a container based on {{.ImageName}}</h2>
3+ <div class="flex-grow card p-6">
4+ <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2>
55 {{if .ErrorMessage}}
6- <div class="text-red-500">{{.ErrorMessage}}</div>
6+ <div class="alert alert-error">
7+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
8+ <span>{{.ErrorMessage}}</span>
9+ </div>
710 {{ end }}
811 <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post"
912 hx-post="/htmx/create-container/{{.ShortID}}"
@@ -11,83 +14,104 @@
1114 hx-target="#create-container-{{.ShortID}}"
1215 hx-target-*="#error-message-create-container-{{.ShortID}}">
1316 <!-- Image Name -->
14- <div class="flex space-x-4 pb-4">
15- <div class="w-1/3">
16- <label class="block">Image name</label>
17- <input type="text" name="image_name" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"
17+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
18+ <div class="md:col-span-1">
19+ <label class="label">
20+ <span class="label-text">Image name</span>
21+ </label>
22+ <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
1823 value="{{.ImageName}}" readonly />
1924 </div>
2025
21- <div class="w-2/3">
22- <label class="block">Image ID</label>
23- <input type="text" name="image_id" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"
26+ <div class="md:col-span-2">
27+ <label class="label">
28+ <span class="label-text">Image ID</span>
29+ </label>
30+ <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
2431 value="{{.ImageID}}" readonly />
2532 </div>
2633 </div>
2734
2835 <!-- Container Name -->
29- <div class="pb-4">
30- <label class="block">Container name</label>
31- <input type="text" name="container_name" class="w-full p-4 rounded-md" value="{{.ContainerName}}" />
36+ <div class="form-control pb-4">
37+ <label class="label">
38+ <span class="label-text">Container name</span>
39+ </label>
40+ <input type="text" name="container_name" class="input input-bordered w-full" value="{{.ContainerName}}" />
3241 </div>
42+
3343 <!-- Http(s) / Subdomain / Domain -->
34- <div class="flex pb-4 space-x-4">
44+ <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
3545 <!-- Protocol -->
36- <div class="w-1/4">
37- <label class="block">Protocol</label>
38- <select name="container_protocol" class="w-full p-4 rounded-md border-2 bg-slate-200 border-gray-300">
46+ <div class="md:col-span-3">
47+ <label class="label">
48+ <span class="label-text">Protocol</span>
49+ </label>
50+ <select name="container_protocol" class="select select-bordered w-full">
3951 <option value="https">https</option>
4052 <option value="http">http</option>
4153 </select>
4254 </div>
4355
4456 <!-- Subdomain -->
45- <div class="w-1/3">
46- <label class="block">Subdomain</label>
47- <input type="text" name="container_subdomain" class="w-full p-4 rounded-md" value="{{.ContainerSubdomain}}" />
57+ <div class="md:col-span-4">
58+ <label class="label">
59+ <span class="label-text">Subdomain</span>
60+ </label>
61+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" />
4862 </div>
4963
5064 <!-- Domain -->
51- <div class="w-5/12">
52- <label class="block">Domain</label>
53- <input type="text" name="container_domain" class="w-full p-4 rounded-md" value="{{.ContainerDomain}}" />
65+ <div class="md:col-span-5">
66+ <label class="label">
67+ <span class="label-text">Domain</span>
68+ </label>
69+ <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" />
5470 </div>
5571 </div>
72+
5673 <!-- Exposed Ports -->
57- <div class="pb-4">
58- <label class="block">Exposed Ports (Optional)</label>
59- <span class="text-gray-500">
60- eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)
61- </span>
62- <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" />
74+ <div class="form-control pb-4">
75+ <label class="label">
76+ <span class="label-text">Exposed Ports (Optional)</span>
77+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
78+ </label>
79+ <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" />
6380 </div>
6481
6582 <!-- Proxy Port -->
66- <div class="mb-6">
67- <label class="block">Container Proxy Port (Required)</label>
68- <div class="text-xs text-gray-500 mb-2">
69- Specify the internal container port that the proxy will route traffic to
70- </div>
71- <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required />
83+ <div class="form-control pb-4">
84+ <label class="label">
85+ <span class="label-text">Container Proxy Port (Required)</span>
86+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
87+ </label>
88+ <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required />
7289 </div>
7390
7491 <!-- Volumes -->
75- <div class="pb-4">
76- <label class="block">Volumes</label>
77- <span class="text-gray-500">e.g. ./data:/container/data </span>
78- <input type="text" name="volumes" class="w-full p-4 rounded-md" value="{{.Volumes}}" />
92+ <div class="form-control pb-4">
93+ <label class="label">
94+ <span class="label-text">Volumes</span>
95+ <span class="label-text-alt">e.g. ./data:/container/data</span>
96+ </label>
97+ <input type="text" name="volumes" class="input input-bordered w-full" value="{{.Volumes}}" />
7998 </div>
8099
81- <div class="pb-4">
82- <label class="block">Environment variables</label>
83- <span class="text-gray-500">e.g. KEY=VALUE </span>
84- <textarea name="environment_variables" class="w-full p-4 rounded-md"
100+ <div class="form-control pb-4">
101+ <label class="label">
102+ <span class="label-text">Environment variables</span>
103+ <span class="label-text-alt">e.g. KEY=VALUE</span>
104+ </label>
105+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
85106 rows="4">{{.EnvironmentVariables}}</textarea>
86107 </div>
108+
87109 <!-- Restart: always/on-failure/only when stopped/never -->
88- <div class="pb-4">
89- <label class="block">Restart</label>
90- <select name="restart" class="w-full p-4 rounded-md border-2 bg-gray-100 border-gray-300">
110+ <div class="form-control pb-4">
111+ <label class="label">
112+ <span class="label-text">Restart</span>
113+ </label>
114+ <select name="restart" class="select select-bordered w-full">
91115 <option value="always">Always</option>
92116 <option value="on-failure">On failure</option>
93117 <option value="unless-stopped">Unless stopped</option>
@@ -96,20 +120,21 @@
96120 </div>
97121
98122 <!-- Skip Proxy Setup Option -->
99- <div class="pb-4">
100- <label class="flex items-center space-x-2">
101- <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" />
123+ <div class="form-control pb-4">
124+ <label class="label cursor-pointer justify-start gap-4">
125+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
102126 <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
103127 </label>
104- <p class="text-sm text-gray-500 ml-7">
128+ <div class="text-sm opacity-70 ml-10">
105129 Use this option if you only want to create the container without triggering proxy setup,
106130 certificate generation, and other related processes.
107- </p>
131+ </div>
108132 </div>
109133
110- <input type="submit" value="Create" id="create-container-submit"
111- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />
134+ <button type="submit" id="create-container-submit" class="btn btn-primary">
135+ Create Container
136+ </button>
112137 </form>
113- <div id="error-message-create-container-{{.ShortID}}" class="text-red-500"></div>
138+ <div id="error-message-create-container-{{.ShortID}}" class="text-error mt-4"></div>
114139 </div>
115140 </div>⏎̸
M internal/templating/models/html/fragments/editcontainer.gohtml +12 -10
@@ -1,9 +1,9 @@
11 <div
22 id="edit-container-{{.ID}}"
3- class="h-fit-content"
3+ class="card bg-base-100 shadow-xl"
44 hx-ext="response-targets"
55 >
6- <div id="editor">
6+ <div id="editor" class="card-body">
77 <form
88 method="post"
99 action="/htmx/container-manager/edit/{{.ID}}"
@@ -14,17 +14,19 @@
1414 <textarea
1515 name="container_config"
1616 id="container_config"
17- class="w-full p-4 rounded-md bg-slate-100 border-gray-300 border-2"
17+ class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
1818 rows="20"
1919 >{{.ContainerInfo}}</textarea
2020 >
21- <input
22- type="submit"
23- value="Save"
24- id="save-button-{{.ID}}"
25- class="bg-gordonmascot-lblue text-white font-bold px-8 py-1 rounded-md hover:bg-gordonmascot-mblue cursor-pointer"
26- />
21+ <div class="card-actions justify-end mt-4">
22+ <input
23+ type="submit"
24+ value="Save"
25+ id="save-button-{{.ID}}"
26+ class="btn btn-primary"
27+ />
28+ </div>
2729 </form>
28- <div id="error-message-edit-container-{{.ID}}" class="text-red-500"></div>
30+ <div id="error-message-edit-container-{{.ID}}" class="text-error mt-2"></div>
2931 </div>
3032 </div>
M internal/templating/models/html/fragments/footer.gohtml +33 -0
@@ -0,0 +1,33 @@
1+<footer class="footer p-10 bg-base-200 text-base-content">
2+ <div>
3+ <span class="footer-title">Gordon</span>
4+ <a class="link link-hover">About us</a>
5+ <a class="link link-hover">Documentation</a>
6+ <a class="link link-hover">Github</a>
7+ </div>
8+ <div>
9+ <span class="footer-title">Container Management</span>
10+ <a class="link link-hover">Dashboard</a>
11+ <a class="link link-hover">Settings</a>
12+ <a class="link link-hover">Stats</a>
13+ </div>
14+ <div>
15+ <span class="footer-title">Legal</span>
16+ <a class="link link-hover">Terms of use</a>
17+ <a class="link link-hover">Privacy policy</a>
18+ <a class="link link-hover">License</a>
19+ </div>
20+</footer>
21+<footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
22+ <div class="items-center grid-flow-col">
23+ <span class="iconfb text-2xl">󰊢</span>
24+ <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>
25+ </div>
26+ <div class="md:place-self-center md:justify-self-end">
27+ <div class="grid grid-flow-col gap-4">
28+ <a class="iconfb text-2xl">󰊤</a>
29+ <a class="iconfb text-2xl">󰗃</a>
30+ <a class="iconfb text-2xl">󰖟</a>
31+ </div>
32+ </div>
33+</footer>
M internal/templating/models/html/fragments/header.gohtml +10 -0
@@ -11,4 +11,14 @@
1111 <script src="/libs/htmx-sse.js"></script>
1212 <script defer src="/libs/hyperscript.min.js"></script>
1313 <script defer src="/assets/js/custom.js"></script>
14+
15+ <!-- daisyUI theme configuration -->
16+ <script>
17+ // Check for saved theme preference or use preferred color scheme
18+ const savedTheme = localStorage.getItem('theme') || 'gordon';
19+ document.documentElement.setAttribute('data-theme', savedTheme);
20+
21+ // Log theme loading for debugging
22+ console.debug('Theme loaded:', savedTheme);
23+ </script>
1424 </head>
M internal/templating/models/html/fragments/imagelist.gohtml +95 -79
@@ -1,82 +1,98 @@
1-<div class="flex flex-col p-4">
2- <!-- Upload button aligned right of Images -->
3- <div class="flex justify-between items-center mb-1">
4- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">Images</h1>
5- <div class="flex space-x-2">
6- <button
7- id="refresh-images-button"
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
9- title="Refresh image list"
10- >
11- <span class="iconf text-xl align-middle">󰑐 </span>
12- Refresh
13- </button>
14- <button
15- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
16- id="upload-button"
17- hx-get="/htmx/upload-image"
18- hx-target="#upload-image"
19- hx-swap="innerHTML"
20- title="Manually upload a .tar container image"
21- >
22- <span class="iconf text-xl align-middle">󰭾 </span>
23- Upload
24- </button>
1+<!-- Image list using daisyUI components -->
2+<div class="card w-full bg-base-300">
3+ <div class="card-body p-4">
4+
5+ <!-- Action buttons -->
6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7+ <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3>
8+ <div class="flex gap-2">
9+ <button
10+ class="btn btn-sm btn-secondary"
11+ hx-get="/htmx/upload-image"
12+ hx-target="#image-manager"
13+ hx-swap="innerHTML">
14+ <span class="iconf">󰦒</span> Upload
15+ </button>
16+ <button
17+ class="btn btn-sm btn-primary"
18+ hx-get="/htmx/image-manager"
19+ hx-target="#image-manager"
20+ hx-swap="innerHTML">
21+ <span class="iconf">󰑓</span> Refresh
22+ </button>
23+ </div>
2524 </div>
26- </div>
27- <div id="upload-image"></div>
28- <table class="p-4 border-t-2">
29- <tbody>
30- {{
31- range.Images
32- }}
33- <tr id="img-{{.ShortID}}" hx-ext="response-targets" class=" hover:bg-slate-100">
34- <td>
35- <span title="{{.ShortID}}">{{.Name}}</span>
36- </td>
37- <td>
38- <span title="Image creation date">{{.CreatedStr}}</span>
39- </td>
40- <td>{{.SizeStr}}</td>
41- <td align="right">
42-
43- <button
44- id="add-button-img-{{.ShortID}}"
45- hx-get="/htmx/create-container/{{.ShortID}}"
46- hx-target="#create-container-{{.ShortID}}"
47- hx-target-*="#error-message-div-{{.ShortID}}"
48- hx-swap="innerHTML"
49- class="py-2 px-4"
50- >
51- <span title="Deploy a container using this image" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue">󰆍</span>
52- </button>
5325
54- <button
55- id="remove-button-img-{{.ShortID}}"
56- hx-delete="/htmx/image-manager/delete/{{.ShortID}}"
57- hx-target="#img-{{.ShortID}}"
58- hx-target-*="#error-message-div-{{.ShortID}}"
59- hx-swap="innerHTML"
60- hx-confirm="Are you sure you want to delete this container image?"
61- class="py-2 px-4"
62- >
63- <span title="Remove this image" class="iconf text-xl text-red-600 hover:text-red-900"></span>
64- </button>
65- </td>
66- </tr>
67- <t>
68- <td colspan="4">
69- <div id="error-message-div-{{.ShortID}}" class="text-red-500"></div>
70- <div class="create-container" id="create-container-{{.ShortID}}"></div>
71- </td>
72- </tr>
73- {{ else }}
74- <tr>
75- <td colspan="4" class="mt-2">No images found.</td>
76- </tr>
77- {{
78- end
79- }}
80- </tbody>
81- </table>
26+ <!-- Image list -->
27+ <table class="table">
28+ <!-- head -->
29+ <thead>
30+ <tr>
31+ <th>Repository</th>
32+ <th>Tag</th>
33+ <th>Size</th>
34+ <th>Created</th>
35+ <th>Actions</th>
36+ </tr>
37+ </thead>
38+ <tbody>
39+ {{ if .NoImage }}
40+ <tr>
41+ <td colspan="5" class="text-center py-4">
42+ <div class="alert alert-info">
43+ <span class="iconf">ℹ️</span>
44+ <span>No images found. Upload an image first.</span>
45+ </div>
46+ </td>
47+ </tr>
48+ {{ else }}
49+ {{ range .Images }}
50+ <tr class="hover">
51+ <td>
52+ <div class="flex items-center gap-3">
53+ <div>
54+ <div class="font-bold">{{ .Repository }}</div>
55+ <div class="text-sm opacity-50">{{ .ID }}</div>
56+ </div>
57+ </div>
58+ </td>
59+ <td>{{ .Tag }}</td>
60+ <td>{{ .Size }}</td>
61+ <td>{{ .Created }}</td>
62+ <th>
63+ <div class="dropdown dropdown-end">
64+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
65+ <span class="iconf">󰅀</span>
66+ </div>
67+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
68+ <li>
69+ <a
70+ href="/htmx/create-container/{{ if gt (len .ID) 12 }}{{ slice .ID 0 12 }}{{ else }}{{ .ID }}{{ end }}"
71+ class="text-success flex items-center">
72+ <span class="iconf mr-2">󰐕</span> Create Container
73+ </a>
74+ </li>
75+ <li>
76+ <button
77+ class="text-error flex items-center"
78+ hx-delete="/htmx/image-manager/delete/{{ .ID }}"
79+ hx-target="#image-manager"
80+ hx-swap="innerHTML"
81+ hx-confirm="Are you sure you want to delete this image?">
82+ <span class="iconf mr-2">󰆴</span> Delete
83+ </button>
84+ </li>
85+ </ul>
86+ </div>
87+ </th>
88+ </tr>
89+ {{ end }}
90+ {{ end }}
91+ </tbody>
92+ </table>
93+ </div>
8294 </div>
95+
96+<script>
97+console.debug('Image list rendered');
98+</script>
M internal/templating/models/html/fragments/menu.gohtml +61 -39
@@ -1,45 +1,67 @@
1-<div
2- id="menubar"
3- class="w-full h-12 md:h-16 fixed top-0 left-0 bg-gordonmascot-lblue flex flex-row justify-evenly items-center px-4 IBMPlexSans">
4- <div class="w-full flex justify-start">
5- <h1
6- class="gordon-title text-3xl md:text-4xl text-gordonmascot-hblue tracking-tighter">
7- Gordon
8- </h1>
9- <span
10- id="actual-version"
11- class="text-gordonmascot-mblue text-xs md:text-sm pr-1 JetBrainsMonoNF">
12- {{ .BuildVersion }}
13- </span>
14- <span
15- hidden
16- id="update-available"
17- class="text-green-300 text-xs md:text-sm JetBrainsMonoNF hidden"
18- title=""
19- ><a href="https://github.com/bnema/gordon/releases">󰨦</a>
20- </span>
1+<!-- DaisyUI Navbar -->
2+<div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
3+ <div class="navbar-start">
4+ <div class="flex items-center">
5+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
6+ Gordon
7+ </h1>
8+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
9+ {{ .BuildVersion }}
10+ </span>
11+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
12+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
13+ </span>
14+ </div>
2115 </div>
22- <div class="w-full">
23- <ul
24- class="flex flex-row space-y-6 font-semibold text-gray-100 text-lg md:text-xl justify-center">
25- <li class="">
26- <span class="JetBrainsMonoBoldNF pr-1"> </span>
27- <a href="{{ .AdminPath }}/manager">Manager</a>
16+ <div class="navbar-center hidden lg:flex">
17+ <ul class="menu menu-horizontal px-1">
18+ <li>
19+ <a href="{{ .AdminPath }}/manager" class="font-semibold">
20+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
21+ </a>
2822 </li>
2923 </ul>
3024 </div>
31- <div class="w-full flex space-x-4 justify-end">
32- <a
33- href="{{ .UserSettingsURL }}"
34- title="User settings"
35- class="iconf text-gordonmascot-mblue text-lg md:text-xl"
36- ></a
37- >
38- <a
39- href="{{ .LogoutURL }}"
40- title="Logout"
41- class="iconf text-gordonmascot-mblue text-lg md:text-xl"
42- >󰍃</a
43- >
25+ <div class="navbar-end">
26+ <!-- Theme toggle -->
27+ <div class="dropdown dropdown-end">
28+ <label tabindex="0" class="btn btn-ghost btn-circle">
29+ <span class="iconf text-lg md:text-xl">󰔎</span>
30+ </label>
31+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
32+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
33+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
34+ <li><button onclick="setTheme('light')">Light</button></li>
35+ <li><button onclick="setTheme('dark')">Dark</button></li>
36+ </ul>
37+ </div>
38+
39+ <!-- User menu -->
40+ <div class="dropdown dropdown-end">
41+ <label tabindex="0" class="btn btn-ghost btn-circle">
42+ <span class="iconf text-lg md:text-xl">󰀉</span>
43+ </label>
44+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
45+ <li>
46+ <a href="{{ .UserSettingsURL }}" title="User settings">
47+ <span class="iconf">󰒓</span> Settings
48+ </a>
49+ </li>
50+ <li>
51+ <a href="{{ .LogoutURL }}" title="Logout">
52+ <span class="iconf">󰍃</span> Logout
53+ </a>
54+ </li>
55+ </ul>
56+ </div>
4457 </div>
4558 </div>
59+
60+<!-- Theme toggle script -->
61+<script>
62+ function setTheme(theme) {
63+ document.documentElement.setAttribute('data-theme', theme);
64+ localStorage.setItem('theme', theme);
65+ console.debug('Theme changed to:', theme);
66+ }
67+</script>
M internal/templating/models/html/fragments/success.gohtml +1 -1
@@ -1,5 +1,5 @@
11 <div
22 id="success"
3- class="flex text-blue-800 content-center justify-center items-center w-full h-full">
3+ class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
44 <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>
55 </div>
M internal/templating/models/html/fragments/uploadimage.gohtml +53 -27
@@ -1,35 +1,61 @@
1-<!-- Form to upload a container image (.tar format) -->
2-<div
3- class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"
4- hx-ext="response-targets"
5- hx-boost="true">
6- <div class="flex-grow bg-slate-100 p-4 rounded-md">
7- <h2>Upload Image Containers</h2>
8- <!-- Upload Form -->
1+<!-- Upload image form using DaisyUI components -->
2+<div class="card bg-base-100 shadow">
3+ <div class="card-body">
4+ <div class="flex justify-between items-center">
5+ <h2 class="card-title text-primary">Upload Image</h2>
6+ <button
7+ class="btn btn-sm btn-circle btn-ghost"
8+ hx-get="/htmx/image-manager"
9+ hx-target="#image-manager"
10+ hx-swap="innerHTML">
11+ <span class="iconf text-lg">✕</span>
12+ </button>
13+ </div>
14+ <div class="divider"></div>
15+
916 <form
10- action="/upload"
11- method="post"
12- enctype="multipart/form-data"
17+ id="upload-form"
18+ hx-encoding="multipart/form-data"
1319 hx-post="/htmx/upload-image"
14- hx-swap="innerHTML"
1520 hx-target="#image-manager"
16- hx-target-*="#error-message-div">
17- <div
18- class="upload-zone w-full h-44 bg-slate-200 rounded-md p-4"
19- hx-drop="true">
20- Drag & Drop your file here or
21- <input
22- type="file"
23- name="file"
21+ hx-swap="innerHTML"
22+ class="space-y-4">
23+
24+ <div class="alert alert-info shadow-lg mb-4">
25+ <span class="iconf">ℹ️</span>
26+ <div>
27+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
28+ <p>After uploading, you can create and manage containers from this image.</p>
29+ </div>
30+ </div>
31+
32+ <div class="form-control w-full">
33+ <label class="label">
34+ <span class="label-text">Select a container image file (.tar)</span>
35+ </label>
36+ <input
37+ type="file"
38+ name="imageFile"
39+ class="file-input file-input-bordered w-full"
2440 accept=".tar"
25- class="bg-transparent outline-none" />
41+ required />
42+ </div>
43+
44+ <div class="card-actions justify-end mt-4">
45+ <button
46+ class="btn btn-ghost"
47+ hx-get="/htmx/image-manager"
48+ hx-target="#image-manager"
49+ hx-swap="innerHTML"
50+ type="button">
51+ Cancel
52+ </button>
53+ <button
54+ class="btn btn-primary"
55+ type="submit">
56+ <span class="iconf mr-1">󰦒</span> Upload
57+ </button>
2658 </div>
27- <input
28- type="submit"
29- value="Upload"
30- id="upload-submit-button"
31- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />
3259 </form>
33- <div id="error-message-div" class="text-red-500"></div>
3460 </div>
3561 </div>
M internal/templating/models/html/login/index.gohtml +79 -63
@@ -1,5 +1,5 @@
11 <!doctype html>
2-<html lang="en">
2+<html lang="en" data-theme="corporate">
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -12,95 +12,111 @@
1212 <script src="/libs/htmx.min.js"></script>
1313 <!-- HTMX SSE -->
1414 <script src="/libs/htmx-sse.js"></script>
15+ <!-- DaisyUI -->
16+ <script src="/libs/daisyui.js"></script>
1517 <!-- Custom JS from assets/js -->
1618 <script src="/assets/js/custom.js"></script>
19+
20+ <!-- daisyUI theme configuration -->
21+ <script>
22+ // Check for saved theme preference or use preferred color scheme
23+ const savedTheme = localStorage.getItem('theme') || 'corporate';
24+ document.documentElement.setAttribute('data-theme', savedTheme);
25+ console.debug('Theme loaded:', savedTheme);
26+ </script>
1727 </head>
1828
19- <body class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center">
29+ <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
2030 <!-- Logo and Title -->
2131 <div
2232 id="logo-mascot"
2333 class="flex flex-col justify-center items-center p-4 mb-4">
24- <div class="circle-shadow flex justify-center w-100">
34+ <div class="flex justify-center w-100">
2535 <img
2636 src="/assets/imgs/gordon-mascot-mq-trsp.png"
2737 alt="logo gordon"
28- class="sm:h-24 h-32 xl:h-48" />
38+ class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
2939 </div>
3040 <div class="flex justify-center mt-4">
3141 <h1
32- class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter">
42+ class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
3343 Gordon
3444 </h1>
3545 <span
36- class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"
46+ class="gordon-v-subtitle text-secondary text-sm ml-2"
3747 >{{ .BuildVersion }}</span
3848 >
3949 </div>
4050 </div>
4151
4252 <!-- Main Content -->
43- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">
44- <!-- Login Section -->
45- <div id="login-section">
46- <div class="flex flex-col items-center">
47- <h2 class="text-2xl font-semibold text-gray-800">Login</h2>
48-
49- <!-- Error Message Display -->
50- {{ if .Error }}
51- <div class="mt-4 w-full max-w-md bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
52- <span class="block sm:inline">
53- {{ if eq .Error "empty_token" }}
54- Please enter a token.
55- {{ else if eq .Error "invalid_token" }}
56- Invalid token. Please try again.
57- {{ else if eq .Error "server_error" }}
58- A server error occurred. Please try again later.
59- {{ else }}
60- An error occurred. Please try again.
61- {{ end }}
62- </span>
63- </div>
64- {{ end }}
65-
66- <!-- Token Input Form -->
67- <div class="mt-4 w-full max-w-md">
68- <form action="login/submit-token" method="POST" class="mb-4">
69- <div class="mb-2">
70- <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label>
71- <input
72- type="text"
73- id="token"
74- name="token"
75- placeholder="Enter your admin token"
76- class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gordonmascot-hblue focus:border-gordonmascot-hblue"
77- required
78- />
79- </div>
80- <button
81- type="submit"
82- class="w-full bg-gordonmascot-mblue hover:bg-gordonmascot-hblue text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out"
53+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
54+ <div class="card-body">
55+ <!-- Login Section -->
56+ <div id="login-section">
57+ <div class="flex flex-col items-center">
58+ <h2 class="card-title text-center mb-4">Login</h2>
59+
60+ <!-- Error Message Display -->
61+ {{ if .Error }}
62+ <div class="alert alert-error mb-4">
63+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
64+ <span>
65+ {{ if eq .Error "empty_token" }}
66+ Please enter a token.
67+ {{ else if eq .Error "invalid_token" }}
68+ Invalid token. Please try again.
69+ {{ else if eq .Error "server_error" }}
70+ A server error occurred. Please try again later.
71+ {{ else }}
72+ An error occurred. Please try again.
73+ {{ end }}
74+ </span>
75+ </div>
76+ {{ end }}
77+
78+ <!-- Token Input Form -->
79+ <div class="w-full">
80+ <form action="login/submit-token" method="POST" class="mb-4">
81+ <div class="form-control mb-4">
82+ <label for="token" class="label">
83+ <span class="label-text">Admin Token</span>
84+ </label>
85+ <input
86+ type="text"
87+ id="token"
88+ name="token"
89+ placeholder="Enter your admin token"
90+ class="input input-bordered w-full"
91+ required
92+ />
93+ </div>
94+ <button
95+ type="submit"
96+ class="btn btn-primary w-full"
97+ >
98+ Continue with Token
99+ </button>
100+ </form>
101+ <div class="divider">OR</div>
102+ </div>
103+
104+ <!-- GitHub OAuth -->
105+ <div class="w-full">
106+ <a
107+ href="login/oauth/github"
108+ class="btn btn-secondary w-full"
83109 >
84- Continue with Token
85- </button>
86- </form>
87- <div class="text-center text-sm text-gray-500 mb-4">- OR -</div>
88- </div>
89-
90- <!-- GitHub OAuth -->
91- <div
92- class="mt-2 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left">
93- <a href="login/oauth/github" class="flex items-center">
94- <img
95- src="/assets/imgs/github/github-mark-white.png"
96- alt="logo github"
97- class="h-8 xl:h-12" />
98- <span class="w-full p-2 text-white">Login with GitHub</span>
99- </a>
110+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
111+ <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
112+ </svg>
113+ Sign in with GitHub
114+ </a>
115+ </div>
100116 </div>
101117 </div>
118+ <!-- End Login Section -->
102119 </div>
103- <!-- End Login Section -->
104120 </div>
105121 </body>
106122 </html>
A internal/templating/models/templ/README.md +48 -0
@@ -0,0 +1,48 @@
1+# Templ Templates for Gordon
2+
3+This directory contains [templ](https://github.com/a-h/templ) templates for the Gordon application. We're migrating from Go's html/template to templ for improved type safety and maintainability.
4+
5+## Directory Structure
6+
7+- `components/` - Reusable UI components
8+- `layouts/` - Page layouts and base templates
9+- `pages/` - Full page templates
10+ - `admin/` - Admin pages
11+ - `login/` - Login pages
12+
13+## Working with Templates
14+
15+To create or modify templates:
16+
17+1. Edit the `.templ` files in the appropriate directory
18+2. Run `templ generate` or `make build-templ` to generate Go code
19+3. Use the templates in your handlers with the `TemplRenderer`
20+
21+## Converting from gohtml
22+
23+When converting from gohtml:
24+
25+1. Create a new `.templ` file
26+2. Copy HTML structure from gohtml
27+3. Replace `{{ .varName }}` with `{ varName }`
28+4. Use templ's control flow syntax like `if`, `for`, etc.
29+5. Use `@` to invoke other components
30+
31+## Example:
32+
33+```templ
34+package components
35+
36+templ Button(label string, classes string) {
37+ <button class={classes}>{ label }</button>
38+}
39+```
40+
41+## Usage in Handlers
42+
43+```go
44+func RenderMyPage(c echo.Context, a *server.App) error {
45+ renderer := render.NewTemplRenderer(a)
46+ return renderer.RenderTempl(c, mypage.PageTemplate("Page Title", someData))
47+}
48+```⏎̸
A internal/templating/models/templ/components/containerlist.templ +132 -0
@@ -0,0 +1,132 @@
1+package components
2+
3+import (
4+ "github.com/docker/docker/api/types"
5+ "strconv"
6+)
7+
8+// ContainerList displays a list of containers
9+templ ContainerList(containers []types.Container, noContainer bool) {
10+ <!-- Container list using daisyUI components -->
11+ <div class="card w-full bg-base-300">
12+ <div class="card-body p-4">
13+
14+ <!-- Action buttons -->
15+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
16+ <h3 class="font-semibold text-base-content">Available Containers: { strconv.Itoa(len(containers)) }</h3>
17+ <div class="btn-group">
18+ <button
19+ class="btn btn-sm btn-primary"
20+ hx-get="/htmx/container-manager"
21+ hx-target="#container-manager"
22+ hx-swap="innerHTML">
23+ <span class="iconf">󰑓</span> Refresh
24+ </button>
25+ </div>
26+ </div>
27+
28+ <!-- Container list -->
29+ <table class="table">
30+ <!-- head -->
31+ <thead>
32+ <tr>
33+ <th>Name</th>
34+ <th>Image</th>
35+ <th>Status</th>
36+ <th>Created</th>
37+ <th>Actions</th>
38+ </tr>
39+ </thead>
40+ <tbody>
41+ if noContainer || len(containers) == 0 {
42+ <tr>
43+ <td colspan="5" class="text-center py-4">
44+ <div class="alert alert-info">
45+ <span class="iconf">ℹ️</span>
46+ <span>No containers found. Upload an image first, then create a container.</span>
47+ </div>
48+ </td>
49+ </tr>
50+ } else {
51+ for _, container := range containers {
52+ <tr class="hover">
53+ <td>
54+ <div class="flex items-center gap-3">
55+ <div>
56+ <div class="font-bold">{ container.Names[0] }</div>
57+ <div class="text-sm opacity-50">{ container.ID }</div>
58+ </div>
59+ </div>
60+ </td>
61+ <td>{ container.Image }</td>
62+ <td>
63+ if container.State == "running" {
64+ <div class="badge badge-success badge-sm">Running</div>
65+ } else if container.State == "exited" {
66+ <div class="badge badge-warning badge-sm">Stopped</div>
67+ } else {
68+ <div class="badge badge-ghost badge-sm">{ container.State }</div>
69+ }
70+ </td>
71+ <td>{ strconv.FormatInt(container.Created, 10) }</td>
72+ <th>
73+ <div class="dropdown dropdown-end">
74+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
75+ <span class="iconf">󰅀</span>
76+ </div>
77+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
78+ if container.State == "running" {
79+ <li>
80+ <button
81+ class="text-warning flex items-center"
82+ hx-post={ "/htmx/container-manager/stop/" + container.ID }
83+ hx-target="#container-manager"
84+ hx-swap="innerHTML">
85+ <span class="iconf mr-2">󰓛</span> Stop
86+ </button>
87+ </li>
88+ } else if container.State == "exited" {
89+ <li>
90+ <button
91+ class="text-success flex items-center"
92+ hx-post={ "/htmx/container-manager/start/" + container.ID }
93+ hx-target="#container-manager"
94+ hx-swap="innerHTML">
95+ <span class="iconf mr-2">󰐊</span> Start
96+ </button>
97+ </li>
98+ }
99+ <li>
100+ <button
101+ class="text-info flex items-center"
102+ hx-get={ "/htmx/container-manager/edit/" + container.ID }
103+ hx-target="#container-manager"
104+ hx-swap="innerHTML">
105+ <span class="iconf mr-2">󰏫</span> Edit
106+ </button>
107+ </li>
108+ <li>
109+ <button
110+ class="text-error flex items-center"
111+ hx-delete={ "/htmx/container-manager/delete/" + container.ID }
112+ hx-target="#container-manager"
113+ hx-swap="innerHTML"
114+ hx-confirm="Are you sure you want to delete this container?">
115+ <span class="iconf mr-2">󰆴</span> Delete
116+ </button>
117+ </li>
118+ </ul>
119+ </div>
120+ </th>
121+ </tr>
122+ }
123+ }
124+ </tbody>
125+ </table>
126+ </div>
127+ </div>
128+
129+ <script>
130+ console.debug('Container list rendered');
131+ </script>
132+}⏎̸
A internal/templating/models/templ/components/containerlist_templ.go +228 -0 generated file: collapsed
A internal/templating/models/templ/components/createcontainer.templ +160 -0
@@ -0,0 +1,160 @@
1+package components
2+
3+// ContainerFormData holds the data needed for the container creation form
4+type ContainerFormData struct {
5+ ImageName string
6+ ImageID string
7+ ShortID string
8+ ContainerName string
9+ ContainerSubdomain string
10+ ContainerDomain string
11+ Ports string
12+ ProxyPort string
13+ Volumes string
14+ EnvironmentVariables string
15+ ErrorMessage string
16+}
17+
18+// CreateContainerForm displays a form for creating a new container
19+templ CreateContainerForm(data ContainerFormData) {
20+ <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets">
21+ <!-- Left Side: Docker Run Options -->
22+ <div class="flex-grow card p-6">
23+ <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2>
24+ if data.ErrorMessage != "" {
25+ <div class="alert alert-error">
26+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
27+ <span>{ data.ErrorMessage }</span>
28+ </div>
29+ }
30+ <form id={ "create-container-form-" + data.ShortID } action={ templ.SafeURL("/create-container/" + data.ShortID) } method="post"
31+ hx-post={ "/htmx/create-container/" + data.ShortID }
32+ hx-swap="innerHTML"
33+ hx-target={ "#create-container-" + data.ShortID }
34+ hx-target-*={ "#error-message-create-container-" + data.ShortID }>
35+ <!-- Image Name -->
36+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
37+ <div class="md:col-span-1">
38+ <label class="label">
39+ <span class="label-text">Image name</span>
40+ </label>
41+ <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
42+ value={ data.ImageName } readonly />
43+ </div>
44+
45+ <div class="md:col-span-2">
46+ <label class="label">
47+ <span class="label-text">Image ID</span>
48+ </label>
49+ <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
50+ value={ data.ImageID } readonly />
51+ </div>
52+ </div>
53+
54+ <!-- Container Name -->
55+ <div class="form-control pb-4">
56+ <label class="label">
57+ <span class="label-text">Container name</span>
58+ </label>
59+ <input type="text" name="container_name" class="input input-bordered w-full" value={ data.ContainerName } />
60+ </div>
61+
62+ <!-- Http(s) / Subdomain / Domain -->
63+ <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
64+ <!-- Protocol -->
65+ <div class="md:col-span-3">
66+ <label class="label">
67+ <span class="label-text">Protocol</span>
68+ </label>
69+ <select name="container_protocol" class="select select-bordered w-full">
70+ <option value="https">https</option>
71+ <option value="http">http</option>
72+ </select>
73+ </div>
74+
75+ <!-- Subdomain -->
76+ <div class="md:col-span-4">
77+ <label class="label">
78+ <span class="label-text">Subdomain</span>
79+ </label>
80+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value={ data.ContainerSubdomain } />
81+ </div>
82+
83+ <!-- Domain -->
84+ <div class="md:col-span-5">
85+ <label class="label">
86+ <span class="label-text">Domain</span>
87+ </label>
88+ <input type="text" name="container_domain" class="input input-bordered w-full" value={ data.ContainerDomain } />
89+ </div>
90+ </div>
91+
92+ <!-- Exposed Ports -->
93+ <div class="form-control pb-4">
94+ <label class="label">
95+ <span class="label-text">Exposed Ports (Optional)</span>
96+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
97+ </label>
98+ <input type="text" name="ports" class="input input-bordered w-full" value={ data.Ports } />
99+ </div>
100+
101+ <!-- Proxy Port -->
102+ <div class="form-control pb-4">
103+ <label class="label">
104+ <span class="label-text">Container Proxy Port (Required)</span>
105+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
106+ </label>
107+ <input type="text" name="container_port" class="input input-bordered w-full" value={ data.ProxyPort } required />
108+ </div>
109+
110+ <!-- Volumes -->
111+ <div class="form-control pb-4">
112+ <label class="label">
113+ <span class="label-text">Volumes</span>
114+ <span class="label-text-alt">e.g. ./data:/container/data</span>
115+ </label>
116+ <input type="text" name="volumes" class="input input-bordered w-full" value={ data.Volumes } />
117+ </div>
118+
119+ <div class="form-control pb-4">
120+ <label class="label">
121+ <span class="label-text">Environment variables</span>
122+ <span class="label-text-alt">e.g. KEY=VALUE</span>
123+ </label>
124+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
125+ rows="4">{ data.EnvironmentVariables }</textarea>
126+ </div>
127+
128+ <!-- Restart: always/on-failure/only when stopped/never -->
129+ <div class="form-control pb-4">
130+ <label class="label">
131+ <span class="label-text">Restart</span>
132+ </label>
133+ <select name="restart" class="select select-bordered w-full">
134+ <option value="always">Always</option>
135+ <option value="on-failure">On failure</option>
136+ <option value="unless-stopped">Unless stopped</option>
137+ <option value="never">Never</option>
138+ </select>
139+ </div>
140+
141+ <!-- Skip Proxy Setup Option -->
142+ <div class="form-control pb-4">
143+ <label class="label cursor-pointer justify-start gap-4">
144+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
145+ <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
146+ </label>
147+ <div class="text-sm opacity-70 ml-10">
148+ Use this option if you only want to create the container without triggering proxy setup,
149+ certificate generation, and other related processes.
150+ </div>
151+ </div>
152+
153+ <button type="submit" id="create-container-submit" class="btn btn-primary">
154+ Create Container
155+ </button>
156+ </form>
157+ <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div>
158+ </div>
159+ </div>
160+} ⏎̸
A internal/templating/models/templ/components/createcontainer_templ.go +283 -0 generated file: collapsed
A internal/templating/models/templ/components/editcontainer.templ +39 -0
@@ -0,0 +1,39 @@
1+package components
2+
3+import t "github.com/a-h/templ"
4+
5+// EditContainer replaces editcontainer.gohtml
6+// It renders a form to edit a container's configuration
7+templ EditContainer(id string, containerInfo string) {
8+ <div
9+ id={ "edit-container-" + id }
10+ class="card bg-base-100 shadow-xl"
11+ hx-ext="response-targets"
12+ >
13+ <div id="editor" class="card-body">
14+ <form
15+ method="post"
16+ action={ t.URL("/htmx/container-manager/edit/" + id) }
17+ hx-post={ string(t.URL("/htmx/container-manager/edit/" + id)) }
18+ hx-swap="innerHTML"
19+ hx-target-*={ "#error-message-edit-container-" + id }
20+ >
21+ <textarea
22+ name="container_config"
23+ id="container_config"
24+ class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
25+ rows="20"
26+ >{ containerInfo }</textarea>
27+ <div class="card-actions justify-end mt-4">
28+ <input
29+ type="submit"
30+ value="Save"
31+ id={ "save-button-" + id }
32+ class="btn btn-primary"
33+ />
34+ </div>
35+ </form>
36+ <div id={ "error-message-edit-container-" + id } class="text-error mt-2"></div>
37+ </div>
38+ </div>
39+} ⏎̸
A internal/templating/models/templ/components/editcontainer_templ.go +131 -0 generated file: collapsed
A internal/templating/models/templ/components/footer.templ +39 -0
@@ -0,0 +1,39 @@
1+package components
2+
3+// Footer is a templ component for the page footer
4+// It replaces footer.gohtml
5+templ Footer() {
6+ <footer class="footer p-10 bg-base-200 text-base-content">
7+ <div>
8+ <span class="footer-title">Gordon</span>
9+ <a class="link link-hover">About us</a>
10+ <a class="link link-hover">Documentation</a>
11+ <a class="link link-hover">Github</a>
12+ </div>
13+ <div>
14+ <span class="footer-title">Container Management</span>
15+ <a class="link link-hover">Dashboard</a>
16+ <a class="link link-hover">Settings</a>
17+ <a class="link link-hover">Stats</a>
18+ </div>
19+ <div>
20+ <span class="footer-title">Legal</span>
21+ <a class="link link-hover">Terms of use</a>
22+ <a class="link link-hover">Privacy policy</a>
23+ <a class="link link-hover">License</a>
24+ </div>
25+ </footer>
26+ <footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
27+ <div class="items-center grid-flow-col">
28+ <span class="iconfb text-2xl">󰊢</span>
29+ <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>
30+ </div>
31+ <div class="md:place-self-center md:justify-self-end">
32+ <div class="grid grid-flow-col gap-4">
33+ <a class="iconfb text-2xl">󰊤</a>
34+ <a class="iconfb text-2xl">󰗃</a>
35+ <a class="iconfb text-2xl">󰖟</a>
36+ </div>
37+ </div>
38+ </footer>
39+}⏎̸
A internal/templating/models/templ/components/footer_templ.go +42 -0 generated file: collapsed
A internal/templating/models/templ/components/header.templ +30 -0
@@ -0,0 +1,30 @@
1+package components
2+
3+// Header is a templ component for the page header
4+// It replaces header.gohtml
5+templ Header(title string) {
6+ <head>
7+ <meta charset="UTF-8" />
8+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9+ <title>{ title }</title>
10+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
11+
12+ <script src="/libs/jquery.min.js"></script>
13+ <script src="/libs/htmx.min.js"></script>
14+ <script src="/libs/htmx-response-targets.js"></script>
15+ <script src="/libs/htmx-debug.js"></script>
16+ <script src="/libs/htmx-sse.js"></script>
17+ <script defer src="/libs/hyperscript.min.js"></script>
18+ <script defer src="/assets/js/custom.js"></script>
19+
20+ <!-- daisyUI theme configuration -->
21+ <script>
22+ // Check for saved theme preference or use preferred color scheme
23+ const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
24+ document.documentElement.setAttribute('data-theme', savedTheme);
25+
26+ // Log theme loading for debugging
27+ console.debug('Theme loaded:', savedTheme);
28+ </script>
29+ </head>
30+}⏎̸
A internal/templating/models/templ/components/header_templ.go +55 -0 generated file: collapsed
A internal/templating/models/templ/components/htmx_components.templ +80 -0
@@ -0,0 +1,80 @@
1+package components
2+
3+// HTMXHeader creates the hero header with HTMX attributes
4+templ HTMXHeader(title string, subtitle string) {
5+ <div id="header" class="hero bg-base-200">
6+ <div class="hero-content text-center">
7+ <div class="max-w-md">
8+ <h1 class="text-5xl font-bold gordon-title">
9+ { title }
10+ </h1>
11+ <p class="py-6 IBMPlexSans">{ subtitle }</p>
12+ <button class="btn btn-primary">Get Started</button>
13+ </div>
14+ </div>
15+ </div>
16+}
17+
18+// HTMXAlert creates an alert box that can be loaded with HTMX
19+templ HTMXAlert(message string) {
20+ <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
21+ <div>
22+ <span class="iconfb">󰋽</span>
23+ <span class="IBMPlexSans">{ message }</span>
24+ </div>
25+ </div>
26+}
27+
28+// HTMXHeaderContainer creates a container to load the HTMX header
29+templ HTMXHeaderContainer() {
30+ <div
31+ id="header"
32+ hx-get="/htmx"
33+ hx-headers='{"HX-Fragment": "header"}'
34+ hx-trigger="load"
35+ hx-swap="outerHTML"
36+ ></div>
37+}
38+
39+// HTMXAppContainer creates a container to load the HTMX app
40+templ HTMXAppContainer() {
41+ <div
42+ id="app"
43+ hx-get="/htmx"
44+ hx-headers='{"HX-Fragment": "hello"}'
45+ hx-trigger="load"
46+ hx-swap="outerHTML"
47+ ></div>
48+}
49+
50+// StatsCard creates individual stat cards for the dashboard
51+templ StatsCard(title string, value string, description string, icon string) {
52+ <div class="stat bg-base-100 shadow">
53+ <div class="stat-figure text-primary">
54+ <span class="iconfb text-3xl">{ icon }</span>
55+ </div>
56+ <div class="stat-title">{ title }</div>
57+ <div class="stat-value">{ value }</div>
58+ <div class="stat-desc">{ description }</div>
59+ </div>
60+}
61+
62+// StatsCards creates a set of stats cards
63+templ StatsCards() {
64+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
65+ @StatsCard("Containers", "31", "Running containers", "󰡨")
66+ @StatsCard("Images", "42", "Available images", "󱧕")
67+ @StatsCard("Networks", "12", "Container networks", "󰙅")
68+ </div>
69+}
70+
71+// TabsNav creates a navigation tabs component
72+templ TabsNav(activeTab string) {
73+ <div class="tabs tabs-boxed my-4">
74+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Overview")}>Overview</a>
75+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Containers")}>Containers</a>
76+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Images")}>Images</a>
77+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Networks")}>Networks</a>
78+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Volumes")}>Volumes</a>
79+ </div>
80+}⏎̸
A internal/templating/models/templ/components/htmx_components_templ.go +438 -0 generated file: collapsed
A internal/templating/models/templ/components/imagelist.templ +122 -0
@@ -0,0 +1,122 @@
1+package components
2+
3+import (
4+ "strconv"
5+)
6+
7+// HumanReadableImage represents an image with human-readable attributes
8+type HumanReadableImage struct {
9+ ID string
10+ ShortID string
11+ Name string
12+ Repository string
13+ Tag string
14+ Size string
15+ Created string
16+ RepoDigests []string
17+ RepoTags []string
18+}
19+
20+// ImageList displays a list of images
21+templ ImageList(images []HumanReadableImage) {
22+ <!-- Image list using daisyUI components -->
23+ <div class="card w-full bg-base-300">
24+ <div class="card-body p-4">
25+
26+ <!-- Action buttons -->
27+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
28+ <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3>
29+ <div class="flex gap-2">
30+ <button
31+ class="btn btn-sm btn-secondary"
32+ hx-get="/htmx/upload-image"
33+ hx-target="#image-manager"
34+ hx-swap="innerHTML">
35+ <span class="iconf">󰦒</span> Upload
36+ </button>
37+ <button
38+ class="btn btn-sm btn-primary"
39+ hx-get="/htmx/image-manager"
40+ hx-target="#image-manager"
41+ hx-swap="innerHTML">
42+ <span class="iconf">󰑓</span> Refresh
43+ </button>
44+ </div>
45+ </div>
46+
47+ <!-- Image list -->
48+ <table class="table">
49+ <!-- head -->
50+ <thead>
51+ <tr>
52+ <th>Repository</th>
53+ <th>Tag</th>
54+ <th>Size</th>
55+ <th>Created</th>
56+ <th>Actions</th>
57+ </tr>
58+ </thead>
59+ <tbody>
60+ /* Check if there are no images */
61+ if len(images) == 0 {
62+ <tr>
63+ <td colspan="5" class="text-center py-4">
64+ <div class="alert alert-info">
65+ <span class="iconf">ℹ️</span>
66+ <span>No images found. Upload an image first.</span>
67+ </div>
68+ </td>
69+ </tr>
70+ } else {
71+ /* List all available images */
72+ for _, img := range images {
73+ <tr class="hover">
74+ <td>
75+ <div class="flex items-center gap-3">
76+ <div>
77+ <div class="font-bold">{ img.Repository }</div>
78+ <div class="text-sm opacity-50">{ img.ID }</div>
79+ </div>
80+ </div>
81+ </td>
82+ <td>{ img.Tag }</td>
83+ <td>{ img.Size }</td>
84+ <td>{ img.Created }</td>
85+ <th>
86+ <div class="dropdown dropdown-end">
87+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
88+ <span class="iconf">󰅀</span>
89+ </div>
90+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
91+ <li>
92+ <a
93+ href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) }
94+ class="text-success flex items-center">
95+ <span class="iconf mr-2">󰐕</span> Create Container
96+ </a>
97+ </li>
98+ <li>
99+ <button
100+ class="text-error flex items-center"
101+ hx-delete={ "/htmx/image-manager/delete/" + img.ShortID }
102+ hx-target="#image-manager"
103+ hx-swap="innerHTML"
104+ hx-confirm="Are you sure you want to delete this image?">
105+ <span class="iconf mr-2">󰆴</span> Delete
106+ </button>
107+ </li>
108+ </ul>
109+ </div>
110+ </th>
111+ </tr>
112+ }
113+ }
114+ </tbody>
115+ </table>
116+ </div>
117+ </div>
118+
119+ <script>
120+ console.debug('Image list rendered');
121+ </script>
122+} ⏎̸
A internal/templating/models/templ/components/imagelist_templ.go +175 -0 generated file: collapsed
A internal/templating/models/templ/components/navbar.templ +72 -0
@@ -0,0 +1,72 @@
1+package components
2+
3+// Navbar is a menu component for navigation
4+templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
5+ <!-- DaisyUI Navbar -->
6+ <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
7+ <div class="navbar-start">
8+ <div class="flex items-center">
9+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
10+ Gordon
11+ </h1>
12+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
13+ { buildVersion }
14+ </span>
15+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
16+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
17+ </span>
18+ </div>
19+ </div>
20+ <div class="navbar-center hidden lg:flex">
21+ <ul class="menu menu-horizontal px-1">
22+ <li>
23+ <a href={ templ.URL(adminPath + "/manager") } class="font-semibold">
24+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
25+ </a>
26+ </li>
27+ </ul>
28+ </div>
29+ <div class="navbar-end">
30+ <!-- Theme toggle -->
31+ <div class="dropdown dropdown-end">
32+ <label tabindex="0" class="btn btn-ghost btn-circle">
33+ <span class="iconf text-lg md:text-xl">󰔎</span>
34+ </label>
35+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
36+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
37+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
38+ <li><button onclick="setTheme('light')">Light</button></li>
39+ <li><button onclick="setTheme('dark')">Dark</button></li>
40+ </ul>
41+ </div>
42+
43+ <!-- User menu -->
44+ <div class="dropdown dropdown-end">
45+ <label tabindex="0" class="btn btn-ghost btn-circle">
46+ <span class="iconf text-lg md:text-xl">󰀉</span>
47+ </label>
48+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
49+ <li>
50+ <a href={ templ.URL(userSettingsURL) } title="User settings">
51+ <span class="iconf">󰒓</span> Settings
52+ </a>
53+ </li>
54+ <li>
55+ <a href={ templ.URL(logoutURL) } title="Logout">
56+ <span class="iconf">󰍃</span> Logout
57+ </a>
58+ </li>
59+ </ul>
60+ </div>
61+ </div>
62+ </div>
63+
64+ <!-- Theme toggle script -->
65+ <script>
66+ function setTheme(theme) {
67+ document.documentElement.setAttribute('data-theme', theme);
68+ localStorage.setItem('theme', theme);
69+ console.debug('Theme changed to:', theme);
70+ }
71+ </script>
72+}⏎̸
A internal/templating/models/templ/components/navbar_templ.go +81 -0 generated file: collapsed
A internal/templating/models/templ/components/success.templ +16 -0
@@ -0,0 +1,16 @@
1+package components
2+
3+// Success displays a success message
4+templ Success(message string) {
5+ <div
6+ id="success"
7+ class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
8+ <p class="text-2xl text-center">
9+ if message != "" {
10+ { message }
11+ } else {
12+ Success!
13+ }
14+ </p>
15+ </div>
16+}⏎̸
A internal/templating/models/templ/components/success_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/components/uploadimage.templ +66 -0
@@ -0,0 +1,66 @@
1+package components
2+
3+// UploadImageForm displays a form for uploading Docker images
4+templ UploadImageForm() {
5+ <!-- Upload image form using DaisyUI components -->
6+ <div class="card bg-base-100 shadow">
7+ <div class="card-body">
8+ <div class="flex justify-between items-center">
9+ <h2 class="card-title text-primary">Upload Image</h2>
10+ <button
11+ class="btn btn-sm btn-circle btn-ghost"
12+ hx-get="/htmx/image-manager"
13+ hx-target="#image-manager"
14+ hx-swap="innerHTML">
15+ <span class="iconf text-lg">✕</span>
16+ </button>
17+ </div>
18+ <div class="divider"></div>
19+
20+ <form
21+ id="upload-form"
22+ hx-encoding="multipart/form-data"
23+ hx-post="/htmx/upload-image"
24+ hx-target="#image-manager"
25+ hx-swap="innerHTML"
26+ class="space-y-4">
27+
28+ <div class="alert alert-info shadow-lg mb-4">
29+ <span class="iconf">ℹ️</span>
30+ <div>
31+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
32+ <p>After uploading, you can create and manage containers from this image.</p>
33+ </div>
34+ </div>
35+
36+ <div class="form-control w-full">
37+ <label class="label">
38+ <span class="label-text">Select a container image file (.tar)</span>
39+ </label>
40+ <input
41+ type="file"
42+ name="imageFile"
43+ class="file-input file-input-bordered w-full"
44+ accept=".tar"
45+ required />
46+ </div>
47+
48+ <div class="card-actions justify-end mt-4">
49+ <button
50+ class="btn btn-ghost"
51+ hx-get="/htmx/image-manager"
52+ hx-target="#image-manager"
53+ hx-swap="innerHTML"
54+ type="button">
55+ Cancel
56+ </button>
57+ <button
58+ class="btn btn-primary"
59+ type="submit">
60+ <span class="iconf mr-1">󰦒</span> Upload
61+ </button>
62+ </div>
63+ </form>
64+ </div>
65+ </div>
66+} ⏎̸
A internal/templating/models/templ/components/uploadimage_templ.go +41 -0 generated file: collapsed
A internal/templating/models/templ/layouts/admin_layout.templ +21 -0
@@ -0,0 +1,21 @@
1+package layouts
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/components"
4+
5+// AdminLayout provides the base layout for admin pages
6+templ AdminLayout(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7+ <!DOCTYPE html>
8+ <html lang="en" data-theme="gordon-dark">
9+ @components.Header(title)
10+ <body class="min-h-screen bg-base-100">
11+ @components.Navbar(buildVersion, adminPath, userSettingsURL, logoutURL)
12+
13+ <!-- Main content starts after navbar height -->
14+ <div class="container mx-auto pt-20 px-4">
15+ { children... }
16+ </div>
17+
18+ @components.Footer()
19+ </body>
20+ </html>
21+}⏎̸
A internal/templating/models/templ/layouts/admin_layout_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/layouts/base.templ +16 -0
@@ -0,0 +1,16 @@
1+package layouts
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/components"
4+
5+// Base layout template that provides the HTML structure
6+// All pages will use this layout template
7+templ Base(title string) {
8+ <!DOCTYPE html>
9+ <html lang="en">
10+ @components.Header(title)
11+ <body class="min-h-screen bg-base-100">
12+ { children... }
13+ @components.Footer()
14+ </body>
15+ </html>
16+}⏎̸
A internal/templating/models/templ/layouts/base_templ.go +64 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/createcontainerfull.templ +30 -0
@@ -0,0 +1,30 @@
1+package admin
2+
3+import (
4+ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5+ "github.com/bnema/gordon/internal/templating/models/templ/components"
6+)
7+
8+// CreateContainerFullData represents the data needed for the create container full page
9+type CreateContainerFullData struct {
10+ Title string
11+ BuildVersion string
12+ AdminPath string
13+ UserSettings string
14+ LogoutURL string
15+ ContainerData components.ContainerFormData
16+}
17+
18+// CreateContainerFullPage replaces createcontainerfull.gohtml
19+// It renders the full page for creating a container
20+templ CreateContainerFullPage(data CreateContainerFullData) {
21+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
22+ <div class="flex flex-col md:flex-row w-full flex-grow">
23+ <div class="card flex-grow">
24+ <h1 class="card-header mb-4">Create Container</h1>
25+ <!-- Include the container creation form component -->
26+ @components.CreateContainerForm(data.ContainerData)
27+ </div>
28+ </div>
29+ }
30+} ⏎̸
A internal/templating/models/templ/pages/admin/createcontainerfull_templ.go +83 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/dashboard.templ +18 -0
@@ -0,0 +1,18 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// DashboardPage implements the admin dashboard
6+templ DashboardPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7+ @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
8+ <div id="dashboard">
9+ <h1
10+ class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
11+ >
12+ Hello World from dashboard.html
13+ </h1>
14+ <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded -->
15+ <script src="libs/chart.js"></script>
16+ </div>
17+ }
18+}⏎̸
A internal/templating/models/templ/pages/admin/dashboard_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/index.templ +36 -0
@@ -0,0 +1,36 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// IndexPageData represents data needed for the index page
6+type IndexPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ UserSettings string
11+ LogoutURL string
12+ LangContent map[string]string // For storing language translations
13+}
14+
15+// IndexPage replaces admin/index.gohtml
16+// It renders the admin dashboard index page
17+templ IndexPage(data IndexPageData) {
18+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
19+ <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
20+ <div class="card flex-grow">
21+ <h2 class="card-header">{ data.LangContent["title"] }</h2>
22+ </div>
23+ <div class="w-full md:w-2/6 flex flex-col space-y-4">
24+ <div class="card">
25+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
26+ </div>
27+ <div class="card">
28+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
29+ </div>
30+ <div class="card">
31+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
32+ </div>
33+ </div>
34+ </div>
35+ }
36+} ⏎̸
A internal/templating/models/templ/pages/admin/index_templ.go +85 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/install.templ +59 -0
@@ -0,0 +1,59 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// InstallPage replaces install.gohtml
6+// It renders the installation page
7+templ InstallPage(title string, version string) {
8+ @layouts.Base(title) {
9+ <!-- Logo and Title -->
10+ <div
11+ id="logo-mascot"
12+ class="flex flex-col justify-center items-center p-4 mb-4"
13+ >
14+ <div class="circle-shadow flex justify-center w-100">
15+ <img
16+ src="/assets/imgs/gordon-mascot-hq-trsp.png"
17+ alt="logo"
18+ class="sm:h-24 h-32 xl:h-48"
19+ />
20+ </div>
21+ <div class="flex justify-center mt-4">
22+ <h1
23+ class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
24+ >
25+ Gordon
26+ </h1>
27+ <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2">
28+ v{ version }
29+ </span>
30+ </div>
31+ </div>
32+
33+ <!-- Main Content -->
34+ <div class="card w-2/3">
35+ <!-- Built-in Proxy -->
36+ <div id="built-in-proxy" class="mt-8">
37+ <div class="flex items-center mb-4">
38+ <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
39+ <span class="text-white font-bold">✓</span>
40+ </div>
41+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
42+ </div>
43+
44+ <div class="bg-indigo-50 p-4 rounded-md">
45+ <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
46+ <p>The proxy automatically:</p>
47+ <ul class="list-disc ml-6 mb-4">
48+ <li>Manages Let's Encrypt certificates</li>
49+ <li>Routes domains to the appropriate containers</li>
50+ <li>Handles HTTP to HTTPS redirects</li>
51+ <li>Provides proper headers for forwarded requests</li>
52+ </ul>
53+ <p>No additional configuration is required.</p>
54+ </div>
55+ </div>
56+ <!-- End Built-in Proxy -->
57+ </div>
58+ }
59+} ⏎̸
A internal/templating/models/templ/pages/admin/install_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/manager.templ +73 -0
@@ -0,0 +1,73 @@
1+package admin
2+
3+import (
4+ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5+ "strconv"
6+)
7+
8+// ManagerPage implements the container and image manager page
9+templ ManagerPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string, containerCount int, imageCount int) {
10+ @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
11+ <!-- Main content -->
12+ <div class="text-sm breadcrumbs mb-4">
13+ <ul>
14+ <li><a href={ templ.URL(adminPath) }>Gordon</a></li>
15+ <li>Manager</li>
16+ </ul>
17+ </div>
18+
19+ <!-- Stats overview -->
20+ <div class="stats shadow mb-6 w-full">
21+ <div class="stat">
22+ <div class="stat-figure text-primary">
23+ <span class="iconf text-3xl">󰡨</span>
24+ </div>
25+ <div class="stat-title">Containers</div>
26+ <div class="stat-value" id="container-count">{ strconv.Itoa(containerCount) }</div>
27+ <div class="stat-desc">Docker containers managed by Gordon</div>
28+ </div>
29+
30+ <div class="stat">
31+ <div class="stat-figure text-secondary">
32+ <span class="iconf text-3xl">󰋊</span>
33+ </div>
34+ <div class="stat-title">Images</div>
35+ <div class="stat-value" id="image-count">{ strconv.Itoa(imageCount) }</div>
36+ <div class="stat-desc">Docker images available</div>
37+ </div>
38+ </div>
39+
40+ <!-- Tabs for containers and images -->
41+ <div role="tablist" class="tabs tabs-boxed mb-4">
42+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
43+ <div role="tabpanel" class="tab-content p-0">
44+ <!-- Container manager section -->
45+ <div id="container-manager"
46+ hx-get="/htmx/container-manager"
47+ hx-trigger="load"
48+ hx-swap="innerHTML">
49+ <!-- Container content loaded via HTMX -->
50+ <div class="text-center p-4">
51+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
52+ Loading containers...
53+ </div>
54+ </div>
55+ </div>
56+
57+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
58+ <div role="tabpanel" class="tab-content p-0">
59+ <!-- Image manager section -->
60+ <div id="image-manager"
61+ hx-get="/htmx/image-manager"
62+ hx-trigger="load"
63+ hx-swap="innerHTML">
64+ <!-- Image content loaded via HTMX -->
65+ <div class="text-center p-4">
66+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
67+ Loading images...
68+ </div>
69+ </div>
70+ </div>
71+ </div>
72+ }
73+}⏎̸
A internal/templating/models/templ/pages/admin/manager_templ.go +99 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/user.templ +58 -0
@@ -0,0 +1,58 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// UserPageData represents data needed for the user page
6+type UserPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ UserSettings string
11+ LogoutURL string
12+ Username string
13+ Email string
14+ Role string
15+}
16+
17+// UserPage replaces admin/user.gohtml
18+// It renders the user profile page
19+templ UserPage(data UserPageData) {
20+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
21+ <div class="text-sm breadcrumbs mb-4">
22+ <ul>
23+ <li><a href={ templ.URL(data.AdminPath) }>Gordon</a></li>
24+ <li>User Profile</li>
25+ </ul>
26+ </div>
27+
28+ <div class="card bg-base-100 shadow-xl">
29+ <div class="card-body">
30+ <h2 class="card-title">User Profile</h2>
31+ <div class="divider"></div>
32+
33+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34+ <div class="form-control">
35+ <label class="label">
36+ <span class="label-text">Username</span>
37+ </label>
38+ <input type="text" class="input input-bordered" value={ data.Username } readonly />
39+ </div>
40+
41+ <div class="form-control">
42+ <label class="label">
43+ <span class="label-text">Email</span>
44+ </label>
45+ <input type="email" class="input input-bordered" value={ data.Email } readonly />
46+ </div>
47+
48+ <div class="form-control">
49+ <label class="label">
50+ <span class="label-text">Role</span>
51+ </label>
52+ <input type="text" class="input input-bordered" value={ data.Role } readonly />
53+ </div>
54+ </div>
55+ </div>
56+ </div>
57+ }
58+} ⏎̸
A internal/templating/models/templ/pages/admin/user_templ.go +122 -0 generated file: collapsed
A internal/templating/models/templ/pages/errors/errors.templ +46 -0
@@ -0,0 +1,46 @@
1+package errors
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// ErrorPageData represents data needed for error pages
6+type ErrorPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ StatusCode int
11+ Message string
12+}
13+
14+// NotFoundPage renders a 404 error page
15+templ NotFoundPage(data ErrorPageData) {
16+ @layouts.Base(data.Title) {
17+ <div class="flex flex-col items-center justify-center min-h-screen p-4">
18+ <div class="flex flex-col items-center mb-8">
19+ <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" />
20+ <h1 class="text-4xl font-bold text-error">404</h1>
21+ <h2 class="text-2xl mb-2">Page Not Found</h2>
22+ <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
23+ <a href={ templ.URL(data.AdminPath) } class="btn btn-primary">
24+ <span class="iconf mr-2">🏠</span> Go Home
25+ </a>
26+ </div>
27+ </div>
28+ }
29+}
30+
31+// ForbiddenPage renders a 403 error page
32+templ ForbiddenPage(data ErrorPageData) {
33+ @layouts.Base(data.Title) {
34+ <div class="flex flex-col items-center justify-center min-h-screen p-4">
35+ <div class="flex flex-col items-center mb-8">
36+ <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" />
37+ <h1 class="text-4xl font-bold text-error">403</h1>
38+ <h2 class="text-2xl mb-2">Access Forbidden</h2>
39+ <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
40+ <a href={ templ.URL(data.AdminPath) } class="btn btn-primary">
41+ <span class="iconf mr-2">🏠</span> Go Home
42+ </a>
43+ </div>
44+ </div>
45+ }
46+} ⏎̸
A internal/templating/models/templ/pages/errors/errors_templ.go +162 -0 generated file: collapsed
A internal/templating/models/templ/pages/login/index.templ +126 -0
@@ -0,0 +1,126 @@
1+package login
2+
3+// LoginPage implements the login page with token and GitHub authentication
4+templ LoginPage(title string, buildVersion string, errorMsg string) {
5+ <!doctype html>
6+ <html lang="en" data-theme="gordon-dark">
7+ <head>
8+ <meta charset="UTF-8" />
9+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10+ <title>{ title }</title>
11+ <meta name="description" content="Gordon Container Management System" />
12+ <meta name="keywords" content="container, management, docker, podman" />
13+ <!-- Custom CSS + Tailwind -->
14+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
15+ <!-- HTMX min js -->
16+ <script src="/libs/htmx.min.js"></script>
17+ <!-- HTMX SSE -->
18+ <script src="/libs/htmx-sse.js"></script>
19+ <!-- DaisyUI -->
20+ <script src="/libs/daisyui.js"></script>
21+ <!-- Custom JS from assets/js -->
22+ <script src="/assets/js/custom.js"></script>
23+
24+ <!-- daisyUI theme configuration -->
25+ <script>
26+ // Check for saved theme preference or use preferred color scheme
27+ const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
28+ document.documentElement.setAttribute('data-theme', savedTheme);
29+ console.debug('Theme loaded:', savedTheme);
30+ </script>
31+ </head>
32+
33+ <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
34+ <!-- Logo and Title -->
35+ <div
36+ id="logo-mascot"
37+ class="flex flex-col justify-center items-center p-4 mb-4">
38+ <div class="flex justify-center w-100">
39+ <img
40+ src="/assets/imgs/gordon-mascot-mq-trsp.png"
41+ alt="logo gordon"
42+ class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
43+ </div>
44+ <div class="flex justify-center mt-4">
45+ <h1
46+ class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
47+ Gordon
48+ </h1>
49+ <span
50+ class="gordon-v-subtitle text-secondary text-sm ml-2"
51+ >{ buildVersion }</span>
52+ </div>
53+ </div>
54+
55+ <!-- Main Content -->
56+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
57+ <div class="card-body">
58+ <!-- Login Section -->
59+ <div id="login-section">
60+ <div class="flex flex-col items-center">
61+ <h2 class="card-title text-center mb-4">Login</h2>
62+
63+ <!-- Error Message Display -->
64+ if errorMsg != "" {
65+ <div class="alert alert-error mb-4">
66+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
67+ <span>
68+ if errorMsg == "empty_token" {
69+ Please enter a token.
70+ } else if errorMsg == "invalid_token" {
71+ Invalid token. Please try again.
72+ } else if errorMsg == "server_error" {
73+ A server error occurred. Please try again later.
74+ } else {
75+ An error occurred. Please try again.
76+ }
77+ </span>
78+ </div>
79+ }
80+
81+ <!-- Token Input Form -->
82+ <div class="w-full">
83+ <form action="login/submit-token" method="POST" class="mb-4">
84+ <div class="form-control mb-4">
85+ <label for="token" class="label">
86+ <span class="label-text">Admin Token</span>
87+ </label>
88+ <input
89+ type="text"
90+ id="token"
91+ name="token"
92+ placeholder="Enter your admin token"
93+ class="input input-bordered w-full"
94+ required
95+ />
96+ </div>
97+ <button
98+ type="submit"
99+ class="btn btn-primary w-full"
100+ >
101+ Continue with Token
102+ </button>
103+ </form>
104+ <div class="divider">OR</div>
105+ </div>
106+
107+ <!-- GitHub OAuth -->
108+ <div class="w-full">
109+ <a
110+ href="login/oauth/github"
111+ class="btn btn-secondary w-full"
112+ >
113+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
114+ <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
115+ </svg>
116+ Sign in with GitHub
117+ </a>
118+ </div>
119+ </div>
120+ </div>
121+ <!-- End Login Section -->
122+ </div>
123+ </div>
124+ </body>
125+ </html>
126+}⏎̸
A internal/templating/models/templ/pages/login/index_templ.go +102 -0 generated file: collapsed
A internal/templating/render/templ.go +42 -0
@@ -0,0 +1,42 @@
1+package render
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+
8+ "github.com/a-h/templ"
9+ "github.com/bnema/gordon/internal/server"
10+ "github.com/labstack/echo/v4"
11+)
12+
13+// Renderer for templ templates
14+type TemplRenderer struct {
15+ BuildVersion string
16+ LatestVersion string
17+}
18+
19+// RenderTempl renders a templ component
20+func (r *TemplRenderer) RenderTempl(ctx echo.Context, component templ.Component) error {
21+ // Set the content type and status code
22+ ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
23+ ctx.Response().WriteHeader(http.StatusOK)
24+
25+ // Create a render context
26+ renderCtx := context.WithValue(ctx.Request().Context(), "BuildVersion", r.BuildVersion)
27+
28+ // Render the component
29+ err := component.Render(renderCtx, ctx.Response().Writer)
30+ if err != nil {
31+ return fmt.Errorf("failed to render templ component: %w", err)
32+ }
33+
34+ return nil
35+}
36+
37+// NewTemplRenderer creates a new templ renderer
38+func NewTemplRenderer(a *server.App) *TemplRenderer {
39+ return &TemplRenderer{
40+ BuildVersion: a.Config.Build.BuildVersion,
41+ }
42+}⏎̸
A internal/templating/templ_embed.go +13 -0
@@ -0,0 +1,13 @@
1+package templating
2+
3+// This file contains templ components embeddings
4+// As we migrate from traditional Go templates to templ,
5+// we'll add the appropriate code here to handle templ templates.
6+
7+import (
8+ // Import templ generated templates, which will make them available to the application
9+ _ "github.com/bnema/gordon/internal/templating/models/templ/components"
10+ _ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
11+ _ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
12+ _ "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
13+)⏎̸
M internal/webui/public/assets/css/custom.css +112 -3
@@ -6,9 +6,118 @@
66
77 /* Embeded path : assets/css/cutsom.css */
88
9-@tailwind base;
10-@tailwind components;
11-@tailwind utilities;
9+@import "tailwindcss";
10+@source "../../../templating/models/html/**/*.{gohtml,go}";
11+@plugin "daisyui";
12+
13+/* Custom DaisyUI theme */
14+@plugin "daisyui/theme" {
15+ name: "gordon-dark";
16+ default: true;
17+ prefersdark: true;
18+ color-scheme: "dark";
19+ --color-base-100: oklch(28% 0.091 267.935);
20+ --color-base-200: oklch(13% 0.028 261.692);
21+ --color-base-300: oklch(20% 0.042 265.755);
22+ --color-base-content: oklch(93% 0.034 272.788);
23+ --color-primary: oklch(87% 0 0);
24+ --color-primary-content: oklch(14% 0 0);
25+ --color-secondary: oklch(83% 0.145 321.434);
26+ --color-secondary-content: oklch(29% 0.136 325.661);
27+ --color-accent: oklch(80% 0.105 251.813);
28+ --color-accent-content: oklch(28% 0.091 267.935);
29+ --color-neutral: oklch(37% 0.034 259.733);
30+ --color-neutral-content: oklch(98% 0.002 247.839);
31+ --color-info: oklch(68% 0.169 237.323);
32+ --color-info-content: oklch(97% 0.013 236.62);
33+ --color-success: oklch(70% 0.14 182.503);
34+ --color-success-content: oklch(98% 0.014 180.72);
35+ --color-warning: oklch(76% 0.188 70.08);
36+ --color-warning-content: oklch(98% 0.022 95.277);
37+ --color-error: oklch(64% 0.246 16.439);
38+ --color-error-content: oklch(96% 0.015 12.422);
39+ --radius-selector: 1rem;
40+ --radius-field: 0rem;
41+ --radius-box: 0.25rem;
42+ --size-selector: 0.25rem;
43+ --size-field: 0.25rem;
44+ --border: 1px;
45+ --depth: 0;
46+ --noise: 0;
47+}
48+
49+
50+/* Custom component classes */
51+@layer components {
52+ /* Button styles */
53+ .btn-primary {
54+ @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
55+ }
56+
57+ .btn-secondary {
58+ @apply flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
59+ }
60+
61+ .btn-danger {
62+ @apply flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
63+ }
64+
65+ /* Alert styles */
66+ .alert-error {
67+ @apply rounded-md bg-red-50 p-4 dark:bg-red-900/20;
68+ }
69+
70+ .alert-error-icon {
71+ @apply h-5 w-5 text-red-400;
72+ }
73+
74+ .alert-error-text {
75+ @apply text-sm font-medium text-red-800 dark:text-red-300;
76+ }
77+
78+ .alert-success {
79+ @apply rounded-md bg-green-50 p-4 dark:bg-green-900/20;
80+ }
81+
82+ .alert-success-icon {
83+ @apply h-5 w-5 text-green-400;
84+ }
85+
86+ .alert-success-text {
87+ @apply text-sm font-medium text-green-800 dark:text-green-300;
88+ }
89+
90+ /* Card styles */
91+ .card {
92+ @apply bg-white p-4 rounded-md shadow mb-4;
93+ }
94+
95+ .card-header {
96+ @apply text-lg font-semibold text-indigo-600 mb-2;
97+ }
98+
99+ /* Background styles */
100+ .bg-app {
101+ @apply bg-indigo-50;
102+ }
103+
104+ .bg-sidebar {
105+ @apply bg-indigo-900;
106+ }
107+
108+ .bg-card {
109+ @apply bg-white;
110+ }
111+
112+ /* Text styles */
113+ .text-title {
114+ @apply text-indigo-600 font-semibold;
115+ }
116+
117+ .text-subtitle {
118+ @apply text-indigo-500;
119+ }
120+}
12121
13122 /* Import IBM Plex Fonts */
14123 @font-face {
M internal/webui/public/assets/css/tailwind.css +8334 -1315
@@ -1,1429 +1,8448 @@
1-/**
2- * Path: internal/ui/public/assets/css/custom.css
3- *
4- * @format
5- */
6-
7-/* Embeded path : assets/css/cutsom.css */
8-
9-*, ::before, ::after {
10- --tw-border-spacing-x: 0;
11- --tw-border-spacing-y: 0;
12- --tw-translate-x: 0;
13- --tw-translate-y: 0;
14- --tw-rotate: 0;
15- --tw-skew-x: 0;
16- --tw-skew-y: 0;
17- --tw-scale-x: 1;
18- --tw-scale-y: 1;
19- --tw-pan-x: ;
20- --tw-pan-y: ;
21- --tw-pinch-zoom: ;
22- --tw-scroll-snap-strictness: proximity;
23- --tw-gradient-from-position: ;
24- --tw-gradient-via-position: ;
25- --tw-gradient-to-position: ;
26- --tw-ordinal: ;
27- --tw-slashed-zero: ;
28- --tw-numeric-figure: ;
29- --tw-numeric-spacing: ;
30- --tw-numeric-fraction: ;
31- --tw-ring-inset: ;
32- --tw-ring-offset-width: 0px;
33- --tw-ring-offset-color: #fff;
34- --tw-ring-color: rgb(59 130 246 / 0.5);
35- --tw-ring-offset-shadow: 0 0 #0000;
36- --tw-ring-shadow: 0 0 #0000;
37- --tw-shadow: 0 0 #0000;
38- --tw-shadow-colored: 0 0 #0000;
39- --tw-blur: ;
40- --tw-brightness: ;
41- --tw-contrast: ;
42- --tw-grayscale: ;
43- --tw-hue-rotate: ;
44- --tw-invert: ;
45- --tw-saturate: ;
46- --tw-sepia: ;
47- --tw-drop-shadow: ;
48- --tw-backdrop-blur: ;
49- --tw-backdrop-brightness: ;
50- --tw-backdrop-contrast: ;
51- --tw-backdrop-grayscale: ;
52- --tw-backdrop-hue-rotate: ;
53- --tw-backdrop-invert: ;
54- --tw-backdrop-opacity: ;
55- --tw-backdrop-saturate: ;
56- --tw-backdrop-sepia: ;
57- --tw-contain-size: ;
58- --tw-contain-layout: ;
59- --tw-contain-paint: ;
60- --tw-contain-style: ;
61-}
62-
63-::backdrop {
64- --tw-border-spacing-x: 0;
65- --tw-border-spacing-y: 0;
66- --tw-translate-x: 0;
67- --tw-translate-y: 0;
68- --tw-rotate: 0;
69- --tw-skew-x: 0;
70- --tw-skew-y: 0;
71- --tw-scale-x: 1;
72- --tw-scale-y: 1;
73- --tw-pan-x: ;
74- --tw-pan-y: ;
75- --tw-pinch-zoom: ;
76- --tw-scroll-snap-strictness: proximity;
77- --tw-gradient-from-position: ;
78- --tw-gradient-via-position: ;
79- --tw-gradient-to-position: ;
80- --tw-ordinal: ;
81- --tw-slashed-zero: ;
82- --tw-numeric-figure: ;
83- --tw-numeric-spacing: ;
84- --tw-numeric-fraction: ;
85- --tw-ring-inset: ;
86- --tw-ring-offset-width: 0px;
87- --tw-ring-offset-color: #fff;
88- --tw-ring-color: rgb(59 130 246 / 0.5);
89- --tw-ring-offset-shadow: 0 0 #0000;
90- --tw-ring-shadow: 0 0 #0000;
91- --tw-shadow: 0 0 #0000;
92- --tw-shadow-colored: 0 0 #0000;
93- --tw-blur: ;
94- --tw-brightness: ;
95- --tw-contrast: ;
96- --tw-grayscale: ;
97- --tw-hue-rotate: ;
98- --tw-invert: ;
99- --tw-saturate: ;
100- --tw-sepia: ;
101- --tw-drop-shadow: ;
102- --tw-backdrop-blur: ;
103- --tw-backdrop-brightness: ;
104- --tw-backdrop-contrast: ;
105- --tw-backdrop-grayscale: ;
106- --tw-backdrop-hue-rotate: ;
107- --tw-backdrop-invert: ;
108- --tw-backdrop-opacity: ;
109- --tw-backdrop-saturate: ;
110- --tw-backdrop-sepia: ;
111- --tw-contain-size: ;
112- --tw-contain-layout: ;
113- --tw-contain-paint: ;
114- --tw-contain-style: ;
115-}
116-
117-/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com
118- */
119-
120-/*
121-1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
122-2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
123-*/
124-
125-*,
126-::before,
127-::after {
128- box-sizing: border-box;
129- /* 1 */
130- border-width: 0;
131- /* 2 */
132- border-style: solid;
133- /* 2 */
134- border-color: #e5e7eb;
135- /* 2 */
136-}
137-
138-::before,
139-::after {
140- --tw-content: '';
141-}
142-
143-/*
144-1. Use a consistent sensible line-height in all browsers.
145-2. Prevent adjustments of font size after orientation changes in iOS.
146-3. Use a more readable tab size.
147-4. Use the user's configured `sans` font-family by default.
148-5. Use the user's configured `sans` font-feature-settings by default.
149-6. Use the user's configured `sans` font-variation-settings by default.
150-7. Disable tap highlights on iOS
151-*/
152-
153-html,
154-:host {
155- line-height: 1.5;
156- /* 1 */
157- -webkit-text-size-adjust: 100%;
158- /* 2 */
159- -moz-tab-size: 4;
160- /* 3 */
161- -o-tab-size: 4;
162- tab-size: 4;
163- /* 3 */
164- font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
165- /* 4 */
166- font-feature-settings: normal;
167- /* 5 */
168- font-variation-settings: normal;
169- /* 6 */
170- -webkit-tap-highlight-color: transparent;
171- /* 7 */
172-}
173-
174-/*
175-1. Remove the margin in all browsers.
176-2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
177-*/
178-
179-body {
180- margin: 0;
181- /* 1 */
182- line-height: inherit;
183- /* 2 */
184-}
185-
186-/*
187-1. Add the correct height in Firefox.
188-2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
189-3. Ensure horizontal rules are visible by default.
190-*/
191-
192-hr {
193- height: 0;
194- /* 1 */
195- color: inherit;
196- /* 2 */
197- border-top-width: 1px;
198- /* 3 */
199-}
200-
201-/*
202-Add the correct text decoration in Chrome, Edge, and Safari.
203-*/
204-
205-abbr:where([title]) {
206- -webkit-text-decoration: underline dotted;
207- text-decoration: underline dotted;
208-}
209-
210-/*
211-Remove the default font size and weight for headings.
212-*/
213-
214-h1,
215-h2,
216-h3,
217-h4,
218-h5,
219-h6 {
220- font-size: inherit;
221- font-weight: inherit;
222-}
223-
224-/*
225-Reset links to optimize for opt-in styling instead of opt-out.
226-*/
227-
228-a {
229- color: inherit;
230- text-decoration: inherit;
231-}
232-
233-/*
234-Add the correct font weight in Edge and Safari.
235-*/
236-
237-b,
238-strong {
239- font-weight: bolder;
240-}
241-
242-/*
243-1. Use the user's configured `mono` font-family by default.
244-2. Use the user's configured `mono` font-feature-settings by default.
245-3. Use the user's configured `mono` font-variation-settings by default.
246-4. Correct the odd `em` font sizing in all browsers.
247-*/
248-
249-code,
250-kbd,
251-samp,
252-pre {
253- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
254- /* 1 */
255- font-feature-settings: normal;
256- /* 2 */
257- font-variation-settings: normal;
258- /* 3 */
259- font-size: 1em;
260- /* 4 */
261-}
262-
263-/*
264-Add the correct font size in all browsers.
265-*/
266-
267-small {
268- font-size: 80%;
269-}
270-
271-/*
272-Prevent `sub` and `sup` elements from affecting the line height in all browsers.
273-*/
274-
275-sub,
276-sup {
277- font-size: 75%;
278- line-height: 0;
279- position: relative;
280- vertical-align: baseline;
281-}
282-
283-sub {
284- bottom: -0.25em;
285-}
286-
287-sup {
288- top: -0.5em;
289-}
290-
291-/*
292-1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
293-2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
294-3. Remove gaps between table borders by default.
295-*/
296-
297-table {
298- text-indent: 0;
299- /* 1 */
300- border-color: inherit;
301- /* 2 */
302- border-collapse: collapse;
303- /* 3 */
304-}
305-
306-/*
307-1. Change the font styles in all browsers.
308-2. Remove the margin in Firefox and Safari.
309-3. Remove default padding in all browsers.
310-*/
311-
312-button,
313-input,
314-optgroup,
315-select,
316-textarea {
317- font-family: inherit;
318- /* 1 */
319- font-feature-settings: inherit;
320- /* 1 */
321- font-variation-settings: inherit;
322- /* 1 */
323- font-size: 100%;
324- /* 1 */
325- font-weight: inherit;
326- /* 1 */
327- line-height: inherit;
328- /* 1 */
329- letter-spacing: inherit;
330- /* 1 */
331- color: inherit;
332- /* 1 */
333- margin: 0;
334- /* 2 */
335- padding: 0;
336- /* 3 */
337-}
338-
339-/*
340-Remove the inheritance of text transform in Edge and Firefox.
341-*/
342-
343-button,
344-select {
345- text-transform: none;
346-}
347-
348-/*
349-1. Correct the inability to style clickable types in iOS and Safari.
350-2. Remove default button styles.
351-*/
352-
353-button,
354-input:where([type='button']),
355-input:where([type='reset']),
356-input:where([type='submit']) {
357- -webkit-appearance: button;
358- /* 1 */
359- background-color: transparent;
360- /* 2 */
361- background-image: none;
362- /* 2 */
363-}
364-
365-/*
366-Use the modern Firefox focus style for all focusable elements.
367-*/
368-
369-:-moz-focusring {
370- outline: auto;
371-}
372-
373-/*
374-Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
375-*/
376-
377-:-moz-ui-invalid {
378- box-shadow: none;
379-}
380-
381-/*
382-Add the correct vertical alignment in Chrome and Firefox.
383-*/
384-
385-progress {
386- vertical-align: baseline;
387-}
388-
389-/*
390-Correct the cursor style of increment and decrement buttons in Safari.
391-*/
392-
393-::-webkit-inner-spin-button,
394-::-webkit-outer-spin-button {
395- height: auto;
396-}
397-
398-/*
399-1. Correct the odd appearance in Chrome and Safari.
400-2. Correct the outline style in Safari.
401-*/
402-
403-[type='search'] {
404- -webkit-appearance: textfield;
405- /* 1 */
406- outline-offset: -2px;
407- /* 2 */
408-}
409-
410-/*
411-Remove the inner padding in Chrome and Safari on macOS.
412-*/
413-
414-::-webkit-search-decoration {
415- -webkit-appearance: none;
416-}
417-
418-/*
419-1. Correct the inability to style clickable types in iOS and Safari.
420-2. Change font properties to `inherit` in Safari.
421-*/
422-
423-::-webkit-file-upload-button {
424- -webkit-appearance: button;
425- /* 1 */
426- font: inherit;
427- /* 2 */
428-}
429-
430-/*
431-Add the correct display in Chrome and Safari.
432-*/
433-
434-summary {
435- display: list-item;
436-}
437-
438-/*
439-Removes the default spacing and border for appropriate elements.
440-*/
441-
442-blockquote,
443-dl,
444-dd,
445-h1,
446-h2,
447-h3,
448-h4,
449-h5,
450-h6,
451-hr,
452-figure,
453-p,
454-pre {
455- margin: 0;
456-}
457-
458-fieldset {
459- margin: 0;
460- padding: 0;
461-}
462-
463-legend {
464- padding: 0;
465-}
466-
467-ol,
468-ul,
469-menu {
470- list-style: none;
471- margin: 0;
472- padding: 0;
473-}
474-
475-/*
476-Reset default styling for dialogs.
477-*/
478-
479-dialog {
480- padding: 0;
481-}
482-
483-/*
484-Prevent resizing textareas horizontally by default.
485-*/
486-
487-textarea {
488- resize: vertical;
489-}
490-
491-/*
492-1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
493-2. Set the default placeholder color to the user's configured gray 400 color.
494-*/
495-
496-input::-moz-placeholder, textarea::-moz-placeholder {
497- opacity: 1;
498- /* 1 */
499- color: #9ca3af;
500- /* 2 */
501-}
502-
503-input::placeholder,
504-textarea::placeholder {
505- opacity: 1;
506- /* 1 */
507- color: #9ca3af;
508- /* 2 */
509-}
510-
511-/*
512-Set the default cursor for buttons.
513-*/
514-
515-button,
516-[role="button"] {
517- cursor: pointer;
518-}
519-
520-/*
521-Make sure disabled buttons don't get the pointer cursor.
522-*/
523-
524-:disabled {
525- cursor: default;
526-}
527-
528-/*
529-1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
530-2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
531- This can trigger a poorly considered lint error in some tools but is included by design.
532-*/
533-
534-img,
535-svg,
536-video,
537-canvas,
538-audio,
539-iframe,
540-embed,
541-object {
542- display: block;
543- /* 1 */
544- vertical-align: middle;
545- /* 2 */
546-}
547-
548-/*
549-Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
550-*/
551-
552-img,
553-video {
554- max-width: 100%;
555- height: auto;
556-}
557-
558-/* Make elements with the HTML hidden attribute stay hidden by default */
559-
560-[hidden]:where(:not([hidden="until-found"])) {
561- display: none;
562-}
563-
564-.container {
565- width: 100%;
566-}
567-
568-@media (min-width: 640px) {
1+/*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */
2+@layer theme, base, components, utilities;
3+@layer theme {
4+ :root, :host {
5+ --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
6+ "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
7+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
8+ "Courier New", monospace;
9+ --color-red-50: oklch(0.971 0.013 17.38);
10+ --color-red-300: oklch(0.808 0.114 19.571);
11+ --color-red-400: oklch(0.704 0.191 22.216);
12+ --color-red-500: oklch(0.637 0.237 25.331);
13+ --color-red-600: oklch(0.577 0.245 27.325);
14+ --color-red-700: oklch(0.505 0.213 27.518);
15+ --color-red-800: oklch(0.444 0.177 26.899);
16+ --color-red-900: oklch(0.396 0.141 25.723);
17+ --color-green-50: oklch(0.982 0.018 155.826);
18+ --color-green-300: oklch(0.871 0.15 154.449);
19+ --color-green-400: oklch(0.792 0.209 151.711);
20+ --color-green-500: oklch(0.723 0.219 149.579);
21+ --color-green-800: oklch(0.448 0.119 151.328);
22+ --color-green-900: oklch(0.393 0.095 152.535);
23+ --color-blue-500: oklch(0.623 0.214 259.815);
24+ --color-indigo-50: oklch(0.962 0.018 272.314);
25+ --color-indigo-100: oklch(0.93 0.034 272.788);
26+ --color-indigo-500: oklch(0.585 0.233 277.117);
27+ --color-indigo-600: oklch(0.511 0.262 276.966);
28+ --color-indigo-700: oklch(0.457 0.24 277.023);
29+ --color-indigo-800: oklch(0.398 0.195 277.366);
30+ --color-indigo-900: oklch(0.359 0.144 278.697);
31+ --color-gray-50: oklch(0.985 0.002 247.839);
32+ --color-gray-300: oklch(0.872 0.01 258.338);
33+ --color-gray-700: oklch(0.373 0.034 259.733);
34+ --color-black: #000;
35+ --color-white: #fff;
36+ --spacing: 0.25rem;
37+ --container-md: 28rem;
38+ --text-xs: 0.75rem;
39+ --text-xs--line-height: calc(1 / 0.75);
40+ --text-sm: 0.875rem;
41+ --text-sm--line-height: calc(1.25 / 0.875);
42+ --text-lg: 1.125rem;
43+ --text-lg--line-height: calc(1.75 / 1.125);
44+ --text-xl: 1.25rem;
45+ --text-xl--line-height: calc(1.75 / 1.25);
46+ --text-2xl: 1.5rem;
47+ --text-2xl--line-height: calc(2 / 1.5);
48+ --text-3xl: 1.875rem;
49+ --text-3xl--line-height: calc(2.25 / 1.875);
50+ --text-4xl: 2.25rem;
51+ --text-4xl--line-height: calc(2.5 / 2.25);
52+ --text-5xl: 3rem;
53+ --text-5xl--line-height: 1;
54+ --font-weight-medium: 500;
55+ --font-weight-semibold: 600;
56+ --font-weight-bold: 700;
57+ --tracking-tighter: -0.05em;
58+ --radius-sm: 0.25rem;
59+ --radius-md: 0.375rem;
60+ --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
61+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
62+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
63+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
64+ --animate-spin: spin 1s linear infinite;
65+ --default-transition-duration: 150ms;
66+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
67+ --default-font-family: var(--font-sans);
68+ --default-font-feature-settings: var(--font-sans--font-feature-settings);
69+ --default-font-variation-settings: var(
70+ --font-sans--font-variation-settings
71+ );
72+ --default-mono-font-family: var(--font-mono);
73+ --default-mono-font-feature-settings: var(
74+ --font-mono--font-feature-settings
75+ );
76+ --default-mono-font-variation-settings: var(
77+ --font-mono--font-variation-settings
78+ );
79+ }
80+}
81+@layer base {
82+ *, ::after, ::before, ::backdrop, ::file-selector-button {
83+ box-sizing: border-box;
84+ margin: 0;
85+ padding: 0;
86+ border: 0 solid;
87+ }
88+ html, :host {
89+ line-height: 1.5;
90+ -webkit-text-size-adjust: 100%;
91+ tab-size: 4;
92+ 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" );
93+ font-feature-settings: var(--default-font-feature-settings, normal);
94+ font-variation-settings: var( --default-font-variation-settings, normal );
95+ -webkit-tap-highlight-color: transparent;
96+ }
97+ body {
98+ line-height: inherit;
99+ }
100+ hr {
101+ height: 0;
102+ color: inherit;
103+ border-top-width: 1px;
104+ }
105+ abbr:where([title]) {
106+ -webkit-text-decoration: underline dotted;
107+ text-decoration: underline dotted;
108+ }
109+ h1, h2, h3, h4, h5, h6 {
110+ font-size: inherit;
111+ font-weight: inherit;
112+ }
113+ a {
114+ color: inherit;
115+ -webkit-text-decoration: inherit;
116+ text-decoration: inherit;
117+ }
118+ b, strong {
119+ font-weight: bolder;
120+ }
121+ code, kbd, samp, pre {
122+ font-family: var( --default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace );
123+ font-feature-settings: var( --default-mono-font-feature-settings, normal );
124+ font-variation-settings: var( --default-mono-font-variation-settings, normal );
125+ font-size: 1em;
126+ }
127+ small {
128+ font-size: 80%;
129+ }
130+ sub, sup {
131+ font-size: 75%;
132+ line-height: 0;
133+ position: relative;
134+ vertical-align: baseline;
135+ }
136+ sub {
137+ bottom: -0.25em;
138+ }
139+ sup {
140+ top: -0.5em;
141+ }
142+ table {
143+ text-indent: 0;
144+ border-color: inherit;
145+ border-collapse: collapse;
146+ }
147+ :-moz-focusring {
148+ outline: auto;
149+ }
150+ progress {
151+ vertical-align: baseline;
152+ }
153+ summary {
154+ display: list-item;
155+ }
156+ ol, ul, menu {
157+ list-style: none;
158+ }
159+ img, svg, video, canvas, audio, iframe, embed, object {
160+ display: block;
161+ vertical-align: middle;
162+ }
163+ img, video {
164+ max-width: 100%;
165+ height: auto;
166+ }
167+ button, input, select, optgroup, textarea, ::file-selector-button {
168+ font: inherit;
169+ font-feature-settings: inherit;
170+ font-variation-settings: inherit;
171+ letter-spacing: inherit;
172+ color: inherit;
173+ border-radius: 0;
174+ background-color: transparent;
175+ opacity: 1;
176+ }
177+ :where(select:is([multiple], [size])) optgroup {
178+ font-weight: bolder;
179+ }
180+ :where(select:is([multiple], [size])) optgroup option {
181+ padding-inline-start: 20px;
182+ }
183+ ::file-selector-button {
184+ margin-inline-end: 4px;
185+ }
186+ ::placeholder {
187+ opacity: 1;
188+ color: color-mix(in oklab, currentColor 50%, transparent);
189+ }
190+ textarea {
191+ resize: vertical;
192+ }
193+ ::-webkit-search-decoration {
194+ -webkit-appearance: none;
195+ }
196+ ::-webkit-date-and-time-value {
197+ min-height: 1lh;
198+ text-align: inherit;
199+ }
200+ ::-webkit-datetime-edit {
201+ display: inline-flex;
202+ }
203+ ::-webkit-datetime-edit-fields-wrapper {
204+ padding: 0;
205+ }
206+ ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
207+ padding-block: 0;
208+ }
209+ :-moz-ui-invalid {
210+ box-shadow: none;
211+ }
212+ button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
213+ appearance: button;
214+ }
215+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
216+ height: auto;
217+ }
218+ [hidden]:where(:not([hidden="until-found"])) {
219+ display: none !important;
220+ }
221+}
222+@layer utilities {
223+ .diff {
224+ position: relative;
225+ display: grid;
226+ width: 100%;
227+ overflow: hidden;
228+ webkit-user-select: none;
229+ user-select: none;
230+ direction: ltr;
231+ container-type: inline-size;
232+ grid-template-columns: auto 1fr;
233+ &:focus-visible, &:has(.diff-item-1:focus) {
234+ outline-style: var(--tw-outline-style);
235+ outline-width: 2px;
236+ outline-offset: 1px;
237+ outline-color: var(--color-base-content);
238+ }
239+ &:focus-visible {
240+ outline-style: var(--tw-outline-style);
241+ outline-width: 2px;
242+ outline-offset: 1px;
243+ outline-color: var(--color-base-content);
244+ .diff-resizer {
245+ min-width: 90cqi;
246+ max-width: 90cqi;
247+ }
248+ }
249+ &:has(.diff-item-2:focus-visible) {
250+ outline-style: var(--tw-outline-style);
251+ outline-width: 2px;
252+ outline-offset: 1px;
253+ .diff-resizer {
254+ min-width: 10cqi;
255+ max-width: 10cqi;
256+ }
257+ }
258+ @supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
259+ &:focus {
260+ .diff-resizer {
261+ min-width: 10cqi;
262+ max-width: 10cqi;
263+ }
264+ }
265+ &:has(.diff-item-1:focus) {
266+ .diff-resizer {
267+ min-width: 90cqi;
268+ max-width: 90cqi;
269+ }
270+ }
271+ }
272+ }
273+ .modal {
274+ pointer-events: none;
275+ visibility: hidden;
276+ position: fixed;
277+ inset: calc(0.25rem * 0);
278+ margin: calc(0.25rem * 0);
279+ display: grid;
280+ height: 100%;
281+ max-height: none;
282+ width: 100%;
283+ max-width: none;
284+ align-items: center;
285+ justify-items: center;
286+ background-color: transparent;
287+ padding: calc(0.25rem * 0);
288+ color: inherit;
289+ overflow-x: hidden;
290+ transition: transform 0.3s ease-out, visibility 0.3s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out;
291+ overflow-y: hidden;
292+ overscroll-behavior: contain;
293+ z-index: 999;
294+ &::backdrop {
295+ display: none;
296+ }
297+ &.modal-open, &[open], &:target {
298+ background-color: oklch(0% 0 0/ 0.4);
299+ transition: transform 0.3s ease-out, background-color 0.3s ease-out, opacity 0.1s ease-out;
300+ pointer-events: auto;
301+ visibility: visible;
302+ opacity: 100%;
303+ .modal-box {
304+ translate: 0 0;
305+ scale: 1;
306+ opacity: 1;
307+ }
308+ }
309+ @starting-style {
310+ &.modal-open, &[open], &:target {
311+ visibility: hidden;
312+ opacity: 0%;
313+ }
314+ }
315+ }
316+ .drawer-side {
317+ pointer-events: none;
318+ visibility: hidden;
319+ position: fixed;
320+ inset-inline-start: calc(0.25rem * 0);
321+ top: calc(0.25rem * 0);
322+ grid-column-start: 1;
323+ grid-row-start: 1;
324+ display: grid;
325+ width: 100%;
326+ grid-template-columns: repeat(1, minmax(0, 1fr));
327+ grid-template-rows: repeat(1, minmax(0, 1fr));
328+ align-items: flex-start;
329+ justify-items: start;
330+ overflow-x: hidden;
331+ overflow-y: hidden;
332+ overscroll-behavior: contain;
333+ opacity: 0%;
334+ transition: opacity 0.2s ease-out 0.1s allow-discrete, visibility 0.3s ease-out 0.1s allow-discrete;
335+ height: 100vh;
336+ height: 100dvh;
337+ > .drawer-overlay {
338+ position: sticky;
339+ top: calc(0.25rem * 0);
340+ cursor: pointer;
341+ place-self: stretch;
342+ background-color: oklch(0% 0 0 / 40%);
343+ }
344+ > * {
345+ grid-column-start: 1;
346+ grid-row-start: 1;
347+ }
348+ > *:not(.drawer-overlay) {
349+ will-change: transform;
350+ transition: translate 0.3s ease-out;
351+ translate: -100%;
352+ [dir="rtl"] & {
353+ translate: 100%;
354+ }
355+ }
356+ }
357+ .drawer-open {
358+ > .drawer-side {
359+ overflow-y: auto;
360+ }
361+ > .drawer-toggle {
362+ display: none;
363+ & ~ .drawer-side {
364+ pointer-events: auto;
365+ visibility: visible;
366+ position: sticky;
367+ display: block;
368+ width: auto;
369+ overscroll-behavior: auto;
370+ opacity: 100%;
371+ & > .drawer-overlay {
372+ cursor: default;
373+ background-color: transparent;
374+ }
375+ & > *:not(.drawer-overlay) {
376+ translate: 0%;
377+ [dir="rtl"] & {
378+ translate: 0%;
379+ }
380+ }
381+ }
382+ &:checked ~ .drawer-side {
383+ pointer-events: auto;
384+ visibility: visible;
385+ }
386+ }
387+ }
388+ .modal-toggle {
389+ position: fixed;
390+ height: calc(0.25rem * 0);
391+ width: calc(0.25rem * 0);
392+ appearance: none;
393+ opacity: 0%;
394+ &:checked + .modal {
395+ pointer-events: auto;
396+ visibility: visible;
397+ opacity: 100%;
398+ background-color: oklch(0% 0 0/ 0.4);
399+ .modal-box {
400+ translate: 0 0;
401+ scale: 1;
402+ opacity: 1;
403+ }
404+ }
405+ @starting-style {
406+ &:checked + .modal {
407+ visibility: hidden;
408+ opacity: 0%;
409+ }
410+ }
411+ }
412+ .drawer-toggle {
413+ position: fixed;
414+ height: calc(0.25rem * 0);
415+ width: calc(0.25rem * 0);
416+ appearance: none;
417+ opacity: 0%;
418+ &:checked {
419+ & ~ .drawer-side {
420+ pointer-events: auto;
421+ visibility: visible;
422+ overflow-y: auto;
423+ opacity: 100%;
424+ & > *:not(.drawer-overlay) {
425+ translate: 0%;
426+ }
427+ }
428+ }
429+ &:focus-visible ~ .drawer-content label.drawer-button {
430+ outline: 2px solid;
431+ outline-offset: 2px;
432+ }
433+ }
434+ .tooltip {
435+ position: relative;
436+ display: inline-block;
437+ --tt-bg: var(--color-neutral);
438+ --tt-off: calc(100% + 0.5rem);
439+ --tt-tail: calc(100% + 1px + 0.25rem);
440+ > :where(.tooltip-content), &[data-tip]:before {
441+ position: absolute;
442+ max-width: 20rem;
443+ border-radius: var(--radius-field);
444+ padding-inline: calc(0.25rem * 2);
445+ padding-block: calc(0.25rem * 1);
446+ text-align: center;
447+ white-space: normal;
448+ color: var(--color-neutral-content);
449+ opacity: 0%;
450+ font-size: 0.875rem;
451+ line-height: 1.25em;
452+ transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms;
453+ background-color: var(--tt-bg);
454+ width: max-content;
455+ pointer-events: none;
456+ z-index: 1;
457+ --tw-content: attr(data-tip);
458+ content: var(--tw-content);
459+ }
460+ &:after {
461+ position: absolute;
462+ position: absolute;
463+ opacity: 0%;
464+ background-color: var(--tt-bg);
465+ transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms;
466+ content: "";
467+ pointer-events: none;
468+ width: 0.625rem;
469+ height: 0.25rem;
470+ display: block;
471+ mask-repeat: no-repeat;
472+ mask-position: -1px 0;
473+ --mask-tooltip: url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");
474+ mask-image: var(--mask-tooltip);
475+ }
476+ &.tooltip-open, &[data-tip]:hover, &:hover, &:has(:focus-visible) {
477+ > .tooltip-content, &[data-tip]:before, &:after {
478+ opacity: 100%;
479+ --tt-pos: 0rem;
480+ transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0ms;
481+ }
482+ }
483+ > .tooltip-content, &[data-tip]:before {
484+ transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem));
485+ inset: auto auto var(--tt-off) 50%;
486+ }
487+ &:after {
488+ transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem));
489+ inset: auto auto var(--tt-tail) 50%;
490+ }
491+ }
492+ .tab {
493+ position: relative;
494+ display: inline-flex;
495+ cursor: pointer;
496+ appearance: none;
497+ flex-wrap: wrap;
498+ align-items: center;
499+ justify-content: center;
500+ text-align: center;
501+ webkit-user-select: none;
502+ user-select: none;
503+ &:hover {
504+ @media (hover: hover) {
505+ color: var(--color-base-content);
506+ }
507+ }
508+ --tab-p: 1rem;
509+ --tab-bg: var(--color-base-100);
510+ --tab-border-color: var(--color-base-300);
511+ --tab-radius-ss: 0;
512+ --tab-radius-se: 0;
513+ --tab-radius-es: 0;
514+ --tab-radius-ee: 0;
515+ --tab-order: 0;
516+ --tab-radius-min: calc(0.75rem - var(--border));
517+ border-color: #0000;
518+ order: var(--tab-order);
519+ height: calc(var(--size-field, 0.25rem) * 10);
520+ font-size: 0.875rem;
521+ padding-inline-start: var(--tab-p);
522+ padding-inline-end: var(--tab-p);
523+ &:is(input[type="radio"]) {
524+ min-width: fit-content;
525+ &:after {
526+ content: attr(aria-label);
527+ }
528+ }
529+ &:is(label) {
530+ position: relative;
531+ input {
532+ position: absolute;
533+ inset: calc(0.25rem * 0);
534+ cursor: pointer;
535+ appearance: none;
536+ opacity: 0%;
537+ }
538+ }
539+ &:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"]) {
540+ & + .tab-content {
541+ display: block;
542+ height: 100%;
543+ }
544+ }
545+ &:not(:checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"]) {
546+ color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
547+ }
548+ &:not(input):empty {
549+ flex-grow: 1;
550+ cursor: default;
551+ }
552+ &:focus {
553+ --tw-outline-style: none;
554+ outline-style: none;
555+ @media (forced-colors: active) {
556+ outline: 2px solid transparent;
557+ outline-offset: 2px;
558+ }
559+ }
560+ &:focus-visible, &:is(label:has(:checked:focus-visible)) {
561+ outline: 2px solid currentColor;
562+ outline-offset: -5px;
563+ }
564+ &[disabled] {
565+ pointer-events: none;
566+ opacity: 40%;
567+ }
568+ }
569+ .menu {
570+ display: flex;
571+ width: fit-content;
572+ flex-direction: column;
573+ flex-wrap: wrap;
574+ padding: calc(0.25rem * 2);
575+ --menu-active-fg: var(--color-neutral-content);
576+ --menu-active-bg: var(--color-neutral);
577+ font-size: 0.875rem;
578+ :where(li ul) {
579+ position: relative;
580+ margin-inline-start: calc(0.25rem * 4);
581+ padding-inline-start: calc(0.25rem * 2);
582+ white-space: nowrap;
583+ &:before {
584+ position: absolute;
585+ inset-inline-start: calc(0.25rem * 0);
586+ top: calc(0.25rem * 3);
587+ bottom: calc(0.25rem * 3);
588+ background-color: var(--color-base-content);
589+ opacity: 10%;
590+ width: var(--border);
591+ content: "";
592+ }
593+ }
594+ :where(li > .menu-dropdown:not(.menu-dropdown-show)) {
595+ display: none;
596+ }
597+ :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), :where(li:not(.menu-title) > details > summary:not(.menu-title)) {
598+ display: grid;
599+ grid-auto-flow: column;
600+ align-content: flex-start;
601+ align-items: center;
602+ gap: calc(0.25rem * 2);
603+ border-radius: var(--radius-field);
604+ padding-inline: calc(0.25rem * 3);
605+ padding-block: calc(0.25rem * 1.5);
606+ text-align: start;
607+ transition-property: color, background-color, box-shadow;
608+ transition-duration: 0.2s;
609+ transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
610+ grid-auto-columns: minmax(auto, max-content) auto max-content;
611+ text-wrap: balance;
612+ user-select: none;
613+ }
614+ :where(li > details > summary) {
615+ --tw-outline-style: none;
616+ outline-style: none;
617+ @media (forced-colors: active) {
618+ outline: 2px solid transparent;
619+ outline-offset: 2px;
620+ }
621+ &::-webkit-details-marker {
622+ display: none;
623+ }
624+ }
625+ :where(li > details > summary), :where(li > .menu-dropdown-toggle) {
626+ &:after {
627+ justify-self: flex-end;
628+ display: block;
629+ height: 0.375rem;
630+ width: 0.375rem;
631+ rotate: -135deg;
632+ translate: 0 -1px;
633+ transition-property: rotate, translate;
634+ transition-duration: 0.2s;
635+ content: "";
636+ transform-origin: 50% 50%;
637+ box-shadow: 2px 2px inset;
638+ pointer-events: none;
639+ }
640+ }
641+ :where(li > details[open] > summary):after, :where(li > .menu-dropdown-toggle.menu-dropdown-show):after {
642+ rotate: 45deg;
643+ translate: 0 1px;
644+ }
645+ :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) {
646+ &.menu-focus, &:focus-visible {
647+ cursor: pointer;
648+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
649+ color: var(--color-base-content);
650+ --tw-outline-style: none;
651+ outline-style: none;
652+ @media (forced-colors: active) {
653+ outline: 2px solid transparent;
654+ outline-offset: 2px;
655+ }
656+ }
657+ }
658+ :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 ) {
659+ cursor: pointer;
660+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
661+ --tw-outline-style: none;
662+ outline-style: none;
663+ @media (forced-colors: active) {
664+ outline: 2px solid transparent;
665+ outline-offset: 2px;
666+ }
667+ box-shadow: 0 1px oklch(0% 0 0 / 0.01) inset, 0 -1px oklch(100% 0 0 / 0.01) inset;
668+ }
669+ :where(li:empty) {
670+ background-color: var(--color-base-content);
671+ opacity: 10%;
672+ margin: 0.5rem 1rem;
673+ height: 1px;
674+ }
675+ :where(li) {
676+ position: relative;
677+ display: flex;
678+ flex-shrink: 0;
679+ flex-direction: column;
680+ flex-wrap: wrap;
681+ align-items: stretch;
682+ .badge {
683+ justify-self: flex-end;
684+ }
685+ & > *:not(ul, .menu-title, details, .btn):active, & > *:not(ul, .menu-title, details, .btn).menu-active, & > details > summary:active {
686+ --tw-outline-style: none;
687+ outline-style: none;
688+ @media (forced-colors: active) {
689+ outline: 2px solid transparent;
690+ outline-offset: 2px;
691+ }
692+ color: var(--menu-active-fg);
693+ background-color: var(--menu-active-bg);
694+ background-size: auto, calc(var(--noise) * 100%);
695+ background-image: none, var(--fx-noise);
696+ &:not(&:active) {
697+ box-shadow: 0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg);
698+ }
699+ }
700+ &.menu-disabled {
701+ pointer-events: none;
702+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
703+ }
704+ }
705+ .dropdown:focus-within {
706+ .menu-dropdown-toggle:after {
707+ rotate: 45deg;
708+ translate: 0 1px;
709+ }
710+ }
711+ .dropdown-content {
712+ margin-top: calc(0.25rem * 2);
713+ padding: calc(0.25rem * 2);
714+ &:before {
715+ display: none;
716+ }
717+ }
718+ }
719+ .floating-label {
720+ position: relative;
721+ display: block;
722+ input {
723+ display: block;
724+ &::placeholder {
725+ transition: top 0.1s ease-out, translate 0.1s ease-out, scale 0.1s ease-out, opacity 0.1s ease-out;
726+ }
727+ }
728+ textarea {
729+ &::placeholder {
730+ transition: top 0.1s ease-out, translate 0.1s ease-out, scale 0.1s ease-out, opacity 0.1s ease-out;
731+ }
732+ }
733+ > span {
734+ position: absolute;
735+ inset-inline-start: calc(0.25rem * 3);
736+ z-index: 1;
737+ background-color: var(--color-base-100);
738+ padding-inline: calc(0.25rem * 1);
739+ opacity: 0%;
740+ font-size: 0.875rem;
741+ top: calc(var(--size-field, 0.25rem) * 10 / 2);
742+ line-height: 1;
743+ border-radius: 2px;
744+ pointer-events: none;
745+ translate: 0 -50%;
746+ transition: top 0.1s ease-out, translate 0.1s ease-out, scale 0.1s ease-out, opacity 0.1s ease-out;
747+ }
748+ &:focus-within, &:not(:has(input:placeholder-shown, textarea:placeholder-shown)) {
749+ ::placeholder {
750+ opacity: 0%;
751+ top: 0;
752+ translate: -12.5% calc(-50% - 0.125em);
753+ scale: 0.75;
754+ pointer-events: auto;
755+ }
756+ > span {
757+ opacity: 100%;
758+ top: 0;
759+ translate: -12.5% calc(-50% - 0.125em);
760+ scale: 0.75;
761+ pointer-events: auto;
762+ }
763+ }
764+ &:has(:disabled, [disabled]) {
765+ > span {
766+ opacity: 0%;
767+ }
768+ }
769+ &:has(.input-xs, .select-xs, .textarea-xs) span {
770+ font-size: 0.6875rem;
771+ top: calc(var(--size-field, 0.25rem) * 6 / 2);
772+ }
773+ &:has(.input-sm, .select-sm, .textarea-sm) span {
774+ font-size: 0.75rem;
775+ top: calc(var(--size-field, 0.25rem) * 8 / 2);
776+ }
777+ &:has(.input-md, .select-md, .textarea-md) span {
778+ font-size: 0.875rem;
779+ top: calc(var(--size-field, 0.25rem) * 10 / 2);
780+ }
781+ &:has(.input-lg, .select-lg, .textarea-lg) span {
782+ font-size: 1.125rem;
783+ top: calc(var(--size-field, 0.25rem) * 12 / 2);
784+ }
785+ &:has(.input-xl, .select-xl, .textarea-xl) span {
786+ font-size: 1.375rem;
787+ top: calc(var(--size-field, 0.25rem) * 14 / 2);
788+ }
789+ }
790+ .collapse-arrow {
791+ > .collapse-title:after {
792+ position: absolute;
793+ display: block;
794+ height: 0.5rem;
795+ width: 0.5rem;
796+ transform: translateY(-100%) rotate(45deg);
797+ transition-property: all;
798+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
799+ transition-duration: 0.2s;
800+ top: 1.9rem;
801+ inset-inline-end: 1.4rem;
802+ content: "";
803+ transform-origin: 75% 75%;
804+ box-shadow: 2px 2px;
805+ pointer-events: none;
806+ }
807+ }
808+ .collapse-plus {
809+ > .collapse-title:after {
810+ position: absolute;
811+ display: block;
812+ height: 0.5rem;
813+ width: 0.5rem;
814+ transition-property: all;
815+ transition-duration: 300ms;
816+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
817+ top: 0.9rem;
818+ inset-inline-end: 1.4rem;
819+ content: "+";
820+ pointer-events: none;
821+ }
822+ }
823+ .diff-item-2 {
824+ position: relative;
825+ grid-column-start: 1;
826+ grid-row-start: 1;
827+ &:after {
828+ pointer-events: none;
829+ position: absolute;
830+ top: calc(1/2 * 100%);
831+ right: 1px;
832+ bottom: calc(0.25rem * 0);
833+ z-index: 2;
834+ border-radius: calc(infinity * 1px);
835+ background-color: color-mix(in oklab, var(--color-base-100) 50%, transparent);
836+ width: 1.2rem;
837+ height: 1.8rem;
838+ border: 2px solid var(--color-base-100);
839+ content: "";
840+ outline: 1px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);
841+ outline-offset: -3px;
842+ backdrop-filter: blur(8px);
843+ box-shadow: 0 1px 2px 0 oklch(0% 0 0 / 0.1);
844+ translate: 50% -50%;
845+ }
846+ > * {
847+ pointer-events: none;
848+ position: absolute;
849+ top: calc(0.25rem * 0);
850+ bottom: calc(0.25rem * 0);
851+ left: calc(0.25rem * 0);
852+ height: 100%;
853+ width: 100cqi;
854+ max-width: none;
855+ object-fit: cover;
856+ object-position: center;
857+ }
858+ @supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
859+ &:after {
860+ content: none;
861+ }
862+ }
863+ }
864+ .pika-single {
865+ &:is(div) {
866+ user-select: none;
867+ font-size: 0.75rem;
868+ z-index: 999;
869+ display: inline-block;
870+ position: relative;
871+ color: var(--color-base-content);
872+ background-color: var(--color-base-100);
873+ border-radius: var(--radius-box);
874+ border: var(--border) solid var(--color-base-200);
875+ padding: 0.5rem;
876+ &:before,
877+ &:after {
878+ content: "";
879+ display: table;
880+ }
881+ &:after {
882+ clear: both;
883+ }
884+ &.is-hidden {
885+ display: none;
886+ }
887+ &.is-bound {
888+ position: absolute;
889+ }
890+ .pika-lendar {
891+ css-float: left;
892+ }
893+ .pika-title {
894+ position: relative;
895+ text-align: center;
896+ select {
897+ cursor: pointer;
898+ position: absolute;
899+ z-index: 999;
900+ margin: 0;
901+ left: 0;
902+ top: 5px;
903+ opacity: 0;
904+ }
905+ }
906+ .pika-label {
907+ display: inline-block;
908+ position: relative;
909+ z-index: 999;
910+ overflow: hidden;
911+ margin: 0;
912+ padding: 5px 3px;
913+ background-color: var(--color-base-100);
914+ }
915+ .pika-prev,
916+ .pika-next {
917+ display: block;
918+ cursor: pointer;
919+ position: absolute;
920+ top: 0;
921+ outline: none;
922+ border: 0;
923+ width: 2.25rem;
924+ height: 2.25rem;
925+ color: #0000;
926+ font-size: 1.2em;
927+ border-radius: var(--radius-field);
928+ &:hover {
929+ background-color: var(--color-base-200);
930+ }
931+ &.is-disabled {
932+ cursor: default;
933+ opacity: 0.2;
934+ }
935+ &:before {
936+ display: inline-block;
937+ width: 2.25rem;
938+ height: 2.25rem;
939+ line-height: 2.25;
940+ color: var(--color-base-content);
941+ }
942+ }
943+ .pika-prev {
944+ left: 0;
945+ &:before {
946+ content: "‹";
947+ }
948+ }
949+ .pika-next {
950+ right: 0;
951+ &:before {
952+ content: "›";
953+ }
954+ }
955+ .pika-select {
956+ display: inline-block;
957+ }
958+ .pika-table {
959+ width: 100%;
960+ border-collapse: collapse;
961+ border-spacing: 0;
962+ border: 0;
963+ th,
964+ td {
965+ padding: 0;
966+ }
967+ th {
968+ opacity: 0.6;
969+ text-align: center;
970+ width: 2.25rem;
971+ height: 2.25rem;
972+ }
973+ }
974+ .pika-button {
975+ cursor: pointer;
976+ display: block;
977+ outline: none;
978+ border: 0;
979+ margin: 0;
980+ width: 2.25rem;
981+ height: 2.25rem;
982+ padding: 5px;
983+ text-align: right;
984+ text-align: center;
985+ }
986+ .pika-week {
987+ color: var(--color-base-content);
988+ }
989+ .is-today {
990+ .pika-button {
991+ background: var(--color-primary);
992+ color: var(--color-primary-content);
993+ }
994+ }
995+ .is-selected,
996+ .has-event {
997+ .pika-button {
998+ &,
999+ &:hover {
1000+ color: var(--color-base-100);
1001+ background-color: var(--color-base-content);
1002+ border-radius: var(--radius-field);
1003+ }
1004+ }
1005+ }
1006+ .has-event {
1007+ .pika-button {
1008+ background: var(--color-base-primary);
1009+ }
1010+ }
1011+ .is-disabled,
1012+ .is-inrange {
1013+ .pika-button {
1014+ background: var(--color-base-primary);
1015+ }
1016+ }
1017+ .is-startrange {
1018+ .pika-button {
1019+ color: var(--color-base-100);
1020+ background: var(--color-base-content);
1021+ border-radius: var(--radius-field);
1022+ }
1023+ }
1024+ .is-endrange {
1025+ .pika-button {
1026+ color: var(--color-base-100);
1027+ background: var(--color-base-content);
1028+ border-radius: var(--radius-field);
1029+ }
1030+ }
1031+ .is-disabled {
1032+ .pika-button {
1033+ pointer-events: none;
1034+ cursor: default;
1035+ color: var(--color-base-content);
1036+ opacity: 0.3;
1037+ }
1038+ }
1039+ .is-outside-current-month {
1040+ .pika-button {
1041+ color: var(--color-base-content);
1042+ opacity: 0.3;
1043+ }
1044+ }
1045+ .is-selection-disabled {
1046+ pointer-events: none;
1047+ cursor: default;
1048+ }
1049+ .pika-button:hover,
1050+ .pika-row.pick-whole-week:hover .pika-button {
1051+ color: var(--color-base-content);
1052+ background-color: var(--color-base-200);
1053+ border-radius: var(--radius-field);
1054+ }
1055+ .pika-table abbr {
1056+ text-decoration: none;
1057+ font-weight: normal;
1058+ }
1059+ }
1060+ }
1061+ .diff-item-1 {
1062+ position: relative;
1063+ z-index: 1;
1064+ grid-column-start: 1;
1065+ grid-row-start: 1;
1066+ overflow: hidden;
1067+ border-right: 2px solid var(--color-base-100);
1068+ > * {
1069+ pointer-events: none;
1070+ position: absolute;
1071+ top: calc(0.25rem * 0);
1072+ bottom: calc(0.25rem * 0);
1073+ left: calc(0.25rem * 0);
1074+ height: 100%;
1075+ width: 100cqi;
1076+ max-width: none;
1077+ object-fit: cover;
1078+ object-position: center;
1079+ }
1080+ }
1081+ .dock {
1082+ position: fixed;
1083+ right: calc(0.25rem * 0);
1084+ bottom: calc(0.25rem * 0);
1085+ left: calc(0.25rem * 0);
1086+ z-index: 1;
1087+ display: flex;
1088+ width: 100%;
1089+ flex-direction: row;
1090+ align-items: center;
1091+ justify-content: space-around;
1092+ background-color: var(--color-base-100);
1093+ padding: calc(0.25rem * 2);
1094+ color: currentColor;
1095+ border-top: 0.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);
1096+ height: 4rem;
1097+ height: calc(4rem + env(safe-area-inset-bottom));
1098+ padding-bottom: env(safe-area-inset-bottom);
1099+ > * {
1100+ position: relative;
1101+ margin-bottom: calc(0.25rem * 2);
1102+ display: flex;
1103+ height: 100%;
1104+ max-width: calc(0.25rem * 32);
1105+ flex-shrink: 1;
1106+ flex-basis: 100%;
1107+ cursor: pointer;
1108+ flex-direction: column;
1109+ align-items: center;
1110+ justify-content: center;
1111+ gap: 1px;
1112+ border-radius: var(--radius-box);
1113+ background-color: transparent;
1114+ transition: opacity 0.2s ease-out;
1115+ &:where(.dock-active) {
1116+ &:after {
1117+ content: "";
1118+ position: absolute;
1119+ height: calc(0.25rem * 1);
1120+ width: calc(0.25rem * 10);
1121+ border-radius: calc(infinity * 1px);
1122+ background-color: currentColor;
1123+ bottom: 0.2rem;
1124+ border-top: 3px solid;
1125+ }
1126+ }
1127+ @media (hover: hover) {
1128+ &:hover {
1129+ opacity: 80%;
1130+ }
1131+ }
1132+ &[aria-disabled="true"], &[disabled] {
1133+ &, &:hover {
1134+ pointer-events: none;
1135+ color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
1136+ opacity: 100%;
1137+ }
1138+ }
1139+ .dock-label {
1140+ font-size: 0.6875rem;
1141+ }
1142+ }
1143+ }
1144+ .dropdown {
1145+ position: relative;
1146+ display: inline-block;
1147+ position-area: var(--anchor-v, bottom) var(--anchor-h, span-right);
1148+ & > *:not(summary):focus {
1149+ --tw-outline-style: none;
1150+ outline-style: none;
1151+ @media (forced-colors: active) {
1152+ outline: 2px solid transparent;
1153+ outline-offset: 2px;
1154+ }
1155+ }
1156+ .dropdown-content {
1157+ position: absolute;
1158+ }
1159+ &:not(details, .dropdown-open, .dropdown-hover:hover, :focus-within) {
1160+ .dropdown-content {
1161+ display: none;
1162+ transform-origin: top;
1163+ opacity: 0%;
1164+ scale: 95%;
1165+ }
1166+ }
1167+ &[popover], .dropdown-content {
1168+ z-index: 999;
1169+ animation: dropdown 0.2s;
1170+ transition-property: opacity, scale, display;
1171+ transition-behavior: allow-discrete;
1172+ transition-duration: 0.2s;
1173+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1174+ }
1175+ @starting-style {
1176+ &[popover], .dropdown-content {
1177+ scale: 95%;
1178+ opacity: 0;
1179+ }
1180+ }
1181+ &.dropdown-open, &:not(.dropdown-hover):focus, &:focus-within {
1182+ > [tabindex]:first-child {
1183+ pointer-events: none;
1184+ }
1185+ .dropdown-content {
1186+ opacity: 100%;
1187+ }
1188+ }
1189+ &.dropdown-hover:hover {
1190+ .dropdown-content {
1191+ opacity: 100%;
1192+ scale: 100%;
1193+ }
1194+ }
1195+ &:is(details) {
1196+ summary {
1197+ &::-webkit-details-marker {
1198+ display: none;
1199+ }
1200+ }
1201+ }
1202+ &.dropdown-open, &:focus, &:focus-within {
1203+ .dropdown-content {
1204+ scale: 100%;
1205+ }
1206+ }
1207+ &:where([popover]) {
1208+ background: #0000;
1209+ }
1210+ &[popover] {
1211+ position: fixed;
1212+ color: inherit;
1213+ @supports not (position-area: bottom) {
1214+ margin: auto;
1215+ &.dropdown-open:not(:popover-open) {
1216+ display: none;
1217+ transform-origin: top;
1218+ opacity: 0%;
1219+ scale: 95%;
1220+ }
1221+ &::backdrop {
1222+ background-color: color-mix(in oklab, #000 30%, #0000);
1223+ }
1224+ }
1225+ &:not(.dropdown-open, :popover-open) {
1226+ display: none;
1227+ transform-origin: top;
1228+ opacity: 0%;
1229+ scale: 95%;
1230+ }
1231+ }
1232+ }
1233+ .btn {
1234+ display: inline-flex;
1235+ flex-shrink: 0;
1236+ cursor: pointer;
1237+ flex-wrap: nowrap;
1238+ align-items: center;
1239+ justify-content: center;
1240+ gap: calc(0.25rem * 1.5);
1241+ text-align: center;
1242+ vertical-align: middle;
1243+ outline-offset: 2px;
1244+ webkit-user-select: none;
1245+ user-select: none;
1246+ padding-inline: var(--btn-p);
1247+ color: var(--btn-fg);
1248+ --tw-prose-links: var(--btn-fg);
1249+ height: var(--size);
1250+ font-size: var(--fontsize, 0.875rem);
1251+ font-weight: 600;
1252+ outline-color: var(--btn-color, var(--color-base-content));
1253+ transition-property: color, background-color, border-color, box-shadow;
1254+ transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
1255+ transition-duration: 0.2s;
1256+ border-start-start-radius: var(--join-ss, var(--radius-field));
1257+ border-start-end-radius: var(--join-se, var(--radius-field));
1258+ border-end-start-radius: var(--join-es, var(--radius-field));
1259+ border-end-end-radius: var(--join-ee, var(--radius-field));
1260+ background-color: var(--btn-bg);
1261+ background-size: auto, calc(var(--noise) * 100%);
1262+ background-image: none, var(--btn-noise);
1263+ border-width: var(--border);
1264+ border-style: solid;
1265+ border-color: var(--btn-border);
1266+ text-shadow: 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 0.15));
1267+ box-shadow: 0 0.5px 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);
1268+ --size: calc(var(--size-field, 0.25rem) * 10);
1269+ --btn-bg: var(--btn-color, var(--color-base-200));
1270+ --btn-fg: var(--color-base-content);
1271+ --btn-p: 1rem;
1272+ --btn-border: color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));
1273+ --btn-shadow: 0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000),
1274+ 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);
1275+ --btn-noise: var(--fx-noise);
1276+ .prose & {
1277+ text-decoration-line: none;
1278+ }
1279+ @media (hover: hover) {
1280+ &:hover {
1281+ --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
1282+ }
1283+ }
1284+ &:focus-visible {
1285+ outline-width: 2px;
1286+ outline-style: solid;
1287+ }
1288+ &:active:not(.btn-active) {
1289+ translate: 0 0.5px;
1290+ --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 5%);
1291+ --btn-border: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
1292+ --btn-shadow: 0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);
1293+ }
1294+ &:is(:disabled, [disabled], .btn-disabled) {
1295+ &:not(.btn-link, .btn-ghost) {
1296+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
1297+ box-shadow: none;
1298+ }
1299+ pointer-events: none;
1300+ --btn-border: #0000;
1301+ --btn-noise: none;
1302+ --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
1303+ @media (hover: hover) {
1304+ &:hover {
1305+ pointer-events: none;
1306+ background-color: color-mix(in oklab, var(--color-neutral) 20%, transparent);
1307+ --btn-border: #0000;
1308+ --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
1309+ }
1310+ }
1311+ }
1312+ &:is(input[type="checkbox"], input[type="radio"]) {
1313+ appearance: none;
1314+ &::after {
1315+ content: attr(aria-label);
1316+ }
1317+ }
1318+ &:where(input:checked:not(.filter .btn)) {
1319+ --btn-color: var(--color-primary);
1320+ --btn-fg: var(--color-primary-content);
1321+ isolation: isolate;
1322+ }
1323+ }
1324+ .loading {
1325+ pointer-events: none;
1326+ display: inline-block;
1327+ aspect-ratio: 1 / 1;
1328+ background-color: currentColor;
1329+ vertical-align: middle;
1330+ width: calc(var(--size-selector, 0.25rem) * 6);
1331+ mask-size: 100%;
1332+ mask-repeat: no-repeat;
1333+ mask-position: center;
1334+ mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
1335+ }
1336+ .tab-disabled {
1337+ pointer-events: none;
1338+ opacity: 40%;
1339+ }
1340+ .react-day-picker {
1341+ user-select: none;
1342+ background-color: var(--color-base-100);
1343+ border-radius: var(--radius-box);
1344+ border: var(--border) solid var(--color-base-200);
1345+ font-size: 0.75rem;
1346+ display: inline-block;
1347+ position: relative;
1348+ overflow: clip;
1349+ &[dir="rtl"] {
1350+ .rdp-nav {
1351+ .rdp-chevron {
1352+ transform-origin: 50%;
1353+ transform: rotate(180deg);
1354+ }
1355+ }
1356+ }
1357+ * {
1358+ box-sizing: border-box;
1359+ }
1360+ .rdp-day {
1361+ width: 2.25rem;
1362+ height: 2.25rem;
1363+ text-align: center;
1364+ }
1365+ .rdp-day_button {
1366+ cursor: pointer;
1367+ font: inherit;
1368+ color: inherit;
1369+ width: 2.25rem;
1370+ height: 2.25rem;
1371+ border: 2px solid #0000;
1372+ border-radius: var(--radius-field);
1373+ background: 0 0;
1374+ justify-content: center;
1375+ align-items: center;
1376+ margin: 0;
1377+ padding: 0;
1378+ display: flex;
1379+ &:disabled {
1380+ cursor: revert;
1381+ }
1382+ &:hover {
1383+ background-color: var(--color-base-200);
1384+ }
1385+ }
1386+ .rdp-caption_label {
1387+ z-index: 1;
1388+ white-space: nowrap;
1389+ border: 0;
1390+ align-items: center;
1391+ display: inline-flex;
1392+ position: relative;
1393+ }
1394+ .rdp-button_next {
1395+ border-radius: var(--radius-field);
1396+ &:hover {
1397+ background-color: var(--color-base-200);
1398+ }
1399+ }
1400+ .rdp-button_previous {
1401+ border-radius: var(--radius-field);
1402+ &:hover {
1403+ background-color: var(--color-base-200);
1404+ }
1405+ }
1406+ .rdp-button_next,
1407+ .rdp-button_previous {
1408+ cursor: pointer;
1409+ font: inherit;
1410+ color: inherit;
1411+ appearance: none;
1412+ width: 2.25rem;
1413+ height: 2.25rem;
1414+ background: 0 0;
1415+ border: none;
1416+ justify-content: center;
1417+ align-items: center;
1418+ margin: 0;
1419+ padding: 0;
1420+ display: inline-flex;
1421+ position: relative;
1422+ &:disabled {
1423+ cursor: revert;
1424+ opacity: 0.5;
1425+ }
1426+ }
1427+ .rdp-chevron {
1428+ fill: var(--color-base-content);
1429+ width: 1rem;
1430+ height: 1rem;
1431+ display: inline-block;
1432+ }
1433+ .rdp-dropdowns {
1434+ align-items: center;
1435+ gap: 0.5rem;
1436+ display: inline-flex;
1437+ position: relative;
1438+ }
1439+ .rdp-dropdown {
1440+ z-index: 2;
1441+ opacity: 0;
1442+ appearance: none;
1443+ cursor: inherit;
1444+ line-height: inherit;
1445+ border: none;
1446+ width: 100%;
1447+ margin: 0;
1448+ padding: 0;
1449+ position: absolute;
1450+ inset-block: 0;
1451+ inset-inline-start: 0;
1452+ &:focus-visible {
1453+ ~ .rdp-caption_label {
1454+ outline: 5px auto highlight;
1455+ outline: 5px auto -webkit-focus-ring-color;
1456+ }
1457+ }
1458+ }
1459+ .rdp-dropdown_root {
1460+ align-items: center;
1461+ display: inline-flex;
1462+ position: relative;
1463+ &[data-disabled="true"] {
1464+ .rdp-chevron {
1465+ opacity: 0.5;
1466+ }
1467+ }
1468+ }
1469+ .rdp-month_caption {
1470+ height: 2.75rem;
1471+ font-size: 0.75rem;
1472+ font-weight: inherit;
1473+ place-content: center;
1474+ display: flex;
1475+ }
1476+ .rdp-months {
1477+ gap: 2rem;
1478+ flex-wrap: wrap;
1479+ max-width: fit-content;
1480+ padding: 0.5rem;
1481+ display: flex;
1482+ position: relative;
1483+ }
1484+ .rdp-month_grid {
1485+ border-collapse: collapse;
1486+ }
1487+ .rdp-nav {
1488+ height: 2.75rem;
1489+ inset-block-start: 0;
1490+ inset-inline-end: 0;
1491+ justify-content: space-between;
1492+ align-items: center;
1493+ width: 100%;
1494+ padding-inline: 0.5rem;
1495+ display: flex;
1496+ position: absolute;
1497+ top: 0.25rem;
1498+ }
1499+ .rdp-weekday {
1500+ opacity: 0.6;
1501+ padding: 0.5rem 0rem;
1502+ text-align: center;
1503+ font-size: smaller;
1504+ font-weight: 500;
1505+ }
1506+ .rdp-week_number {
1507+ opacity: 0.6;
1508+ height: 2.25rem;
1509+ width: 2.25rem;
1510+ border: none;
1511+ border-radius: 100%;
1512+ text-align: center;
1513+ font-size: small;
1514+ font-weight: 400;
1515+ }
1516+ .rdp-today:not(.rdp-outside) {
1517+ .rdp-day_button {
1518+ background: var(--color-primary);
1519+ color: var(--color-primary-content);
1520+ }
1521+ }
1522+ .rdp-selected {
1523+ font-weight: inherit;
1524+ font-size: 0.75rem;
1525+ .rdp-day_button {
1526+ color: var(--color-base-100);
1527+ background-color: var(--color-base-content);
1528+ border-radius: var(--radius-field);
1529+ border: none;
1530+ &:hover {
1531+ background-color: var(--color-base-content);
1532+ }
1533+ }
1534+ }
1535+ .rdp-outside {
1536+ opacity: 0.75;
1537+ }
1538+ .rdp-disabled {
1539+ opacity: 0.5;
1540+ }
1541+ .rdp-hidden {
1542+ visibility: hidden;
1543+ color: var(--color-base-content);
1544+ }
1545+ .rdp-range_start {
1546+ .rdp-day_button {
1547+ border-radius: var(--radius-field) 0 0 var(--radius-field);
1548+ }
1549+ }
1550+ .rdp-range_start .rdp-day_button {
1551+ background-color: var(--color-base-content);
1552+ color: var(--color-base-content);
1553+ }
1554+ .rdp-range_middle {
1555+ background-color: var(--color-base-200);
1556+ }
1557+ .rdp-range_middle .rdp-day_button {
1558+ border: unset;
1559+ border-radius: unset;
1560+ color: inherit;
1561+ }
1562+ .rdp-range_end {
1563+ color: var(--color-base-content);
1564+ .rdp-day_button {
1565+ border-radius: 0 var(--radius-field) var(--radius-field) 0;
1566+ }
1567+ }
1568+ .rdp-range_end .rdp-day_button {
1569+ color: var(--color-base-content);
1570+ background-color: var(--color-base-content);
1571+ }
1572+ .rdp-range_start.rdp-range_end {
1573+ background: revert;
1574+ }
1575+ .rdp-focusable {
1576+ cursor: pointer;
1577+ }
1578+ .rdp-footer {
1579+ border-top: var(--border) solid var(--color-base-200);
1580+ padding: 0.5rem;
1581+ }
1582+ }
1583+ .collapse {
1584+ &:not(td, tr, colgroup) {
1585+ visibility: visible;
1586+ }
1587+ position: relative;
1588+ display: grid;
1589+ overflow: hidden;
1590+ border-radius: var(--radius-box, 1rem);
1591+ width: 100%;
1592+ grid-template-rows: max-content 0fr;
1593+ transition: grid-template-rows 0.2s;
1594+ > input:is([type="checkbox"], [type="radio"]) {
1595+ grid-column-start: 1;
1596+ grid-row-start: 1;
1597+ appearance: none;
1598+ opacity: 0;
1599+ z-index: 1;
1600+ width: 100%;
1601+ padding: 1rem;
1602+ padding-inline-end: 3rem;
1603+ min-height: 3.75rem;
1604+ transition: background-color 0.2s ease-out;
1605+ }
1606+ &:is([open], :focus:not(.collapse-close)),
1607+ &:not(.collapse-close):has(> input:is([type="checkbox"], [type="radio"]):checked) {
1608+ grid-template-rows: max-content 1fr;
1609+ }
1610+ &:is([open], :focus:not(.collapse-close)) > .collapse-content,
1611+ &:not(.collapse-close)
1612+ > :where(input:is([type="checkbox"], [type="radio"]):checked ~ .collapse-content) {
1613+ visibility: visible;
1614+ min-height: fit-content;
1615+ }
1616+ &:focus-visible,
1617+ &:has(> input:is([type="checkbox"], [type="radio"]):focus-visible) {
1618+ outline-color: var(--color-base-content);
1619+ outline-style: solid;
1620+ outline-width: 2px;
1621+ outline-offset: 2px;
1622+ }
1623+ &:not(.collapse-close) {
1624+ > input[type="checkbox"],
1625+ > input[type="radio"]:not(:checked),
1626+ > .collapse-title {
1627+ cursor: pointer;
1628+ }
1629+ }
1630+ &:focus:not(.collapse-close, .collapse[open]) > .collapse-title {
1631+ cursor: unset;
1632+ }
1633+ &:is([open], :focus:not(.collapse-close)) > :where(.collapse-content),
1634+ &:not(.collapse-close)
1635+ > :where(input:is([type="checkbox"], [type="radio"]):checked ~ .collapse-content) {
1636+ padding-bottom: 1rem;
1637+ transition: padding 0.2s ease-out,
1638+ background-color 0.2s ease-out;
1639+ }
1640+ &:is([open]) {
1641+ &.collapse-arrow {
1642+ > .collapse-title:after {
1643+ transform: translateY(-50%) rotate(225deg);
1644+ }
1645+ }
1646+ }
1647+ &.collapse-open {
1648+ &.collapse-arrow {
1649+ > .collapse-title:after {
1650+ transform: translateY(-50%) rotate(225deg);
1651+ }
1652+ }
1653+ &.collapse-plus {
1654+ > .collapse-title:after {
1655+ content: "−";
1656+ }
1657+ }
1658+ }
1659+ &.collapse-arrow:focus:not(.collapse-close) {
1660+ > .collapse-title:after {
1661+ transform: translateY(-50%) rotate(225deg);
1662+ }
1663+ }
1664+ &.collapse-arrow:not(.collapse-close) {
1665+ > input:is([type="checkbox"], [type="radio"]):checked ~ .collapse-title:after {
1666+ transform: translateY(-50%) rotate(225deg);
1667+ }
1668+ }
1669+ &[open] {
1670+ &.collapse-plus {
1671+ > .collapse-title:after {
1672+ content: "−";
1673+ }
1674+ }
1675+ }
1676+ &.collapse-plus:focus:not(.collapse-close) {
1677+ > .collapse-title:after {
1678+ content: "−";
1679+ }
1680+ }
1681+ &.collapse-plus:not(.collapse-close) {
1682+ > input:is([type="checkbox"], [type="radio"]):checked ~ .collapse-title:after {
1683+ content: "−";
1684+ }
1685+ }
1686+ &:is(details) {
1687+ width: 100%;
1688+ & summary {
1689+ position: relative;
1690+ display: block;
1691+ &::-webkit-details-marker {
1692+ display: none;
1693+ }
1694+ }
1695+ }
1696+ &:is(details) summary {
1697+ outline: none;
1698+ }
1699+ }
1700+ .collapse-content {
1701+ grid-column-start: 1;
1702+ grid-row-start: 1;
1703+ visibility: hidden;
1704+ grid-column-start: 1;
1705+ grid-row-start: 2;
1706+ min-height: 0;
1707+ transition: visibility 0.2s;
1708+ transition: padding 0.2s ease-out,
1709+ background-color 0.2s ease-out;
1710+ padding-left: 1rem;
1711+ padding-right: 1rem;
1712+ cursor: unset;
1713+ }
1714+ .validator {
1715+ &:user-valid, &:has(:user-valid) {
1716+ &, &:focus, &:checked, &[aria-checked="true"], &:focus-within {
1717+ --input-color: var(--color-success);
1718+ }
1719+ }
1720+ &:user-invalid, &:has(:user-invalid) {
1721+ &, &:focus, &:checked, &[aria-checked="true"], &:focus-within {
1722+ --input-color: var(--color-error);
1723+ }
1724+ & ~ .validator-hint {
1725+ visibility: visible;
1726+ display: block;
1727+ color: var(--color-error);
1728+ }
1729+ }
1730+ & ~ .validator-hint {
1731+ visibility: hidden;
1732+ }
1733+ }
1734+ .collapse-open {
1735+ grid-template-rows: max-content 1fr;
1736+ > .collapse-content {
1737+ visibility: visible;
1738+ min-height: fit-content;
1739+ padding-bottom: 1rem;
1740+ transition: padding 0.2sease-out,
1741+ background-color 0.2sease-out;
1742+ }
1743+ }
1744+ .collapse {
1745+ visibility: collapse;
1746+ }
1747+ .visible {
1748+ visibility: visible;
1749+ }
1750+ .tabs-lift {
1751+ --tabs-height: auto;
1752+ --tabs-direction: row;
1753+ > .tab {
1754+ --tab-border: 0 0 var(--border) 0;
1755+ --tab-radius-ss: min(var(--radius-field), var(--tab-radius-min));
1756+ --tab-radius-se: min(var(--radius-field), var(--tab-radius-min));
1757+ --tab-radius-es: 0;
1758+ --tab-radius-ee: 0;
1759+ --tab-paddings: var(--border) var(--tab-p) 0 var(--tab-p);
1760+ --tab-border-colors: #0000 #0000 var(--tab-border-color) #0000;
1761+ --tab-corner-width: calc(100% + min(var(--radius-field), var(--tab-radius-min)) * 2);
1762+ --tab-corner-height: min(var(--radius-field), var(--tab-radius-min));
1763+ --tab-corner-position: top left, top right;
1764+ border-width: var(--tab-border);
1765+ border-start-start-radius: var(--tab-radius-ss);
1766+ border-start-end-radius: var(--tab-radius-se);
1767+ border-end-start-radius: var(--tab-radius-es);
1768+ border-end-end-radius: var(--tab-radius-ee);
1769+ padding: var(--tab-paddings);
1770+ border-color: var(--tab-border-colors);
1771+ &:is(.tab-active, [aria-selected="true"]):not(.tab-disabled, [disabled]), &:is(input:checked, label:has(:checked)) {
1772+ --tab-border: var(--border) var(--border) 0 var(--border);
1773+ --tab-border-colors: var(--tab-border-color) var(--tab-border-color) #0000
1774+ var(--tab-border-color);
1775+ --tab-paddings: 0 calc(var(--tab-p) - var(--border)) var(--border)
1776+ calc(var(--tab-p) - var(--border));
1777+ --tab-inset: auto auto 0 auto;
1778+ --tab-grad: calc(69% - var(--border));
1779+ --radius-start: radial-gradient(
1780+ circle at top left,
1781+ #0000 var(--tab-grad),
1782+ var(--tab-border-color) calc(var(--tab-grad) + 0.25px),
1783+ var(--tab-border-color) calc(var(--tab-grad) + var(--border)),
1784+ var(--tab-bg) calc(var(--tab-grad) + var(--border) + 0.25px)
1785+ );
1786+ --radius-end: radial-gradient(
1787+ circle at top right,
1788+ #0000 var(--tab-grad),
1789+ var(--tab-border-color) calc(var(--tab-grad) + 0.25px),
1790+ var(--tab-border-color) calc(var(--tab-grad) + var(--border)),
1791+ var(--tab-bg) calc(var(--tab-grad) + var(--border) + 0.25px)
1792+ );
1793+ background-color: var(--tab-bg);
1794+ &:before {
1795+ z-index: 1;
1796+ content: "";
1797+ display: block;
1798+ position: absolute;
1799+ width: var(--tab-corner-width);
1800+ height: var(--tab-corner-height);
1801+ background-position: var(--tab-corner-position);
1802+ background-image: var(--radius-start), var(--radius-end);
1803+ background-size: min(var(--radius-field), var(--tab-radius-min)) min(var(--radius-field), var(--tab-radius-min));
1804+ background-repeat: no-repeat;
1805+ inset: var(--tab-inset);
1806+ }
1807+ &:first-child:before {
1808+ --radius-start: none;
1809+ }
1810+ [dir="rtl"] &:first-child:before {
1811+ transform: rotateY(180deg);
1812+ }
1813+ &:last-child:before {
1814+ --radius-end: none;
1815+ }
1816+ [dir="rtl"] &:last-child:before {
1817+ transform: rotateY(180deg);
1818+ }
1819+ }
1820+ }
1821+ &:has(.tab-content) {
1822+ > .tab:first-child {
1823+ &:not(.tab-active, [aria-selected="true"]) {
1824+ --tab-border-colors: var(--tab-border-color) var(--tab-border-color) #0000
1825+ var(--tab-border-color);
1826+ }
1827+ }
1828+ }
1829+ .tab-content {
1830+ --tabcontent-margin: calc(-1 * var(--border)) 0 0 0;
1831+ --tabcontent-radius-ss: 0;
1832+ --tabcontent-radius-se: var(--radius-box);
1833+ --tabcontent-radius-es: var(--radius-box);
1834+ --tabcontent-radius-ee: var(--radius-box);
1835+ }
1836+ :checked, label:has(:checked), :is(.tab-active, [aria-selected="true"]) {
1837+ & + .tab-content {
1838+ &:nth-child(1), &:nth-child(n + 3) {
1839+ --tabcontent-radius-ss: var(--radius-box);
1840+ }
1841+ }
1842+ }
1843+ }
1844+ .radial-progress {
1845+ position: relative;
1846+ display: inline-grid;
1847+ height: var(--size);
1848+ width: var(--size);
1849+ place-content: center;
1850+ border-radius: calc(infinity * 1px);
1851+ background-color: transparent;
1852+ vertical-align: middle;
1853+ box-sizing: content-box;
1854+ --value: 0;
1855+ --size: 5rem;
1856+ --thickness: calc(var(--size) / 10);
1857+ --radialprogress: calc(var(--value) * 1%);
1858+ transition: --radialprogress 0.3s linear;
1859+ &:before {
1860+ position: absolute;
1861+ inset: calc(0.25rem * 0);
1862+ border-radius: calc(infinity * 1px);
1863+ content: "";
1864+ background: radial-gradient(farthest-side, currentColor 98%, #0000) top/var(--thickness) var(--thickness) no-repeat, conic-gradient(currentColor var(--radialprogress), #0000 0);
1865+ webkit-mask: radial-gradient( farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + 0.5px - var(--thickness)) );
1866+ mask: radial-gradient( farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + 0.5px - var(--thickness)) );
1867+ }
1868+ &:after {
1869+ position: absolute;
1870+ border-radius: calc(infinity * 1px);
1871+ background-color: currentColor;
1872+ transition: transform 0.3s linear;
1873+ content: "";
1874+ inset: calc(50% - var(--thickness) / 2);
1875+ transform: rotate(calc(var(--value) * 3.6deg - 90deg)) translate(calc(var(--size) / 2 - 50%));
1876+ }
1877+ }
1878+ .list {
1879+ display: flex;
1880+ flex-direction: column;
1881+ font-size: 0.875rem;
1882+ :where(.list-row) {
1883+ --list-grid-cols: minmax(0, auto) 1fr;
1884+ position: relative;
1885+ display: grid;
1886+ grid-auto-flow: column;
1887+ gap: calc(0.25rem * 4);
1888+ border-radius: var(--radius-box);
1889+ padding: calc(0.25rem * 4);
1890+ word-break: break-word;
1891+ grid-template-columns: var(--list-grid-cols);
1892+ &:has(.list-col-grow:nth-child(1)) {
1893+ --list-grid-cols: 1fr;
1894+ }
1895+ &:has(.list-col-grow:nth-child(2)) {
1896+ --list-grid-cols: minmax(0, auto) 1fr;
1897+ }
1898+ &:has(.list-col-grow:nth-child(3)) {
1899+ --list-grid-cols: minmax(0, auto) minmax(0, auto) 1fr;
1900+ }
1901+ &:has(.list-col-grow:nth-child(4)) {
1902+ --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
1903+ }
1904+ &:has(.list-col-grow:nth-child(5)) {
1905+ --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
1906+ }
1907+ &:has(.list-col-grow:nth-child(6)) {
1908+ --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto)
1909+ minmax(0, auto) 1fr;
1910+ }
1911+ :not(.list-col-wrap) {
1912+ grid-row-start: 1;
1913+ }
1914+ }
1915+ & > :not(:last-child) {
1916+ &.list-row, .list-row {
1917+ &:after {
1918+ content: "";
1919+ border-bottom: var(--border) solid;
1920+ inset-inline: var(--radius-box);
1921+ position: absolute;
1922+ bottom: calc(0.25rem * 0);
1923+ border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
1924+ }
1925+ }
1926+ }
1927+ }
1928+ .toast {
1929+ position: fixed;
1930+ inset-inline-start: auto;
1931+ inset-inline-end: calc(0.25rem * 0);
1932+ top: auto;
1933+ bottom: calc(0.25rem * 0);
1934+ margin: calc(0.25rem * 4);
1935+ display: flex;
1936+ min-width: fit-content;
1937+ flex-direction: column;
1938+ gap: calc(0.25rem * 2);
1939+ background-color: transparent;
1940+ white-space: nowrap;
1941+ translate: var(--toast-x, 0) var(--toast-y, 0);
1942+ & > * {
1943+ animation: toast 0.25s ease-out;
1944+ }
1945+ &:where(.toast-start) {
1946+ inset-inline-start: calc(0.25rem * 0);
1947+ inset-inline-end: auto;
1948+ --toast-x: 0;
1949+ }
1950+ &:where(.toast-center) {
1951+ inset-inline-start: calc(1/2 * 100%);
1952+ inset-inline-end: calc(1/2 * 100%);
1953+ --toast-x: -50%;
1954+ }
1955+ &:where(.toast-end) {
1956+ inset-inline-start: auto;
1957+ inset-inline-end: calc(0.25rem * 0);
1958+ --toast-x: 0;
1959+ }
1960+ &:where(.toast-bottom) {
1961+ top: auto;
1962+ bottom: calc(0.25rem * 0);
1963+ --toast-y: 0;
1964+ }
1965+ &:where(.toast-middle) {
1966+ top: calc(1/2 * 100%);
1967+ bottom: auto;
1968+ --toast-y: -50%;
1969+ }
1970+ &:where(.toast-top) {
1971+ top: calc(0.25rem * 0);
1972+ bottom: auto;
1973+ --toast-y: 0;
1974+ }
1975+ }
1976+ .toggle {
1977+ border: var(--border) solid currentColor;
1978+ color: var(--input-color);
1979+ position: relative;
1980+ display: inline-grid;
1981+ flex-shrink: 0;
1982+ cursor: pointer;
1983+ appearance: none;
1984+ place-content: center;
1985+ vertical-align: middle;
1986+ webkit-user-select: none;
1987+ user-select: none;
1988+ grid-template-columns: 0fr 1fr 1fr;
1989+ --radius-selector-max: calc(
1990+ var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
1991+ );
1992+ border-radius: calc( var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)) );
1993+ padding: var(--toggle-p);
1994+ box-shadow: 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;
1995+ transition: color 0.3s, grid-template-columns 0.2s;
1996+ --input-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
1997+ --toggle-p: 0.1875rem;
1998+ --size: calc(var(--size-selector, 0.25rem) * 6);
1999+ width: calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);
2000+ height: var(--size);
2001+ > * {
2002+ z-index: 1;
2003+ grid-column: span 1 / span 1;
2004+ grid-column-start: 2;
2005+ grid-row-start: 1;
2006+ height: 100%;
2007+ cursor: pointer;
2008+ appearance: none;
2009+ background-color: transparent;
2010+ padding: calc(0.25rem * 0.5);
2011+ transition: opacity 0.2s, rotate 0.4s;
2012+ border: none;
2013+ &:focus {
2014+ --tw-outline-style: none;
2015+ outline-style: none;
2016+ @media (forced-colors: active) {
2017+ outline: 2px solid transparent;
2018+ outline-offset: 2px;
2019+ }
2020+ }
2021+ &:nth-child(2) {
2022+ color: var(--color-base-100);
2023+ rotate: 0deg;
2024+ }
2025+ &:nth-child(3) {
2026+ color: var(--color-base-100);
2027+ opacity: 0%;
2028+ rotate: -15deg;
2029+ }
2030+ }
2031+ &:has(:checked) {
2032+ > :nth-child(2) {
2033+ opacity: 0%;
2034+ rotate: 15deg;
2035+ }
2036+ > :nth-child(3) {
2037+ opacity: 100%;
2038+ rotate: 0deg;
2039+ }
2040+ }
2041+ &:before {
2042+ position: relative;
2043+ inset-inline-start: calc(0.25rem * 0);
2044+ grid-column-start: 2;
2045+ grid-row-start: 1;
2046+ aspect-ratio: 1 / 1;
2047+ height: 100%;
2048+ border-radius: var(--radius-selector);
2049+ background-color: currentColor;
2050+ translate: 0;
2051+ --tw-content: "";
2052+ content: var(--tw-content);
2053+ transition: background-color 0.1s, translate 0.2s, inset-inline-start 0.2s;
2054+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);
2055+ background-size: auto, calc(var(--noise) * 100%);
2056+ background-image: none, var(--fx-noise);
2057+ }
2058+ @media (forced-colors: active) {
2059+ &:before {
2060+ outline-style: var(--tw-outline-style);
2061+ outline-width: 1px;
2062+ outline-offset: calc(1px * -1);
2063+ }
2064+ }
2065+ @media print {
2066+ &:before {
2067+ outline: 0.25rem solid;
2068+ outline-offset: -1rem;
2069+ }
2070+ }
2071+ &:focus-visible, &:has(:focus-visible) {
2072+ outline: 2px solid currentColor;
2073+ outline-offset: 2px;
2074+ }
2075+ &:checked, &[aria-checked="true"], &:has(> input:checked) {
2076+ grid-template-columns: 1fr 1fr 0fr;
2077+ background-color: var(--color-base-100);
2078+ --input-color: var(--color-base-content);
2079+ &:before {
2080+ background-color: currentColor;
2081+ }
2082+ @starting-style {
2083+ &:before {
2084+ opacity: 0;
2085+ }
2086+ }
2087+ }
2088+ &:indeterminate {
2089+ grid-template-columns: 0.5fr 1fr 0.5fr;
2090+ }
2091+ &:disabled {
2092+ cursor: not-allowed;
2093+ opacity: 30%;
2094+ &:before {
2095+ background-color: transparent;
2096+ border: var(--border) solid currentColor;
2097+ }
2098+ }
2099+ }
2100+ .input {
2101+ cursor: text;
2102+ border: var(--border) solid #0000;
2103+ position: relative;
2104+ display: inline-flex;
2105+ flex-shrink: 1;
2106+ appearance: none;
2107+ align-items: center;
2108+ gap: calc(0.25rem * 2);
2109+ background-color: var(--color-base-100);
2110+ padding-inline: calc(0.25rem * 3);
2111+ vertical-align: middle;
2112+ white-space: nowrap;
2113+ width: clamp(3rem, 20rem, 100%);
2114+ height: var(--size);
2115+ font-size: 0.875rem;
2116+ border-start-start-radius: var(--join-ss, var(--radius-field));
2117+ border-start-end-radius: var(--join-se, var(--radius-field));
2118+ border-end-start-radius: var(--join-es, var(--radius-field));
2119+ border-end-end-radius: var(--join-ee, var(--radius-field));
2120+ border-color: var(--input-color);
2121+ 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) * 0.1)) inset;
2122+ --size: calc(var(--size-field, 0.25rem) * 10);
2123+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
2124+ &:where(input) {
2125+ display: inline-flex;
2126+ }
2127+ :where(input) {
2128+ display: inline-flex;
2129+ height: 100%;
2130+ width: 100%;
2131+ appearance: none;
2132+ background-color: transparent;
2133+ border: none;
2134+ &:focus, &:focus-within {
2135+ --tw-outline-style: none;
2136+ outline-style: none;
2137+ @media (forced-colors: active) {
2138+ outline: 2px solid transparent;
2139+ outline-offset: 2px;
2140+ }
2141+ }
2142+ }
2143+ &:focus, &:focus-within {
2144+ --input-color: var(--color-base-content);
2145+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
2146+ outline: 2px solid var(--input-color);
2147+ outline-offset: 2px;
2148+ isolation: isolate;
2149+ }
2150+ &:has(> input[disabled]), &:is(:disabled, [disabled]) {
2151+ cursor: not-allowed;
2152+ border-color: var(--color-base-200);
2153+ background-color: var(--color-base-200);
2154+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
2155+ &::placeholder {
2156+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
2157+ }
2158+ box-shadow: none;
2159+ }
2160+ &:has(> input[disabled]) > input[disabled] {
2161+ cursor: not-allowed;
2162+ }
2163+ &::-webkit-date-and-time-value {
2164+ text-align: inherit;
2165+ }
2166+ &[type="number"] {
2167+ &::-webkit-inner-spin-button {
2168+ margin-block: calc(0.25rem * -3);
2169+ margin-inline-end: calc(0.25rem * -3);
2170+ }
2171+ }
2172+ &::-webkit-calendar-picker-indicator {
2173+ position: absolute;
2174+ inset-inline-end: 0.75em;
2175+ }
2176+ }
2177+ .indicator {
2178+ position: relative;
2179+ display: inline-flex;
2180+ width: max-content;
2181+ :where(.indicator-item) {
2182+ z-index: 1;
2183+ position: absolute;
2184+ white-space: nowrap;
2185+ top: var(--inidicator-t, 0);
2186+ bottom: var(--inidicator-b, auto);
2187+ left: var(--inidicator-s, auto);
2188+ right: var(--inidicator-e, 0);
2189+ translate: var(--inidicator-x, 50%) var(--indicator-y, -50%);
2190+ }
2191+ }
2192+ .table {
2193+ font-size: 0.875rem;
2194+ position: relative;
2195+ width: 100%;
2196+ border-radius: var(--radius-box);
2197+ text-align: left;
2198+ &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
2199+ text-align: right;
2200+ }
2201+ tr.row-hover {
2202+ &, &:nth-child(even) {
2203+ &:hover {
2204+ @media (hover: hover) {
2205+ background-color: var(--color-base-200);
2206+ }
2207+ }
2208+ }
2209+ }
2210+ :where(th, td) {
2211+ padding-inline: calc(0.25rem * 4);
2212+ padding-block: calc(0.25rem * 3);
2213+ vertical-align: middle;
2214+ }
2215+ :where(thead, tfoot) {
2216+ white-space: nowrap;
2217+ color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
2218+ font-size: 0.875rem;
2219+ font-weight: 600;
2220+ }
2221+ :where(tfoot) {
2222+ border-top: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
2223+ }
2224+ :where(.table-pin-rows thead tr) {
2225+ position: sticky;
2226+ top: calc(0.25rem * 0);
2227+ z-index: 1;
2228+ background-color: var(--color-base-100);
2229+ }
2230+ :where(.table-pin-rows tfoot tr) {
2231+ position: sticky;
2232+ bottom: calc(0.25rem * 0);
2233+ z-index: 1;
2234+ background-color: var(--color-base-100);
2235+ }
2236+ :where(.table-pin-cols tr th) {
2237+ position: sticky;
2238+ right: calc(0.25rem * 0);
2239+ left: calc(0.25rem * 0);
2240+ background-color: var(--color-base-100);
2241+ }
2242+ :where(thead tr, tbody tr:not(:last-child)) {
2243+ border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
2244+ }
2245+ }
2246+ .avatar-offline {
2247+ &:before {
2248+ content: "";
2249+ position: absolute;
2250+ z-index: 1;
2251+ display: block;
2252+ border-radius: calc(infinity * 1px);
2253+ background-color: var(--color-base-300);
2254+ outline: 2px solid var(--color-base-100);
2255+ width: 15%;
2256+ height: 15%;
2257+ top: 7%;
2258+ right: 7%;
2259+ }
2260+ }
2261+ .avatar-online {
2262+ &:before {
2263+ content: "";
2264+ position: absolute;
2265+ z-index: 1;
2266+ display: block;
2267+ border-radius: calc(infinity * 1px);
2268+ background-color: var(--color-success);
2269+ outline: 2px solid var(--color-base-100);
2270+ width: 15%;
2271+ height: 15%;
2272+ top: 7%;
2273+ right: 7%;
2274+ }
2275+ }
2276+ .steps {
2277+ display: inline-grid;
2278+ grid-auto-flow: column;
2279+ overflow: hidden;
2280+ overflow-x: auto;
2281+ counter-reset: step;
2282+ grid-auto-columns: 1fr;
2283+ .step {
2284+ display: grid;
2285+ grid-template-columns: repeat(1, minmax(0, 1fr));
2286+ grid-template-columns: auto;
2287+ grid-template-rows: repeat(2, minmax(0, 1fr));
2288+ grid-template-rows: 40px 1fr;
2289+ place-items: center;
2290+ text-align: center;
2291+ min-width: 4rem;
2292+ --step-bg: var(--color-base-300);
2293+ --step-fg: var(--color-base-content);
2294+ &:before {
2295+ top: calc(0.25rem * 0);
2296+ grid-column-start: 1;
2297+ grid-row-start: 1;
2298+ height: calc(0.25rem * 2);
2299+ width: 100%;
2300+ border: 1px solid;
2301+ color: var(--step-bg);
2302+ background-color: var(--step-bg);
2303+ --tw-content: "";
2304+ content: var(--tw-content);
2305+ margin-inline-start: -100%;
2306+ }
2307+ > .step-icon, &:not(:has(.step-icon)):after {
2308+ content: counter(step);
2309+ counter-increment: step;
2310+ z-index: 1;
2311+ color: var(--step-fg);
2312+ background-color: var(--step-bg);
2313+ border: 1px solid var(--step-bg);
2314+ position: relative;
2315+ grid-column-start: 1;
2316+ grid-row-start: 1;
2317+ display: grid;
2318+ height: calc(0.25rem * 8);
2319+ width: calc(0.25rem * 8);
2320+ place-items: center;
2321+ place-self: center;
2322+ border-radius: calc(infinity * 1px);
2323+ }
2324+ &:first-child:before {
2325+ content: none;
2326+ }
2327+ &[data-content]:after {
2328+ content: attr(data-content);
2329+ }
2330+ }
2331+ .step-neutral {
2332+ + .step-neutral:before, &:after, > .step-icon {
2333+ --step-bg: var(--color-neutral);
2334+ --step-fg: var(--color-neutral-content);
2335+ }
2336+ }
2337+ .step-primary {
2338+ + .step-primary:before, &:after, > .step-icon {
2339+ --step-bg: var(--color-primary);
2340+ --step-fg: var(--color-primary-content);
2341+ }
2342+ }
2343+ .step-secondary {
2344+ + .step-secondary:before, &:after, > .step-icon {
2345+ --step-bg: var(--color-secondary);
2346+ --step-fg: var(--color-secondary-content);
2347+ }
2348+ }
2349+ .step-accent {
2350+ + .step-accent:before, &:after, > .step-icon {
2351+ --step-bg: var(--color-accent);
2352+ --step-fg: var(--color-accent-content);
2353+ }
2354+ }
2355+ .step-info {
2356+ + .step-info:before, &:after, > .step-icon {
2357+ --step-bg: var(--color-info);
2358+ --step-fg: var(--color-info-content);
2359+ }
2360+ }
2361+ .step-success {
2362+ + .step-success:before, &:after, > .step-icon {
2363+ --step-bg: var(--color-success);
2364+ --step-fg: var(--color-success-content);
2365+ }
2366+ }
2367+ .step-warning {
2368+ + .step-warning:before, &:after, > .step-icon {
2369+ --step-bg: var(--color-warning);
2370+ --step-fg: var(--color-warning-content);
2371+ }
2372+ }
2373+ .step-error {
2374+ + .step-error:before, &:after, > .step-icon {
2375+ --step-bg: var(--color-error);
2376+ --step-fg: var(--color-error-content);
2377+ }
2378+ }
2379+ }
2380+ .diff-resizer {
2381+ position: relative;
2382+ top: calc(1/2 * 100%);
2383+ z-index: 1;
2384+ grid-column-start: 1;
2385+ grid-row-start: 1;
2386+ height: calc(0.25rem * 2);
2387+ width: 50cqi;
2388+ max-width: calc(100cqi - 1rem);
2389+ min-width: 1rem;
2390+ resize: horizontal;
2391+ overflow: hidden;
2392+ opacity: 0%;
2393+ transform: scaleY(3) translate(0.35rem, 0.08rem);
2394+ cursor: ew-resize;
2395+ transform-origin: 100% 100%;
2396+ clip-path: inset(calc(100% - 0.75rem) 0 0 calc(100% - 0.75rem));
2397+ transition: min-width 0.3s ease-out, max-width 0.3s ease-out;
2398+ }
2399+ .range {
2400+ appearance: none;
2401+ webkit-appearance: none;
2402+ --range-thumb: var(--color-base-100);
2403+ --range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
2404+ --range-progress: currentColor;
2405+ --range-fill: 1;
2406+ --range-p: 0.25rem;
2407+ --range-bg: color-mix(in oklab, currentColor 10%, #0000);
2408+ cursor: pointer;
2409+ overflow: hidden;
2410+ background-color: transparent;
2411+ vertical-align: middle;
2412+ width: clamp(3rem, 20rem, 100%);
2413+ --radius-selector-max: calc(
2414+ var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
2415+ );
2416+ border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
2417+ border: none;
2418+ height: var(--range-thumb-size);
2419+ [dir="rtl"] & {
2420+ --range-dir: -1;
2421+ }
2422+ &:focus {
2423+ outline: none;
2424+ }
2425+ &:focus-visible {
2426+ outline: 2px solid;
2427+ outline-offset: 2px;
2428+ }
2429+ &::-webkit-slider-runnable-track {
2430+ width: 100%;
2431+ background-color: var(--range-bg);
2432+ border-radius: var(--radius-selector);
2433+ height: calc(var(--range-thumb-size) * 0.5);
2434+ }
2435+ @media (forced-colors: active) {
2436+ &::-webkit-slider-runnable-track {
2437+ border: 1px solid;
2438+ }
2439+ }
2440+ @media (forced-colors: active) {
2441+ &::-moz-range-track {
2442+ border: 1px solid;
2443+ }
2444+ }
2445+ &::-webkit-slider-thumb {
2446+ position: relative;
2447+ box-sizing: border-box;
2448+ border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
2449+ background-color: currentColor;
2450+ height: var(--range-thumb-size);
2451+ width: var(--range-thumb-size);
2452+ border: var(--range-p) solid;
2453+ appearance: none;
2454+ webkit-appearance: none;
2455+ top: 50%;
2456+ color: var(--range-progress);
2457+ transform: translateY(-50%);
2458+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.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));
2459+ }
2460+ &::-moz-range-track {
2461+ width: 100%;
2462+ background-color: var(--range-bg);
2463+ border-radius: var(--radius-selector);
2464+ height: calc(var(--range-thumb-size) * 0.5);
2465+ }
2466+ &::-moz-range-thumb {
2467+ position: relative;
2468+ box-sizing: border-box;
2469+ border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
2470+ background-color: currentColor;
2471+ height: var(--range-thumb-size);
2472+ width: var(--range-thumb-size);
2473+ border: var(--range-p) solid;
2474+ top: 50%;
2475+ color: var(--range-progress);
2476+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.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));
2477+ }
2478+ &:disabled {
2479+ cursor: not-allowed;
2480+ opacity: 30%;
2481+ }
2482+ }
2483+ .countdown {
2484+ display: inline-flex;
2485+ &.countdown {
2486+ line-height: 1em;
2487+ }
2488+ & > * {
2489+ display: inline-block;
2490+ overflow-y: hidden;
2491+ height: 1em;
2492+ &:before {
2493+ position: relative;
2494+ content: "00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";
2495+ white-space: pre;
2496+ top: calc(var(--value) * -1em);
2497+ text-align: center;
2498+ transition: all 1s cubic-bezier(1, 0, 0, 1);
2499+ }
2500+ }
2501+ }
2502+ .tabs-border {
2503+ .tab {
2504+ --tab-border-color: #0000 #0000 var(--tab-border-color) #0000;
2505+ position: relative;
2506+ border-radius: var(--radius-field);
2507+ &:before {
2508+ --tw-content: "";
2509+ content: var(--tw-content);
2510+ background-color: var(--tab-border-color);
2511+ transition: background-color 0.2s ease;
2512+ width: 80%;
2513+ height: 3px;
2514+ border-radius: var(--radius-field);
2515+ bottom: 0;
2516+ left: 10%;
2517+ position: absolute;
2518+ }
2519+ &:is(.tab-active, [aria-selected="true"]):not(.tab-disabled, [disabled]), &:is(input:checked), &:is(label:has(:checked)) {
2520+ &:before {
2521+ --tab-border-color: currentColor;
2522+ border-top: 3px solid;
2523+ }
2524+ }
2525+ }
2526+ }
2527+ .chat-bubble {
2528+ position: relative;
2529+ display: block;
2530+ width: fit-content;
2531+ border-radius: var(--radius-field);
2532+ background-color: var(--color-base-300);
2533+ padding-inline: calc(0.25rem * 4);
2534+ padding-block: calc(0.25rem * 2);
2535+ color: var(--color-base-content);
2536+ grid-row-end: 3;
2537+ min-height: 2rem;
2538+ min-width: 2.5rem;
2539+ max-width: 90%;
2540+ &:before {
2541+ position: absolute;
2542+ bottom: calc(0.25rem * 0);
2543+ height: calc(0.25rem * 3);
2544+ width: calc(0.25rem * 3);
2545+ background-color: inherit;
2546+ content: "";
2547+ mask-repeat: no-repeat;
2548+ mask-image: var(--mask-chat);
2549+ mask-position: 0px -1px;
2550+ mask-size: 13px;
2551+ }
2552+ }
2553+ .timeline {
2554+ position: relative;
2555+ display: flex;
2556+ > li {
2557+ position: relative;
2558+ display: grid;
2559+ flex-shrink: 0;
2560+ align-items: center;
2561+ grid-template-rows: var(--timeline-row-start, minmax(0, 1fr)) auto var( --timeline-row-end, minmax(0, 1fr) );
2562+ grid-template-columns: var(--timeline-col-start, minmax(0, 1fr)) auto var( --timeline-col-end, minmax(0, 1fr) );
2563+ > hr {
2564+ border: none;
2565+ width: 100%;
2566+ &:first-child {
2567+ grid-column-start: 1;
2568+ grid-row-start: 2;
2569+ }
2570+ &:last-child {
2571+ grid-column-start: 3;
2572+ grid-column-end: none;
2573+ grid-row-start: 2;
2574+ grid-row-end: auto;
2575+ }
2576+ @media print {
2577+ border: 0.1px solid var(--color-base-300);
2578+ }
2579+ }
2580+ }
2581+ :where(hr) {
2582+ height: calc(0.25rem * 1);
2583+ background-color: var(--color-base-300);
2584+ }
2585+ &:has(.timeline-middle hr) {
2586+ &:first-child {
2587+ border-start-start-radius: 0;
2588+ border-end-start-radius: 0;
2589+ border-start-end-radius: var(--radius-selector);
2590+ border-end-end-radius: var(--radius-selector);
2591+ }
2592+ &:last-child {
2593+ border-start-start-radius: var(--radius-selector);
2594+ border-end-start-radius: var(--radius-selector);
2595+ border-start-end-radius: 0;
2596+ border-end-end-radius: 0;
2597+ }
2598+ }
2599+ &:not(:has(.timeline-middle)) {
2600+ :first-child hr:last-child {
2601+ border-start-start-radius: var(--radius-selector);
2602+ border-end-start-radius: var(--radius-selector);
2603+ border-start-end-radius: 0;
2604+ border-end-end-radius: 0;
2605+ }
2606+ :last-child hr:first-child {
2607+ border-start-start-radius: 0;
2608+ border-end-start-radius: 0;
2609+ border-start-end-radius: var(--radius-selector);
2610+ border-end-end-radius: var(--radius-selector);
2611+ }
2612+ }
2613+ }
2614+ .card {
2615+ position: relative;
2616+ display: flex;
2617+ flex-direction: column;
2618+ border-radius: var(--radius-box);
2619+ outline-width: 2px;
2620+ transition: outline 0.2s ease-in-out;
2621+ outline: 0 solid #0000;
2622+ outline-offset: 2px;
2623+ &:focus {
2624+ --tw-outline-style: none;
2625+ outline-style: none;
2626+ @media (forced-colors: active) {
2627+ outline: 2px solid transparent;
2628+ outline-offset: 2px;
2629+ }
2630+ }
2631+ &:focus-visible {
2632+ outline-color: currentColor;
2633+ }
2634+ :where(figure:first-child) {
2635+ overflow: hidden;
2636+ border-start-start-radius: inherit;
2637+ border-start-end-radius: inherit;
2638+ border-end-start-radius: unset;
2639+ border-end-end-radius: unset;
2640+ }
2641+ :where(figure:last-child) {
2642+ overflow: hidden;
2643+ border-start-start-radius: unset;
2644+ border-start-end-radius: unset;
2645+ border-end-start-radius: inherit;
2646+ border-end-end-radius: inherit;
2647+ }
2648+ &:where(.card-border) {
2649+ border: var(--border) solid var(--color-base-200);
2650+ }
2651+ &:where(.card-dash) {
2652+ border: var(--border) dashed var(--color-base-200);
2653+ }
2654+ &.image-full {
2655+ display: grid;
2656+ &:before {
2657+ position: relative;
2658+ grid-column-start: 1;
2659+ grid-row-start: 1;
2660+ border-radius: var(--radius-box);
2661+ background-color: var(--color-neutral);
2662+ opacity: 75%;
2663+ content: "";
2664+ }
2665+ > * {
2666+ grid-column-start: 1;
2667+ grid-row-start: 1;
2668+ }
2669+ > .card-body {
2670+ position: relative;
2671+ color: var(--color-neutral-content);
2672+ }
2673+ :where(figure) {
2674+ overflow: hidden;
2675+ border-radius: inherit;
2676+ }
2677+ > figure img {
2678+ height: 100%;
2679+ object-fit: cover;
2680+ }
2681+ }
2682+ figure {
2683+ display: flex;
2684+ align-items: center;
2685+ justify-content: center;
2686+ }
2687+ &:has(> input:is(input[type="checkbox"], input[type="radio"])) {
2688+ cursor: pointer;
2689+ user-select: none;
2690+ }
2691+ &:has(> :checked) {
2692+ outline: 2px solid currentColor;
2693+ }
2694+ }
2695+ .swap {
2696+ position: relative;
2697+ display: inline-grid;
2698+ cursor: pointer;
2699+ place-content: center;
2700+ vertical-align: middle;
2701+ webkit-user-select: none;
2702+ user-select: none;
2703+ input {
2704+ appearance: none;
2705+ border: none;
2706+ }
2707+ > * {
2708+ grid-column-start: 1;
2709+ grid-row-start: 1;
2710+ transition-property: transform, rotate, opacity;
2711+ transition-duration: 0.2s;
2712+ transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
2713+ }
2714+ .swap-on, .swap-indeterminate, input:indeterminate ~ .swap-on {
2715+ opacity: 0%;
2716+ }
2717+ input:is(:checked, :indeterminate) {
2718+ & ~ .swap-off {
2719+ opacity: 0%;
2720+ }
2721+ }
2722+ input:checked ~ .swap-on, input:indeterminate ~ .swap-indeterminate {
2723+ opacity: 100%;
2724+ }
2725+ }
2726+ .collapse-title {
2727+ grid-column-start: 1;
2728+ grid-row-start: 1;
2729+ position: relative;
2730+ width: 100%;
2731+ padding: 1rem;
2732+ padding-inline-end: 3rem;
2733+ min-height: 3.75rem;
2734+ transition: background-color 0.2s ease-out;
2735+ }
2736+ .mockup-browser {
2737+ position: relative;
2738+ overflow: hidden;
2739+ overflow-x: auto;
2740+ border-radius: var(--radius-box);
2741+ pre[data-prefix] {
2742+ &:before {
2743+ content: attr(data-prefix);
2744+ display: inline-block;
2745+ text-align: right;
2746+ }
2747+ }
2748+ .mockup-browser-toolbar {
2749+ margin-block: calc(0.25rem * 3);
2750+ display: inline-flex;
2751+ width: 100%;
2752+ align-items: center;
2753+ padding-right: 1.4em;
2754+ &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
2755+ flex-direction: row-reverse;
2756+ }
2757+ &:before {
2758+ content: "";
2759+ margin-right: 4.8rem;
2760+ display: inline-block;
2761+ aspect-ratio: 1 / 1;
2762+ height: calc(0.25rem * 3);
2763+ border-radius: calc(infinity * 1px);
2764+ opacity: 30%;
2765+ box-shadow: 1.4em 0, 2.8em 0, 4.2em 0;
2766+ }
2767+ .input {
2768+ margin-inline: auto;
2769+ display: flex;
2770+ height: 100%;
2771+ align-items: center;
2772+ gap: calc(0.25rem * 2);
2773+ overflow: hidden;
2774+ background-color: var(--color-base-200);
2775+ text-overflow: ellipsis;
2776+ white-space: nowrap;
2777+ font-size: 0.75rem;
2778+ direction: ltr;
2779+ &:before {
2780+ content: "";
2781+ width: calc(0.25rem * 4);
2782+ height: calc(0.25rem * 4);
2783+ opacity: 30%;
2784+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='currentColor' class='size-4'%3E%3Cpath fill-rule='evenodd' d='M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z' clip-rule='evenodd' /%3E%3C/svg%3E%0A");
2785+ }
2786+ }
2787+ }
2788+ }
2789+ .select {
2790+ border: var(--border) solid #0000;
2791+ position: relative;
2792+ display: inline-flex;
2793+ flex-shrink: 1;
2794+ appearance: none;
2795+ align-items: center;
2796+ gap: calc(0.25rem * 1.5);
2797+ background-color: var(--color-base-100);
2798+ padding-inline-start: calc(0.25rem * 4);
2799+ padding-inline-end: calc(0.25rem * 7);
2800+ vertical-align: middle;
2801+ width: clamp(3rem, 20rem, 100%);
2802+ height: var(--size);
2803+ font-size: 0.875rem;
2804+ border-start-start-radius: var(--join-ss, var(--radius-field));
2805+ border-start-end-radius: var(--join-se, var(--radius-field));
2806+ border-end-start-radius: var(--join-es, var(--radius-field));
2807+ border-end-end-radius: var(--join-ee, var(--radius-field));
2808+ background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%);
2809+ background-position: calc(100% - 20px) calc(1px + 50%), calc(100% - 16.1px) calc(1px + 50%);
2810+ background-size: 4px 4px, 4px 4px;
2811+ background-repeat: no-repeat;
2812+ text-overflow: ellipsis;
2813+ 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) * 0.1)) inset;
2814+ border-color: var(--input-color);
2815+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
2816+ --size: calc(var(--size-field, 0.25rem) * 10);
2817+ [dir="rtl"] & {
2818+ background-position: calc(0% + 12px) calc(1px + 50%), calc(0% + 16px) calc(1px + 50%);
2819+ }
2820+ select {
2821+ margin-inline-start: calc(0.25rem * -4);
2822+ margin-inline-end: calc(0.25rem * -7);
2823+ width: calc(100% + 2.75rem);
2824+ appearance: none;
2825+ padding-inline-start: calc(0.25rem * 4);
2826+ padding-inline-end: calc(0.25rem * 7);
2827+ height: calc(100% - 2px);
2828+ background: inherit;
2829+ border-radius: inherit;
2830+ border-style: none;
2831+ &:focus, &:focus-within {
2832+ --tw-outline-style: none;
2833+ outline-style: none;
2834+ @media (forced-colors: active) {
2835+ outline: 2px solid transparent;
2836+ outline-offset: 2px;
2837+ }
2838+ }
2839+ &:not(:last-child) {
2840+ margin-inline-end: calc(0.25rem * -5.5);
2841+ background-image: none;
2842+ }
2843+ }
2844+ &:focus, &:focus-within {
2845+ --input-color: var(--color-base-content);
2846+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
2847+ outline: 2px solid var(--input-color);
2848+ outline-offset: 2px;
2849+ }
2850+ &:has(> select[disabled]), &:is(:disabled, [disabled]) {
2851+ cursor: not-allowed;
2852+ border-color: var(--color-base-200);
2853+ background-color: var(--color-base-200);
2854+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
2855+ &::placeholder {
2856+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
2857+ }
2858+ }
2859+ &:has(> select[disabled]) > select[disabled] {
2860+ cursor: not-allowed;
2861+ }
2862+ }
2863+ .menu-horizontal {
2864+ display: inline-flex;
2865+ flex-direction: row;
2866+ & > li:not(.menu-title) > details > ul {
2867+ position: absolute;
2868+ margin-inline-start: calc(0.25rem * 0);
2869+ margin-top: calc(0.25rem * 4);
2870+ padding-block: calc(0.25rem * 2);
2871+ padding-inline-end: calc(0.25rem * 2);
2872+ }
2873+ & > li > details > ul {
2874+ &:before {
2875+ content: none;
2876+ }
2877+ }
2878+ :where(& > li:not(.menu-title) > details > ul) {
2879+ border-radius: var(--radius-box);
2880+ background-color: var(--color-base-100);
2881+ box-shadow: 0 1px 3px 0 oklch(0% 0 0/0.1), 0 1px 2px -1px oklch(0% 0 0/0.1);
2882+ }
2883+ }
2884+ .menu-vertical {
2885+ display: inline-flex;
2886+ flex-direction: column;
2887+ & > li:not(.menu-title) > details > ul {
2888+ position: relative;
2889+ margin-inline-start: calc(0.25rem * 4);
2890+ margin-top: calc(0.25rem * 0);
2891+ padding-block: calc(0.25rem * 0);
2892+ padding-inline-end: calc(0.25rem * 0);
2893+ }
2894+ }
2895+ .mockup-code {
2896+ position: relative;
2897+ overflow: hidden;
2898+ overflow-x: auto;
2899+ border-radius: var(--radius-box);
2900+ background-color: var(--color-neutral);
2901+ padding-block: calc(0.25rem * 5);
2902+ color: var(--color-neutral-content);
2903+ font-size: 0.875rem;
2904+ direction: ltr;
2905+ &:before {
2906+ content: "";
2907+ margin-bottom: calc(0.25rem * 4);
2908+ display: block;
2909+ height: calc(0.25rem * 3);
2910+ width: calc(0.25rem * 3);
2911+ border-radius: calc(infinity * 1px);
2912+ opacity: 30%;
2913+ box-shadow: 1.4em 0, 2.8em 0, 4.2em 0;
2914+ }
2915+ pre {
2916+ padding-right: calc(0.25rem * 5);
2917+ &:before {
2918+ content: "";
2919+ margin-right: 2ch;
2920+ }
2921+ &[data-prefix] {
2922+ &:before {
2923+ content: attr(data-prefix);
2924+ display: inline-block;
2925+ width: calc(0.25rem * 8);
2926+ text-align: right;
2927+ opacity: 50%;
2928+ }
2929+ }
2930+ }
2931+ }
2932+ .mockup-window {
2933+ position: relative;
2934+ display: flex;
2935+ flex-direction: column;
2936+ overflow: hidden;
2937+ overflow-x: auto;
2938+ border-radius: var(--radius-box);
2939+ padding-top: calc(0.25rem * 5);
2940+ &:before {
2941+ content: "";
2942+ margin-bottom: calc(0.25rem * 4);
2943+ display: block;
2944+ aspect-ratio: 1 / 1;
2945+ height: calc(0.25rem * 3);
2946+ flex-shrink: 0;
2947+ align-self: flex-start;
2948+ border-radius: calc(infinity * 1px);
2949+ opacity: 30%;
2950+ box-shadow: 1.4em 0, 2.8em 0, 4.2em 0;
2951+ }
2952+ [dir="rtl"] &:before {
2953+ align-self: flex-end;
2954+ }
2955+ pre[data-prefix] {
2956+ &:before {
2957+ content: attr(data-prefix);
2958+ display: inline-block;
2959+ text-align: right;
2960+ }
2961+ }
2962+ }
2963+ .avatar {
2964+ position: relative;
2965+ display: inline-flex;
2966+ vertical-align: middle;
2967+ & > div {
2968+ display: block;
2969+ aspect-ratio: 1 / 1;
2970+ overflow: hidden;
2971+ }
2972+ img {
2973+ height: 100%;
2974+ width: 100%;
2975+ object-fit: cover;
2976+ }
2977+ }
2978+ .checkbox {
2979+ border: var(--border) solid var(--input-color, color-mix(in oklab, var(--color-base-content) 20%, #0000));
2980+ position: relative;
2981+ flex-shrink: 0;
2982+ cursor: pointer;
2983+ appearance: none;
2984+ border-radius: var(--radius-selector);
2985+ padding: calc(0.25rem * 1);
2986+ vertical-align: middle;
2987+ color: var(--color-base-content);
2988+ box-shadow: 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 0 #0000 inset, 0 0 #0000;
2989+ transition: background-color 0.2s, box-shadow 0.2s;
2990+ --size: calc(var(--size-selector, 0.25rem) * 6);
2991+ width: var(--size);
2992+ height: var(--size);
2993+ background-size: auto, calc(var(--noise) * 100%);
2994+ background-image: none, var(--fx-noise);
2995+ &:before {
2996+ --tw-content: "";
2997+ content: var(--tw-content);
2998+ display: block;
2999+ width: 100%;
3000+ height: 100%;
3001+ rotate: 45deg;
3002+ background-color: currentColor;
3003+ opacity: 0%;
3004+ transition: clip-path 0.3s, opacity 0.1s, rotate 0.3s, translate 0.3s;
3005+ transition-delay: 0.1s;
3006+ clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 70% 80%, 70% 100%);
3007+ box-shadow: 0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
3008+ font-size: 1rem;
3009+ line-height: 0.75;
3010+ }
3011+ &:focus-visible {
3012+ outline: 2px solid var(--input-color, currentColor);
3013+ outline-offset: 2px;
3014+ }
3015+ &:checked, &[aria-checked="true"] {
3016+ background-color: var(--input-color, #0000);
3017+ box-shadow: 0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1));
3018+ &:before {
3019+ clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 0%, 70% 0%, 70% 100%);
3020+ opacity: 100%;
3021+ }
3022+ @media (forced-colors: active) {
3023+ &:before {
3024+ rotate: 0deg;
3025+ background-color: transparent;
3026+ --tw-content: "✔︎";
3027+ clip-path: none;
3028+ }
3029+ }
3030+ @media print {
3031+ &:before {
3032+ rotate: 0deg;
3033+ background-color: transparent;
3034+ --tw-content: "✔︎";
3035+ clip-path: none;
3036+ }
3037+ }
3038+ }
3039+ &:indeterminate {
3040+ &:before {
3041+ rotate: 0deg;
3042+ opacity: 100%;
3043+ translate: 0 -35%;
3044+ clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 80% 80%, 80% 100%);
3045+ }
3046+ }
3047+ &:disabled {
3048+ cursor: not-allowed;
3049+ opacity: 20%;
3050+ }
3051+ }
3052+ .radio {
3053+ position: relative;
3054+ flex-shrink: 0;
3055+ cursor: pointer;
3056+ appearance: none;
3057+ border-radius: calc(infinity * 1px);
3058+ padding: calc(0.25rem * 1);
3059+ vertical-align: middle;
3060+ border: var(--border) solid var(--input-color, color-mix(in srgb, currentColor 20%, #0000));
3061+ box-shadow: 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset;
3062+ --size: calc(var(--size-selector, 0.25rem) * 6);
3063+ width: var(--size);
3064+ height: var(--size);
3065+ color: var(--input-color, currentColor);
3066+ &:before {
3067+ display: block;
3068+ width: 100%;
3069+ height: 100%;
3070+ border-radius: calc(infinity * 1px);
3071+ --tw-content: "";
3072+ content: var(--tw-content);
3073+ background-size: auto, calc(var(--noise) * 100%);
3074+ background-image: none, var(--fx-noise);
3075+ }
3076+ &:focus-visible {
3077+ outline: 2px solid currentColor;
3078+ }
3079+ &:checked, &[aria-checked="true"] {
3080+ animation: radio 0.2s ease-out;
3081+ border-color: currentColor;
3082+ background-color: var(--color-base-100);
3083+ &:before {
3084+ background-color: currentColor;
3085+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1));
3086+ }
3087+ @media (forced-colors: active) {
3088+ &:before {
3089+ outline-style: var(--tw-outline-style);
3090+ outline-width: 1px;
3091+ outline-offset: calc(1px * -1);
3092+ }
3093+ }
3094+ @media print {
3095+ &:before {
3096+ outline: 0.25rem solid;
3097+ outline-offset: -1rem;
3098+ }
3099+ }
3100+ }
3101+ &:disabled {
3102+ cursor: not-allowed;
3103+ opacity: 20%;
3104+ }
3105+ }
3106+ .rating {
3107+ position: relative;
3108+ display: inline-flex;
3109+ vertical-align: middle;
3110+ & input {
3111+ border: none;
3112+ appearance: none;
3113+ }
3114+ :where(*) {
3115+ animation: rating 0.25s ease-out;
3116+ height: calc(0.25rem * 6);
3117+ width: calc(0.25rem * 6);
3118+ border-radius: 0;
3119+ background-color: var(--color-base-content);
3120+ opacity: 20%;
3121+ &:is(input) {
3122+ cursor: pointer;
3123+ }
3124+ }
3125+ & .rating-hidden {
3126+ width: calc(0.25rem * 2);
3127+ background-color: transparent;
3128+ }
3129+ input[type="radio"]:checked {
3130+ background-image: none;
3131+ }
3132+ * {
3133+ &:checked, &[aria-checked="true"], &[aria-current="true"], &:has(~ *:checked, ~ *[aria-checked="true"], ~ *[aria-current="true"]) {
3134+ opacity: 100%;
3135+ }
3136+ &:focus-visible {
3137+ transition: scale 0.2s ease-out;
3138+ scale: 1.1;
3139+ }
3140+ }
3141+ & *:active:focus {
3142+ animation: none;
3143+ scale: 1.1;
3144+ }
3145+ &.rating-xs :where(*:not(.rating-hidden)) {
3146+ width: calc(0.25rem * 4);
3147+ height: calc(0.25rem * 4);
3148+ }
3149+ &.rating-sm :where(*:not(.rating-hidden)) {
3150+ width: calc(0.25rem * 5);
3151+ height: calc(0.25rem * 5);
3152+ }
3153+ &.rating-md :where(*:not(.rating-hidden)) {
3154+ width: calc(0.25rem * 6);
3155+ height: calc(0.25rem * 6);
3156+ }
3157+ &.rating-lg :where(*:not(.rating-hidden)) {
3158+ width: calc(0.25rem * 7);
3159+ height: calc(0.25rem * 7);
3160+ }
3161+ &.rating-xl :where(*:not(.rating-hidden)) {
3162+ width: calc(0.25rem * 8);
3163+ height: calc(0.25rem * 8);
3164+ }
3165+ }
3166+ .drawer {
3167+ position: relative;
3168+ display: grid;
3169+ width: 100%;
3170+ grid-auto-columns: max-content auto;
3171+ }
3172+ .stats {
3173+ position: relative;
3174+ display: inline-grid;
3175+ grid-auto-flow: column;
3176+ overflow-x: auto;
3177+ border-radius: var(--radius-box);
3178+ }
3179+ .progress {
3180+ position: relative;
3181+ height: calc(0.25rem * 2);
3182+ width: 100%;
3183+ appearance: none;
3184+ overflow: hidden;
3185+ border-radius: var(--radius-box);
3186+ background-color: color-mix(in oklab, currentColor 20%, transparent);
3187+ color: var(--color-base-content);
3188+ &:indeterminate {
3189+ background-image: repeating-linear-gradient( 90deg, currentColor -1%, currentColor 10%, #0000 10%, #0000 90% );
3190+ background-size: 200%;
3191+ background-position-x: 15%;
3192+ animation: progress 5s ease-in-out infinite;
3193+ @supports (-moz-appearance: none) {
3194+ &::-moz-progress-bar {
3195+ background-color: transparent;
3196+ background-image: repeating-linear-gradient( 90deg, currentColor -1%, currentColor 10%, #0000 10%, #0000 90% );
3197+ background-size: 200%;
3198+ background-position-x: 15%;
3199+ animation: progress 5s ease-in-out infinite;
3200+ }
3201+ }
3202+ }
3203+ @supports (-moz-appearance: none) {
3204+ &::-moz-progress-bar {
3205+ border-radius: var(--radius-box);
3206+ background-color: currentColor;
3207+ }
3208+ }
3209+ @supports (-webkit-appearance: none) {
3210+ &::-webkit-progress-bar {
3211+ border-radius: var(--radius-box);
3212+ background-color: transparent;
3213+ }
3214+ &::-webkit-progress-value {
3215+ border-radius: var(--radius-box);
3216+ background-color: currentColor;
3217+ }
3218+ }
3219+ }
3220+ .absolute {
3221+ position: absolute;
3222+ }
3223+ .fixed {
3224+ position: fixed;
3225+ }
3226+ .relative {
3227+ position: relative;
3228+ }
3229+ .static {
3230+ position: static;
3231+ }
3232+ .sticky {
3233+ position: sticky;
3234+ }
3235+ .tooltip-bottom {
3236+ > .tooltip-content, &[data-tip]:before {
3237+ transform: translateX(-50%) translateY(var(--tt-pos, -0.25rem));
3238+ inset: var(--tt-off) auto auto 50%;
3239+ }
3240+ &:after {
3241+ transform: translateX(-50%) translateY(var(--tt-pos, -0.25rem)) rotate(180deg);
3242+ inset: var(--tt-tail) auto auto 50%;
3243+ }
3244+ }
3245+ .tooltip-left {
3246+ > .tooltip-content, &[data-tip]:before {
3247+ transform: translateX(calc(var(--tt-pos, 0.25rem) - 0.25rem)) translateY(-50%);
3248+ inset: 50% var(--tt-off) auto auto;
3249+ }
3250+ &:after {
3251+ transform: translateX(var(--tt-pos, 0.25rem)) translateY(-50%) rotate(-90deg);
3252+ inset: 50% calc(var(--tt-tail) + 1px) auto auto;
3253+ }
3254+ }
3255+ .tooltip-right {
3256+ > .tooltip-content, &[data-tip]:before {
3257+ transform: translateX(calc(var(--tt-pos, -0.25rem) + 0.25rem)) translateY(-50%);
3258+ inset: 50% auto auto var(--tt-off);
3259+ }
3260+ &:after {
3261+ transform: translateX(var(--tt-pos, -0.25rem)) translateY(-50%) rotate(90deg);
3262+ inset: 50% auto auto calc(var(--tt-tail) + 1px);
3263+ }
3264+ }
3265+ .tooltip-top {
3266+ > .tooltip-content, &[data-tip]:before {
3267+ transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem));
3268+ inset: auto auto var(--tt-off) 50%;
3269+ }
3270+ &:after {
3271+ transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem));
3272+ inset: auto auto var(--tt-tail) 50%;
3273+ }
3274+ }
3275+ .dropdown-right {
3276+ --anchor-h: right;
3277+ --anchor-v: span-bottom;
3278+ .dropdown-content {
3279+ inset-inline-start: 100%;
3280+ top: calc(0.25rem * 0);
3281+ bottom: auto;
3282+ transform-origin: left;
3283+ }
3284+ }
3285+ .chat-end {
3286+ place-items: end;
3287+ grid-template-columns: 1fr auto;
3288+ .chat-header {
3289+ grid-column-start: 1;
3290+ }
3291+ .chat-footer {
3292+ grid-column-start: 1;
3293+ }
3294+ .chat-image {
3295+ grid-column-start: 2;
3296+ }
3297+ .chat-bubble {
3298+ grid-column-start: 1;
3299+ border-end-end-radius: 0;
3300+ &:before {
3301+ transform: rotateY(180deg);
3302+ inset-inline-start: 100%;
3303+ }
3304+ [dir="rtl"] &:before {
3305+ transform: rotateY(0deg);
3306+ }
3307+ }
3308+ }
3309+ .chat-start {
3310+ place-items: start;
3311+ grid-template-columns: auto 1fr;
3312+ .chat-header {
3313+ grid-column-start: 2;
3314+ }
3315+ .chat-footer {
3316+ grid-column-start: 2;
3317+ }
3318+ .chat-image {
3319+ grid-column-start: 1;
3320+ }
3321+ .chat-bubble {
3322+ grid-column-start: 2;
3323+ border-end-start-radius: 0;
3324+ &:before {
3325+ transform: rotateY(0deg);
3326+ inset-inline-start: -0.75rem;
3327+ }
3328+ [dir="rtl"] &:before {
3329+ transform: rotateY(180deg);
3330+ }
3331+ }
3332+ }
3333+ .dropdown-left {
3334+ --anchor-h: left;
3335+ --anchor-v: span-bottom;
3336+ .dropdown-content {
3337+ inset-inline-end: 100%;
3338+ top: calc(0.25rem * 0);
3339+ bottom: auto;
3340+ transform-origin: right;
3341+ }
3342+ }
3343+ .dropdown-center {
3344+ --anchor-h: center;
3345+ :where(.dropdown-content) {
3346+ inset-inline-end: calc(1/2 * 100%);
3347+ translate: 50% 0;
3348+ }
3349+ &.dropdown-left {
3350+ --anchor-h: left;
3351+ --anchor-v: center;
3352+ .dropdown-content {
3353+ top: auto;
3354+ bottom: calc(1/2 * 100%);
3355+ translate: 0 50%;
3356+ }
3357+ }
3358+ &.dropdown-right {
3359+ --anchor-h: right;
3360+ --anchor-v: center;
3361+ .dropdown-content {
3362+ top: auto;
3363+ bottom: calc(1/2 * 100%);
3364+ translate: 0 50%;
3365+ }
3366+ }
3367+ }
3368+ .dropdown-end {
3369+ --anchor-h: span-left;
3370+ :where(.dropdown-content) {
3371+ inset-inline-end: calc(0.25rem * 0);
3372+ translate: 0 0;
3373+ }
3374+ &.dropdown-left {
3375+ --anchor-h: left;
3376+ --anchor-v: span-top;
3377+ .dropdown-content {
3378+ top: auto;
3379+ bottom: calc(0.25rem * 0);
3380+ }
3381+ }
3382+ &.dropdown-right {
3383+ --anchor-h: right;
3384+ --anchor-v: span-top;
3385+ .dropdown-content {
3386+ top: auto;
3387+ bottom: calc(0.25rem * 0);
3388+ }
3389+ }
3390+ }
3391+ .dropdown-start {
3392+ --anchor-h: span-right;
3393+ :where(.dropdown-content) {
3394+ inset-inline-end: auto;
3395+ }
3396+ &.dropdown-left {
3397+ --anchor-h: left;
3398+ --anchor-v: span-bottom;
3399+ .dropdown-content {
3400+ top: calc(0.25rem * 0);
3401+ bottom: auto;
3402+ }
3403+ }
3404+ &.dropdown-right {
3405+ --anchor-h: right;
3406+ --anchor-v: span-bottom;
3407+ .dropdown-content {
3408+ top: calc(0.25rem * 0);
3409+ bottom: auto;
3410+ }
3411+ }
3412+ }
3413+ .dropdown-bottom {
3414+ --anchor-v: bottom;
3415+ .dropdown-content {
3416+ top: 100%;
3417+ bottom: auto;
3418+ transform-origin: top;
3419+ }
3420+ }
3421+ .dropdown-top {
3422+ --anchor-v: top;
3423+ .dropdown-content {
3424+ top: auto;
3425+ bottom: 100%;
3426+ transform-origin: bottom;
3427+ }
3428+ }
3429+ .top-0 {
3430+ top: calc(var(--spacing) * 0);
3431+ }
3432+ .top-4 {
3433+ top: calc(var(--spacing) * 4);
3434+ }
3435+ .right-4 {
3436+ right: calc(var(--spacing) * 4);
3437+ }
3438+ .dock-sm {
3439+ height: calc(0.25rem * 14);
3440+ height: 3.5rem;
3441+ height: calc(3.5rem + env(safe-area-inset-bottom));
3442+ .dock-active {
3443+ &:after {
3444+ bottom: -0.1rem;
3445+ }
3446+ }
3447+ .dock-label {
3448+ font-size: 0.625rem;
3449+ }
3450+ }
3451+ .dock-lg {
3452+ height: 4.5rem;
3453+ height: calc(4.5rem + env(safe-area-inset-bottom));
3454+ .dock-active {
3455+ &:after {
3456+ bottom: 0.4rem;
3457+ }
3458+ }
3459+ .dock-label {
3460+ font-size: 0.6875rem;
3461+ }
3462+ }
3463+ .dock-xl {
3464+ height: 5rem;
3465+ height: calc(5rem + env(safe-area-inset-bottom));
3466+ .dock-active {
3467+ &:after {
3468+ bottom: 0.4rem;
3469+ }
3470+ }
3471+ .dock-label {
3472+ font-size: 0.75rem;
3473+ }
3474+ }
3475+ .dock-xs {
3476+ height: 3rem;
3477+ height: calc(3rem + env(safe-area-inset-bottom));
3478+ .dock-active {
3479+ &:after {
3480+ bottom: -0.1rem;
3481+ }
3482+ }
3483+ .dock-label {
3484+ font-size: 0.625rem;
3485+ }
3486+ }
3487+ .file-input {
3488+ cursor: pointer;
3489+ cursor: pointer;
3490+ border: var(--border) solid #0000;
3491+ display: inline-flex;
3492+ appearance: none;
3493+ align-items: center;
3494+ background-color: var(--color-base-100);
3495+ vertical-align: middle;
3496+ webkit-user-select: none;
3497+ user-select: none;
3498+ width: clamp(3rem, 20rem, 100%);
3499+ height: var(--size);
3500+ padding-inline-end: 0.75rem;
3501+ font-size: 0.875rem;
3502+ line-height: 2;
3503+ border-start-start-radius: var(--join-ss, var(--radius-field));
3504+ border-start-end-radius: var(--join-se, var(--radius-field));
3505+ border-end-start-radius: var(--join-es, var(--radius-field));
3506+ border-end-end-radius: var(--join-ee, var(--radius-field));
3507+ border-color: var(--input-color);
3508+ 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) * 0.1)) inset;
3509+ --size: calc(var(--size-field, 0.25rem) * 10);
3510+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
3511+ &::file-selector-button {
3512+ margin-inline-end: calc(0.25rem * 4);
3513+ cursor: pointer;
3514+ padding-inline: calc(0.25rem * 4);
3515+ webkit-user-select: none;
3516+ user-select: none;
3517+ height: calc(100% + var(--border) * 2);
3518+ margin-block: calc(var(--border) * -1);
3519+ margin-inline-start: calc(var(--border) * -1);
3520+ font-size: 0.875rem;
3521+ color: var(--btn-fg);
3522+ border-width: var(--border);
3523+ border-style: solid;
3524+ border-color: var(--btn-border);
3525+ border-start-start-radius: calc(var(--join-ss, var(--radius-field) - var(--border)));
3526+ border-end-start-radius: calc(var(--join-es, var(--radius-field) - var(--border)));
3527+ font-weight: 600;
3528+ background-color: var(--btn-bg);
3529+ background-size: calc(var(--noise) * 100%);
3530+ background-image: var(--btn-noise);
3531+ text-shadow: 0 0.5px oklch(1 0 0 / calc(var(--depth) * 0.15));
3532+ box-shadow: 0 0.5px 0 0.5px color-mix( in oklab, color-mix(in oklab, white 30%, var(--btn-bg)) calc(var(--depth) * 20%), #0000 ) inset, var(--btn-shadow);
3533+ --size: calc(var(--size-field, 0.25rem) * 10);
3534+ --btn-bg: var(--btn-color, var(--color-base-200));
3535+ --btn-fg: var(--color-base-content);
3536+ --btn-border: color-mix(in oklab, var(--btn-bg), #000 5%);
3537+ --btn-shadow: 0 3px 2px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000),
3538+ 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000);
3539+ --btn-noise: var(--fx-noise);
3540+ }
3541+ &:focus {
3542+ --input-color: var(--color-base-content);
3543+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) 10%, #0000);
3544+ outline: 2px solid var(--input-color);
3545+ outline-offset: 2px;
3546+ isolation: isolate;
3547+ }
3548+ &:has(> input[disabled]), &:is(:disabled, [disabled]) {
3549+ cursor: not-allowed;
3550+ border-color: var(--color-base-200);
3551+ background-color: var(--color-base-200);
3552+ &::placeholder {
3553+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
3554+ }
3555+ box-shadow: none;
3556+ color: color-mix(in oklch, var(--color-base-content) 20%, #0000);
3557+ &::file-selector-button {
3558+ cursor: not-allowed;
3559+ border-color: var(--color-base-200);
3560+ background-color: var(--color-base-200);
3561+ --btn-border: #0000;
3562+ --btn-noise: none;
3563+ --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
3564+ }
3565+ }
3566+ }
3567+ .hero-content {
3568+ isolation: isolate;
3569+ display: flex;
3570+ max-width: 80rem;
3571+ align-items: center;
3572+ justify-content: center;
3573+ gap: calc(0.25rem * 4);
3574+ padding: calc(0.25rem * 4);
3575+ }
3576+ .textarea {
3577+ border: var(--border) solid #0000;
3578+ min-height: calc(0.25rem * 20);
3579+ flex-shrink: 1;
3580+ appearance: none;
3581+ border-radius: var(--radius-field);
3582+ background-color: var(--color-base-100);
3583+ padding-block: calc(0.25rem * 2);
3584+ vertical-align: middle;
3585+ width: clamp(3rem, 20rem, 100%);
3586+ padding-inline-start: 0.75rem;
3587+ padding-inline-end: 0.75rem;
3588+ font-size: 0.875rem;
3589+ border-color: var(--input-color);
3590+ 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) * 0.1)) inset;
3591+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
3592+ textarea {
3593+ appearance: none;
3594+ background-color: transparent;
3595+ border: none;
3596+ &:focus, &:focus-within {
3597+ --tw-outline-style: none;
3598+ outline-style: none;
3599+ @media (forced-colors: active) {
3600+ outline: 2px solid transparent;
3601+ outline-offset: 2px;
3602+ }
3603+ }
3604+ }
3605+ &:focus, &:focus-within {
3606+ --input-color: var(--color-base-content);
3607+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
3608+ outline: 2px solid var(--input-color);
3609+ outline-offset: 2px;
3610+ isolation: isolate;
3611+ }
3612+ &:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
3613+ cursor: not-allowed;
3614+ border-color: var(--color-base-200);
3615+ background-color: var(--color-base-200);
3616+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
3617+ &::placeholder {
3618+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
3619+ }
3620+ box-shadow: none;
3621+ }
3622+ &:has(> textarea[disabled]) > textarea[disabled] {
3623+ cursor: not-allowed;
3624+ }
3625+ }
3626+ .btn-active {
3627+ --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
3628+ --btn-shadow: 0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);
3629+ isolation: isolate;
3630+ }
3631+ .isolate {
3632+ isolation: isolate;
3633+ }
3634+ .mockup-phone-camera {
3635+ grid-column: 1/1;
3636+ grid-row: 1/1;
3637+ background: #000;
3638+ height: 32px;
3639+ width: 126px;
3640+ border-radius: 17px;
3641+ z-index: 1;
3642+ margin-top: 6px;
3643+ }
3644+ .stack {
3645+ display: inline-grid;
3646+ grid-template-columns: 3px 4px 1fr 4px 3px;
3647+ grid-template-rows: 3px 4px 1fr 4px 3px;
3648+ & > * {
3649+ height: 100%;
3650+ width: 100%;
3651+ &:nth-child(n + 2) {
3652+ width: 100%;
3653+ opacity: 70%;
3654+ }
3655+ &:nth-child(2) {
3656+ z-index: 2;
3657+ opacity: 90%;
3658+ }
3659+ &:nth-child(1) {
3660+ z-index: 3;
3661+ width: 100%;
3662+ }
3663+ }
3664+ &, &.stack-bottom {
3665+ > * {
3666+ grid-column: 3 / 4;
3667+ grid-row: 3 / 6;
3668+ &:nth-child(2) {
3669+ grid-column: 2 / 5;
3670+ grid-row: 2 / 5;
3671+ }
3672+ &:nth-child(1) {
3673+ grid-column: 1 / 6;
3674+ grid-row: 1 / 4;
3675+ }
3676+ }
3677+ }
3678+ &.stack-top {
3679+ > * {
3680+ grid-column: 3 / 4;
3681+ grid-row: 1 / 4;
3682+ &:nth-child(2) {
3683+ grid-column: 2 / 5;
3684+ grid-row: 2 / 5;
3685+ }
3686+ &:nth-child(1) {
3687+ grid-column: 1 / 6;
3688+ grid-row: 3 / 6;
3689+ }
3690+ }
3691+ }
3692+ &.stack-start {
3693+ > * {
3694+ grid-column: 1 / 4;
3695+ grid-row: 3 / 4;
3696+ &:nth-child(2) {
3697+ grid-column: 2 / 5;
3698+ grid-row: 2 / 5;
3699+ }
3700+ &:nth-child(1) {
3701+ grid-column: 3 / 6;
3702+ grid-row: 1 / 6;
3703+ }
3704+ }
3705+ }
3706+ &.stack-end {
3707+ > * {
3708+ grid-column: 3 / 6;
3709+ grid-row: 3 / 4;
3710+ &:nth-child(2) {
3711+ grid-column: 2 / 5;
3712+ grid-row: 2 / 5;
3713+ }
3714+ &:nth-child(1) {
3715+ grid-column: 1 / 4;
3716+ grid-row: 1 / 6;
3717+ }
3718+ }
3719+ }
3720+ }
3721+ .stack\! {
3722+ display: inline-grid !important;
3723+ grid-template-columns: 3px 4px 1fr 4px 3px !important;
3724+ grid-template-rows: 3px 4px 1fr 4px 3px !important;
3725+ & > * {
3726+ height: 100% !important;
3727+ width: 100% !important;
3728+ &:nth-child(n + 2) {
3729+ width: 100% !important;
3730+ opacity: 70% !important;
3731+ }
3732+ &:nth-child(2) {
3733+ z-index: 2 !important;
3734+ opacity: 90% !important;
3735+ }
3736+ &:nth-child(1) {
3737+ z-index: 3 !important;
3738+ width: 100% !important;
3739+ }
3740+ }
3741+ &, &.stack-bottom {
3742+ > * {
3743+ grid-column: 3 / 4 !important;
3744+ grid-row: 3 / 6 !important;
3745+ &:nth-child(2) {
3746+ grid-column: 2 / 5 !important;
3747+ grid-row: 2 / 5 !important;
3748+ }
3749+ &:nth-child(1) {
3750+ grid-column: 1 / 6 !important;
3751+ grid-row: 1 / 4 !important;
3752+ }
3753+ }
3754+ }
3755+ &.stack-top {
3756+ > * {
3757+ grid-column: 3 / 4 !important;
3758+ grid-row: 1 / 4 !important;
3759+ &:nth-child(2) {
3760+ grid-column: 2 / 5 !important;
3761+ grid-row: 2 / 5 !important;
3762+ }
3763+ &:nth-child(1) {
3764+ grid-column: 1 / 6 !important;
3765+ grid-row: 3 / 6 !important;
3766+ }
3767+ }
3768+ }
3769+ &.stack-start {
3770+ > * {
3771+ grid-column: 1 / 4 !important;
3772+ grid-row: 3 / 4 !important;
3773+ &:nth-child(2) {
3774+ grid-column: 2 / 5 !important;
3775+ grid-row: 2 / 5 !important;
3776+ }
3777+ &:nth-child(1) {
3778+ grid-column: 3 / 6 !important;
3779+ grid-row: 1 / 6 !important;
3780+ }
3781+ }
3782+ }
3783+ &.stack-end {
3784+ > * {
3785+ grid-column: 3 / 6 !important;
3786+ grid-row: 3 / 4 !important;
3787+ &:nth-child(2) {
3788+ grid-column: 2 / 5 !important;
3789+ grid-row: 2 / 5 !important;
3790+ }
3791+ &:nth-child(1) {
3792+ grid-column: 1 / 4 !important;
3793+ grid-row: 1 / 6 !important;
3794+ }
3795+ }
3796+ }
3797+ }
3798+ .modal-backdrop {
3799+ grid-column-start: 1;
3800+ grid-row-start: 1;
3801+ display: grid;
3802+ align-self: stretch;
3803+ justify-self: stretch;
3804+ color: transparent;
3805+ z-index: -1;
3806+ button {
3807+ cursor: pointer;
3808+ }
3809+ }
3810+ .z-50 {
3811+ z-index: 50;
3812+ }
3813+ .z-\[1\] {
3814+ z-index: 1;
3815+ }
3816+ .tab-content {
3817+ order: var(--tabcontent-order);
3818+ display: none;
3819+ border-color: transparent;
3820+ --tabcontent-radius-ss: 0;
3821+ --tabcontent-radius-se: 0;
3822+ --tabcontent-radius-es: 0;
3823+ --tabcontent-radius-ee: 0;
3824+ --tabcontent-order: 1;
3825+ width: 100%;
3826+ margin: var(--tabcontent-margin);
3827+ border-width: var(--border);
3828+ border-start-start-radius: var(--tabcontent-radius-ss);
3829+ border-start-end-radius: var(--tabcontent-radius-se);
3830+ border-end-start-radius: var(--tabcontent-radius-es);
3831+ border-end-end-radius: var(--tabcontent-radius-ee);
3832+ }
3833+ .mockup-phone-display {
3834+ grid-column: 1/1;
3835+ grid-row: 1/1;
3836+ overflow: hidden;
3837+ border-radius: 49px;
3838+ width: 390px;
3839+ height: 845px;
3840+ }
3841+ .timeline-end {
3842+ grid-column-start: 1;
3843+ grid-column-end: 4;
3844+ grid-row-start: 3;
3845+ grid-row-end: 4;
3846+ margin: calc(0.25rem * 1);
3847+ align-self: flex-start;
3848+ justify-self: center;
3849+ }
3850+ .timeline-start {
3851+ grid-column-start: 1;
3852+ grid-column-end: 4;
3853+ grid-row-start: 1;
3854+ grid-row-end: 2;
3855+ margin: calc(0.25rem * 1);
3856+ align-self: flex-end;
3857+ justify-self: center;
3858+ }
3859+ .timeline-horizontal {
3860+ flex-direction: row;
3861+ > li {
3862+ align-items: center;
3863+ > hr {
3864+ height: calc(0.25rem * 1);
3865+ width: 100%;
3866+ &:first-child {
3867+ grid-column-start: 1;
3868+ grid-row-start: 2;
3869+ }
3870+ &:last-child {
3871+ grid-column-start: 3;
3872+ grid-column-end: none;
3873+ grid-row-start: 2;
3874+ grid-row-end: auto;
3875+ }
3876+ }
3877+ }
3878+ .timeline-start {
3879+ grid-column-start: 1;
3880+ grid-column-end: 4;
3881+ grid-row-start: 1;
3882+ grid-row-end: 2;
3883+ align-self: flex-end;
3884+ justify-self: center;
3885+ }
3886+ .timeline-end {
3887+ grid-column-start: 1;
3888+ grid-column-end: 4;
3889+ grid-row-start: 3;
3890+ grid-row-end: 4;
3891+ align-self: flex-start;
3892+ justify-self: center;
3893+ }
3894+ &:has(.timeline-middle) {
3895+ > li {
3896+ > hr {
3897+ &:first-child {
3898+ border-start-start-radius: 0;
3899+ border-end-start-radius: 0;
3900+ border-start-end-radius: var(--radius-selector);
3901+ border-end-end-radius: var(--radius-selector);
3902+ }
3903+ &:last-child {
3904+ border-start-start-radius: var(--radius-selector);
3905+ border-end-start-radius: var(--radius-selector);
3906+ border-start-end-radius: 0;
3907+ border-end-end-radius: 0;
3908+ }
3909+ }
3910+ }
3911+ }
3912+ &:not(:has(.timeline-middle)) {
3913+ :first-child {
3914+ > hr:last-child {
3915+ border-start-start-radius: var(--radius-selector);
3916+ border-end-start-radius: var(--radius-selector);
3917+ border-start-end-radius: 0;
3918+ border-end-end-radius: 0;
3919+ }
3920+ }
3921+ :last-child {
3922+ > hr:first-child {
3923+ border-start-start-radius: 0;
3924+ border-end-start-radius: 0;
3925+ border-start-end-radius: var(--radius-selector);
3926+ border-end-end-radius: var(--radius-selector);
3927+ }
3928+ }
3929+ }
3930+ }
3931+ .timeline-vertical {
3932+ flex-direction: column;
3933+ > li {
3934+ justify-items: center;
3935+ --timeline-row-start: minmax(0, 1fr);
3936+ --timeline-row-end: minmax(0, 1fr);
3937+ > hr {
3938+ height: 100%;
3939+ width: calc(0.25rem * 1);
3940+ &:first-child {
3941+ grid-column-start: 2;
3942+ grid-row-start: 1;
3943+ }
3944+ &:last-child {
3945+ grid-column-start: 2;
3946+ grid-column-end: auto;
3947+ grid-row-start: 3;
3948+ grid-row-end: none;
3949+ }
3950+ }
3951+ }
3952+ .timeline-start {
3953+ grid-column-start: 1;
3954+ grid-column-end: 2;
3955+ grid-row-start: 1;
3956+ grid-row-end: 4;
3957+ align-self: center;
3958+ justify-self: flex-end;
3959+ }
3960+ .timeline-end {
3961+ grid-column-start: 3;
3962+ grid-column-end: 4;
3963+ grid-row-start: 1;
3964+ grid-row-end: 4;
3965+ align-self: center;
3966+ justify-self: flex-start;
3967+ }
3968+ &:has(.timeline-middle) {
3969+ > li {
3970+ > hr {
3971+ &:first-child {
3972+ border-top-left-radius: 0;
3973+ border-top-right-radius: 0;
3974+ border-bottom-right-radius: var(--radius-selector);
3975+ border-bottom-left-radius: var(--radius-selector);
3976+ }
3977+ &:last-child {
3978+ border-top-left-radius: var(--radius-selector);
3979+ border-top-right-radius: var(--radius-selector);
3980+ border-bottom-right-radius: 0;
3981+ border-bottom-left-radius: 0;
3982+ }
3983+ }
3984+ }
3985+ }
3986+ &:not(:has(.timeline-middle)) {
3987+ :first-child {
3988+ > hr:last-child {
3989+ border-top-left-radius: var(--radius-selector);
3990+ border-top-right-radius: var(--radius-selector);
3991+ border-bottom-right-radius: 0;
3992+ border-bottom-left-radius: 0;
3993+ }
3994+ }
3995+ :last-child {
3996+ > hr:first-child {
3997+ border-top-left-radius: 0;
3998+ border-top-right-radius: 0;
3999+ border-bottom-right-radius: var(--radius-selector);
4000+ border-bottom-left-radius: var(--radius-selector);
4001+ }
4002+ }
4003+ }
4004+ &.timeline-snap-icon {
4005+ > li {
4006+ --timeline-col-start: minmax(0, 1fr);
4007+ --timeline-row-start: 0.5rem;
4008+ }
4009+ }
4010+ }
4011+ .timeline-compact {
4012+ --timeline-row-start: 0;
4013+ .timeline-start {
4014+ grid-column-start: 1;
4015+ grid-column-end: 4;
4016+ grid-row-start: 3;
4017+ grid-row-end: 4;
4018+ align-self: flex-start;
4019+ justify-self: center;
4020+ }
4021+ li:has(.timeline-start) {
4022+ .timeline-end {
4023+ grid-column-start: none;
4024+ grid-row-start: auto;
4025+ }
4026+ }
4027+ &.timeline-vertical {
4028+ > li {
4029+ --timeline-col-start: 0;
4030+ }
4031+ .timeline-start {
4032+ grid-column-start: 3;
4033+ grid-column-end: 4;
4034+ grid-row-start: 1;
4035+ grid-row-end: 4;
4036+ align-self: center;
4037+ justify-self: flex-start;
4038+ }
4039+ li:has(.timeline-start) {
4040+ .timeline-end {
4041+ grid-column-start: auto;
4042+ grid-row-start: none;
4043+ }
4044+ }
4045+ }
4046+ }
4047+ .stat-figure {
4048+ grid-column-start: 2;
4049+ grid-row: span 3 / span 3;
4050+ grid-row-start: 1;
4051+ place-self: center;
4052+ justify-self: flex-end;
4053+ }
4054+ .hero {
4055+ display: grid;
4056+ width: 100%;
4057+ place-items: center;
4058+ background-size: cover;
4059+ background-position: center;
4060+ & > * {
4061+ grid-column-start: 1;
4062+ grid-row-start: 1;
4063+ }
4064+ }
4065+ .hero-overlay {
4066+ grid-column-start: 1;
4067+ grid-row-start: 1;
4068+ height: 100%;
4069+ width: 100%;
4070+ background-color: color-mix(in oklab, var(--color-neutral) 50%, transparent);
4071+ }
4072+ .modal-box {
4073+ grid-column-start: 1;
4074+ grid-row-start: 1;
4075+ max-height: 100vh;
4076+ width: calc(11/12 * 100%);
4077+ max-width: 32rem;
4078+ background-color: var(--color-base-100);
4079+ padding: calc(0.25rem * 6);
4080+ transition: translate 0.3s ease-out, scale 0.3s ease-out, opacity 0.2s ease-out 0.05s, box-shadow 0.3s ease-out;
4081+ border-top-left-radius: var(--modal-tl, var(--radius-box));
4082+ border-top-right-radius: var(--modal-tr, var(--radius-box));
4083+ border-bottom-left-radius: var(--modal-bl, var(--radius-box));
4084+ border-bottom-right-radius: var(--modal-br, var(--radius-box));
4085+ scale: 95%;
4086+ opacity: 0;
4087+ box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px;
4088+ overflow-y: auto;
4089+ overscroll-behavior: contain;
4090+ }
4091+ .drawer-content {
4092+ grid-column-start: 2;
4093+ grid-row-start: 1;
4094+ min-width: calc(0.25rem * 0);
4095+ }
4096+ .timeline-middle {
4097+ grid-column-start: 2;
4098+ grid-row-start: 2;
4099+ }
4100+ .drawer-end {
4101+ grid-auto-columns: auto max-content;
4102+ > .drawer-toggle {
4103+ & ~ .drawer-content {
4104+ grid-column-start: 1;
4105+ }
4106+ & ~ .drawer-side {
4107+ grid-column-start: 2;
4108+ justify-items: end;
4109+ }
4110+ & ~ .drawer-side > *:not(.drawer-overlay) {
4111+ translate: 100%;
4112+ [dir="rtl"] & {
4113+ translate: -100%;
4114+ }
4115+ }
4116+ &:checked ~ .drawer-side > *:not(.drawer-overlay) {
4117+ translate: 0%;
4118+ }
4119+ }
4120+ }
4121+ .stat-value {
4122+ grid-column-start: 1;
4123+ white-space: nowrap;
4124+ font-size: 2rem;
4125+ font-weight: 800;
4126+ }
4127+ .stat-desc {
4128+ grid-column-start: 1;
4129+ white-space: nowrap;
4130+ color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
4131+ font-size: 0.75rem;
4132+ }
4133+ .stat-title {
4134+ grid-column-start: 1;
4135+ white-space: nowrap;
4136+ color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
4137+ font-size: 0.75rem;
4138+ }
4139+ .stat-actions {
4140+ grid-column-start: 1;
4141+ white-space: nowrap;
4142+ }
4143+ .chat-image {
4144+ grid-row: span 2 / span 2;
4145+ align-self: flex-end;
4146+ }
4147+ .chat-footer {
4148+ grid-row-start: 3;
4149+ display: flex;
4150+ gap: calc(0.25rem * 1);
4151+ font-size: 0.6875rem;
4152+ }
4153+ .chat-header {
4154+ grid-row-start: 1;
4155+ display: flex;
4156+ gap: calc(0.25rem * 1);
4157+ font-size: 0.6875rem;
4158+ }
4159+ .list-col-wrap {
4160+ grid-row-start: 2;
4161+ }
5694162 .container {
570- max-width: 640px;
4163+ width: 100%;
4164+ @media (width >= 40rem) {
4165+ max-width: 40rem;
4166+ }
4167+ @media (width >= 48rem) {
4168+ max-width: 48rem;
4169+ }
4170+ @media (width >= 64rem) {
4171+ max-width: 64rem;
4172+ }
4173+ @media (width >= 80rem) {
4174+ max-width: 80rem;
4175+ }
4176+ @media (width >= 96rem) {
4177+ max-width: 96rem;
4178+ }
4179+ }
4180+ .divider {
4181+ display: flex;
4182+ height: calc(0.25rem * 4);
4183+ flex-direction: row;
4184+ align-items: center;
4185+ align-self: stretch;
4186+ white-space: nowrap;
4187+ margin: var(--divider-m, 1rem 0);
4188+ &:before, &:after {
4189+ content: "";
4190+ height: calc(0.25rem * 0.5);
4191+ width: 100%;
4192+ flex-grow: 1;
4193+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
4194+ }
4195+ @media print {
4196+ &:before, &:after {
4197+ border: 0.5px solid;
4198+ }
4199+ }
4200+ &:not(:empty) {
4201+ gap: calc(0.25rem * 4);
4202+ }
4203+ }
4204+ .filter {
4205+ display: flex;
4206+ flex-wrap: wrap;
4207+ input[type="radio"] {
4208+ width: auto;
4209+ }
4210+ input {
4211+ overflow: hidden;
4212+ opacity: 100%;
4213+ scale: 1;
4214+ transition: margin 0.1s, opacity 0.3s, padding 0.3s, border-width 0.1s;
4215+ &:not(:last-child) {
4216+ margin-inline-end: calc(0.25rem * 1);
4217+ }
4218+ &.filter-reset {
4219+ aspect-ratio: 1 / 1;
4220+ &::after {
4221+ content: "×";
4222+ }
4223+ }
4224+ }
4225+ &:not(:has(input:checked:not(.filter-reset))) {
4226+ .filter-reset, input[type="reset"] {
4227+ scale: 0;
4228+ border-width: 0;
4229+ margin-inline: calc(0.25rem * 0);
4230+ width: calc(0.25rem * 0);
4231+ padding-inline: calc(0.25rem * 0);
4232+ opacity: 0%;
4233+ }
4234+ }
4235+ &:has(input:checked:not(.filter-reset)) {
4236+ input:not(:checked, .filter-reset, input[type="reset"]) {
4237+ scale: 0;
4238+ border-width: 0;
4239+ margin-inline: calc(0.25rem * 0);
4240+ width: calc(0.25rem * 0);
4241+ padding-inline: calc(0.25rem * 0);
4242+ opacity: 0%;
4243+ }
4244+ }
4245+ }
4246+ .mx-auto {
4247+ margin-inline: auto;
4248+ }
4249+ .file-input-ghost {
4250+ background-color: transparent;
4251+ transition: background-color 0.2s;
4252+ box-shadow: none;
4253+ border-color: #0000;
4254+ &::file-selector-button {
4255+ margin-inline-start: calc(0.25rem * 0);
4256+ margin-inline-end: calc(0.25rem * 4);
4257+ height: 100%;
4258+ cursor: pointer;
4259+ padding-inline: calc(0.25rem * 4);
4260+ webkit-user-select: none;
4261+ user-select: none;
4262+ margin-block: 0;
4263+ border-start-end-radius: calc(var(--join-ss, var(--radius-field) - var(--border)));
4264+ border-end-end-radius: calc(var(--join-es, var(--radius-field) - var(--border)));
4265+ }
4266+ &:focus, &:focus-within {
4267+ background-color: var(--color-base-100);
4268+ color: var(--color-base-content);
4269+ border-color: #0000;
4270+ box-shadow: none;
4271+ }
4272+ }
4273+ .input-lg {
4274+ --size: calc(var(--size-field, 0.25rem) * 12);
4275+ font-size: 1.125rem;
4276+ &[type="number"] {
4277+ &::-webkit-inner-spin-button {
4278+ margin-block: calc(0.25rem * -3);
4279+ margin-inline-end: calc(0.25rem * -3);
4280+ }
4281+ }
4282+ }
4283+ .input-md {
4284+ --size: calc(var(--size-field, 0.25rem) * 10);
4285+ font-size: 0.875rem;
4286+ &[type="number"] {
4287+ &::-webkit-inner-spin-button {
4288+ margin-block: calc(0.25rem * -3);
4289+ margin-inline-end: calc(0.25rem * -3);
4290+ }
4291+ }
4292+ }
4293+ .input-sm {
4294+ --size: calc(var(--size-field, 0.25rem) * 8);
4295+ font-size: 0.75rem;
4296+ &[type="number"] {
4297+ &::-webkit-inner-spin-button {
4298+ margin-block: calc(0.25rem * -2);
4299+ margin-inline-end: calc(0.25rem * -3);
4300+ }
4301+ }
4302+ }
4303+ .input-xl {
4304+ --size: calc(var(--size-field, 0.25rem) * 14);
4305+ font-size: 1.375rem;
4306+ &[type="number"] {
4307+ &::-webkit-inner-spin-button {
4308+ margin-block: calc(0.25rem * -4);
4309+ margin-inline-end: calc(0.25rem * -3);
4310+ }
4311+ }
4312+ }
4313+ .input-xs {
4314+ --size: calc(var(--size-field, 0.25rem) * 6);
4315+ font-size: 0.6875rem;
4316+ &[type="number"] {
4317+ &::-webkit-inner-spin-button {
4318+ margin-block: calc(0.25rem * -1);
4319+ margin-inline-end: calc(0.25rem * -3);
4320+ }
4321+ }
4322+ }
4323+ .my-4 {
4324+ margin-block: calc(var(--spacing) * 4);
4325+ }
4326+ .label {
4327+ display: inline-flex;
4328+ align-items: center;
4329+ gap: calc(0.25rem * 1.5);
4330+ white-space: nowrap;
4331+ color: color-mix(in oklab, currentColor 60%, transparent);
4332+ &:has(input) {
4333+ cursor: pointer;
4334+ }
4335+ &:is(.input > *, .select > *) {
4336+ display: flex;
4337+ height: calc(100% - 0.5rem);
4338+ align-items: center;
4339+ padding-inline: calc(0.25rem * 3);
4340+ white-space: nowrap;
4341+ font-size: inherit;
4342+ &:first-child {
4343+ margin-inline-start: calc(0.25rem * -3);
4344+ margin-inline-end: calc(0.25rem * 3);
4345+ border-inline-end: var(--border) solid color-mix(in oklab, currentColor 10%, #0000);
4346+ }
4347+ &:last-child {
4348+ margin-inline-start: calc(0.25rem * 3);
4349+ margin-inline-end: calc(0.25rem * -3);
4350+ border-inline-start: var(--border) solid color-mix(in oklab, currentColor 10%, #0000);
4351+ }
4352+ }
4353+ }
4354+ .steps-vertical {
4355+ grid-auto-rows: 1fr;
4356+ grid-auto-flow: row;
4357+ .step {
4358+ display: grid;
4359+ grid-template-columns: repeat(2, minmax(0, 1fr));
4360+ grid-template-columns: 40px 1fr;
4361+ grid-template-rows: repeat(1, minmax(0, 1fr));
4362+ grid-template-rows: auto;
4363+ gap: 0.5rem;
4364+ min-height: 4rem;
4365+ justify-items: start;
4366+ &:before {
4367+ height: 100%;
4368+ width: calc(0.25rem * 2);
4369+ translate: -50% -50%;
4370+ margin-inline-start: 50%;
4371+ }
4372+ [dir="rtl"] &:before {
4373+ translate: 50% -50%;
4374+ }
4375+ }
4376+ }
4377+ .steps-horizontal {
4378+ grid-auto-columns: 1fr;
4379+ display: inline-grid;
4380+ grid-auto-flow: column;
4381+ overflow: hidden;
4382+ overflow-x: auto;
4383+ .step {
4384+ display: grid;
4385+ grid-template-columns: repeat(1, minmax(0, 1fr));
4386+ grid-template-columns: auto;
4387+ grid-template-rows: repeat(2, minmax(0, 1fr));
4388+ grid-template-rows: 40px 1fr;
4389+ place-items: center;
4390+ text-align: center;
4391+ min-width: 4rem;
4392+ &:before {
4393+ height: calc(0.25rem * 2);
4394+ width: 100%;
4395+ translate: 0;
4396+ content: "";
4397+ margin-inline-start: -100%;
4398+ }
4399+ [dir="rtl"] &:before {
4400+ translate: 0;
4401+ }
4402+ }
4403+ }
4404+ .join-horizontal {
4405+ flex-direction: row;
4406+ > .join-item:first-child {
4407+ --join-ss: var(--radius-field);
4408+ --join-se: 0;
4409+ --join-es: var(--radius-field);
4410+ --join-ee: 0;
4411+ }
4412+ :first-child:not(:last-child) {
4413+ .join-item {
4414+ --join-ss: var(--radius-field);
4415+ --join-se: 0;
4416+ --join-es: var(--radius-field);
4417+ --join-ee: 0;
4418+ }
4419+ }
4420+ > .join-item:last-child {
4421+ --join-ss: 0;
4422+ --join-se: var(--radius-field);
4423+ --join-es: 0;
4424+ --join-ee: var(--radius-field);
4425+ }
4426+ :last-child:not(:first-child) {
4427+ .join-item {
4428+ --join-ss: 0;
4429+ --join-se: var(--radius-field);
4430+ --join-es: 0;
4431+ --join-ee: var(--radius-field);
4432+ }
4433+ }
4434+ > .join-item:only-child {
4435+ --join-ss: var(--radius-field);
4436+ --join-se: var(--radius-field);
4437+ --join-es: var(--radius-field);
4438+ --join-ee: var(--radius-field);
4439+ }
4440+ :only-child {
4441+ .join-item {
4442+ --join-ss: var(--radius-field);
4443+ --join-se: var(--radius-field);
4444+ --join-es: var(--radius-field);
4445+ --join-ee: var(--radius-field);
4446+ }
4447+ }
4448+ .join-item {
4449+ &:where(*:not(:first-child)) {
4450+ margin-inline-start: calc(var(--border, 1px) * -1);
4451+ margin-block-start: 0;
4452+ }
4453+ }
4454+ }
4455+ .join-vertical {
4456+ flex-direction: column;
4457+ > .join-item:first-child {
4458+ --join-ss: var(--radius-field);
4459+ --join-se: var(--radius-field);
4460+ --join-es: 0;
4461+ --join-ee: 0;
4462+ }
4463+ :first-child:not(:last-child) {
4464+ .join-item {
4465+ --join-ss: var(--radius-field);
4466+ --join-se: var(--radius-field);
4467+ --join-es: 0;
4468+ --join-ee: 0;
4469+ }
4470+ }
4471+ > .join-item:last-child {
4472+ --join-ss: 0;
4473+ --join-se: 0;
4474+ --join-es: var(--radius-field);
4475+ --join-ee: var(--radius-field);
4476+ }
4477+ :last-child:not(:first-child) {
4478+ .join-item {
4479+ --join-ss: 0;
4480+ --join-se: 0;
4481+ --join-es: var(--radius-field);
4482+ --join-ee: var(--radius-field);
4483+ }
4484+ }
4485+ > .join-item:only-child {
4486+ --join-ss: var(--radius-field);
4487+ --join-se: var(--radius-field);
4488+ --join-es: var(--radius-field);
4489+ --join-ee: var(--radius-field);
4490+ }
4491+ :only-child {
4492+ .join-item {
4493+ --join-ss: var(--radius-field);
4494+ --join-se: var(--radius-field);
4495+ --join-es: var(--radius-field);
4496+ --join-ee: var(--radius-field);
4497+ }
4498+ }
4499+ .join-item {
4500+ &:where(*:not(:first-child)) {
4501+ margin-inline-start: 0;
4502+ margin-block-start: calc(var(--border, 1px) * -1);
4503+ }
4504+ }
4505+ }
4506+ .join-item {
4507+ &:where(*:not(:first-child)) {
4508+ margin-inline-start: calc(var(--border, 1px) * -1);
4509+ margin-block-start: 0;
4510+ }
4511+ }
4512+ .modal-action {
4513+ margin-top: calc(0.25rem * 6);
4514+ display: flex;
4515+ justify-content: flex-end;
4516+ gap: calc(0.25rem * 2);
4517+ }
4518+ .validator-hint {
4519+ margin-top: calc(0.25rem * 2);
4520+ font-size: var(--text-xs);
4521+ line-height: var(--tw-leading, var(--text-xs--line-height));
4522+ }
4523+ .mt-2 {
4524+ margin-top: calc(var(--spacing) * 2);
4525+ }
4526+ .mt-4 {
4527+ margin-top: calc(var(--spacing) * 4);
4528+ }
4529+ .mt-8 {
4530+ margin-top: calc(var(--spacing) * 8);
4531+ }
4532+ .mt-16 {
4533+ margin-top: calc(var(--spacing) * 16);
4534+ }
4535+ .breadcrumbs {
4536+ max-width: 100%;
4537+ overflow-x: auto;
4538+ padding-block: calc(0.25rem * 2);
4539+ > menu, > ul, > ol {
4540+ display: flex;
4541+ min-height: min-content;
4542+ align-items: center;
4543+ white-space: nowrap;
4544+ > li {
4545+ display: flex;
4546+ align-items: center;
4547+ > * {
4548+ display: flex;
4549+ cursor: pointer;
4550+ align-items: center;
4551+ gap: calc(0.25rem * 2);
4552+ &:hover {
4553+ @media (hover: hover) {
4554+ text-decoration-line: underline;
4555+ }
4556+ }
4557+ &:focus {
4558+ --tw-outline-style: none;
4559+ outline-style: none;
4560+ @media (forced-colors: active) {
4561+ outline: 2px solid transparent;
4562+ outline-offset: 2px;
4563+ }
4564+ }
4565+ &:focus-visible {
4566+ outline: 2px solid currentColor;
4567+ outline-offset: 2px;
4568+ }
4569+ }
4570+ & + *:before {
4571+ content: "";
4572+ margin-right: calc(0.25rem * 3);
4573+ margin-left: calc(0.25rem * 2);
4574+ display: block;
4575+ height: calc(0.25rem * 1.5);
4576+ width: calc(0.25rem * 1.5);
4577+ opacity: 40%;
4578+ rotate: 45deg;
4579+ border-top: 1px solid;
4580+ border-right: 1px solid;
4581+ background-color: #0000;
4582+ }
4583+ [dir="rtl"] & + *:before {
4584+ rotate: -135deg;
4585+ }
4586+ }
4587+ }
4588+ }
4589+ .mr-1 {
4590+ margin-right: calc(var(--spacing) * 1);
4591+ }
4592+ .mr-2 {
4593+ margin-right: calc(var(--spacing) * 2);
4594+ }
4595+ .fieldset-legend {
4596+ margin-bottom: calc(0.25rem * -1);
4597+ display: flex;
4598+ align-items: center;
4599+ justify-content: space-between;
4600+ gap: calc(0.25rem * 2);
4601+ padding-block: calc(0.25rem * 2);
4602+ color: var(--color-base-content);
4603+ font-weight: 600;
4604+ }
4605+ .footer-title {
4606+ margin-bottom: calc(0.25rem * 2);
4607+ text-transform: uppercase;
4608+ opacity: 60%;
4609+ font-weight: 600;
4610+ }
4611+ .mb-2 {
4612+ margin-bottom: calc(var(--spacing) * 2);
4613+ }
4614+ .mb-4 {
4615+ margin-bottom: calc(var(--spacing) * 4);
4616+ }
4617+ .mb-6 {
4618+ margin-bottom: calc(var(--spacing) * 6);
4619+ }
4620+ .mb-8 {
4621+ margin-bottom: calc(var(--spacing) * 8);
4622+ }
4623+ .ml-2 {
4624+ margin-left: calc(var(--spacing) * 2);
4625+ }
4626+ .ml-6 {
4627+ margin-left: calc(var(--spacing) * 6);
4628+ }
4629+ .ml-10 {
4630+ margin-left: calc(var(--spacing) * 10);
4631+ }
4632+ .carousel-item {
4633+ box-sizing: content-box;
4634+ display: flex;
4635+ flex: none;
4636+ scroll-snap-align: start;
4637+ }
4638+ .status {
4639+ display: inline-block;
4640+ aspect-ratio: 1 / 1;
4641+ width: calc(0.25rem * 2);
4642+ height: calc(0.25rem * 2);
4643+ border-radius: var(--radius-selector);
4644+ background-color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
4645+ background-position: center;
4646+ background-repeat: no-repeat;
4647+ vertical-align: middle;
4648+ color: color-mix(in oklab, var(--color-black) 30%, transparent);
4649+ background-image: radial-gradient( circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * 0.5)), #0000 );
4650+ box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);
4651+ }
4652+ .badge {
4653+ display: inline-flex;
4654+ align-items: center;
4655+ justify-content: center;
4656+ gap: calc(0.25rem * 2);
4657+ border-radius: var(--radius-selector);
4658+ vertical-align: middle;
4659+ color: var(--badge-fg);
4660+ border: var(--border) solid var(--badge-color, var(--color-base-200));
4661+ font-size: 0.875rem;
4662+ width: fit-content;
4663+ padding-inline: calc(0.25rem * 3 - var(--border));
4664+ background-size: auto, calc(var(--noise) * 100%);
4665+ background-image: none, var(--fx-noise);
4666+ background-color: var(--badge-bg);
4667+ --badge-bg: var(--badge-color, var(--color-base-100));
4668+ --badge-fg: var(--color-base-content);
4669+ --size: calc(var(--size-selector, 0.25rem) * 6);
4670+ height: var(--size);
4671+ &.badge-outline {
4672+ --badge-fg: var(--badge-color);
4673+ --badge-bg: #0000;
4674+ background-image: none;
4675+ }
4676+ &.badge-dash {
4677+ --badge-fg: var(--badge-color);
4678+ --badge-bg: #0000;
4679+ border-style: dashed;
4680+ background-image: none;
4681+ }
4682+ &.badge-soft {
4683+ color: var(--badge-color, var(--color-base-content));
4684+ background-color: color-mix( in oklab, var(--badge-color, var(--color-base-content)) 8%, var(--color-base-100) );
4685+ border-color: color-mix( in oklab, var(--badge-color, var(--color-base-content)) 10%, var(--color-base-100) );
4686+ background-image: none;
4687+ }
4688+ }
4689+ .kbd {
4690+ display: inline-flex;
4691+ align-items: center;
4692+ justify-content: center;
4693+ border-radius: var(--radius-field);
4694+ background-color: var(--color-base-200);
4695+ vertical-align: middle;
4696+ padding-left: 0.5em;
4697+ padding-right: 0.5em;
4698+ border: var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);
4699+ border-bottom: calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);
4700+ --size: calc(var(--size-selector, 0.25rem) * 6);
4701+ font-size: 0.875rem;
4702+ height: var(--size);
4703+ min-width: var(--size);
4704+ }
4705+ .tabs {
4706+ display: flex;
4707+ flex-wrap: wrap;
4708+ --tabs-height: auto;
4709+ --tabs-direction: row;
4710+ height: var(--tabs-height);
4711+ flex-direction: var(--tabs-direction);
4712+ }
4713+ .navbar {
4714+ display: flex;
4715+ width: 100%;
4716+ align-items: center;
4717+ padding: 0.5rem;
4718+ min-height: 4rem;
4719+ }
4720+ .footer {
4721+ display: grid;
4722+ width: 100%;
4723+ grid-auto-flow: row;
4724+ place-items: start;
4725+ column-gap: calc(0.25rem * 4);
4726+ row-gap: calc(0.25rem * 10);
4727+ font-size: 0.875rem;
4728+ line-height: 1.25rem;
4729+ & > * {
4730+ display: grid;
4731+ place-items: start;
4732+ gap: calc(0.25rem * 2);
4733+ }
4734+ &.footer-center {
4735+ grid-auto-flow: column dense;
4736+ place-items: center;
4737+ text-align: center;
4738+ & > * {
4739+ place-items: center;
4740+ }
4741+ }
4742+ }
4743+ .stat {
4744+ display: inline-grid;
4745+ width: 100%;
4746+ column-gap: calc(0.25rem * 4);
4747+ padding-inline: calc(0.25rem * 6);
4748+ padding-block: calc(0.25rem * 4);
4749+ grid-template-columns: repeat(1, 1fr);
4750+ &:not(:last-child) {
4751+ border-inline-end: var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);
4752+ border-block-end: none;
4753+ }
4754+ }
4755+ .navbar-end {
4756+ display: inline-flex;
4757+ align-items: center;
4758+ width: 50%;
4759+ justify-content: flex-end;
4760+ }
4761+ .navbar-start {
4762+ display: inline-flex;
4763+ align-items: center;
4764+ width: 50%;
4765+ justify-content: flex-start;
4766+ }
4767+ .card-body {
4768+ display: flex;
4769+ flex: auto;
4770+ flex-direction: column;
4771+ gap: calc(0.25rem * 2);
4772+ padding: var(--card-p, 1.5rem);
4773+ font-size: var(--card-fs, 0.875rem);
4774+ :where(p) {
4775+ flex-grow: 1;
4776+ }
4777+ }
4778+ .navbar-center {
4779+ display: inline-flex;
4780+ align-items: center;
4781+ flex-shrink: 0;
4782+ }
4783+ .fieldset-label {
4784+ display: flex;
4785+ align-items: center;
4786+ gap: calc(0.25rem * 1.5);
4787+ color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
4788+ &:has(input) {
4789+ cursor: pointer;
4790+ }
4791+ }
4792+ .carousel {
4793+ display: inline-flex;
4794+ overflow-x: scroll;
4795+ scroll-snap-type: x mandatory;
4796+ scroll-behavior: smooth;
4797+ scrollbar-width: none;
4798+ &::-webkit-scrollbar {
4799+ display: none;
4800+ }
4801+ }
4802+ .alert {
4803+ display: grid;
4804+ align-items: center;
4805+ gap: calc(0.25rem * 4);
4806+ border-radius: var(--radius-box);
4807+ padding-inline: calc(0.25rem * 4);
4808+ padding-block: calc(0.25rem * 3);
4809+ color: var(--color-base-content);
4810+ background-color: var(--alert-color, var(--color-base-200));
4811+ justify-content: start;
4812+ justify-items: start;
4813+ grid-auto-flow: column;
4814+ grid-template-columns: auto minmax(auto, 1fr);
4815+ text-align: start;
4816+ border: var(--border) solid var(--color-base-200);
4817+ font-size: 0.875rem;
4818+ line-height: 1.25rem;
4819+ background-size: auto, calc(var(--noise) * 100%);
4820+ background-image: none, var(--fx-noise);
4821+ box-shadow: 0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * 0.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) * 0.08));
4822+ &.alert-outline {
4823+ background-color: transparent;
4824+ color: var(--alert-color);
4825+ box-shadow: none;
4826+ background-image: none;
4827+ }
4828+ &.alert-dash {
4829+ background-color: transparent;
4830+ color: var(--alert-color);
4831+ border-style: dashed;
4832+ box-shadow: none;
4833+ background-image: none;
4834+ }
4835+ &.alert-soft {
4836+ color: var(--alert-color, var(--color-base-content));
4837+ background: color-mix( in oklab, var(--alert-color, var(--color-base-content)) 8%, var(--color-base-100) );
4838+ border-color: color-mix( in oklab, var(--alert-color, var(--color-base-content)) 10%, var(--color-base-100) );
4839+ box-shadow: none;
4840+ background-image: none;
4841+ }
4842+ }
4843+ .fieldset {
4844+ display: grid;
4845+ gap: calc(0.25rem * 1.5);
4846+ padding-block: calc(0.25rem * 1);
4847+ font-size: 0.75rem;
4848+ grid-template-columns: 1fr;
4849+ grid-auto-rows: max-content;
4850+ }
4851+ .card-actions {
4852+ display: flex;
4853+ flex-wrap: wrap;
4854+ align-items: flex-start;
4855+ gap: calc(0.25rem * 2);
4856+ }
4857+ .avatar-placeholder {
4858+ & > div {
4859+ display: flex;
4860+ align-items: center;
4861+ justify-content: center;
4862+ }
4863+ }
4864+ .card-title {
4865+ display: flex;
4866+ align-items: center;
4867+ gap: calc(0.25rem * 2);
4868+ font-size: var(--cardtitle-fs, 1.125rem);
4869+ font-weight: 600;
4870+ }
4871+ .join {
4872+ display: inline-flex;
4873+ align-items: stretch;
4874+ --join-ss: 0;
4875+ --join-se: 0;
4876+ --join-es: 0;
4877+ --join-ee: 0;
4878+ :where(.join-item) {
4879+ border-start-start-radius: var(--join-ss, 0);
4880+ border-start-end-radius: var(--join-se, 0);
4881+ border-end-start-radius: var(--join-es, 0);
4882+ border-end-end-radius: var(--join-ee, 0);
4883+ * {
4884+ --join-ss: var(--radius-field);
4885+ --join-se: var(--radius-field);
4886+ --join-es: var(--radius-field);
4887+ --join-ee: var(--radius-field);
4888+ }
4889+ }
4890+ > .join-item:where(:first-child) {
4891+ --join-ss: var(--radius-field);
4892+ --join-se: 0;
4893+ --join-es: var(--radius-field);
4894+ --join-ee: 0;
4895+ }
4896+ :first-child:not(:last-child) {
4897+ :where(.join-item) {
4898+ --join-ss: var(--radius-field);
4899+ --join-se: 0;
4900+ --join-es: var(--radius-field);
4901+ --join-ee: 0;
4902+ }
4903+ }
4904+ > .join-item:where(:last-child) {
4905+ --join-ss: 0;
4906+ --join-se: var(--radius-field);
4907+ --join-es: 0;
4908+ --join-ee: var(--radius-field);
4909+ }
4910+ :last-child:not(:first-child) {
4911+ :where(.join-item) {
4912+ --join-ss: 0;
4913+ --join-se: var(--radius-field);
4914+ --join-es: 0;
4915+ --join-ee: var(--radius-field);
4916+ }
4917+ }
4918+ > .join-item:where(:only-child) {
4919+ --join-ss: var(--radius-field);
4920+ --join-se: var(--radius-field);
4921+ --join-es: var(--radius-field);
4922+ --join-ee: var(--radius-field);
4923+ }
4924+ :only-child {
4925+ :where(.join-item) {
4926+ --join-ss: var(--radius-field);
4927+ --join-se: var(--radius-field);
4928+ --join-es: var(--radius-field);
4929+ --join-ee: var(--radius-field);
4930+ }
4931+ }
4932+ }
4933+ .mockup-phone {
4934+ display: inline-grid;
4935+ justify-items: center;
4936+ border: 6px solid #6b6b6b;
4937+ border-radius: 65px;
4938+ background-color: #000;
4939+ padding: 11px;
4940+ overflow: hidden;
4941+ }
4942+ .chat {
4943+ display: grid;
4944+ column-gap: calc(0.25rem * 3);
4945+ padding-block: calc(0.25rem * 1);
4946+ }
4947+ .avatar-group {
4948+ display: flex;
4949+ overflow: hidden;
4950+ :where(.avatar) {
4951+ overflow: hidden;
4952+ border-radius: calc(infinity * 1px);
4953+ border: 4px solid var(--color-base-100);
4954+ }
4955+ }
4956+ .prose {
4957+ :root & {
4958+ --tw-prose-body: color-mix(in oklab, var(--color-base-content) 80%, #0000);
4959+ --tw-prose-headings: var(--color-base-content);
4960+ --tw-prose-lead: var(--color-base-content);
4961+ --tw-prose-links: var(--color-base-content);
4962+ --tw-prose-bold: var(--color-base-content);
4963+ --tw-prose-counters: var(--color-base-content);
4964+ --tw-prose-bullets: color-mix(in oklab, var(--color-base-content) 50%, #0000);
4965+ --tw-prose-hr: color-mix(in oklab, var(--color-base-content) 20%, #0000);
4966+ --tw-prose-quotes: var(--color-base-content);
4967+ --tw-prose-quote-borders: color-mix(in oklab, var(--color-base-content) 20%, #0000);
4968+ --tw-prose-captions: color-mix(in oklab, var(--color-base-content) 50%, #0000);
4969+ --tw-prose-code: var(--color-base-content);
4970+ --tw-prose-pre-code: var(--color-neutral-content);
4971+ --tw-prose-pre-bg: var(--color-neutral);
4972+ --tw-prose-th-borders: color-mix(in oklab, var(--color-base-content) 50%, #0000);
4973+ --tw-prose-td-borders: color-mix(in oklab, var(--color-base-content) 20%, #0000);
4974+ --tw-prose-kbd: color-mix(in oklab, var(--color-base-content) 80%, #0000);
4975+ :where(code):not(pre > code) {
4976+ background-color: var(--color-base-200);
4977+ border-radius: var(--radius-selector);
4978+ border: var(--border) solid var(--color-base-300);
4979+ padding-inline: 0.5em;
4980+ font-weight: inherit;
4981+ &:before,
4982+ &:after {
4983+ display: none;
4984+ }
4985+ }
4986+ }
4987+ }
4988+ .mask {
4989+ display: inline-block;
4990+ vertical-align: middle;
4991+ mask-size: contain;
4992+ mask-repeat: no-repeat;
4993+ mask-position: center;
4994+ }
4995+ .block {
4996+ display: block;
4997+ }
4998+ .contents {
4999+ display: contents;
5000+ }
5001+ .flex {
5002+ display: flex;
5003+ }
5004+ .grid {
5005+ display: grid;
5006+ }
5007+ .hidden {
5008+ display: none;
5009+ }
5010+ .inline {
5011+ display: inline;
5012+ }
5013+ .inline-block {
5014+ display: inline-block;
5015+ }
5016+ .inline-flex {
5017+ display: inline-flex;
5018+ }
5019+ .inline-grid {
5020+ display: inline-grid;
5021+ }
5022+ .table {
5023+ display: table;
5024+ }
5025+ .modal-bottom {
5026+ place-items: end;
5027+ :where(.modal-box) {
5028+ height: auto;
5029+ width: 100%;
5030+ max-width: none;
5031+ max-height: calc(100vh - 5em);
5032+ translate: 0 100%;
5033+ scale: 1;
5034+ --modal-tl: var(--radius-box);
5035+ --modal-tr: var(--radius-box);
5036+ --modal-bl: 0;
5037+ --modal-br: 0;
5038+ }
5039+ }
5040+ .modal-end {
5041+ place-items: end;
5042+ :where(.modal-box) {
5043+ height: 100vh;
5044+ max-height: none;
5045+ width: auto;
5046+ max-width: none;
5047+ translate: 100% 0;
5048+ scale: 1;
5049+ --modal-tl: var(--radius-box);
5050+ --modal-tr: 0;
5051+ --modal-bl: var(--radius-box);
5052+ --modal-br: 0;
5053+ }
5054+ }
5055+ .modal-middle {
5056+ place-items: center;
5057+ :where(.modal-box) {
5058+ height: auto;
5059+ width: calc(11/12 * 100%);
5060+ max-width: 32rem;
5061+ max-height: calc(100vh - 5em);
5062+ translate: 0 2%;
5063+ scale: 98%;
5064+ --modal-tl: var(--radius-box);
5065+ --modal-tr: var(--radius-box);
5066+ --modal-bl: var(--radius-box);
5067+ --modal-br: var(--radius-box);
5068+ }
5069+ }
5070+ .modal-start {
5071+ place-items: start;
5072+ :where(.modal-box) {
5073+ height: 100vh;
5074+ max-height: none;
5075+ width: auto;
5076+ max-width: none;
5077+ translate: -100% 0;
5078+ scale: 1;
5079+ --modal-tl: 0;
5080+ --modal-tr: var(--radius-box);
5081+ --modal-bl: 0;
5082+ --modal-br: var(--radius-box);
5083+ }
5084+ }
5085+ .modal-top {
5086+ place-items: start;
5087+ :where(.modal-box) {
5088+ height: auto;
5089+ width: 100%;
5090+ max-width: none;
5091+ max-height: calc(100vh - 5em);
5092+ translate: 0 -100%;
5093+ scale: 1;
5094+ --modal-tl: 0;
5095+ --modal-tr: 0;
5096+ --modal-bl: var(--radius-box);
5097+ --modal-br: var(--radius-box);
5098+ }
5099+ }
5100+ .card-side {
5101+ align-items: stretch;
5102+ flex-direction: row;
5103+ :where(figure:first-child) {
5104+ overflow: hidden;
5105+ border-start-start-radius: inherit;
5106+ border-start-end-radius: unset;
5107+ border-end-start-radius: inherit;
5108+ border-end-end-radius: unset;
5109+ }
5110+ :where(figure:last-child) {
5111+ overflow: hidden;
5112+ border-start-start-radius: unset;
5113+ border-start-end-radius: inherit;
5114+ border-end-start-radius: unset;
5115+ border-end-end-radius: inherit;
5116+ }
5117+ figure > * {
5118+ max-width: unset;
5119+ }
5120+ :where(figure > *) {
5121+ width: 100%;
5122+ height: 100%;
5123+ object-fit: cover;
5124+ }
5125+ }
5126+ .divider-horizontal {
5127+ --divider-m: 0 1rem;
5128+ &.divider {
5129+ height: auto;
5130+ width: calc(0.25rem * 4);
5131+ flex-direction: column;
5132+ &:before {
5133+ height: 100%;
5134+ width: calc(0.25rem * 0.5);
5135+ }
5136+ &:after {
5137+ height: 100%;
5138+ width: calc(0.25rem * 0.5);
5139+ }
5140+ }
5141+ }
5142+ .divider-vertical {
5143+ --divider-m: 1rem 0;
5144+ &.divider {
5145+ height: calc(0.25rem * 4);
5146+ width: auto;
5147+ flex-direction: row;
5148+ &:before {
5149+ height: calc(0.25rem * 0.5);
5150+ width: 100%;
5151+ }
5152+ &:after {
5153+ height: calc(0.25rem * 0.5);
5154+ width: 100%;
5155+ }
5156+ }
5157+ }
5158+ .btn-circle {
5159+ border-radius: calc(infinity * 1px);
5160+ padding-inline: calc(0.25rem * 0);
5161+ width: var(--size);
5162+ height: var(--size);
5163+ }
5164+ .btn-square {
5165+ padding-inline: calc(0.25rem * 0);
5166+ width: var(--size);
5167+ height: var(--size);
5168+ }
5169+ .size-4 {
5170+ width: calc(var(--spacing) * 4);
5171+ height: calc(var(--spacing) * 4);
5172+ }
5173+ .status-lg {
5174+ width: calc(0.25rem * 3);
5175+ height: calc(0.25rem * 3);
5176+ }
5177+ .status-md {
5178+ width: calc(0.25rem * 2);
5179+ height: calc(0.25rem * 2);
5180+ }
5181+ .status-sm {
5182+ width: calc(0.25rem * 1);
5183+ height: calc(0.25rem * 1);
5184+ }
5185+ .status-xl {
5186+ width: calc(0.25rem * 4);
5187+ height: calc(0.25rem * 4);
5188+ }
5189+ .status-xs {
5190+ width: calc(0.25rem * 0.5);
5191+ height: calc(0.25rem * 0.5);
5192+ }
5193+ .tabs-lg {
5194+ :where(.tab) {
5195+ height: calc(var(--size-field, 0.25rem) * 12);
5196+ font-size: 1.125rem;
5197+ --tab-p: 1rem;
5198+ --tab-radius-min: calc(1.5rem - var(--border));
5199+ }
5200+ }
5201+ .tabs-md {
5202+ :where(.tab) {
5203+ height: calc(var(--size-field, 0.25rem) * 10);
5204+ font-size: 0.875rem;
5205+ --tab-p: 0.75rem;
5206+ --tab-radius-min: calc(0.75rem - var(--border));
5207+ }
5208+ }
5209+ .tabs-sm {
5210+ :where(.tab) {
5211+ height: calc(var(--size-field, 0.25rem) * 8);
5212+ font-size: 0.875rem;
5213+ --tab-p: 0.5rem;
5214+ --tab-radius-min: calc(0.5rem - var(--border));
5215+ }
5216+ }
5217+ .tabs-xl {
5218+ :where(.tab) {
5219+ height: calc(var(--size-field, 0.25rem) * 14);
5220+ font-size: 1.125rem;
5221+ --tab-p: 1.25rem;
5222+ --tab-radius-min: calc(2rem - var(--border));
5223+ }
5224+ }
5225+ .tabs-xs {
5226+ :where(.tab) {
5227+ height: calc(var(--size-field, 0.25rem) * 6);
5228+ font-size: 0.75rem;
5229+ --tab-p: 0.375rem;
5230+ --tab-radius-min: calc(0.5rem - var(--border));
5231+ }
5232+ }
5233+ .dock-md {
5234+ height: 4rem;
5235+ height: calc(4rem + env(safe-area-inset-bottom));
5236+ .dock-label {
5237+ font-size: 0.6875rem;
5238+ }
5239+ }
5240+ .h-5 {
5241+ height: calc(var(--spacing) * 5);
5242+ }
5243+ .h-6 {
5244+ height: calc(var(--spacing) * 6);
5245+ }
5246+ .h-8 {
5247+ height: calc(var(--spacing) * 8);
5248+ }
5249+ .h-32 {
5250+ height: calc(var(--spacing) * 32);
5251+ }
5252+ .h-44 {
5253+ height: calc(var(--spacing) * 44);
5254+ }
5255+ .h-64 {
5256+ height: calc(var(--spacing) * 64);
5257+ }
5258+ .h-96 {
5259+ height: calc(var(--spacing) * 96);
5260+ }
5261+ .h-full {
5262+ height: 100%;
5263+ }
5264+ .min-h-screen {
5265+ min-height: 100vh;
5266+ }
5267+ .btn-wide {
5268+ width: 100%;
5269+ max-width: calc(0.25rem * 64);
5270+ }
5271+ .cally {
5272+ font-size: 0.7rem;
5273+ &::part(container) {
5274+ padding: 0.5rem 1rem;
5275+ user-select: none;
5276+ }
5277+ ::part(th) {
5278+ font-weight: normal;
5279+ block-size: auto;
5280+ }
5281+ &::part(header) {
5282+ direction: ltr;
5283+ }
5284+ ::part(head) {
5285+ opacity: 0.5;
5286+ font-size: 0.7rem;
5287+ }
5288+ &::part(button) {
5289+ border-radius: var(--radius-field);
5290+ border: none;
5291+ padding: 0.5rem;
5292+ background: #0000;
5293+ }
5294+ &::part(button):hover {
5295+ background: var(--color-base-200);
5296+ }
5297+ ::part(day) {
5298+ border-radius: var(--radius-field);
5299+ font-size: 0.7rem;
5300+ }
5301+ ::part(button day today) {
5302+ background: var(--color-primary);
5303+ color: var(--color-primary-content);
5304+ }
5305+ ::part(selected) {
5306+ color: var(--color-base-100);
5307+ background: var(--color-base-content);
5308+ border-radius: var(--radius-field);
5309+ }
5310+ ::part(range-inner) {
5311+ border-radius: 0;
5312+ }
5313+ ::part(range-start) {
5314+ border-start-end-radius: 0;
5315+ border-end-end-radius: 0;
5316+ }
5317+ ::part(range-end) {
5318+ border-start-start-radius: 0;
5319+ border-end-start-radius: 0;
5320+ }
5321+ ::part(range-start range-end) {
5322+ border-radius: var(--radius-field);
5323+ }
5324+ calendar-month {
5325+ width: 100%;
5326+ }
5327+ }
5328+ .rating-half {
5329+ :where(*:not(.rating-hidden)) {
5330+ width: calc(0.25rem * 3);
5331+ }
5332+ &.rating-xs *:not(.rating-hidden) {
5333+ width: calc(0.25rem * 2);
5334+ }
5335+ &.rating-sm *:not(.rating-hidden) {
5336+ width: calc(0.25rem * 2.5);
5337+ }
5338+ &.rating-md *:not(.rating-hidden) {
5339+ width: calc(0.25rem * 3);
5340+ }
5341+ &.rating-lg *:not(.rating-hidden) {
5342+ width: .875rem;
5343+ }
5344+ &.rating-xl *:not(.rating-hidden) {
5345+ width: calc(0.25rem * 4);
5346+ }
5347+ }
5348+ .btn-block {
5349+ width: 100%;
5350+ }
5351+ .loading-lg {
5352+ width: calc(var(--size-selector, 0.25rem) * 7);
5353+ }
5354+ .loading-md {
5355+ width: calc(var(--size-selector, 0.25rem) * 6);
5356+ }
5357+ .loading-sm {
5358+ width: calc(var(--size-selector, 0.25rem) * 5);
5359+ }
5360+ .loading-xl {
5361+ width: calc(var(--size-selector, 0.25rem) * 8);
5362+ }
5363+ .loading-xs {
5364+ width: calc(var(--size-selector, 0.25rem) * 4);
5365+ }
5366+ .w-2\/3 {
5367+ width: calc(2/3 * 100%);
5368+ }
5369+ .w-5 {
5370+ width: calc(var(--spacing) * 5);
5371+ }
5372+ .w-6 {
5373+ width: calc(var(--spacing) * 6);
5374+ }
5375+ .w-8 {
5376+ width: calc(var(--spacing) * 8);
5377+ }
5378+ .w-40 {
5379+ width: calc(var(--spacing) * 40);
5380+ }
5381+ .w-52 {
5382+ width: calc(var(--spacing) * 52);
5383+ }
5384+ .w-64 {
5385+ width: calc(var(--spacing) * 64);
5386+ }
5387+ .w-100 {
5388+ width: calc(var(--spacing) * 100);
5389+ }
5390+ .w-full {
5391+ width: 100%;
5392+ }
5393+ .max-w-md {
5394+ max-width: var(--container-md);
5395+ }
5396+ .flex-1 {
5397+ flex: 1;
5398+ }
5399+ .flex-shrink {
5400+ flex-shrink: 1;
5401+ }
5402+ .shrink-0 {
5403+ flex-shrink: 0;
5404+ }
5405+ .flex-grow {
5406+ flex-grow: 1;
5407+ }
5408+ .border-collapse {
5409+ border-collapse: collapse;
5410+ }
5411+ .swap-rotate {
5412+ .swap-on, input:indeterminate ~ .swap-on {
5413+ rotate: 45deg;
5414+ }
5415+ input:is(:checked, :indeterminate) ~ .swap-on, &.swap-active .swap-on {
5416+ rotate: 0deg;
5417+ }
5418+ input:is(:checked, :indeterminate) ~ .swap-off, &.swap-active .swap-off {
5419+ rotate: calc(45deg * -1);
5420+ }
5421+ }
5422+ .swap-flip {
5423+ transform-style: preserve-3d;
5424+ perspective: 20rem;
5425+ .swap-on, .swap-indeterminate, input:indeterminate ~ .swap-on {
5426+ transform: rotateY(180deg);
5427+ backface-visibility: hidden;
5428+ opacity: 100%;
5429+ }
5430+ input:is(:checked, :indeterminate) ~ .swap-on, &.swap-active .swap-on {
5431+ transform: rotateY(0deg);
5432+ }
5433+ input:is(:checked, :indeterminate) ~ .swap-off, &.swap-active .swap-off {
5434+ transform: rotateY(-180deg);
5435+ backface-visibility: hidden;
5436+ opacity: 100%;
5437+ }
5438+ }
5439+ .transform {
5440+ transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y);
5441+ }
5442+ .skeleton {
5443+ border-radius: var(--radius-box);
5444+ background-color: var(--color-base-300);
5445+ @media (prefers-reduced-motion: reduce) {
5446+ transition-duration: 15s;
5447+ }
5448+ will-change: background-position;
5449+ animation: skeleton 1.8s ease-in-out infinite;
5450+ background-image: linear-gradient( 105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100% );
5451+ background-size: 200% auto;
5452+ background-repeat: no-repeat;
5453+ background-position-x: -50%;
5454+ }
5455+ .animate-spin {
5456+ animation: var(--animate-spin);
5457+ }
5458+ .link {
5459+ cursor: pointer;
5460+ text-decoration-line: underline;
5461+ &:focus {
5462+ --tw-outline-style: none;
5463+ outline-style: none;
5464+ @media (forced-colors: active) {
5465+ outline: 2px solid transparent;
5466+ outline-offset: 2px;
5467+ }
5468+ }
5469+ &:focus-visible {
5470+ outline: 2px solid currentColor;
5471+ outline-offset: 2px;
5472+ }
5473+ }
5474+ .cursor-pointer {
5475+ cursor: pointer;
5476+ }
5477+ .resize {
5478+ resize: both;
5479+ }
5480+ .carousel-horizontal {
5481+ flex-direction: row;
5482+ overflow-x: scroll;
5483+ scroll-snap-type: x mandatory;
5484+ }
5485+ .carousel-vertical {
5486+ flex-direction: column;
5487+ overflow-y: scroll;
5488+ scroll-snap-type: y mandatory;
5489+ }
5490+ .carousel-center {
5491+ .carousel-item {
5492+ scroll-snap-align: center;
5493+ }
5494+ }
5495+ .carousel-end {
5496+ .carousel-item {
5497+ scroll-snap-align: end;
5498+ }
5499+ }
5500+ .carousel-start {
5501+ .carousel-item {
5502+ scroll-snap-align: start;
5503+ }
5504+ }
5505+ .list-disc {
5506+ list-style-type: disc;
5507+ }
5508+ .alert-horizontal {
5509+ justify-content: start;
5510+ justify-items: start;
5511+ grid-auto-flow: column;
5512+ grid-template-columns: auto minmax(auto, 1fr);
5513+ text-align: start;
5514+ }
5515+ .alert-vertical {
5516+ justify-content: center;
5517+ justify-items: center;
5518+ grid-auto-flow: row;
5519+ grid-template-columns: auto;
5520+ text-align: center;
5521+ }
5522+ .stats-horizontal {
5523+ grid-auto-flow: column;
5524+ overflow-x: auto;
5525+ .stat:not(:last-child) {
5526+ border-inline-end: var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);
5527+ border-block-end: none;
5528+ }
5529+ }
5530+ .stats-vertical {
5531+ grid-auto-flow: row;
5532+ overflow-y: auto;
5533+ .stat:not(:last-child) {
5534+ border-inline-end: none;
5535+ border-block-end: var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);
5536+ }
5537+ }
5538+ .footer-horizontal {
5539+ grid-auto-flow: column;
5540+ &.footer-center {
5541+ grid-auto-flow: row dense;
5542+ }
5543+ }
5544+ .footer-vertical {
5545+ grid-auto-flow: row;
5546+ &.footer-center {
5547+ grid-auto-flow: column dense;
5548+ }
5549+ }
5550+ .grid-flow-col {
5551+ grid-auto-flow: column;
5552+ }
5553+ .grid-cols-1 {
5554+ grid-template-columns: repeat(1, minmax(0, 1fr));
5555+ }
5556+ .flex-col {
5557+ flex-direction: column;
5558+ }
5559+ .flex-wrap {
5560+ flex-wrap: wrap;
5561+ }
5562+ .content-center {
5563+ align-content: center;
5564+ }
5565+ .items-center {
5566+ align-items: center;
5567+ }
5568+ .items-start {
5569+ align-items: flex-start;
5570+ }
5571+ .justify-between {
5572+ justify-content: space-between;
5573+ }
5574+ .justify-center {
5575+ justify-content: center;
5576+ }
5577+ .justify-end {
5578+ justify-content: flex-end;
5579+ }
5580+ .justify-start {
5581+ justify-content: flex-start;
5582+ }
5583+ .gap-2 {
5584+ gap: calc(var(--spacing) * 2);
5585+ }
5586+ .gap-3 {
5587+ gap: calc(var(--spacing) * 3);
5588+ }
5589+ .gap-4 {
5590+ gap: calc(var(--spacing) * 4);
5591+ }
5592+ .space-y-2 {
5593+ :where(& > :not(:last-child)) {
5594+ --tw-space-y-reverse: 0;
5595+ margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
5596+ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
5597+ }
5598+ }
5599+ .space-y-4 {
5600+ :where(& > :not(:last-child)) {
5601+ --tw-space-y-reverse: 0;
5602+ margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
5603+ margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
5604+ }
5605+ }
5606+ .tabs-box {
5607+ background-color: var(--color-base-200);
5608+ padding: calc(0.25rem * 1);
5609+ border-radius: calc( var(--radius-field) + min(0.25rem, calc(var(--radius-field) + var(--radius-field) + var(--radius-field))) );
5610+ box-shadow: 0 -0.5px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 0.5px oklch(0% 0 0 / calc(var(--depth) * 0.05)) inset;
5611+ .tab {
5612+ border-radius: var(--radius-field);
5613+ border-style: none;
5614+ &:focus-visible, &:is(label:has(:checked:focus-visible)) {
5615+ outline-offset: 2px;
5616+ }
5617+ }
5618+ :is(.tab-active, [aria-selected="true"]):not(.tab-disabled, [disabled]), :is(input:checked), :is(label:has(:checked)) {
5619+ background-color: var(--tab-bg, var(--color-base-100));
5620+ box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
5621+ @media (forced-colors: active) {
5622+ border: 1px solid;
5623+ }
5624+ }
5625+ }
5626+ .timeline-box {
5627+ border: var(--border) solid;
5628+ border-radius: var(--radius-box);
5629+ border-color: var(--color-base-300);
5630+ background-color: var(--color-base-100);
5631+ padding-inline: calc(0.25rem * 4);
5632+ padding-block: calc(0.25rem * 2);
5633+ font-size: 0.75rem;
5634+ box-shadow: 0 1px 2px 0 oklch(0% 0 0/0.05);
5635+ }
5636+ .menu-lg {
5637+ :where(li:not(.menu-title) > *:not(ul, details, .menu-title)), :where(li:not(.menu-title) > details > summary:not(.menu-title)) {
5638+ border-radius: var(--radius-field);
5639+ padding-inline: calc(0.25rem * 4);
5640+ padding-block: calc(0.25rem * 1.5);
5641+ font-size: 1.125rem;
5642+ }
5643+ .menu-title {
5644+ padding-inline: calc(0.25rem * 6);
5645+ padding-block: calc(0.25rem * 3);
5646+ }
5647+ }
5648+ .menu-md {
5649+ :where(li:not(.menu-title) > *:not(ul, details, .menu-title)), :where(li:not(.menu-title) > details > summary:not(.menu-title)) {
5650+ border-radius: var(--radius-field);
5651+ padding-inline: calc(0.25rem * 3);
5652+ padding-block: calc(0.25rem * 1.5);
5653+ font-size: 0.875rem;
5654+ }
5655+ .menu-title {
5656+ padding-inline: calc(0.25rem * 3);
5657+ padding-block: calc(0.25rem * 2);
5658+ }
5659+ }
5660+ .menu-sm {
5661+ :where(li:not(.menu-title) > *:not(ul, details, .menu-title)), :where(li:not(.menu-title) > details > summary:not(.menu-title)) {
5662+ border-radius: var(--radius-field);
5663+ padding-inline: calc(0.25rem * 2.5);
5664+ padding-block: calc(0.25rem * 1);
5665+ font-size: 0.75rem;
5666+ }
5667+ .menu-title {
5668+ padding-inline: calc(0.25rem * 3);
5669+ padding-block: calc(0.25rem * 2);
5670+ }
5671+ }
5672+ .menu-xl {
5673+ :where(li:not(.menu-title) > *:not(ul, details, .menu-title)), :where(li:not(.menu-title) > details > summary:not(.menu-title)) {
5674+ border-radius: var(--radius-field);
5675+ padding-inline: calc(0.25rem * 5);
5676+ padding-block: calc(0.25rem * 1.5);
5677+ font-size: 1.375rem;
5678+ }
5679+ .menu-title {
5680+ padding-inline: calc(0.25rem * 6);
5681+ padding-block: calc(0.25rem * 3);
5682+ }
5683+ }
5684+ .menu-xs {
5685+ :where(li:not(.menu-title) > *:not(ul, details, .menu-title)), :where(li:not(.menu-title) > details > summary:not(.menu-title)) {
5686+ border-radius: var(--radius-field);
5687+ padding-inline: calc(0.25rem * 2);
5688+ padding-block: calc(0.25rem * 1);
5689+ font-size: 0.6875rem;
5690+ }
5691+ .menu-title {
5692+ padding-inline: calc(0.25rem * 2);
5693+ padding-block: calc(0.25rem * 1);
5694+ }
5695+ }
5696+ .rounded-box {
5697+ border-radius: var(--radius-box);
5698+ }
5699+ .rounded-box {
5700+ border-radius: var(--radius-box);
5701+ }
5702+ .rounded-field {
5703+ border-radius: var(--radius-field);
5704+ }
5705+ .rounded-field {
5706+ border-radius: var(--radius-field);
5707+ }
5708+ .rounded-full {
5709+ border-radius: calc(infinity * 1px);
5710+ }
5711+ .rounded-md {
5712+ border-radius: var(--radius-md);
5713+ }
5714+ .rounded-selector {
5715+ border-radius: var(--radius-selector);
5716+ }
5717+ .rounded-selector {
5718+ border-radius: var(--radius-selector);
5719+ }
5720+ .rounded-sm {
5721+ border-radius: var(--radius-sm);
5722+ }
5723+ .rounded-t-box {
5724+ border-top-left-radius: var(--radius-box);
5725+ border-top-right-radius: var(--radius-box);
5726+ }
5727+ .rounded-t-box {
5728+ border-top-left-radius: var(--radius-box);
5729+ border-top-right-radius: var(--radius-box);
5730+ }
5731+ .rounded-t-field {
5732+ border-top-left-radius: var(--radius-field);
5733+ border-top-right-radius: var(--radius-field);
5734+ }
5735+ .rounded-t-field {
5736+ border-top-left-radius: var(--radius-field);
5737+ border-top-right-radius: var(--radius-field);
5738+ }
5739+ .rounded-t-selector {
5740+ border-top-left-radius: var(--radius-selector);
5741+ border-top-right-radius: var(--radius-selector);
5742+ }
5743+ .rounded-t-selector {
5744+ border-top-left-radius: var(--radius-selector);
5745+ border-top-right-radius: var(--radius-selector);
5746+ }
5747+ .rounded-l-box {
5748+ border-top-left-radius: var(--radius-box);
5749+ border-bottom-left-radius: var(--radius-box);
5750+ }
5751+ .rounded-l-box {
5752+ border-top-left-radius: var(--radius-box);
5753+ border-bottom-left-radius: var(--radius-box);
5754+ }
5755+ .rounded-l-field {
5756+ border-top-left-radius: var(--radius-field);
5757+ border-bottom-left-radius: var(--radius-field);
5758+ }
5759+ .rounded-l-field {
5760+ border-top-left-radius: var(--radius-field);
5761+ border-bottom-left-radius: var(--radius-field);
5762+ }
5763+ .rounded-l-selector {
5764+ border-top-left-radius: var(--radius-selector);
5765+ border-bottom-left-radius: var(--radius-selector);
5766+ }
5767+ .rounded-l-selector {
5768+ border-top-left-radius: var(--radius-selector);
5769+ border-bottom-left-radius: var(--radius-selector);
5770+ }
5771+ .rounded-tl-box {
5772+ border-top-left-radius: var(--radius-box);
5773+ }
5774+ .rounded-tl-box {
5775+ border-top-left-radius: var(--radius-box);
5776+ }
5777+ .rounded-tl-field {
5778+ border-top-left-radius: var(--radius-field);
5779+ }
5780+ .rounded-tl-field {
5781+ border-top-left-radius: var(--radius-field);
5782+ }
5783+ .rounded-tl-selector {
5784+ border-top-left-radius: var(--radius-selector);
5785+ }
5786+ .rounded-tl-selector {
5787+ border-top-left-radius: var(--radius-selector);
5788+ }
5789+ .rounded-r-box {
5790+ border-top-right-radius: var(--radius-box);
5791+ border-bottom-right-radius: var(--radius-box);
5792+ }
5793+ .rounded-r-box {
5794+ border-top-right-radius: var(--radius-box);
5795+ border-bottom-right-radius: var(--radius-box);
5796+ }
5797+ .rounded-r-field {
5798+ border-top-right-radius: var(--radius-field);
5799+ border-bottom-right-radius: var(--radius-field);
5800+ }
5801+ .rounded-r-field {
5802+ border-top-right-radius: var(--radius-field);
5803+ border-bottom-right-radius: var(--radius-field);
5804+ }
5805+ .rounded-r-selector {
5806+ border-top-right-radius: var(--radius-selector);
5807+ border-bottom-right-radius: var(--radius-selector);
5808+ }
5809+ .rounded-r-selector {
5810+ border-top-right-radius: var(--radius-selector);
5811+ border-bottom-right-radius: var(--radius-selector);
5812+ }
5813+ .rounded-tr-box {
5814+ border-top-right-radius: var(--radius-box);
5815+ }
5816+ .rounded-tr-box {
5817+ border-top-right-radius: var(--radius-box);
5818+ }
5819+ .rounded-tr-field {
5820+ border-top-right-radius: var(--radius-field);
5821+ }
5822+ .rounded-tr-field {
5823+ border-top-right-radius: var(--radius-field);
5824+ }
5825+ .rounded-tr-selector {
5826+ border-top-right-radius: var(--radius-selector);
5827+ }
5828+ .rounded-tr-selector {
5829+ border-top-right-radius: var(--radius-selector);
5830+ }
5831+ .rounded-b-box {
5832+ border-bottom-left-radius: var(--radius-box);
5833+ border-bottom-right-radius: var(--radius-box);
5834+ }
5835+ .rounded-b-box {
5836+ border-bottom-right-radius: var(--radius-box);
5837+ border-bottom-left-radius: var(--radius-box);
5838+ }
5839+ .rounded-b-field {
5840+ border-bottom-left-radius: var(--radius-field);
5841+ border-bottom-right-radius: var(--radius-field);
5842+ }
5843+ .rounded-b-field {
5844+ border-bottom-right-radius: var(--radius-field);
5845+ border-bottom-left-radius: var(--radius-field);
5846+ }
5847+ .rounded-b-selector {
5848+ border-bottom-left-radius: var(--radius-selector);
5849+ border-bottom-right-radius: var(--radius-selector);
5850+ }
5851+ .rounded-b-selector {
5852+ border-bottom-right-radius: var(--radius-selector);
5853+ border-bottom-left-radius: var(--radius-selector);
5854+ }
5855+ .rounded-br-box {
5856+ border-bottom-right-radius: var(--radius-box);
5857+ }
5858+ .rounded-br-box {
5859+ border-bottom-right-radius: var(--radius-box);
5860+ }
5861+ .rounded-br-field {
5862+ border-bottom-right-radius: var(--radius-field);
5863+ }
5864+ .rounded-br-field {
5865+ border-bottom-right-radius: var(--radius-field);
5866+ }
5867+ .rounded-br-selector {
5868+ border-bottom-right-radius: var(--radius-selector);
5869+ }
5870+ .rounded-br-selector {
5871+ border-bottom-right-radius: var(--radius-selector);
5872+ }
5873+ .rounded-bl-box {
5874+ border-bottom-left-radius: var(--radius-box);
5875+ }
5876+ .rounded-bl-box {
5877+ border-bottom-left-radius: var(--radius-box);
5878+ }
5879+ .rounded-bl-field {
5880+ border-bottom-left-radius: var(--radius-field);
5881+ }
5882+ .rounded-bl-field {
5883+ border-bottom-left-radius: var(--radius-field);
5884+ }
5885+ .rounded-bl-selector {
5886+ border-bottom-left-radius: var(--radius-selector);
5887+ }
5888+ .rounded-bl-selector {
5889+ border-bottom-left-radius: var(--radius-selector);
5890+ }
5891+ .border {
5892+ border-style: var(--tw-border-style);
5893+ border-width: 1px;
5894+ }
5895+ .border-t {
5896+ border-top-style: var(--tw-border-style);
5897+ border-top-width: 1px;
5898+ }
5899+ .btn-dash {
5900+ &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) {
5901+ --btn-shadow: "";
5902+ border-style: dashed;
5903+ --btn-bg: #0000;
5904+ --btn-fg: var(--btn-color);
5905+ --btn-border: var(--btn-color);
5906+ --btn-noise: none;
5907+ }
5908+ }
5909+ .badge-ghost {
5910+ border-color: var(--color-base-200);
5911+ background-color: var(--color-base-200);
5912+ color: var(--color-base-content);
5913+ background-image: none;
5914+ }
5915+ .select-ghost {
5916+ background-color: transparent;
5917+ transition: background-color 0.2s;
5918+ box-shadow: none;
5919+ border-color: #0000;
5920+ &:focus, &:focus-within {
5921+ background-color: var(--color-base-100);
5922+ color: var(--color-base-content);
5923+ border-color: #0000;
5924+ box-shadow: none;
5925+ }
5926+ }
5927+ .input-ghost {
5928+ background-color: transparent;
5929+ box-shadow: none;
5930+ border-color: #0000;
5931+ &:focus, &:focus-within {
5932+ background-color: var(--color-base-100);
5933+ color: var(--color-base-content);
5934+ border-color: #0000;
5935+ box-shadow: none;
5936+ }
5937+ }
5938+ .textarea-ghost {
5939+ background-color: transparent;
5940+ box-shadow: none;
5941+ border-color: #0000;
5942+ &:focus, &:focus-within {
5943+ background-color: var(--color-base-100);
5944+ color: var(--color-base-content);
5945+ border-color: #0000;
5946+ box-shadow: none;
5947+ }
5948+ }
5949+ .alert-error {
5950+ border-color: var(--color-error);
5951+ color: var(--color-error-content);
5952+ --alert-color: var(--color-error);
5953+ }
5954+ .alert-info {
5955+ border-color: var(--color-info);
5956+ color: var(--color-info-content);
5957+ --alert-color: var(--color-info);
5958+ }
5959+ .alert-success {
5960+ border-color: var(--color-success);
5961+ color: var(--color-success-content);
5962+ --alert-color: var(--color-success);
5963+ }
5964+ .alert-warning {
5965+ border-color: var(--color-warning);
5966+ color: var(--color-warning-content);
5967+ --alert-color: var(--color-warning);
5968+ }
5969+ .border-base-300 {
5970+ border-color: var(--color-base-300);
5971+ }
5972+ .glass {
5973+ border: none;
5974+ backdrop-filter: blur(var(--glass-blur, 40px));
5975+ background-color: #0000;
5976+ background-image: linear-gradient(
5977+ 135deg,
5978+ oklch(100% 0 0 / var(--glass-opacity, 30%)) 0%,
5979+ oklch(0% 0 0 / 0%) 100%
5980+ ),
5981+ linear-gradient(
5982+ var(--glass-reflect-degree, 100deg),
5983+ oklch(100% 0 0 / var(--glass-reflect-opacity, 5%)) 25%,
5984+ oklch(0% 0 0 / 0%) 25%
5985+ );
5986+ box-shadow: 0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity, 20%)) inset,
5987+ 0 0 0 2px oklch(0% 0 0 / 5%);
5988+ text-shadow: 0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity, 5%));
5989+ }
5990+ .chat-bubble-accent {
5991+ background-color: var(--color-accent);
5992+ color: var(--color-accent-content);
5993+ }
5994+ .chat-bubble-error {
5995+ background-color: var(--color-error);
5996+ color: var(--color-error-content);
5997+ }
5998+ .chat-bubble-info {
5999+ background-color: var(--color-info);
6000+ color: var(--color-info-content);
6001+ }
6002+ .chat-bubble-neutral {
6003+ background-color: var(--color-neutral);
6004+ color: var(--color-neutral-content);
6005+ }
6006+ .chat-bubble-primary {
6007+ background-color: var(--color-primary);
6008+ color: var(--color-primary-content);
6009+ }
6010+ .chat-bubble-secondary {
6011+ background-color: var(--color-secondary);
6012+ color: var(--color-secondary-content);
6013+ }
6014+ .chat-bubble-success {
6015+ background-color: var(--color-success);
6016+ color: var(--color-success-content);
6017+ }
6018+ .chat-bubble-warning {
6019+ background-color: var(--color-warning);
6020+ color: var(--color-warning-content);
6021+ }
6022+ .status-accent {
6023+ background-color: var(--color-accent);
6024+ color: var(--color-accent);
6025+ }
6026+ .status-error {
6027+ background-color: var(--color-error);
6028+ color: var(--color-error);
6029+ }
6030+ .status-info {
6031+ background-color: var(--color-info);
6032+ color: var(--color-info);
6033+ }
6034+ .status-neutral {
6035+ background-color: var(--color-neutral);
6036+ color: var(--color-neutral);
6037+ }
6038+ .status-primary {
6039+ background-color: var(--color-primary);
6040+ color: var(--color-primary);
6041+ }
6042+ .status-secondary {
6043+ background-color: var(--color-secondary);
6044+ color: var(--color-secondary);
6045+ }
6046+ .status-success {
6047+ background-color: var(--color-success);
6048+ color: var(--color-success);
6049+ }
6050+ .status-warning {
6051+ background-color: var(--color-warning);
6052+ color: var(--color-warning);
6053+ }
6054+ .table-zebra {
6055+ tbody {
6056+ tr {
6057+ &:nth-child(even) {
6058+ background-color: var(--color-base-200);
6059+ :where(.table-pin-cols tr th) {
6060+ background-color: var(--color-base-200);
6061+ }
6062+ }
6063+ &.row-hover {
6064+ &, &:nth-child(even) {
6065+ &:hover {
6066+ @media (hover: hover) {
6067+ background-color: var(--color-base-300);
6068+ }
6069+ }
6070+ }
6071+ }
6072+ }
6073+ }
6074+ }
6075+ .bg-base-100 {
6076+ background-color: var(--color-base-100);
6077+ }
6078+ .bg-base-200 {
6079+ background-color: var(--color-base-200);
6080+ }
6081+ .bg-base-300 {
6082+ background-color: var(--color-base-300);
6083+ }
6084+ .bg-blue-500 {
6085+ background-color: var(--color-blue-500);
6086+ }
6087+ .bg-green-500 {
6088+ background-color: var(--color-green-500);
6089+ }
6090+ .bg-indigo-50 {
6091+ background-color: var(--color-indigo-50);
6092+ }
6093+ .bg-indigo-100 {
6094+ background-color: var(--color-indigo-100);
6095+ }
6096+ .bg-indigo-500 {
6097+ background-color: var(--color-indigo-500);
6098+ }
6099+ .bg-red-500 {
6100+ background-color: var(--color-red-500);
6101+ }
6102+ .divider-accent {
6103+ &:before, &:after {
6104+ background-color: var(--color-accent);
6105+ }
6106+ }
6107+ .divider-error {
6108+ &:before, &:after {
6109+ background-color: var(--color-error);
6110+ }
6111+ }
6112+ .divider-info {
6113+ &:before, &:after {
6114+ background-color: var(--color-info);
6115+ }
6116+ }
6117+ .divider-neutral {
6118+ &:before, &:after {
6119+ background-color: var(--color-neutral);
6120+ }
6121+ }
6122+ .divider-primary {
6123+ &:before, &:after {
6124+ background-color: var(--color-primary);
6125+ }
6126+ }
6127+ .divider-secondary {
6128+ &:before, &:after {
6129+ background-color: var(--color-secondary);
6130+ }
6131+ }
6132+ .divider-success {
6133+ &:before, &:after {
6134+ background-color: var(--color-success);
6135+ }
6136+ }
6137+ .divider-warning {
6138+ &:before, &:after {
6139+ background-color: var(--color-warning);
6140+ }
6141+ }
6142+ .stroke-current {
6143+ stroke: currentColor;
6144+ }
6145+ .checkbox-lg {
6146+ padding: 0.3125rem;
6147+ --size: calc(var(--size-selector, 0.25rem) * 7);
6148+ }
6149+ .checkbox-md {
6150+ padding: 0.25rem;
6151+ --size: calc(var(--size-selector, 0.25rem) * 6);
6152+ }
6153+ .checkbox-sm {
6154+ padding: 0.1875rem;
6155+ --size: calc(var(--size-selector, 0.25rem) * 5);
6156+ }
6157+ .checkbox-xl {
6158+ padding: 0.375rem;
6159+ --size: calc(var(--size-selector, 0.25rem) * 8);
6160+ }
6161+ .checkbox-xs {
6162+ padding: 0.125rem;
6163+ --size: calc(var(--size-selector, 0.25rem) * 4);
6164+ }
6165+ .radio-lg {
6166+ padding: 0.3125rem;
6167+ &:is([type="radio"]) {
6168+ --size: calc(var(--size-selector, 0.25rem) * 7);
6169+ }
6170+ }
6171+ .radio-md {
6172+ padding: 0.25rem;
6173+ &:is([type="radio"]) {
6174+ --size: calc(var(--size-selector, 0.25rem) * 6);
6175+ }
6176+ }
6177+ .radio-sm {
6178+ padding: 0.1875rem;
6179+ &:is([type="radio"]) {
6180+ --size: calc(var(--size-selector, 0.25rem) * 5);
6181+ }
6182+ }
6183+ .radio-xl {
6184+ padding: 0.375rem;
6185+ &:is([type="radio"]) {
6186+ --size: calc(var(--size-selector, 0.25rem) * 8);
6187+ }
6188+ }
6189+ .radio-xs {
6190+ padding: 0.125rem;
6191+ &:is([type="radio"]) {
6192+ --size: calc(var(--size-selector, 0.25rem) * 4);
6193+ }
6194+ }
6195+ .p-0 {
6196+ padding: calc(var(--spacing) * 0);
6197+ }
6198+ .p-2 {
6199+ padding: calc(var(--spacing) * 2);
6200+ }
6201+ .p-3 {
6202+ padding: calc(var(--spacing) * 3);
6203+ }
6204+ .p-4 {
6205+ padding: calc(var(--spacing) * 4);
6206+ }
6207+ .p-6 {
6208+ padding: calc(var(--spacing) * 6);
6209+ }
6210+ .p-10 {
6211+ padding: calc(var(--spacing) * 10);
6212+ }
6213+ .menu-title {
6214+ padding-inline: calc(0.25rem * 3);
6215+ padding-block: calc(0.25rem * 2);
6216+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
6217+ font-size: 0.875rem;
6218+ font-weight: 600;
6219+ }
6220+ .table-lg {
6221+ :not(thead, tfoot) tr {
6222+ font-size: 1.125rem;
6223+ }
6224+ :where(th, td) {
6225+ padding-inline: calc(0.25rem * 5);
6226+ padding-block: calc(0.25rem * 4);
6227+ }
6228+ }
6229+ .table-md {
6230+ :not(thead, tfoot) tr {
6231+ font-size: 0.875rem;
6232+ }
6233+ :where(th, td) {
6234+ padding-inline: calc(0.25rem * 4);
6235+ padding-block: calc(0.25rem * 3);
6236+ }
6237+ }
6238+ .table-sm {
6239+ :not(thead, tfoot) tr {
6240+ font-size: 0.75rem;
6241+ }
6242+ :where(th, td) {
6243+ padding-inline: calc(0.25rem * 3);
6244+ padding-block: calc(0.25rem * 2);
6245+ }
6246+ }
6247+ .table-xl {
6248+ :not(thead, tfoot) tr {
6249+ font-size: 1.375rem;
6250+ }
6251+ :where(th, td) {
6252+ padding-inline: calc(0.25rem * 6);
6253+ padding-block: calc(0.25rem * 5);
6254+ }
6255+ }
6256+ .table-xs {
6257+ :not(thead, tfoot) tr {
6258+ font-size: 0.6875rem;
6259+ }
6260+ :where(th, td) {
6261+ padding-inline: calc(0.25rem * 2);
6262+ padding-block: calc(0.25rem * 1);
6263+ }
6264+ }
6265+ .badge-lg {
6266+ --size: calc(var(--size-selector, 0.25rem) * 7);
6267+ font-size: 1rem;
6268+ padding-inline: calc(0.25rem * 3.5 - var(--border));
6269+ }
6270+ .badge-md {
6271+ --size: calc(var(--size-selector, 0.25rem) * 6);
6272+ font-size: 0.875rem;
6273+ padding-inline: calc(0.25rem * 3 - var(--border));
6274+ }
6275+ .badge-sm {
6276+ --size: calc(var(--size-selector, 0.25rem) * 5);
6277+ font-size: 0.75rem;
6278+ padding-inline: calc(0.25rem * 2.5 - var(--border));
6279+ }
6280+ .badge-xl {
6281+ --size: calc(var(--size-selector, 0.25rem) * 8);
6282+ font-size: 1.125rem;
6283+ padding-inline: calc(0.25rem * 4 - var(--border));
6284+ }
6285+ .badge-xs {
6286+ --size: calc(var(--size-selector, 0.25rem) * 4);
6287+ font-size: 0.625rem;
6288+ padding-inline: calc(0.25rem * 2 - var(--border));
6289+ }
6290+ .px-1 {
6291+ padding-inline: calc(var(--spacing) * 1);
6292+ }
6293+ .px-4 {
6294+ padding-inline: calc(var(--spacing) * 4);
6295+ }
6296+ .px-10 {
6297+ padding-inline: calc(var(--spacing) * 10);
6298+ }
6299+ .py-4 {
6300+ padding-block: calc(var(--spacing) * 4);
6301+ }
6302+ .py-6 {
6303+ padding-block: calc(var(--spacing) * 6);
6304+ }
6305+ .file-input-xl {
6306+ padding-inline-end: calc(0.25rem * 6);
6307+ --size: calc(var(--size-field, 0.25rem) * 14);
6308+ font-size: 1.125rem;
6309+ line-height: 3rem;
6310+ &::file-selector-button {
6311+ font-size: 1.375rem;
6312+ }
6313+ }
6314+ .pt-16 {
6315+ padding-top: calc(var(--spacing) * 16);
6316+ }
6317+ .pt-20 {
6318+ padding-top: calc(var(--spacing) * 20);
6319+ }
6320+ .pr-1 {
6321+ padding-right: calc(var(--spacing) * 1);
6322+ }
6323+ .pb-4 {
6324+ padding-bottom: calc(var(--spacing) * 4);
6325+ }
6326+ .text-center {
6327+ text-align: center;
6328+ }
6329+ .font-mono {
6330+ font-family: var(--font-mono);
6331+ }
6332+ .file-input-lg {
6333+ --size: calc(var(--size-field, 0.25rem) * 12);
6334+ font-size: 1.125rem;
6335+ line-height: 2.5rem;
6336+ &::file-selector-button {
6337+ font-size: 1.125rem;
6338+ }
6339+ }
6340+ .file-input-md {
6341+ --size: calc(var(--size-field, 0.25rem) * 10);
6342+ font-size: 0.875rem;
6343+ line-height: 2;
6344+ &::file-selector-button {
6345+ font-size: 0.875rem;
6346+ }
6347+ }
6348+ .file-input-sm {
6349+ --size: calc(var(--size-field, 0.25rem) * 8);
6350+ font-size: 0.75rem;
6351+ line-height: 1.5rem;
6352+ &::file-selector-button {
6353+ font-size: 0.75rem;
6354+ }
6355+ }
6356+ .file-input-xs {
6357+ --size: calc(var(--size-field, 0.25rem) * 6);
6358+ font-size: 0.6875rem;
6359+ line-height: 1rem;
6360+ &::file-selector-button {
6361+ font-size: 0.6875rem;
6362+ }
6363+ }
6364+ .text-2xl {
6365+ font-size: var(--text-2xl);
6366+ line-height: var(--tw-leading, var(--text-2xl--line-height));
6367+ }
6368+ .text-3xl {
6369+ font-size: var(--text-3xl);
6370+ line-height: var(--tw-leading, var(--text-3xl--line-height));
6371+ }
6372+ .text-4xl {
6373+ font-size: var(--text-4xl);
6374+ line-height: var(--tw-leading, var(--text-4xl--line-height));
6375+ }
6376+ .text-5xl {
6377+ font-size: var(--text-5xl);
6378+ line-height: var(--tw-leading, var(--text-5xl--line-height));
6379+ }
6380+ .text-lg {
6381+ font-size: var(--text-lg);
6382+ line-height: var(--tw-leading, var(--text-lg--line-height));
6383+ }
6384+ .text-sm {
6385+ font-size: var(--text-sm);
6386+ line-height: var(--tw-leading, var(--text-sm--line-height));
6387+ }
6388+ .text-xs {
6389+ font-size: var(--text-xs);
6390+ line-height: var(--tw-leading, var(--text-xs--line-height));
6391+ }
6392+ .kbd-lg {
6393+ --size: calc(var(--size-selector, 0.25rem) * 7);
6394+ font-size: 1rem;
6395+ }
6396+ .kbd-md {
6397+ --size: calc(var(--size-selector, 0.25rem) * 6);
6398+ font-size: 0.875rem;
6399+ }
6400+ .kbd-sm {
6401+ --size: calc(var(--size-selector, 0.25rem) * 5);
6402+ font-size: 0.75rem;
6403+ }
6404+ .kbd-xl {
6405+ --size: calc(var(--size-selector, 0.25rem) * 8);
6406+ font-size: 1.125rem;
6407+ }
6408+ .kbd-xs {
6409+ --size: calc(var(--size-selector, 0.25rem) * 4);
6410+ font-size: 0.625rem;
6411+ }
6412+ .select-lg {
6413+ --size: calc(var(--size-field, 0.25rem) * 12);
6414+ font-size: 1.125rem;
6415+ }
6416+ .select-md {
6417+ --size: calc(var(--size-field, 0.25rem) * 10);
6418+ font-size: 0.875rem;
6419+ }
6420+ .select-sm {
6421+ --size: calc(var(--size-field, 0.25rem) * 8);
6422+ font-size: 0.75rem;
6423+ }
6424+ .select-xl {
6425+ --size: calc(var(--size-field, 0.25rem) * 14);
6426+ font-size: 1.375rem;
6427+ }
6428+ .select-xs {
6429+ --size: calc(var(--size-field, 0.25rem) * 6);
6430+ font-size: 0.6875rem;
6431+ }
6432+ .textarea-lg {
6433+ font-size: 1.125rem;
6434+ }
6435+ .textarea-md {
6436+ font-size: 0.875rem;
6437+ }
6438+ .textarea-sm {
6439+ font-size: 0.75rem;
6440+ }
6441+ .textarea-xl {
6442+ font-size: 1.375rem;
6443+ }
6444+ .textarea-xs {
6445+ font-size: 0.6875rem;
6446+ }
6447+ .font-bold {
6448+ --tw-font-weight: var(--font-weight-bold);
6449+ font-weight: var(--font-weight-bold);
6450+ }
6451+ .font-semibold {
6452+ --tw-font-weight: var(--font-weight-semibold);
6453+ font-weight: var(--font-weight-semibold);
6454+ }
6455+ .tracking-tighter {
6456+ --tw-tracking: var(--tracking-tighter);
6457+ letter-spacing: var(--tracking-tighter);
6458+ }
6459+ .text-wrap {
6460+ text-wrap: wrap;
6461+ }
6462+ .file-input-accent {
6463+ --btn-color: var(--color-accent);
6464+ &::file-selector-button {
6465+ color: var(--color-accent-content);
6466+ }
6467+ &, &:focus, &:focus-within {
6468+ --input-color: var(--color-accent);
6469+ }
6470+ }
6471+ .file-input-error {
6472+ --btn-color: var(--color-error);
6473+ &::file-selector-button {
6474+ color: var(--color-error-content);
6475+ }
6476+ &, &:focus, &:focus-within {
6477+ --input-color: var(--color-error);
6478+ }
6479+ }
6480+ .file-input-info {
6481+ --btn-color: var(--color-info);
6482+ &::file-selector-button {
6483+ color: var(--color-info-content);
6484+ }
6485+ &, &:focus, &:focus-within {
6486+ --input-color: var(--color-info);
6487+ }
6488+ }
6489+ .file-input-neutral {
6490+ --btn-color: var(--color-neutral);
6491+ &::file-selector-button {
6492+ color: var(--color-neutral-content);
6493+ }
6494+ &, &:focus, &:focus-within {
6495+ --input-color: var(--color-neutral);
6496+ }
6497+ }
6498+ .file-input-primary {
6499+ --btn-color: var(--color-primary);
6500+ &::file-selector-button {
6501+ color: var(--color-primary-content);
6502+ }
6503+ &, &:focus, &:focus-within {
6504+ --input-color: var(--color-primary);
6505+ }
6506+ }
6507+ .file-input-secondary {
6508+ --btn-color: var(--color-secondary);
6509+ &::file-selector-button {
6510+ color: var(--color-secondary-content);
6511+ }
6512+ &, &:focus, &:focus-within {
6513+ --input-color: var(--color-secondary);
6514+ }
6515+ }
6516+ .file-input-success {
6517+ --btn-color: var(--color-success);
6518+ &::file-selector-button {
6519+ color: var(--color-success-content);
6520+ }
6521+ &, &:focus, &:focus-within {
6522+ --input-color: var(--color-success);
6523+ }
6524+ }
6525+ .file-input-warning {
6526+ --btn-color: var(--color-warning);
6527+ &::file-selector-button {
6528+ color: var(--color-warning-content);
6529+ }
6530+ &, &:focus, &:focus-within {
6531+ --input-color: var(--color-warning);
6532+ }
6533+ }
6534+ .checkbox-accent {
6535+ color: var(--color-accent-content);
6536+ --input-color: var(--color-accent);
6537+ }
6538+ .checkbox-error {
6539+ color: var(--color-error-content);
6540+ --input-color: var(--color-error);
6541+ }
6542+ .checkbox-info {
6543+ color: var(--color-info-content);
6544+ --input-color: var(--color-info);
6545+ }
6546+ .checkbox-neutral {
6547+ color: var(--color-neutral-content);
6548+ --input-color: var(--color-neutral);
6549+ }
6550+ .checkbox-primary {
6551+ color: var(--color-primary-content);
6552+ --input-color: var(--color-primary);
6553+ }
6554+ .checkbox-secondary {
6555+ color: var(--color-secondary-content);
6556+ --input-color: var(--color-secondary);
6557+ }
6558+ .checkbox-success {
6559+ color: var(--color-success-content);
6560+ --input-color: var(--color-success);
6561+ }
6562+ .checkbox-warning {
6563+ color: var(--color-warning-content);
6564+ --input-color: var(--color-warning);
6565+ }
6566+ .link-accent {
6567+ color: var(--color-accent);
6568+ @media (hover: hover) {
6569+ &:hover {
6570+ color: color-mix(in oklab, var(--color-accent) 80%, #000);
6571+ }
6572+ }
6573+ }
6574+ .link-error {
6575+ color: var(--color-error);
6576+ @media (hover: hover) {
6577+ &:hover {
6578+ color: color-mix(in oklab, var(--color-error) 80%, #000);
6579+ }
6580+ }
6581+ }
6582+ .link-info {
6583+ color: var(--color-info);
6584+ @media (hover: hover) {
6585+ &:hover {
6586+ color: color-mix(in oklab, var(--color-info) 80%, #000);
6587+ }
6588+ }
6589+ }
6590+ .link-neutral {
6591+ color: var(--color-neutral);
6592+ @media (hover: hover) {
6593+ &:hover {
6594+ color: color-mix(in oklab, var(--color-neutral) 80%, #000);
6595+ }
6596+ }
6597+ }
6598+ .link-primary {
6599+ color: var(--color-primary);
6600+ @media (hover: hover) {
6601+ &:hover {
6602+ color: color-mix(in oklab, var(--color-primary) 80%, #000);
6603+ }
6604+ }
6605+ }
6606+ .link-secondary {
6607+ color: var(--color-secondary);
6608+ @media (hover: hover) {
6609+ &:hover {
6610+ color: color-mix(in oklab, var(--color-secondary) 80%, #000);
6611+ }
6612+ }
6613+ }
6614+ .link-success {
6615+ color: var(--color-success);
6616+ @media (hover: hover) {
6617+ &:hover {
6618+ color: color-mix(in oklab, var(--color-success) 80%, #000);
6619+ }
6620+ }
6621+ }
6622+ .link-warning {
6623+ color: var(--color-warning);
6624+ @media (hover: hover) {
6625+ &:hover {
6626+ color: color-mix(in oklab, var(--color-warning) 80%, #000);
6627+ }
6628+ }
6629+ }
6630+ .range-accent {
6631+ color: var(--color-accent);
6632+ --range-thumb: var(--color-accent-content);
6633+ }
6634+ .range-error {
6635+ color: var(--color-error);
6636+ --range-thumb: var(--color-error-content);
6637+ }
6638+ .range-info {
6639+ color: var(--color-info);
6640+ --range-thumb: var(--color-info-content);
6641+ }
6642+ .range-neutral {
6643+ color: var(--color-neutral);
6644+ --range-thumb: var(--color-neutral-content);
6645+ }
6646+ .range-primary {
6647+ color: var(--color-primary);
6648+ --range-thumb: var(--color-primary-content);
6649+ }
6650+ .range-secondary {
6651+ color: var(--color-secondary);
6652+ --range-thumb: var(--color-secondary-content);
6653+ }
6654+ .range-success {
6655+ color: var(--color-success);
6656+ --range-thumb: var(--color-success-content);
6657+ }
6658+ .range-warning {
6659+ color: var(--color-warning);
6660+ --range-thumb: var(--color-warning-content);
6661+ }
6662+ .tooltip-accent {
6663+ --tt-bg: var(--color-accent);
6664+ > .tooltip-content, &[data-tip]:before {
6665+ color: var(--color-accent-content);
6666+ }
6667+ }
6668+ .tooltip-error {
6669+ --tt-bg: var(--color-error);
6670+ > .tooltip-content, &[data-tip]:before {
6671+ color: var(--color-error-content);
6672+ }
6673+ }
6674+ .tooltip-info {
6675+ --tt-bg: var(--color-info);
6676+ > .tooltip-content, &[data-tip]:before {
6677+ color: var(--color-info-content);
6678+ }
6679+ }
6680+ .tooltip-primary {
6681+ --tt-bg: var(--color-primary);
6682+ > .tooltip-content, &[data-tip]:before {
6683+ color: var(--color-primary-content);
6684+ }
6685+ }
6686+ .tooltip-secondary {
6687+ --tt-bg: var(--color-secondary);
6688+ > .tooltip-content, &[data-tip]:before {
6689+ color: var(--color-secondary-content);
6690+ }
6691+ }
6692+ .tooltip-success {
6693+ --tt-bg: var(--color-success);
6694+ > .tooltip-content, &[data-tip]:before {
6695+ color: var(--color-success-content);
6696+ }
6697+ }
6698+ .tooltip-warning {
6699+ --tt-bg: var(--color-warning);
6700+ > .tooltip-content, &[data-tip]:before {
6701+ color: var(--color-warning-content);
6702+ }
6703+ }
6704+ .progress-accent {
6705+ color: var(--color-accent);
6706+ }
6707+ .progress-error {
6708+ color: var(--color-error);
6709+ }
6710+ .progress-info {
6711+ color: var(--color-info);
6712+ }
6713+ .progress-neutral {
6714+ color: var(--color-neutral);
6715+ }
6716+ .progress-primary {
6717+ color: var(--color-primary);
6718+ }
6719+ .progress-secondary {
6720+ color: var(--color-secondary);
6721+ }
6722+ .progress-success {
6723+ color: var(--color-success);
6724+ }
6725+ .progress-warning {
6726+ color: var(--color-warning);
6727+ }
6728+ .text-accent {
6729+ color: var(--color-accent);
6730+ }
6731+ .text-base-content {
6732+ color: var(--color-base-content);
6733+ }
6734+ .text-base-content\/70 {
6735+ color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
6736+ }
6737+ .text-error {
6738+ color: var(--color-error);
6739+ }
6740+ .text-indigo-600 {
6741+ color: var(--color-indigo-600);
6742+ }
6743+ .text-indigo-800 {
6744+ color: var(--color-indigo-800);
6745+ }
6746+ .text-info {
6747+ color: var(--color-info);
6748+ }
6749+ .text-primary {
6750+ color: var(--color-primary);
6751+ }
6752+ .text-secondary {
6753+ color: var(--color-secondary);
6754+ }
6755+ .text-success {
6756+ color: var(--color-success);
6757+ }
6758+ .text-warning {
6759+ color: var(--color-warning);
6760+ }
6761+ .text-white {
6762+ color: var(--color-white);
6763+ }
6764+ .lowercase {
6765+ text-transform: lowercase;
6766+ }
6767+ .uppercase {
6768+ text-transform: uppercase;
6769+ }
6770+ .btn-link {
6771+ text-decoration-line: underline;
6772+ outline-color: currentColor;
6773+ --btn-border: #0000;
6774+ --btn-bg: #0000;
6775+ --btn-fg: var(--color-primary);
6776+ --btn-noise: none;
6777+ --btn-shadow: "";
6778+ &:is(.btn-active, :hover, :active:focus, :focus-visible) {
6779+ text-decoration-line: underline;
6780+ --btn-border: #0000;
6781+ --btn-bg: #0000;
6782+ }
6783+ }
6784+ .link-hover {
6785+ text-decoration-line: none;
6786+ &:hover {
6787+ @media (hover: hover) {
6788+ text-decoration-line: underline;
6789+ }
6790+ }
6791+ }
6792+ .underline {
6793+ text-decoration-line: underline;
6794+ }
6795+ .swap-active {
6796+ .swap-off {
6797+ opacity: 0%;
6798+ }
6799+ .swap-on {
6800+ opacity: 100%;
6801+ }
6802+ }
6803+ .opacity-0 {
6804+ opacity: 0%;
6805+ }
6806+ .opacity-50 {
6807+ opacity: 50%;
6808+ }
6809+ .opacity-70 {
6810+ opacity: 70%;
6811+ }
6812+ .shadow {
6813+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
6814+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
6815+ }
6816+ .shadow-lg {
6817+ --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
6818+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
6819+ }
6820+ .shadow-md {
6821+ --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
6822+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
6823+ }
6824+ .shadow-xl {
6825+ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
6826+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
6827+ }
6828+ .outline {
6829+ outline-style: var(--tw-outline-style);
6830+ outline-width: 1px;
6831+ }
6832+ .btn-ghost {
6833+ &:not(.btn-active, :hover, :active:focus, :focus-visible) {
6834+ --btn-shadow: "";
6835+ --btn-bg: #0000;
6836+ --btn-border: #0000;
6837+ --btn-noise: none;
6838+ &:not(:disabled, [disabled], .btn-disabled) {
6839+ outline-color: currentColor;
6840+ --btn-fg: currentColor;
6841+ }
6842+ }
6843+ }
6844+ .blur {
6845+ --tw-blur: blur(8px);
6846+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
6847+ }
6848+ .drop-shadow-xl {
6849+ --tw-drop-shadow: drop-shadow(var(--drop-shadow-xl));
6850+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
6851+ }
6852+ .filter {
6853+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
6854+ }
6855+ .backdrop-filter {
6856+ -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
6857+ backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
6858+ }
6859+ .transition {
6860+ transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter;
6861+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
6862+ transition-duration: var(--tw-duration, var(--default-transition-duration));
5716863 }
572-}
573-
574-@media (min-width: 768px) {
575- .container {
576- max-width: 768px;
6864+ .transition-opacity {
6865+ transition-property: opacity;
6866+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
6867+ transition-duration: var(--tw-duration, var(--default-transition-duration));
5776868 }
578-}
579-
580-@media (min-width: 1024px) {
581- .container {
582- max-width: 1024px;
6869+ .duration-300 {
6870+ --tw-duration: 300ms;
6871+ transition-duration: 300ms;
5836872 }
584-}
585-
586-@media (min-width: 1280px) {
587- .container {
588- max-width: 1280px;
6873+ .ease-in {
6874+ --tw-ease: var(--ease-in);
6875+ transition-timing-function: var(--ease-in);
5896876 }
590-}
591-
592-@media (min-width: 1536px) {
593- .container {
594- max-width: 1536px;
6877+ .ease-in-out {
6878+ --tw-ease: var(--ease-in-out);
6879+ transition-timing-function: var(--ease-in-out);
6880+ }
6881+ .ease-out {
6882+ --tw-ease: var(--ease-out);
6883+ transition-timing-function: var(--ease-out);
6884+ }
6885+ .tabs-bottom {
6886+ --tabs-height: auto;
6887+ --tabs-direction: row;
6888+ .tab {
6889+ --tab-order: 1;
6890+ --tab-border: var(--border) 0 0 0;
6891+ --tab-radius-ss: 0;
6892+ --tab-radius-se: 0;
6893+ --tab-radius-es: min(var(--radius-field), var(--tab-radius-min));
6894+ --tab-radius-ee: min(var(--radius-field), var(--tab-radius-min));
6895+ --tab-border-colors: var(--tab-border-color) #0000 #0000 #0000;
6896+ --tab-paddings: 0 var(--tab-p) var(--border) var(--tab-p);
6897+ --tab-corner-width: calc(100% + min(var(--radius-field), var(--tab-radius-min)) * 2);
6898+ --tab-corner-height: min(var(--radius-field), var(--tab-radius-min));
6899+ --tab-corner-position: top left, top right;
6900+ &:is(.tab-active, [aria-selected="true"]):not(.tab-disabled, [disabled]), &:is(input:checked), &:is(label:has(:checked)) {
6901+ --tab-border: 0 var(--border) var(--border) var(--border);
6902+ --tab-border-colors: #0000 var(--tab-border-color) var(--tab-border-color)
6903+ var(--tab-border-color);
6904+ --tab-paddings: var(--border) calc(var(--tab-p) - var(--border)) 0
6905+ calc(var(--tab-p) - var(--border));
6906+ --tab-inset: 0 auto auto auto;
6907+ --radius-start: radial-gradient(
6908+ circle at bottom left,
6909+ #0000 var(--tab-grad),
6910+ var(--tab-border-color) calc(var(--tab-grad) + 0.25px),
6911+ var(--tab-border-color) calc(var(--tab-grad) + var(--border)),
6912+ var(--tab-bg) calc(var(--tab-grad) + var(--border) + 0.25px)
6913+ );
6914+ --radius-end: radial-gradient(
6915+ circle at bottom right,
6916+ #0000 var(--tab-grad),
6917+ var(--tab-border-color) calc(var(--tab-grad) + 0.25px),
6918+ var(--tab-border-color) calc(var(--tab-grad) + var(--border)),
6919+ var(--tab-bg) calc(var(--tab-grad) + var(--border) + 0.25px)
6920+ );
6921+ }
6922+ }
6923+ &:has(.tab-content) {
6924+ > .tab:first-child {
6925+ &:not(.tab-active, [aria-selected="true"]) {
6926+ --tab-border-colors: #0000 var(--tab-border-color) var(--tab-border-color)
6927+ var(--tab-border-color);
6928+ }
6929+ }
6930+ }
6931+ .tab-content {
6932+ --tabcontent-order: 0;
6933+ --tabcontent-margin: 0 0 calc(-1 * var(--border)) 0;
6934+ --tabcontent-radius-ss: var(--radius-box);
6935+ --tabcontent-radius-se: var(--radius-box);
6936+ --tabcontent-radius-es: 0;
6937+ --tabcontent-radius-ee: var(--radius-box);
6938+ }
6939+ :checked, :is(label:has(:checked)), :is(.tab-active, [aria-selected="true"]) {
6940+ & + .tab-content:not(:nth-child(2)) {
6941+ --tabcontent-radius-es: var(--radius-box);
6942+ }
6943+ }
6944+ }
6945+ .tabs-top {
6946+ --tabs-height: auto;
6947+ --tabs-direction: row;
6948+ .tab {
6949+ --tab-order: 0;
6950+ --tab-border: 0 0 var(--border) 0;
6951+ --tab-radius-ss: min(var(--radius-field), var(--tab-radius-min));
6952+ --tab-radius-se: min(var(--radius-field), var(--tab-radius-min));
6953+ --tab-radius-es: 0;
6954+ --tab-radius-ee: 0;
6955+ --tab-paddings: var(--border) var(--tab-p) 0 var(--tab-p);
6956+ --tab-border-colors: #0000 #0000 var(--tab-border-color) #0000;
6957+ --tab-corner-width: calc(100% + min(var(--radius-field), var(--tab-radius-min)) * 2);
6958+ --tab-corner-height: min(var(--radius-field), var(--tab-radius-min));
6959+ --tab-corner-position: top left, top right;
6960+ &:is(.tab-active, [aria-selected="true"]):not(.tab-disabled, [disabled]), &:is(input:checked), &:is(label:has(:checked)) {
6961+ --tab-border: var(--border) var(--border) 0 var(--border);
6962+ --tab-border-colors: var(--tab-border-color) var(--tab-border-color) #0000
6963+ var(--tab-border-color);
6964+ --tab-paddings: 0 calc(var(--tab-p) - var(--border)) var(--border)
6965+ calc(var(--tab-p) - var(--border));
6966+ --tab-inset: auto auto 0 auto;
6967+ --radius-start: radial-gradient(
6968+ circle at top left,
6969+ #0000 var(--tab-grad),
6970+ var(--tab-border-color) calc(var(--tab-grad) + 0.25px),
6971+ var(--tab-border-color) calc(var(--tab-grad) + var(--border)),
6972+ var(--tab-bg) calc(var(--tab-grad) + var(--border) + 0.25px)
6973+ );
6974+ --radius-end: radial-gradient(
6975+ circle at top right,
6976+ #0000 var(--tab-grad),
6977+ var(--tab-border-color) calc(var(--tab-grad) + 0.25px),
6978+ var(--tab-border-color) calc(var(--tab-grad) + var(--border)),
6979+ var(--tab-bg) calc(var(--tab-grad) + var(--border) + 0.25px)
6980+ );
6981+ }
6982+ }
6983+ &:has(.tab-content) {
6984+ > .tab:first-child {
6985+ &:not(.tab-active, [aria-selected="true"]) {
6986+ --tab-border-colors: var(--tab-border-color) var(--tab-border-color) #0000
6987+ var(--tab-border-color);
6988+ }
6989+ }
6990+ }
6991+ .tab-content {
6992+ --tabcontent-order: 1;
6993+ --tabcontent-margin: calc(-1 * var(--border)) 0 0 0;
6994+ --tabcontent-radius-ss: 0;
6995+ --tabcontent-radius-se: var(--radius-box);
6996+ --tabcontent-radius-es: var(--radius-box);
6997+ --tabcontent-radius-ee: var(--radius-box);
6998+ }
6999+ :checked, label:has(:checked), :is(.tab-active, [aria-selected="true"]) {
7000+ & + .tab-content {
7001+ &:nth-child(1), &:nth-child(n + 3) {
7002+ --tabcontent-radius-ss: var(--radius-box);
7003+ }
7004+ }
7005+ }
7006+ }
7007+ .btn-outline {
7008+ &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) {
7009+ --btn-shadow: "";
7010+ --btn-bg: #0000;
7011+ --btn-fg: var(--btn-color);
7012+ --btn-border: var(--btn-color);
7013+ --btn-noise: none;
7014+ }
7015+ }
7016+ .btn-soft {
7017+ &:not(.btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled) {
7018+ --btn-shadow: "";
7019+ --btn-fg: var(--btn-color, var(--color-base-content));
7020+ --btn-bg: color-mix(
7021+ in oklab,
7022+ var(--btn-color, var(--color-base-content)) 8%,
7023+ var(--color-base-100)
7024+ );
7025+ --btn-border: color-mix(
7026+ in oklab,
7027+ var(--btn-color, var(--color-base-content)) 10%,
7028+ var(--color-base-100)
7029+ );
7030+ --btn-noise: none;
7031+ }
7032+ }
7033+ .indicator-center {
7034+ --inidicator-s: 50%;
7035+ --inidicator-e: 50%;
7036+ --inidicator-x: -50%;
7037+ [dir="rtl"] & {
7038+ --inidicator-x: 50%;
7039+ }
7040+ }
7041+ .mask-half-1 {
7042+ mask-size: 200%;
7043+ mask-position: left;
7044+ mask-position: left;
7045+ &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
7046+ mask-position: right;
7047+ }
7048+ }
7049+ .mask-half-2 {
7050+ mask-size: 200%;
7051+ mask-position: right;
7052+ mask-position: right;
7053+ &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
7054+ mask-position: left;
7055+ }
7056+ }
7057+ .btn-lg {
7058+ --fontsize: 1.125rem;
7059+ --btn-p: 1.25rem;
7060+ --size: calc(var(--size-field, 0.25rem) * 12);
7061+ }
7062+ .btn-md {
7063+ --fontsize: 0.875rem;
7064+ --btn-p: 1rem;
7065+ --size: calc(var(--size-field, 0.25rem) * 10);
7066+ }
7067+ .btn-sm {
7068+ --fontsize: 0.75rem;
7069+ --btn-p: 0.75rem;
7070+ --size: calc(var(--size-field, 0.25rem) * 8);
7071+ }
7072+ .btn-xl {
7073+ --fontsize: 1.375rem;
7074+ --btn-p: 1.5rem;
7075+ --size: calc(var(--size-field, 0.25rem) * 14);
7076+ }
7077+ .btn-xs {
7078+ --fontsize: 0.6875rem;
7079+ --btn-p: 0.5rem;
7080+ --size: calc(var(--size-field, 0.25rem) * 6);
7081+ }
7082+ .card-lg {
7083+ .card-body {
7084+ --card-p: 2rem;
7085+ --card-fs: 1rem;
7086+ }
7087+ .card-title {
7088+ --cardtitle-fs: 1.25rem;
7089+ }
7090+ }
7091+ .card-md {
7092+ .card-body {
7093+ --card-p: 1.5rem;
7094+ --card-fs: 0.875rem;
7095+ }
7096+ .card-title {
7097+ --cardtitle-fs: 1.125rem;
7098+ }
7099+ }
7100+ .card-sm {
7101+ .card-body {
7102+ --card-p: 1rem;
7103+ --card-fs: 0.75rem;
7104+ }
7105+ .card-title {
7106+ --cardtitle-fs: 1rem;
7107+ }
7108+ }
7109+ .card-xl {
7110+ .card-body {
7111+ --card-p: 2.5rem;
7112+ --card-fs: 1.125rem;
7113+ }
7114+ .card-title {
7115+ --cardtitle-fs: 1.375rem;
7116+ }
7117+ }
7118+ .card-xs {
7119+ .card-body {
7120+ --card-p: 0.5rem;
7121+ --card-fs: 0.6875rem;
7122+ }
7123+ .card-title {
7124+ --cardtitle-fs: 0.875rem;
7125+ }
7126+ }
7127+ .indicator-bottom {
7128+ --inidicator-t: auto;
7129+ --inidicator-b: 0;
7130+ --indicator-y: 50%;
7131+ }
7132+ .indicator-end {
7133+ --inidicator-s: auto;
7134+ --inidicator-e: 0;
7135+ --inidicator-x: 50%;
7136+ }
7137+ .indicator-middle {
7138+ --inidicator-t: 50%;
7139+ --inidicator-b: 50%;
7140+ --indicator-y: -50%;
7141+ }
7142+ .indicator-start {
7143+ --inidicator-s: 0;
7144+ --inidicator-e: auto;
7145+ --inidicator-x: -50%;
7146+ }
7147+ .indicator-top {
7148+ --inidicator-t: 0;
7149+ --inidicator-b: auto;
7150+ --indicator-y: -50%;
7151+ }
7152+ .badge-accent {
7153+ --badge-color: var(--color-accent);
7154+ --badge-fg: var(--color-accent-content);
7155+ }
7156+ .badge-error {
7157+ --badge-color: var(--color-error);
7158+ --badge-fg: var(--color-error-content);
7159+ }
7160+ .badge-info {
7161+ --badge-color: var(--color-info);
7162+ --badge-fg: var(--color-info-content);
7163+ }
7164+ .badge-neutral {
7165+ --badge-color: var(--color-neutral);
7166+ --badge-fg: var(--color-neutral-content);
7167+ }
7168+ .badge-primary {
7169+ --badge-color: var(--color-primary);
7170+ --badge-fg: var(--color-primary-content);
7171+ }
7172+ .badge-secondary {
7173+ --badge-color: var(--color-secondary);
7174+ --badge-fg: var(--color-secondary-content);
7175+ }
7176+ .badge-success {
7177+ --badge-color: var(--color-success);
7178+ --badge-fg: var(--color-success-content);
7179+ }
7180+ .badge-warning {
7181+ --badge-color: var(--color-warning);
7182+ --badge-fg: var(--color-warning-content);
7183+ }
7184+ .btn-accent {
7185+ --btn-color: var(--color-accent);
7186+ --btn-fg: var(--color-accent-content);
7187+ }
7188+ .btn-error {
7189+ --btn-color: var(--color-error);
7190+ --btn-fg: var(--color-error-content);
7191+ }
7192+ .btn-info {
7193+ --btn-color: var(--color-info);
7194+ --btn-fg: var(--color-info-content);
7195+ }
7196+ .btn-neutral {
7197+ --btn-color: var(--color-neutral);
7198+ --btn-fg: var(--color-neutral-content);
7199+ }
7200+ .btn-primary {
7201+ --btn-color: var(--color-primary);
7202+ --btn-fg: var(--color-primary-content);
7203+ }
7204+ .btn-secondary {
7205+ --btn-color: var(--color-secondary);
7206+ --btn-fg: var(--color-secondary-content);
7207+ }
7208+ .btn-success {
7209+ --btn-color: var(--color-success);
7210+ --btn-fg: var(--color-success-content);
7211+ }
7212+ .btn-warning {
7213+ --btn-color: var(--color-warning);
7214+ --btn-fg: var(--color-warning-content);
7215+ }
7216+ .timeline-snap-icon {
7217+ > li {
7218+ --timeline-col-start: 0.5rem;
7219+ --timeline-row-start: minmax(0, 1fr);
7220+ }
7221+ }
7222+ .toggle-lg {
7223+ &:is([type="checkbox"]), &:has([type="checkbox"]) {
7224+ --toggle-p: 0.25rem;
7225+ --size: calc(var(--size-selector, 0.25rem) * 7);
7226+ }
7227+ }
7228+ .toggle-md {
7229+ &:is([type="checkbox"]), &:has([type="checkbox"]) {
7230+ --toggle-p: 0.1875rem;
7231+ --size: calc(var(--size-selector, 0.25rem) * 6);
7232+ }
7233+ }
7234+ .toggle-sm {
7235+ &:is([type="checkbox"]), &:has([type="checkbox"]) {
7236+ --toggle-p: 0.125rem;
7237+ --size: calc(var(--size-selector, 0.25rem) * 5);
7238+ }
7239+ }
7240+ .toggle-xl {
7241+ &:is([type="checkbox"]), &:has([type="checkbox"]) {
7242+ --toggle-p: 0.3125rem;
7243+ --size: calc(var(--size-selector, 0.25rem) * 8);
7244+ }
7245+ }
7246+ .toggle-xs {
7247+ &:is([type="checkbox"]), &:has([type="checkbox"]) {
7248+ --toggle-p: 0.0625rem;
7249+ --size: calc(var(--size-selector, 0.25rem) * 4);
7250+ }
7251+ }
7252+ .\[image\:tag\] {
7253+ image: tag;
7254+ }
7255+ .input-accent {
7256+ &, &:focus, &:focus-within {
7257+ --input-color: var(--color-accent);
7258+ }
7259+ }
7260+ .input-error {
7261+ &, &:focus, &:focus-within {
7262+ --input-color: var(--color-error);
7263+ }
7264+ }
7265+ .input-info {
7266+ &, &:focus, &:focus-within {
7267+ --input-color: var(--color-info);
7268+ }
7269+ }
7270+ .input-neutral {
7271+ &, &:focus, &:focus-within {
7272+ --input-color: var(--color-neutral);
7273+ }
7274+ }
7275+ .input-primary {
7276+ &, &:focus, &:focus-within {
7277+ --input-color: var(--color-primary);
7278+ }
7279+ }
7280+ .input-secondary {
7281+ &, &:focus, &:focus-within {
7282+ --input-color: var(--color-secondary);
7283+ }
7284+ }
7285+ .input-success {
7286+ &, &:focus, &:focus-within {
7287+ --input-color: var(--color-success);
7288+ }
7289+ }
7290+ .input-warning {
7291+ &, &:focus, &:focus-within {
7292+ --input-color: var(--color-warning);
7293+ }
7294+ }
7295+ .loading-ball {
7296+ mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cellipse cx='12' cy='5' rx='4' ry='4'%3E%3Canimate attributeName='cy' values='5;20;20.5;20;5' keyTimes='0;0.469;0.5;0.531;1' dur='.8s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1'/%3E%3Canimate attributeName='rx' values='4;4;4.8;4;4' keyTimes='0;0.469;0.5;0.531;1' dur='.8s' repeatCount='indefinite'/%3E%3Canimate attributeName='ry' values='4;4;3;4;4' keyTimes='0;0.469;0.5;0.531;1' dur='.8s' repeatCount='indefinite'/%3E%3C/ellipse%3E%3C/svg%3E");
7297+ }
7298+ .loading-bars {
7299+ mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite'/%3E%3C/rect%3E%3Crect x='9' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.65s'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.65s'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.65s'/%3E%3C/rect%3E%3Crect x='17' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.5s'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.5s'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.5s'/%3E%3C/rect%3E%3C/svg%3E");
7300+ }
7301+ .loading-dots {
7302+ mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='4' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='1.05s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1'/%3E%3C/circle%3E%3Ccircle cx='12' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='1.05s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1' begin='0.1s'/%3E%3C/circle%3E%3Ccircle cx='20' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='1.05s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1' begin='0.2s'/%3E%3C/circle%3E%3C/svg%3E");
7303+ }
7304+ .loading-infinity {
7305+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' style='shape-rendering:auto;' width='200px' height='200px' viewBox='0 0 100 100' preserveAspectRatio='xMidYMid'%3E%3Cpath fill='none' stroke='black' stroke-width='10' stroke-dasharray='205.271 51.318' d='M24.3 30C11.4 30 5 43.3 5 50s6.4 20 19.3 20c19.3 0 32.1-40 51.4-40C88.6 30 95 43.3 95 50s-6.4 20-19.3 20C56.4 70 43.6 30 24.3 30z' stroke-linecap='round' style='transform:scale(0.8);transform-origin:50px 50px'%3E%3Canimate attributeName='stroke-dashoffset' repeatCount='indefinite' dur='2s' keyTimes='0;1' values='0;256.589'/%3E%3C/path%3E%3C/svg%3E");
7306+ }
7307+ .loading-ring {
7308+ mask-image: url("data:image/svg+xml,%3Csvg width='44' height='44' viewBox='0 0 44 44' xmlns='http://www.w3.org/2000/svg' stroke='white'%3E%3Cg fill='none' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
7309+ }
7310+ .loading-spinner {
7311+ mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
7312+ }
7313+ .mask-circle {
7314+ mask-image: url("data:image/svg+xml,%3csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle fill='black' cx='100' cy='100' r='100' fill-rule='evenodd'/%3e%3c/svg%3e");
7315+ }
7316+ .mask-decagon {
7317+ mask-image: url("data:image/svg+xml,%3csvg width='192' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 0 58.779 19.098 36.327 50v61.804l-36.327 50L96 200l-58.779-19.098-36.327-50V69.098l36.327-50z' fill-rule='evenodd'/%3e%3c/svg%3e");
7318+ }
7319+ .mask-diamond {
7320+ mask-image: url("data:image/svg+xml,%3csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m100 0 100 100-100 100L0 100z' fill-rule='evenodd'/%3e%3c/svg%3e");
7321+ }
7322+ .mask-heart {
7323+ mask-image: url("data:image/svg+xml,%3csvg width='200' height='185' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M100 184.606a15.384 15.384 0 0 1-8.653-2.678C53.565 156.28 37.205 138.695 28.182 127.7 8.952 104.264-.254 80.202.005 54.146.308 24.287 24.264 0 53.406 0c21.192 0 35.869 11.937 44.416 21.879a2.884 2.884 0 0 0 4.356 0C110.725 11.927 125.402 0 146.594 0c29.142 0 53.098 24.287 53.4 54.151.26 26.061-8.956 50.122-28.176 73.554-9.023 10.994-25.383 28.58-63.165 54.228a15.384 15.384 0 0 1-8.653 2.673Z' fill='black' fill-rule='nonzero'/%3e%3c/svg%3e");
7324+ }
7325+ .mask-hexagon {
7326+ mask-image: url("data:image/svg+xml,%3csvg width='182' height='201' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M.3 65.486c0-9.196 6.687-20.063 14.211-25.078l61.86-35.946c8.36-5.016 20.899-5.016 29.258 0l61.86 35.946c8.36 5.015 14.211 15.882 14.211 25.078v71.055c0 9.196-6.687 20.063-14.211 25.079l-61.86 35.945c-8.36 4.18-20.899 4.18-29.258 0L14.51 161.62C6.151 157.44.3 145.737.3 136.54V65.486Z' fill='black' fill-rule='nonzero'/%3e%3c/svg%3e");
7327+ }
7328+ .mask-hexagon-2 {
7329+ mask-image: url("data:image/svg+xml,%3csvg width='200' height='182' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M64.786 181.4c-9.196 0-20.063-6.687-25.079-14.21L3.762 105.33c-5.016-8.36-5.016-20.9 0-29.259l35.945-61.86C44.723 5.851 55.59 0 64.786 0h71.055c9.196 0 20.063 6.688 25.079 14.211l35.945 61.86c4.18 8.36 4.18 20.899 0 29.258l-35.945 61.86c-4.18 8.36-15.883 14.211-25.079 14.211H64.786Z' fill='black' fill-rule='nonzero'/%3e%3c/svg%3e");
7330+ }
7331+ .mask-pentagon {
7332+ mask-image: url("data:image/svg+xml,%3csvg width='192' height='181' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 0 95.106 69.098-36.327 111.804H37.22L.894 69.098z' fill-rule='evenodd'/%3e%3c/svg%3e");
7333+ }
7334+ .mask-squircle {
7335+ mask-image: url("data:image/svg+xml,%3csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M100 0C20 0 0 20 0 100s20 100 100 100 100-20 100-100S180 0 100 0Z'/%3e%3c/svg%3e");
7336+ }
7337+ .mask-star {
7338+ mask-image: url("data:image/svg+xml,%3csvg width='192' height='180' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 137.263-58.779 42.024 22.163-68.389L.894 68.481l72.476-.243L96 0l22.63 68.238 72.476.243-58.49 42.417 22.163 68.389z' fill-rule='evenodd'/%3e%3c/svg%3e");
7339+ }
7340+ .mask-star-2 {
7341+ mask-image: url("data:image/svg+xml,%3csvg width='192' height='180' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 153.044-58.779 26.243 7.02-63.513L.894 68.481l63.117-13.01L96 0l31.989 55.472 63.117 13.01-43.347 47.292 7.02 63.513z' fill-rule='evenodd'/%3e%3c/svg%3e");
7342+ }
7343+ .mask-triangle {
7344+ mask-image: url("data:image/svg+xml,%3csvg width='174' height='149' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m87 148.476-86.603.185L43.86 74.423 87 0l43.14 74.423 43.463 74.238z' fill-rule='evenodd'/%3e%3c/svg%3e");
7345+ }
7346+ .mask-triangle-2 {
7347+ mask-image: url("data:image/svg+xml,%3csvg width='174' height='150' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m87 .738 86.603-.184-43.463 74.238L87 149.214 43.86 74.792.397.554z' fill-rule='evenodd'/%3e%3c/svg%3e");
7348+ }
7349+ .mask-triangle-3 {
7350+ mask-image: url("data:image/svg+xml,%3csvg width='150' height='174' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m149.369 87.107.185 86.603-74.239-43.463L.893 87.107l74.422-43.14L149.554.505z' fill-rule='evenodd'/%3e%3c/svg%3e");
7351+ }
7352+ .mask-triangle-4 {
7353+ mask-image: url("data:image/svg+xml,%3csvg width='150' height='174' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M.631 87.107.446.505l74.239 43.462 74.422 43.14-74.422 43.14L.446 173.71z' fill-rule='evenodd'/%3e%3c/svg%3e");
7354+ }
7355+ .radio-accent {
7356+ --input-color: var(--color-accent);
7357+ }
7358+ .radio-error {
7359+ --input-color: var(--color-error);
7360+ }
7361+ .radio-info {
7362+ --input-color: var(--color-info);
7363+ }
7364+ .radio-neutral {
7365+ --input-color: var(--color-neutral);
7366+ }
7367+ .radio-primary {
7368+ --input-color: var(--color-primary);
7369+ }
7370+ .radio-secondary {
7371+ --input-color: var(--color-secondary);
7372+ }
7373+ .radio-success {
7374+ --input-color: var(--color-success);
7375+ }
7376+ .radio-warning {
7377+ --input-color: var(--color-warning);
7378+ }
7379+ .range-lg {
7380+ --range-thumb-size: calc(var(--size-selector, 0.25rem) * 7);
7381+ }
7382+ .range-md {
7383+ --range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
7384+ }
7385+ .range-sm {
7386+ --range-thumb-size: calc(var(--size-selector, 0.25rem) * 5);
7387+ }
7388+ .range-xl {
7389+ --range-thumb-size: calc(var(--size-selector, 0.25rem) * 8);
7390+ }
7391+ .range-xs {
7392+ --range-thumb-size: calc(var(--size-selector, 0.25rem) * 4);
7393+ }
7394+ .select-accent {
7395+ &, &:focus, &:focus-within {
7396+ --input-color: var(--color-accent);
7397+ }
7398+ }
7399+ .select-error {
7400+ &, &:focus, &:focus-within {
7401+ --input-color: var(--color-error);
7402+ }
7403+ }
7404+ .select-info {
7405+ &, &:focus, &:focus-within {
7406+ --input-color: var(--color-info);
7407+ }
7408+ }
7409+ .select-neutral {
7410+ &, &:focus, &:focus-within {
7411+ --input-color: var(--color-neutral);
7412+ }
7413+ }
7414+ .select-primary {
7415+ &, &:focus, &:focus-within {
7416+ --input-color: var(--color-primary);
7417+ }
7418+ }
7419+ .select-secondary {
7420+ &, &:focus, &:focus-within {
7421+ --input-color: var(--color-secondary);
7422+ }
7423+ }
7424+ .select-success {
7425+ &, &:focus, &:focus-within {
7426+ --input-color: var(--color-success);
7427+ }
7428+ }
7429+ .select-warning {
7430+ &, &:focus, &:focus-within {
7431+ --input-color: var(--color-warning);
7432+ }
7433+ }
7434+ .textarea-accent {
7435+ &, &:focus, &:focus-within {
7436+ --input-color: var(--color-accent);
7437+ }
7438+ }
7439+ .textarea-error {
7440+ &, &:focus, &:focus-within {
7441+ --input-color: var(--color-error);
7442+ }
7443+ }
7444+ .textarea-info {
7445+ &, &:focus, &:focus-within {
7446+ --input-color: var(--color-info);
7447+ }
7448+ }
7449+ .textarea-neutral {
7450+ &, &:focus, &:focus-within {
7451+ --input-color: var(--color-neutral);
7452+ }
7453+ }
7454+ .textarea-primary {
7455+ &, &:focus, &:focus-within {
7456+ --input-color: var(--color-primary);
7457+ }
7458+ }
7459+ .textarea-secondary {
7460+ &, &:focus, &:focus-within {
7461+ --input-color: var(--color-secondary);
7462+ }
7463+ }
7464+ .textarea-success {
7465+ &, &:focus, &:focus-within {
7466+ --input-color: var(--color-success);
7467+ }
7468+ }
7469+ .textarea-warning {
7470+ &, &:focus, &:focus-within {
7471+ --input-color: var(--color-warning);
7472+ }
7473+ }
7474+ .toggle-accent {
7475+ &:checked, &[aria-checked="true"] {
7476+ --input-color: var(--color-accent);
7477+ }
7478+ }
7479+ .toggle-error {
7480+ &:checked, &[aria-checked="true"] {
7481+ --input-color: var(--color-error);
7482+ }
7483+ }
7484+ .toggle-info {
7485+ &:checked, &[aria-checked="true"] {
7486+ --input-color: var(--color-info);
7487+ }
7488+ }
7489+ .toggle-neutral {
7490+ &:checked, &[aria-checked="true"] {
7491+ --input-color: var(--color-neutral);
7492+ }
7493+ }
7494+ .toggle-primary {
7495+ &:checked, &[aria-checked="true"] {
7496+ --input-color: var(--color-primary);
7497+ }
7498+ }
7499+ .toggle-secondary {
7500+ &:checked, &[aria-checked="true"] {
7501+ --input-color: var(--color-secondary);
7502+ }
7503+ }
7504+ .toggle-success {
7505+ &:checked, &[aria-checked="true"] {
7506+ --input-color: var(--color-success);
7507+ }
7508+ }
7509+ .toggle-warning {
7510+ &:checked, &[aria-checked="true"] {
7511+ --input-color: var(--color-warning);
7512+ }
7513+ }
7514+ .sm\:h-24 {
7515+ @media (width >= 40rem) {
7516+ height: calc(var(--spacing) * 24);
7517+ }
7518+ }
7519+ .sm\:flex-row {
7520+ @media (width >= 40rem) {
7521+ flex-direction: row;
7522+ }
7523+ }
7524+ .sm\:items-center {
7525+ @media (width >= 40rem) {
7526+ align-items: center;
7527+ }
7528+ }
7529+ .md\:col-span-1 {
7530+ @media (width >= 48rem) {
7531+ grid-column: span 1 / span 1;
7532+ }
7533+ }
7534+ .md\:col-span-2 {
7535+ @media (width >= 48rem) {
7536+ grid-column: span 2 / span 2;
7537+ }
7538+ }
7539+ .md\:col-span-3 {
7540+ @media (width >= 48rem) {
7541+ grid-column: span 3 / span 3;
7542+ }
7543+ }
7544+ .md\:col-span-4 {
7545+ @media (width >= 48rem) {
7546+ grid-column: span 4 / span 4;
7547+ }
7548+ }
7549+ .md\:col-span-5 {
7550+ @media (width >= 48rem) {
7551+ grid-column: span 5 / span 5;
7552+ }
7553+ }
7554+ .md\:ml-4 {
7555+ @media (width >= 48rem) {
7556+ margin-left: calc(var(--spacing) * 4);
7557+ }
7558+ }
7559+ .md\:w-2\/6 {
7560+ @media (width >= 48rem) {
7561+ width: calc(2/6 * 100%);
7562+ }
7563+ }
7564+ .md\:grid-cols-2 {
7565+ @media (width >= 48rem) {
7566+ grid-template-columns: repeat(2, minmax(0, 1fr));
7567+ }
7568+ }
7569+ .md\:grid-cols-3 {
7570+ @media (width >= 48rem) {
7571+ grid-template-columns: repeat(3, minmax(0, 1fr));
7572+ }
7573+ }
7574+ .md\:grid-cols-12 {
7575+ @media (width >= 48rem) {
7576+ grid-template-columns: repeat(12, minmax(0, 1fr));
7577+ }
7578+ }
7579+ .md\:flex-row {
7580+ @media (width >= 48rem) {
7581+ flex-direction: row;
7582+ }
7583+ }
7584+ .md\:space-y-0 {
7585+ @media (width >= 48rem) {
7586+ :where(& > :not(:last-child)) {
7587+ --tw-space-y-reverse: 0;
7588+ margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));
7589+ margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)));
7590+ }
7591+ }
7592+ }
7593+ .md\:space-x-4 {
7594+ @media (width >= 48rem) {
7595+ :where(& > :not(:last-child)) {
7596+ --tw-space-x-reverse: 0;
7597+ margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));
7598+ margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
7599+ }
7600+ }
7601+ }
7602+ .md\:place-self-center {
7603+ @media (width >= 48rem) {
7604+ place-self: center;
7605+ }
7606+ }
7607+ .md\:justify-self-end {
7608+ @media (width >= 48rem) {
7609+ justify-self: flex-end;
7610+ }
7611+ }
7612+ .md\:p-0 {
7613+ @media (width >= 48rem) {
7614+ padding: calc(var(--spacing) * 0);
7615+ }
7616+ }
7617+ .md\:p-8 {
7618+ @media (width >= 48rem) {
7619+ padding: calc(var(--spacing) * 8);
7620+ }
7621+ }
7622+ .md\:px-6 {
7623+ @media (width >= 48rem) {
7624+ padding-inline: calc(var(--spacing) * 6);
7625+ }
7626+ }
7627+ .md\:pt-10 {
7628+ @media (width >= 48rem) {
7629+ padding-top: calc(var(--spacing) * 10);
7630+ }
7631+ }
7632+ .md\:pt-32 {
7633+ @media (width >= 48rem) {
7634+ padding-top: calc(var(--spacing) * 32);
7635+ }
7636+ }
7637+ .md\:text-3xl {
7638+ @media (width >= 48rem) {
7639+ font-size: var(--text-3xl);
7640+ line-height: var(--tw-leading, var(--text-3xl--line-height));
7641+ }
7642+ }
7643+ .md\:text-sm {
7644+ @media (width >= 48rem) {
7645+ font-size: var(--text-sm);
7646+ line-height: var(--tw-leading, var(--text-sm--line-height));
7647+ }
7648+ }
7649+ .md\:text-xl {
7650+ @media (width >= 48rem) {
7651+ font-size: var(--text-xl);
7652+ line-height: var(--tw-leading, var(--text-xl--line-height));
7653+ }
7654+ }
7655+ .lg\:flex {
7656+ @media (width >= 64rem) {
7657+ display: flex;
7658+ }
7659+ }
7660+ .lg\:flex-row {
7661+ @media (width >= 64rem) {
7662+ flex-direction: row;
7663+ }
7664+ }
7665+ .lg\:text-4xl {
7666+ @media (width >= 64rem) {
7667+ font-size: var(--text-4xl);
7668+ line-height: var(--tw-leading, var(--text-4xl--line-height));
7669+ }
7670+ }
7671+ .xl\:h-48 {
7672+ @media (width >= 80rem) {
7673+ height: calc(var(--spacing) * 48);
7674+ }
7675+ }
7676+ .xl\:text-5xl {
7677+ @media (width >= 80rem) {
7678+ font-size: var(--text-5xl);
7679+ line-height: var(--tw-leading, var(--text-5xl--line-height));
7680+ }
5957681 }
5967682 }
597-
598-.fixed {
599- position: fixed;
600-}
601-
602-.relative {
603- position: relative;
604-}
605-
606-.left-0 {
607- left: 0px;
608-}
609-
610-.top-0 {
611- top: 0px;
612-}
613-
614-.mb-1 {
615- margin-bottom: 0.25rem;
616-}
617-
618-.mb-2 {
619- margin-bottom: 0.5rem;
620-}
621-
622-.mb-4 {
623- margin-bottom: 1rem;
624-}
625-
626-.mb-6 {
627- margin-bottom: 1.5rem;
628-}
629-
630-.ml-2 {
631- margin-left: 0.5rem;
632-}
633-
634-.ml-6 {
635- margin-left: 1.5rem;
636-}
637-
638-.ml-7 {
639- margin-left: 1.75rem;
640-}
641-
642-.mt-1 {
643- margin-top: 0.25rem;
644-}
645-
646-.mt-2 {
647- margin-top: 0.5rem;
648-}
649-
650-.mt-4 {
651- margin-top: 1rem;
652-}
653-
654-.mt-8 {
655- margin-top: 2rem;
656-}
657-
658-.block {
659- display: block;
660-}
661-
662-.flex {
663- display: flex;
664-}
665-
666-.table {
667- display: table;
668-}
669-
670-.hidden {
671- display: none;
672-}
673-
674-.h-12 {
675- height: 3rem;
676-}
677-
678-.h-32 {
679- height: 8rem;
680-}
681-
682-.h-44 {
683- height: 11rem;
684-}
685-
686-.h-5 {
687- height: 1.25rem;
688-}
689-
690-.h-8 {
691- height: 2rem;
692-}
693-
694-.h-full {
695- height: 100%;
7683+@layer components {
7684+ .btn-primary {
7685+ display: flex;
7686+ width: 100%;
7687+ justify-content: center;
7688+ border-radius: var(--radius-md);
7689+ border-style: var(--tw-border-style);
7690+ border-width: 1px;
7691+ border-color: transparent;
7692+ background-color: var(--color-indigo-600);
7693+ padding-inline: calc(var(--spacing) * 4);
7694+ padding-block: calc(var(--spacing) * 2);
7695+ font-size: var(--text-sm);
7696+ line-height: var(--tw-leading, var(--text-sm--line-height));
7697+ --tw-font-weight: var(--font-weight-medium);
7698+ font-weight: var(--font-weight-medium);
7699+ color: var(--color-white);
7700+ &:hover {
7701+ @media (hover: hover) {
7702+ background-color: var(--color-indigo-700);
7703+ }
7704+ }
7705+ &:focus {
7706+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor);
7707+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
7708+ }
7709+ &:focus {
7710+ --tw-ring-color: var(--color-indigo-500);
7711+ }
7712+ &:focus {
7713+ --tw-ring-offset-width: 2px;
7714+ --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
7715+ }
7716+ &:focus {
7717+ --tw-outline-style: none;
7718+ outline-style: none;
7719+ }
7720+ }
7721+ .btn-secondary {
7722+ display: flex;
7723+ width: 100%;
7724+ justify-content: center;
7725+ border-radius: var(--radius-md);
7726+ border-style: var(--tw-border-style);
7727+ border-width: 1px;
7728+ border-color: var(--color-gray-300);
7729+ background-color: var(--color-white);
7730+ padding-inline: calc(var(--spacing) * 4);
7731+ padding-block: calc(var(--spacing) * 2);
7732+ font-size: var(--text-sm);
7733+ line-height: var(--tw-leading, var(--text-sm--line-height));
7734+ --tw-font-weight: var(--font-weight-medium);
7735+ font-weight: var(--font-weight-medium);
7736+ color: var(--color-gray-700);
7737+ &:hover {
7738+ @media (hover: hover) {
7739+ background-color: var(--color-gray-50);
7740+ }
7741+ }
7742+ &:focus {
7743+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor);
7744+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
7745+ }
7746+ &:focus {
7747+ --tw-ring-color: var(--color-indigo-500);
7748+ }
7749+ &:focus {
7750+ --tw-ring-offset-width: 2px;
7751+ --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
7752+ }
7753+ &:focus {
7754+ --tw-outline-style: none;
7755+ outline-style: none;
7756+ }
7757+ }
7758+ .btn-danger {
7759+ display: flex;
7760+ width: 100%;
7761+ justify-content: center;
7762+ border-radius: var(--radius-md);
7763+ border-style: var(--tw-border-style);
7764+ border-width: 1px;
7765+ border-color: transparent;
7766+ background-color: var(--color-red-600);
7767+ padding-inline: calc(var(--spacing) * 4);
7768+ padding-block: calc(var(--spacing) * 2);
7769+ font-size: var(--text-sm);
7770+ line-height: var(--tw-leading, var(--text-sm--line-height));
7771+ --tw-font-weight: var(--font-weight-medium);
7772+ font-weight: var(--font-weight-medium);
7773+ color: var(--color-white);
7774+ &:hover {
7775+ @media (hover: hover) {
7776+ background-color: var(--color-red-700);
7777+ }
7778+ }
7779+ &:focus {
7780+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor);
7781+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
7782+ }
7783+ &:focus {
7784+ --tw-ring-color: var(--color-red-500);
7785+ }
7786+ &:focus {
7787+ --tw-ring-offset-width: 2px;
7788+ --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
7789+ }
7790+ &:focus {
7791+ --tw-outline-style: none;
7792+ outline-style: none;
7793+ }
7794+ }
7795+ .alert-error {
7796+ border-radius: var(--radius-md);
7797+ background-color: var(--color-red-50);
7798+ padding: calc(var(--spacing) * 4);
7799+ @media (prefers-color-scheme: dark) {
7800+ background-color: color-mix(in oklab, var(--color-red-900) 20%, transparent);
7801+ }
7802+ }
7803+ .alert-error-icon {
7804+ height: calc(var(--spacing) * 5);
7805+ width: calc(var(--spacing) * 5);
7806+ color: var(--color-red-400);
7807+ }
7808+ .alert-error-text {
7809+ font-size: var(--text-sm);
7810+ line-height: var(--tw-leading, var(--text-sm--line-height));
7811+ --tw-font-weight: var(--font-weight-medium);
7812+ font-weight: var(--font-weight-medium);
7813+ color: var(--color-red-800);
7814+ @media (prefers-color-scheme: dark) {
7815+ color: var(--color-red-300);
7816+ }
7817+ }
7818+ .alert-success {
7819+ border-radius: var(--radius-md);
7820+ background-color: var(--color-green-50);
7821+ padding: calc(var(--spacing) * 4);
7822+ @media (prefers-color-scheme: dark) {
7823+ background-color: color-mix(in oklab, var(--color-green-900) 20%, transparent);
7824+ }
7825+ }
7826+ .alert-success-icon {
7827+ height: calc(var(--spacing) * 5);
7828+ width: calc(var(--spacing) * 5);
7829+ color: var(--color-green-400);
7830+ }
7831+ .alert-success-text {
7832+ font-size: var(--text-sm);
7833+ line-height: var(--tw-leading, var(--text-sm--line-height));
7834+ --tw-font-weight: var(--font-weight-medium);
7835+ font-weight: var(--font-weight-medium);
7836+ color: var(--color-green-800);
7837+ @media (prefers-color-scheme: dark) {
7838+ color: var(--color-green-300);
7839+ }
7840+ }
7841+ .card {
7842+ margin-bottom: calc(var(--spacing) * 4);
7843+ border-radius: var(--radius-md);
7844+ background-color: var(--color-white);
7845+ padding: calc(var(--spacing) * 4);
7846+ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
7847+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
7848+ }
7849+ .card-header {
7850+ margin-bottom: calc(var(--spacing) * 2);
7851+ font-size: var(--text-lg);
7852+ line-height: var(--tw-leading, var(--text-lg--line-height));
7853+ --tw-font-weight: var(--font-weight-semibold);
7854+ font-weight: var(--font-weight-semibold);
7855+ color: var(--color-indigo-600);
7856+ }
7857+ .bg-app {
7858+ background-color: var(--color-indigo-50);
7859+ }
7860+ .bg-sidebar {
7861+ background-color: var(--color-indigo-900);
7862+ }
7863+ .bg-card {
7864+ background-color: var(--color-white);
7865+ }
7866+ .text-title {
7867+ --tw-font-weight: var(--font-weight-semibold);
7868+ font-weight: var(--font-weight-semibold);
7869+ color: var(--color-indigo-600);
7870+ }
7871+ .text-subtitle {
7872+ color: var(--color-indigo-500);
7873+ }
6967874 }
697-
698-.w-1\/3 {
699- width: 33.333333%;
700-}
701-
702-.w-1\/4 {
703- width: 25%;
704-}
705-
706-.w-2\/3 {
707- width: 66.666667%;
708-}
709-
710-.w-5 {
711- width: 1.25rem;
712-}
713-
714-.w-5\/12 {
715- width: 41.666667%;
716-}
717-
718-.w-8 {
719- width: 2rem;
720-}
721-
722-.w-full {
723- width: 100%;
724-}
725-
726-.max-w-md {
727- max-width: 28rem;
728-}
729-
730-.flex-1 {
731- flex: 1 1 0%;
732-}
733-
734-.flex-grow {
735- flex-grow: 1;
736-}
737-
738-.table-auto {
739- table-layout: auto;
740-}
741-
742-.cursor-pointer {
743- cursor: pointer;
744-}
745-
746-.list-disc {
747- list-style-type: disc;
748-}
749-
750-.flex-row {
751- flex-direction: row;
752-}
753-
754-.flex-col {
755- flex-direction: column;
756-}
757-
758-.content-center {
759- align-content: center;
760-}
761-
762-.items-center {
763- align-items: center;
764-}
765-
766-.justify-start {
767- justify-content: flex-start;
768-}
769-
770-.justify-end {
771- justify-content: flex-end;
772-}
773-
774-.justify-center {
775- justify-content: center;
776-}
777-
778-.justify-between {
779- justify-content: space-between;
780-}
781-
782-.justify-evenly {
783- justify-content: space-evenly;
784-}
785-
786-.space-x-2 > :not([hidden]) ~ :not([hidden]) {
787- --tw-space-x-reverse: 0;
788- margin-right: calc(0.5rem * var(--tw-space-x-reverse));
789- margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
790-}
791-
792-.space-x-4 > :not([hidden]) ~ :not([hidden]) {
793- --tw-space-x-reverse: 0;
794- margin-right: calc(1rem * var(--tw-space-x-reverse));
795- margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
796-}
797-
798-.space-y-4 > :not([hidden]) ~ :not([hidden]) {
799- --tw-space-y-reverse: 0;
800- margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
801- margin-bottom: calc(1rem * var(--tw-space-y-reverse));
802-}
803-
804-.space-y-6 > :not([hidden]) ~ :not([hidden]) {
805- --tw-space-y-reverse: 0;
806- margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
807- margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
808-}
809-
810-.rounded {
811- border-radius: 0.25rem;
812-}
813-
814-.rounded-full {
815- border-radius: 9999px;
816-}
817-
818-.rounded-md {
819- border-radius: 0.375rem;
820-}
821-
822-.rounded-sm {
823- border-radius: 0.125rem;
824-}
825-
826-.border {
827- border-width: 1px;
828-}
829-
830-.border-2 {
831- border-width: 2px;
832-}
833-
834-.border-t-2 {
835- border-top-width: 2px;
836-}
837-
838-.border-gray-300 {
839- --tw-border-opacity: 1;
840- border-color: rgb(209 213 219 / var(--tw-border-opacity));
841-}
842-
843-.border-red-400 {
844- --tw-border-opacity: 1;
845- border-color: rgb(248 113 113 / var(--tw-border-opacity));
846-}
847-
848-.bg-blue-500 {
849- --tw-bg-opacity: 1;
850- background-color: rgb(59 130 246 / var(--tw-bg-opacity));
851-}
852-
853-.bg-gordonmascot-hblue {
854- --tw-bg-opacity: 1;
855- background-color: rgb(1 30 92 / var(--tw-bg-opacity));
856-}
857-
858-.bg-gordonmascot-lblue {
859- --tw-bg-opacity: 1;
860- background-color: rgb(0 123 192 / var(--tw-bg-opacity));
861-}
862-
863-.bg-gordonmascot-mblue {
864- --tw-bg-opacity: 1;
865- background-color: rgb(0 82 147 / var(--tw-bg-opacity));
866-}
867-
868-.bg-gray-100 {
869- --tw-bg-opacity: 1;
870- background-color: rgb(243 244 246 / var(--tw-bg-opacity));
871-}
872-
873-.bg-gray-300 {
874- --tw-bg-opacity: 1;
875- background-color: rgb(209 213 219 / var(--tw-bg-opacity));
876-}
877-
878-.bg-green-500 {
879- --tw-bg-opacity: 1;
880- background-color: rgb(34 197 94 / var(--tw-bg-opacity));
881-}
882-
883-.bg-red-100 {
884- --tw-bg-opacity: 1;
885- background-color: rgb(254 226 226 / var(--tw-bg-opacity));
886-}
887-
888-.bg-slate-100 {
889- --tw-bg-opacity: 1;
890- background-color: rgb(241 245 249 / var(--tw-bg-opacity));
891-}
892-
893-.bg-slate-200 {
894- --tw-bg-opacity: 1;
895- background-color: rgb(226 232 240 / var(--tw-bg-opacity));
896-}
897-
898-.bg-transparent {
899- background-color: transparent;
900-}
901-
902-.bg-white {
903- --tw-bg-opacity: 1;
904- background-color: rgb(255 255 255 / var(--tw-bg-opacity));
905-}
906-
907-.bg-zinc-200 {
908- --tw-bg-opacity: 1;
909- background-color: rgb(228 228 231 / var(--tw-bg-opacity));
910-}
911-
912-.p-0 {
913- padding: 0px;
914-}
915-
916-.p-2 {
917- padding: 0.5rem;
918-}
919-
920-.p-4 {
921- padding: 1rem;
922-}
923-
924-.px-2 {
925- padding-left: 0.5rem;
926- padding-right: 0.5rem;
927-}
928-
929-.px-3 {
930- padding-left: 0.75rem;
931- padding-right: 0.75rem;
932-}
933-
934-.px-4 {
935- padding-left: 1rem;
936- padding-right: 1rem;
937-}
938-
939-.px-8 {
940- padding-left: 2rem;
941- padding-right: 2rem;
942-}
943-
944-.py-1 {
945- padding-top: 0.25rem;
946- padding-bottom: 0.25rem;
947-}
948-
949-.py-2 {
950- padding-top: 0.5rem;
951- padding-bottom: 0.5rem;
952-}
953-
954-.py-3 {
955- padding-top: 0.75rem;
956- padding-bottom: 0.75rem;
957-}
958-
959-.pb-4 {
960- padding-bottom: 1rem;
961-}
962-
963-.pr-1 {
964- padding-right: 0.25rem;
965-}
966-
967-.pt-16 {
968- padding-top: 4rem;
969-}
970-
971-.text-center {
972- text-align: center;
973-}
974-
975-.align-middle {
976- vertical-align: middle;
977-}
978-
979-.text-2xl {
980- font-size: 1.5rem;
981- line-height: 2rem;
982-}
983-
984-.text-3xl {
985- font-size: 1.875rem;
986- line-height: 2.25rem;
987-}
988-
989-.text-4xl {
990- font-size: 2.25rem;
991- line-height: 2.5rem;
992-}
993-
994-.text-5xl {
995- font-size: 3rem;
996- line-height: 1;
997-}
998-
999-.text-lg {
1000- font-size: 1.125rem;
1001- line-height: 1.75rem;
1002-}
1003-
1004-.text-sm {
1005- font-size: 0.875rem;
1006- line-height: 1.25rem;
1007-}
1008-
1009-.text-xl {
1010- font-size: 1.25rem;
1011- line-height: 1.75rem;
1012-}
1013-
1014-.text-xs {
1015- font-size: 0.75rem;
1016- line-height: 1rem;
1017-}
1018-
1019-.font-bold {
1020- font-weight: 700;
1021-}
1022-
1023-.font-medium {
1024- font-weight: 500;
1025-}
1026-
1027-.font-semibold {
1028- font-weight: 600;
1029-}
1030-
1031-.tracking-tighter {
1032- letter-spacing: -0.05em;
1033-}
1034-
1035-.text-blue-600 {
1036- --tw-text-opacity: 1;
1037- color: rgb(37 99 235 / var(--tw-text-opacity));
1038-}
1039-
1040-.text-blue-800 {
1041- --tw-text-opacity: 1;
1042- color: rgb(30 64 175 / var(--tw-text-opacity));
1043-}
1044-
1045-.text-gordonmascot-hblue {
1046- --tw-text-opacity: 1;
1047- color: rgb(1 30 92 / var(--tw-text-opacity));
1048-}
1049-
1050-.text-gordonmascot-mblue {
1051- --tw-text-opacity: 1;
1052- color: rgb(0 82 147 / var(--tw-text-opacity));
1053-}
1054-
1055-.text-gray-100 {
1056- --tw-text-opacity: 1;
1057- color: rgb(243 244 246 / var(--tw-text-opacity));
1058-}
1059-
1060-.text-gray-400 {
1061- --tw-text-opacity: 1;
1062- color: rgb(156 163 175 / var(--tw-text-opacity));
1063-}
1064-
1065-.text-gray-500 {
1066- --tw-text-opacity: 1;
1067- color: rgb(107 114 128 / var(--tw-text-opacity));
1068-}
1069-
1070-.text-gray-700 {
1071- --tw-text-opacity: 1;
1072- color: rgb(55 65 81 / var(--tw-text-opacity));
1073-}
1074-
1075-.text-gray-800 {
1076- --tw-text-opacity: 1;
1077- color: rgb(31 41 55 / var(--tw-text-opacity));
1078-}
1079-
1080-.text-green-300 {
1081- --tw-text-opacity: 1;
1082- color: rgb(134 239 172 / var(--tw-text-opacity));
1083-}
1084-
1085-.text-red-500 {
1086- --tw-text-opacity: 1;
1087- color: rgb(239 68 68 / var(--tw-text-opacity));
1088-}
1089-
1090-.text-red-600 {
1091- --tw-text-opacity: 1;
1092- color: rgb(220 38 38 / var(--tw-text-opacity));
1093-}
1094-
1095-.text-red-700 {
1096- --tw-text-opacity: 1;
1097- color: rgb(185 28 28 / var(--tw-text-opacity));
1098-}
1099-
1100-.text-white {
1101- --tw-text-opacity: 1;
1102- color: rgb(255 255 255 / var(--tw-text-opacity));
1103-}
1104-
1105-.shadow {
1106- --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
1107- --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
1108- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1109-}
1110-
1111-.shadow-sm {
1112- --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
1113- --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
1114- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1115-}
1116-
1117-.outline-none {
1118- outline: 2px solid transparent;
1119- outline-offset: 2px;
1120-}
1121-
1122-.transition {
1123- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
1124- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
1125- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
1126- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1127- transition-duration: 150ms;
1128-}
1129-
1130-.duration-150 {
1131- transition-duration: 150ms;
1132-}
1133-
1134-.ease-in-out {
1135- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1136-}
1137-
1138-/* Import IBM Plex Fonts */
1139-
11407875 @font-face {
11417876 font-family: "IBMPlexSans-Bold";
1142-
11437877 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2") format("woff2");
1144-
11457878 font-weight: bold;
1146-
11477879 font-style: normal;
11487880 }
1149-
11507881 @font-face {
11517882 font-family: "IBMPlexSans-Regular";
1152-
11537883 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2") format("woff2");
1154-
11557884 font-weight: normal;
1156-
11577885 font-style: normal;
11587886 }
1159-
1160-/* Import Jetbrains Mono Nerd Font */
1161-
11627887 @font-face {
11637888 font-family: "JetBrainsMono-Nerd-Font-Regular";
1164-
1165- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2")
1166- format("woff2");
1167-
7889+ src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2") format("woff2");
11687890 font-weight: normal;
1169-
11707891 font-style: normal;
11717892 }
1172-
1173-/* Import Jetbrains Mono Nerd Font Bold */
1174-
11757893 @font-face {
11767894 font-family: "JetBrainsMono-Nerd-Font-Bold";
1177-
1178- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2")
1179- format("woff2");
1180-
7895+ src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2") format("woff2");
11817896 font-weight: bold;
1182-
11837897 font-style: normal;
11847898 }
1185-
11867899 @font-face {
11877900 font-family: "LilitaOne-Regular";
1188-
11897901 src: url("/assets/fonts/LilitaOne/LilitaOne-Regular.ttf") format("truetype");
1190-
11917902 font-weight: normal;
1192-
11937903 font-style: normal;
11947904 }
1195-
11967905 .fade-me-out.htmx-swapping {
11977906 opacity: 0;
11987907 transition: opacity 1s ease-out;
11997908 }
1200-
1201-/* View transition */
1202-
12037909 @keyframes fade-in {
12047910 from {
12057911 opacity: 0;
12067912 }
12077913 }
1208-
12097914 @keyframes fade-out {
12107915 to {
12117916 opacity: 0;
12127917 }
12137918 }
1214-
12157919 @keyframes slide-from-right {
12167920 from {
12177921 transform: translateX(90px);
12187922 }
12197923 }
1220-
12217924 @keyframes slide-to-left {
12227925 to {
12237926 transform: translateX(-90px);
12247927 }
12257928 }
1226-
12277929 @keyframes wiggle {
1228- 0%,
1229- 100% {
7930+ 0%, 100% {
12307931 transform: rotate(-3deg);
12317932 }
1232-
12337933 50% {
12347934 transform: rotate(3deg);
12357935 }
12367936 }
1237-
12387937 #update-available {
12397938 animation: wiggle 1s ease-in-out infinite;
12407939 }
1241-
12427940 .slide-it {
12437941 view-transition-name: slide-it;
12447942 }
1245-
12467943 ::view-transition-old(slide-it) {
1247- animation:
1248- 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
1249- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
7944+ animation: 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
12507945 }
1251-
12527946 ::view-transition-new(slide-it) {
1253- animation:
1254- 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
1255- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
7947+ animation: 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
12567948 }
1257-
12587949 .gordon-title {
12597950 font-family: "LilitaOne-Regular", sans-serif;
12607951 }
1261-
12627952 .IBMPlexSans {
12637953 font-family: "IBMPlexSans-Regular", sans-serif;
12647954 }
1265-
1266-.JetBrainsMonoNF, .iconf{
7955+.JetBrainsMonoNF, .iconf {
12677956 font-family: "JetBrainsMono-Nerd-Font-Regular", sans-serif;
12687957 }
1269-
1270-.JetBrainsMonoBoldNF, .iconfb{
7958+.JetBrainsMonoBoldNF, .iconfb {
12717959 font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif;
12727960 }
1273-
1274-.hover\:bg-blue-700:hover {
1275- --tw-bg-opacity: 1;
1276- background-color: rgb(29 78 216 / var(--tw-bg-opacity));
1277-}
1278-
1279-.hover\:bg-gordonmascot-hblue:hover {
1280- --tw-bg-opacity: 1;
1281- background-color: rgb(1 30 92 / var(--tw-bg-opacity));
1282-}
1283-
1284-.hover\:bg-gordonmascot-mblue:hover {
1285- --tw-bg-opacity: 1;
1286- background-color: rgb(0 82 147 / var(--tw-bg-opacity));
1287-}
1288-
1289-.hover\:bg-slate-100:hover {
1290- --tw-bg-opacity: 1;
1291- background-color: rgb(241 245 249 / var(--tw-bg-opacity));
1292-}
1293-
1294-.hover\:text-gordonmascot-hblue:hover {
1295- --tw-text-opacity: 1;
1296- color: rgb(1 30 92 / var(--tw-text-opacity));
1297-}
1298-
1299-.hover\:text-red-900:hover {
1300- --tw-text-opacity: 1;
1301- color: rgb(127 29 29 / var(--tw-text-opacity));
1302-}
1303-
1304-.focus\:border-gordonmascot-hblue:focus {
1305- --tw-border-opacity: 1;
1306- border-color: rgb(1 30 92 / var(--tw-border-opacity));
1307-}
1308-
1309-.focus\:outline-none:focus {
1310- outline: 2px solid transparent;
1311- outline-offset: 2px;
1312-}
1313-
1314-.focus\:ring-gordonmascot-hblue:focus {
1315- --tw-ring-opacity: 1;
1316- --tw-ring-color: rgb(1 30 92 / var(--tw-ring-opacity));
1317-}
1318-
1319-@media (min-width: 640px) {
1320- .sm\:inline {
1321- display: inline;
7961+@layer base {
7962+ :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
7963+ color-scheme: light;
7964+ --color-base-100: oklch(100% 0 0);
7965+ --color-base-200: oklch(98% 0 0);
7966+ --color-base-300: oklch(95% 0 0);
7967+ --color-base-content: oklch(21% 0.006 285.885);
7968+ --color-primary: oklch(45% 0.24 277.023);
7969+ --color-primary-content: oklch(93% 0.034 272.788);
7970+ --color-secondary: oklch(65% 0.241 354.308);
7971+ --color-secondary-content: oklch(94% 0.028 342.258);
7972+ --color-accent: oklch(77% 0.152 181.912);
7973+ --color-accent-content: oklch(38% 0.063 188.416);
7974+ --color-neutral: oklch(14% 0.005 285.823);
7975+ --color-neutral-content: oklch(92% 0.004 286.32);
7976+ --color-info: oklch(74% 0.16 232.661);
7977+ --color-info-content: oklch(29% 0.066 243.157);
7978+ --color-success: oklch(76% 0.177 163.223);
7979+ --color-success-content: oklch(37% 0.077 168.94);
7980+ --color-warning: oklch(82% 0.189 84.429);
7981+ --color-warning-content: oklch(41% 0.112 45.904);
7982+ --color-error: oklch(71% 0.194 13.428);
7983+ --color-error-content: oklch(27% 0.105 12.094);
7984+ --radius-selector: 0.5rem;
7985+ --radius-field: 0.25rem;
7986+ --radius-box: 0.5rem;
7987+ --size-selector: 0.25rem;
7988+ --size-field: 0.25rem;
7989+ --border: 1px;
7990+ --depth: 1;
7991+ --noise: 0;
13227992 }
1323-
1324- .sm\:h-24 {
1325- height: 6rem;
7993+}
7994+@layer base {
7995+ @media (prefers-color-scheme: dark) {
7996+ :root {
7997+ color-scheme: dark;
7998+ --color-base-100: oklch(25.33% 0.016 252.42);
7999+ --color-base-200: oklch(23.26% 0.014 253.1);
8000+ --color-base-300: oklch(21.15% 0.012 254.09);
8001+ --color-base-content: oklch(97.807% 0.029 256.847);
8002+ --color-primary: oklch(58% 0.233 277.117);
8003+ --color-primary-content: oklch(96% 0.018 272.314);
8004+ --color-secondary: oklch(65% 0.241 354.308);
8005+ --color-secondary-content: oklch(94% 0.028 342.258);
8006+ --color-accent: oklch(77% 0.152 181.912);
8007+ --color-accent-content: oklch(38% 0.063 188.416);
8008+ --color-neutral: oklch(14% 0.005 285.823);
8009+ --color-neutral-content: oklch(92% 0.004 286.32);
8010+ --color-info: oklch(74% 0.16 232.661);
8011+ --color-info-content: oklch(29% 0.066 243.157);
8012+ --color-success: oklch(76% 0.177 163.223);
8013+ --color-success-content: oklch(37% 0.077 168.94);
8014+ --color-warning: oklch(82% 0.189 84.429);
8015+ --color-warning-content: oklch(41% 0.112 45.904);
8016+ --color-error: oklch(71% 0.194 13.428);
8017+ --color-error-content: oklch(27% 0.105 12.094);
8018+ --radius-selector: 0.5rem;
8019+ --radius-field: 0.25rem;
8020+ --radius-box: 0.5rem;
8021+ --size-selector: 0.25rem;
8022+ --size-field: 0.25rem;
8023+ --border: 1px;
8024+ --depth: 1;
8025+ --noise: 0;
8026+ }
13268027 }
13278028 }
1328-
1329-@media (min-width: 768px) {
1330- .md\:ml-4 {
1331- margin-left: 1rem;
8029+@layer base {
8030+ :root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
8031+ color-scheme: light;
8032+ --color-base-100: oklch(100% 0 0);
8033+ --color-base-200: oklch(98% 0 0);
8034+ --color-base-300: oklch(95% 0 0);
8035+ --color-base-content: oklch(21% 0.006 285.885);
8036+ --color-primary: oklch(45% 0.24 277.023);
8037+ --color-primary-content: oklch(93% 0.034 272.788);
8038+ --color-secondary: oklch(65% 0.241 354.308);
8039+ --color-secondary-content: oklch(94% 0.028 342.258);
8040+ --color-accent: oklch(77% 0.152 181.912);
8041+ --color-accent-content: oklch(38% 0.063 188.416);
8042+ --color-neutral: oklch(14% 0.005 285.823);
8043+ --color-neutral-content: oklch(92% 0.004 286.32);
8044+ --color-info: oklch(74% 0.16 232.661);
8045+ --color-info-content: oklch(29% 0.066 243.157);
8046+ --color-success: oklch(76% 0.177 163.223);
8047+ --color-success-content: oklch(37% 0.077 168.94);
8048+ --color-warning: oklch(82% 0.189 84.429);
8049+ --color-warning-content: oklch(41% 0.112 45.904);
8050+ --color-error: oklch(71% 0.194 13.428);
8051+ --color-error-content: oklch(27% 0.105 12.094);
8052+ --radius-selector: 0.5rem;
8053+ --radius-field: 0.25rem;
8054+ --radius-box: 0.5rem;
8055+ --size-selector: 0.25rem;
8056+ --size-field: 0.25rem;
8057+ --border: 1px;
8058+ --depth: 1;
8059+ --noise: 0;
13328060 }
1333-
1334- .md\:h-16 {
1335- height: 4rem;
8061+}
8062+@layer base {
8063+ :root:has(input.theme-controller[value=dark]:checked),[data-theme=dark] {
8064+ color-scheme: dark;
8065+ --color-base-100: oklch(25.33% 0.016 252.42);
8066+ --color-base-200: oklch(23.26% 0.014 253.1);
8067+ --color-base-300: oklch(21.15% 0.012 254.09);
8068+ --color-base-content: oklch(97.807% 0.029 256.847);
8069+ --color-primary: oklch(58% 0.233 277.117);
8070+ --color-primary-content: oklch(96% 0.018 272.314);
8071+ --color-secondary: oklch(65% 0.241 354.308);
8072+ --color-secondary-content: oklch(94% 0.028 342.258);
8073+ --color-accent: oklch(77% 0.152 181.912);
8074+ --color-accent-content: oklch(38% 0.063 188.416);
8075+ --color-neutral: oklch(14% 0.005 285.823);
8076+ --color-neutral-content: oklch(92% 0.004 286.32);
8077+ --color-info: oklch(74% 0.16 232.661);
8078+ --color-info-content: oklch(29% 0.066 243.157);
8079+ --color-success: oklch(76% 0.177 163.223);
8080+ --color-success-content: oklch(37% 0.077 168.94);
8081+ --color-warning: oklch(82% 0.189 84.429);
8082+ --color-warning-content: oklch(41% 0.112 45.904);
8083+ --color-error: oklch(71% 0.194 13.428);
8084+ --color-error-content: oklch(27% 0.105 12.094);
8085+ --radius-selector: 0.5rem;
8086+ --radius-field: 0.25rem;
8087+ --radius-box: 0.5rem;
8088+ --size-selector: 0.25rem;
8089+ --size-field: 0.25rem;
8090+ --border: 1px;
8091+ --depth: 1;
8092+ --noise: 0;
13368093 }
1337-
1338- .md\:w-2\/6 {
1339- width: 33.333333%;
8094+}
8095+@layer base {
8096+ @property --radialprogress {
8097+ syntax: "<percentage>";
8098+ inherits: true;
8099+ initial-value: 0%;
13408100 }
1341-
1342- .md\:flex-row {
1343- flex-direction: row;
8101+}
8102+@layer base {
8103+ :root {
8104+ scrollbar-color: color-mix(in oklch, currentColor 35%, #0000) #0000;
13448105 }
1345-
1346- .md\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
1347- --tw-space-x-reverse: 0;
1348- margin-right: calc(1rem * var(--tw-space-x-reverse));
1349- margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
8106+}
8107+@layer base {
8108+ :root:has( .modal-open, .modal[open], .modal:target, .modal-toggle:checked, .drawer:not([class*="drawer-open"]) > .drawer-toggle:checked ) {
8109+ overflow: hidden;
13508110 }
1351-
1352- .md\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
1353- --tw-space-y-reverse: 0;
1354- margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
1355- margin-bottom: calc(0px * var(--tw-space-y-reverse));
8111+}
8112+@layer base {
8113+ :root,
8114+[data-theme] {
8115+ background-color: var(--root-bg, var(--color-base-100));
8116+ color: var(--color-base-content);
13568117 }
1357-
1358- .md\:rounded-md {
1359- border-radius: 0.375rem;
8118+}
8119+@layer base {
8120+ :root {
8121+ --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");
13608122 }
1361-
1362- .md\:p-0 {
1363- padding: 0px;
8123+ .chat {
8124+ --mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");
13648125 }
1365-
1366- .md\:p-8 {
1367- padding: 2rem;
8126+}
8127+@layer base {
8128+ :where(
8129+ :root:has(
8130+ .modal-open,
8131+ .modal[open],
8132+ .modal:target,
8133+ .modal-toggle:checked,
8134+ .drawer:not(.drawer-open) > .drawer-toggle:checked
8135+ )
8136+) {
8137+ scrollbar-gutter: stable;
8138+ background-image: linear-gradient(var(--color-base-100), var(--color-base-100));
8139+ --root-bg: color-mix(in srgb, var(--color-base-100), oklch(0% 0 0) 40%);
13688140 }
1369-
1370- .md\:px-6 {
1371- padding-left: 1.5rem;
1372- padding-right: 1.5rem;
8141+}
8142+@keyframes skeleton {
8143+ 0% {
8144+ background-position: 150%;
13738145 }
1374-
1375- .md\:pt-10 {
1376- padding-top: 2.5rem;
8146+ 100% {
8147+ background-position: -50%;
13778148 }
1378-
1379- .md\:pt-32 {
1380- padding-top: 8rem;
8149+}
8150+@keyframes progress {
8151+ 50% {
8152+ background-position-x: -115%;
13818153 }
1382-
1383- .md\:text-4xl {
1384- font-size: 2.25rem;
1385- line-height: 2.5rem;
8154+}
8155+@keyframes radio {
8156+ 0% {
8157+ padding: 5px;
13868158 }
1387-
1388- .md\:text-sm {
1389- font-size: 0.875rem;
1390- line-height: 1.25rem;
8159+ 50% {
8160+ padding: 3px;
13918161 }
1392-
1393- .md\:text-xl {
1394- font-size: 1.25rem;
1395- line-height: 1.75rem;
8162+}
8163+@keyframes dropdown {
8164+ 0% {
8165+ opacity: 0;
13968166 }
1397-
1398- .md\:shadow {
1399- --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
1400- --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
1401- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
8167+}
8168+@keyframes rating {
8169+ 0%, 40% {
8170+ scale: 1.1;
8171+ filter: brightness(1.05) contrast(1.05);
14028172 }
14038173 }
1404-
1405-@media (min-width: 1024px) {
1406- .lg\:flex-row {
1407- flex-direction: row;
8174+@keyframes toast {
8175+ 0% {
8176+ scale: 0.9;
8177+ opacity: 0;
14088178 }
1409-
1410- .lg\:text-4xl {
1411- font-size: 2.25rem;
1412- line-height: 2.5rem;
8179+ 100% {
8180+ scale: 1;
8181+ opacity: 1;
14138182 }
14148183 }
1415-
1416-@media (min-width: 1280px) {
1417- .xl\:h-12 {
1418- height: 3rem;
8184+@layer base {
8185+ @media (prefers-color-scheme: dark) {
8186+ :root {
8187+ color-scheme: dark;
8188+ --color-base-100: oklch(28% 0.091 267.935);
8189+ --color-base-200: oklch(13% 0.028 261.692);
8190+ --color-base-300: oklch(20% 0.042 265.755);
8191+ --color-base-content: oklch(93% 0.034 272.788);
8192+ --color-primary: oklch(87% 0 0);
8193+ --color-primary-content: oklch(14% 0 0);
8194+ --color-secondary: oklch(83% 0.145 321.434);
8195+ --color-secondary-content: oklch(29% 0.136 325.661);
8196+ --color-accent: oklch(80% 0.105 251.813);
8197+ --color-accent-content: oklch(28% 0.091 267.935);
8198+ --color-neutral: oklch(37% 0.034 259.733);
8199+ --color-neutral-content: oklch(98% 0.002 247.839);
8200+ --color-info: oklch(68% 0.169 237.323);
8201+ --color-info-content: oklch(97% 0.013 236.62);
8202+ --color-success: oklch(70% 0.14 182.503);
8203+ --color-success-content: oklch(98% 0.014 180.72);
8204+ --color-warning: oklch(76% 0.188 70.08);
8205+ --color-warning-content: oklch(98% 0.022 95.277);
8206+ --color-error: oklch(64% 0.246 16.439);
8207+ --color-error-content: oklch(96% 0.015 12.422);
8208+ --radius-selector: 1rem;
8209+ --radius-field: 0rem;
8210+ --radius-box: 0.25rem;
8211+ --size-selector: 0.25rem;
8212+ --size-field: 0.25rem;
8213+ --border: 1px;
8214+ --depth: 0;
8215+ --noise: 0;
8216+ }
14198217 }
1420-
1421- .xl\:h-48 {
1422- height: 12rem;
8218+}
8219+@layer base {
8220+ :where(:root),:root:has(input.theme-controller[value=gordon-dark]:checked),[data-theme="gordon-dark"] {
8221+ color-scheme: dark;
8222+ --color-base-100: oklch(28% 0.091 267.935);
8223+ --color-base-200: oklch(13% 0.028 261.692);
8224+ --color-base-300: oklch(20% 0.042 265.755);
8225+ --color-base-content: oklch(93% 0.034 272.788);
8226+ --color-primary: oklch(87% 0 0);
8227+ --color-primary-content: oklch(14% 0 0);
8228+ --color-secondary: oklch(83% 0.145 321.434);
8229+ --color-secondary-content: oklch(29% 0.136 325.661);
8230+ --color-accent: oklch(80% 0.105 251.813);
8231+ --color-accent-content: oklch(28% 0.091 267.935);
8232+ --color-neutral: oklch(37% 0.034 259.733);
8233+ --color-neutral-content: oklch(98% 0.002 247.839);
8234+ --color-info: oklch(68% 0.169 237.323);
8235+ --color-info-content: oklch(97% 0.013 236.62);
8236+ --color-success: oklch(70% 0.14 182.503);
8237+ --color-success-content: oklch(98% 0.014 180.72);
8238+ --color-warning: oklch(76% 0.188 70.08);
8239+ --color-warning-content: oklch(98% 0.022 95.277);
8240+ --color-error: oklch(64% 0.246 16.439);
8241+ --color-error-content: oklch(96% 0.015 12.422);
8242+ --radius-selector: 1rem;
8243+ --radius-field: 0rem;
8244+ --radius-box: 0.25rem;
8245+ --size-selector: 0.25rem;
8246+ --size-field: 0.25rem;
8247+ --border: 1px;
8248+ --depth: 0;
8249+ --noise: 0;
14238250 }
1424-
1425- .xl\:text-5xl {
1426- font-size: 3rem;
1427- line-height: 1;
8251+}
8252+@property --tw-rotate-x {
8253+ syntax: "*";
8254+ inherits: false;
8255+ initial-value: rotateX(0);
8256+}
8257+@property --tw-rotate-y {
8258+ syntax: "*";
8259+ inherits: false;
8260+ initial-value: rotateY(0);
8261+}
8262+@property --tw-rotate-z {
8263+ syntax: "*";
8264+ inherits: false;
8265+ initial-value: rotateZ(0);
8266+}
8267+@property --tw-skew-x {
8268+ syntax: "*";
8269+ inherits: false;
8270+ initial-value: skewX(0);
8271+}
8272+@property --tw-skew-y {
8273+ syntax: "*";
8274+ inherits: false;
8275+ initial-value: skewY(0);
8276+}
8277+@property --tw-space-y-reverse {
8278+ syntax: "*";
8279+ inherits: false;
8280+ initial-value: 0;
8281+}
8282+@property --tw-border-style {
8283+ syntax: "*";
8284+ inherits: false;
8285+ initial-value: solid;
8286+}
8287+@property --tw-font-weight {
8288+ syntax: "*";
8289+ inherits: false;
8290+}
8291+@property --tw-tracking {
8292+ syntax: "*";
8293+ inherits: false;
8294+}
8295+@property --tw-shadow {
8296+ syntax: "*";
8297+ inherits: false;
8298+ initial-value: 0 0 #0000;
8299+}
8300+@property --tw-shadow-color {
8301+ syntax: "*";
8302+ inherits: false;
8303+}
8304+@property --tw-inset-shadow {
8305+ syntax: "*";
8306+ inherits: false;
8307+ initial-value: 0 0 #0000;
8308+}
8309+@property --tw-inset-shadow-color {
8310+ syntax: "*";
8311+ inherits: false;
8312+}
8313+@property --tw-ring-color {
8314+ syntax: "*";
8315+ inherits: false;
8316+}
8317+@property --tw-ring-shadow {
8318+ syntax: "*";
8319+ inherits: false;
8320+ initial-value: 0 0 #0000;
8321+}
8322+@property --tw-inset-ring-color {
8323+ syntax: "*";
8324+ inherits: false;
8325+}
8326+@property --tw-inset-ring-shadow {
8327+ syntax: "*";
8328+ inherits: false;
8329+ initial-value: 0 0 #0000;
8330+}
8331+@property --tw-ring-inset {
8332+ syntax: "*";
8333+ inherits: false;
8334+}
8335+@property --tw-ring-offset-width {
8336+ syntax: "<length>";
8337+ inherits: false;
8338+ initial-value: 0px;
8339+}
8340+@property --tw-ring-offset-color {
8341+ syntax: "*";
8342+ inherits: false;
8343+ initial-value: #fff;
8344+}
8345+@property --tw-ring-offset-shadow {
8346+ syntax: "*";
8347+ inherits: false;
8348+ initial-value: 0 0 #0000;
8349+}
8350+@property --tw-outline-style {
8351+ syntax: "*";
8352+ inherits: false;
8353+ initial-value: solid;
8354+}
8355+@property --tw-blur {
8356+ syntax: "*";
8357+ inherits: false;
8358+}
8359+@property --tw-brightness {
8360+ syntax: "*";
8361+ inherits: false;
8362+}
8363+@property --tw-contrast {
8364+ syntax: "*";
8365+ inherits: false;
8366+}
8367+@property --tw-grayscale {
8368+ syntax: "*";
8369+ inherits: false;
8370+}
8371+@property --tw-hue-rotate {
8372+ syntax: "*";
8373+ inherits: false;
8374+}
8375+@property --tw-invert {
8376+ syntax: "*";
8377+ inherits: false;
8378+}
8379+@property --tw-opacity {
8380+ syntax: "*";
8381+ inherits: false;
8382+}
8383+@property --tw-saturate {
8384+ syntax: "*";
8385+ inherits: false;
8386+}
8387+@property --tw-sepia {
8388+ syntax: "*";
8389+ inherits: false;
8390+}
8391+@property --tw-drop-shadow {
8392+ syntax: "*";
8393+ inherits: false;
8394+}
8395+@property --tw-backdrop-blur {
8396+ syntax: "*";
8397+ inherits: false;
8398+}
8399+@property --tw-backdrop-brightness {
8400+ syntax: "*";
8401+ inherits: false;
8402+}
8403+@property --tw-backdrop-contrast {
8404+ syntax: "*";
8405+ inherits: false;
8406+}
8407+@property --tw-backdrop-grayscale {
8408+ syntax: "*";
8409+ inherits: false;
8410+}
8411+@property --tw-backdrop-hue-rotate {
8412+ syntax: "*";
8413+ inherits: false;
8414+}
8415+@property --tw-backdrop-invert {
8416+ syntax: "*";
8417+ inherits: false;
8418+}
8419+@property --tw-backdrop-opacity {
8420+ syntax: "*";
8421+ inherits: false;
8422+}
8423+@property --tw-backdrop-saturate {
8424+ syntax: "*";
8425+ inherits: false;
8426+}
8427+@property --tw-backdrop-sepia {
8428+ syntax: "*";
8429+ inherits: false;
8430+}
8431+@property --tw-duration {
8432+ syntax: "*";
8433+ inherits: false;
8434+}
8435+@property --tw-ease {
8436+ syntax: "*";
8437+ inherits: false;
8438+}
8439+@property --tw-space-x-reverse {
8440+ syntax: "*";
8441+ inherits: false;
8442+ initial-value: 0;
8443+}
8444+@keyframes spin {
8445+ to {
8446+ transform: rotate(360deg);
14288447 }
14298448 }
M internal/webui/public/assets/js/custom.js +33 -1
@@ -187,18 +187,36 @@ function refreshContainerList() {
187187
188188 // Show loading indicator
189189 const containerManager = $('#container-manager');
190+ if (containerManager.length === 0) {
191+ console.error("Container manager element not found in DOM");
192+ return;
193+ }
194+
195+ console.debug("Container manager element found:", containerManager[0]);
196+ console.debug("Container manager classes:", containerManager.attr('class'));
197+
190198 const originalContent = containerManager.html();
191199 containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');
192200
201+ // Ensure the container is visible
202+ containerManager.removeClass('hidden');
203+
193204 $.ajax({
194205 url: '/htmx/container-manager',
195206 type: 'GET',
196207 success: function(data) {
208+ console.debug("Container data received, length:", data.length);
197209 $('#container-manager').html(data);
210+
211+ // Force visibility
212+ $('#container-manager').removeClass('hidden');
213+
198214 console.debug("Container list refreshed successfully");
199215 },
200216 error: function(xhr, status, error) {
201217 console.error("Error refreshing container list:", error);
218+ console.error("Status:", status);
219+ console.error("Response:", xhr.responseText);
202220 // Restore original content on error
203221 containerManager.html(originalContent);
204222 }
@@ -211,6 +229,14 @@ function refreshImageList() {
211229
212230 // Show loading indicator
213231 const imageManager = $('#image-manager');
232+ if (imageManager.length === 0) {
233+ console.error("Image manager element not found in DOM");
234+ return;
235+ }
236+
237+ console.debug("Image manager element found:", imageManager[0]);
238+ console.debug("Image manager classes:", imageManager.attr('class'));
239+
214240 const originalContent = imageManager.html();
215241 imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');
216242
@@ -218,11 +244,14 @@ function refreshImageList() {
218244 url: '/htmx/image-manager',
219245 type: 'GET',
220246 success: function(data) {
247+ console.debug("Image data received, length:", data.length);
221248 $('#image-manager').html(data);
222249 console.debug("Image list refreshed successfully");
223250 },
224251 error: function(xhr, status, error) {
225252 console.error("Error refreshing image list:", error);
253+ console.error("Status:", status);
254+ console.error("Response:", xhr.responseText);
226255 // Restore original content on error
227256 imageManager.html(originalContent);
228257 }
@@ -236,6 +265,9 @@ function setupHtmxEventListeners() {
236265 const targetId = event.detail.target.id;
237266 console.debug(`HTMX afterOnLoad event for target: ${targetId}`);
238267
268+ // Remove the tab visibility fix which was interfering with DaisyUI
269+ // DaisyUI handles tab visibility automatically with the input[type="radio"] approach
270+
239271 // Check if the target is an action response
240272 if (targetId && targetId.startsWith('actionResponse-')) {
241273 console.debug(`Action completed for ${targetId}`);
@@ -257,7 +289,7 @@ function setupHtmxEventListeners() {
257289 console.debug('Image manager updated');
258290
259291 // Show notification for successful upload
260- if (event.detail.xhr.responseText.includes('Success')) {
292+ if (event.detail.xhr && event.detail.xhr.responseText && event.detail.xhr.responseText.includes('Success')) {
261293 showNotification('Image uploaded successfully', 'success');
262294 }
263295
A internal/webui/public/libs/chart.js +20 -0 too large to show in the browser

too large to show in the browser view raw

A internal/webui/public/libs/daisyui.js +8 -0 too large to show in the browser

too large to show in the browser view raw

M package.json +3 -1
@@ -1,5 +1,7 @@
11 {
2+ "type": "module",
23 "devDependencies": {
4+ "daisyui": "^5.0.6",
35 "prettier": "^3.5.3",
46 "prettier-plugin-tailwindcss": "^0.5.14",
57 "tailwindcss": "^4.0.14"
@@ -9,7 +11,7 @@
911 "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css"
1012 },
1113 "dependencies": {
12- "caniuse-lite": "^1.0.30001705",
14+ "@tailwindcss/cli": "^4.0.14",
1315 "dotenv": "^16.4.7"
1416 }
1517 }
M pkg/docker/client.go +19 -3
@@ -28,14 +28,15 @@ func InitializeClient(config *Config) error {
2828 return fmt.Errorf("sock field in Config is empty")
2929 }
3030
31+ logger.Debug("Checking socket file existence", "socket_path", config.Sock)
3132 if _, err := os.Stat(config.Sock); os.IsNotExist(err) {
32- logger.Error("Failed to initialize Docker client", "error", err)
33+ logger.Error("Failed to initialize Docker client", "error", err, "socket_path", config.Sock)
3334 return err
3435 }
3536
3637 // For Podman compatibility, we need to ensure we're using the right connection approach
3738 host := "unix://" + config.Sock
38- logger.Debug("Setting up Docker client", "host", host)
39+ logger.Debug("Setting up Docker client", "host", host, "podman_enabled", config.PodmanEnable)
3940
4041 // Create the Docker client with appropriate options
4142 cli, err := client.NewClientWithOpts(
@@ -47,7 +48,18 @@ func InitializeClient(config *Config) error {
4748 return fmt.Errorf("error initializing Docker client: %s", err)
4849 }
4950
50- logger.Debug("Docker client initialized", "socket", config.Sock, "podman_enabled", config.PodmanEnable)
51+ // Test the connection to make sure it works
52+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
53+ defer cancel()
54+
55+ logger.Debug("Testing Docker client connection", "socket", config.Sock)
56+ _, err = cli.Ping(ctx)
57+ if err != nil {
58+ logger.Error("Docker client ping failed", "error", err, "socket", config.Sock)
59+ return fmt.Errorf("docker daemon not responding: %s", err)
60+ }
61+
62+ logger.Debug("Docker client initialized successfully", "socket", config.Sock, "podman_enabled", config.PodmanEnable)
5163 dockerCli = cli
5264 currentConfig = config
5365 return nil
@@ -56,17 +68,20 @@ func InitializeClient(config *Config) error {
5668 func CheckIfInitialized() error {
5769 if currentConfig == nil || currentConfig.Sock == "" {
5870 logger.Error("Docker client check failed", "error", "client not initialized or configuration missing")
71+ logger.Debug("Docker client initialization failed: configuration is missing or empty")
5972 return fmt.Errorf("docker client is not initialized or configuration is missing")
6073 }
6174
6275 socketPath := currentConfig.Sock
6376 if _, err := os.Stat(socketPath); os.IsNotExist(err) {
6477 logger.Error("Docker client check failed", "error", "socket file not found", "path", socketPath)
78+ logger.Debug("Docker client check failed: socket file not found", "path", socketPath)
6579 return fmt.Errorf("sock file does not exist: %s", socketPath)
6680 }
6781
6882 if dockerCli == nil {
6983 logger.Error("Docker client check failed", "error", "client is nil")
84+ logger.Debug("Docker client check failed: client is nil")
7085 return fmt.Errorf("docker client is not initialized")
7186 }
7287
@@ -76,6 +91,7 @@ func CheckIfInitialized() error {
7691 _, err := dockerCli.ContainerList(ctx, container.ListOptions{})
7792 if err != nil {
7893 logger.Error("Docker client check failed", "error", err)
94+ logger.Debug("Docker client check failed: cannot connect to Docker daemon", "error", err)
7995 return fmt.Errorf("cannot connect to Docker daemon: %s", err)
8096 }
8197
M pkg/docker/containers.go +7 -5
@@ -100,10 +100,7 @@ func ListRunningContainers() ([]types.Container, error) {
100100 if err != nil {
101101 return nil, err
102102 }
103- // Check if the list is empty
104- if len(containers) == 0 {
105- return nil, fmt.Errorf("no containers found")
106- }
103+ // Just return the containers, even if it's an empty slice
107104 return containers, nil
108105 }
109106
@@ -420,6 +417,11 @@ func CheckIfNetworkExists(networkName string) (bool, error) {
420417
421418 // GetNetworkInfo returns information about a network
422419 func GetNetworkInfo(networkName string) (*network.Inspect, error) {
420+ // Check if Docker client is initialized
421+ if err := CheckIfInitialized(); err != nil {
422+ return nil, fmt.Errorf("cannot get network info: %w", err)
423+ }
424+
423425 networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{})
424426 if err != nil {
425427 return nil, err
@@ -947,7 +949,7 @@ func ListenForContainerEvents(networkFilter string, startCallback func(string, s
947949 logger.Debug("Container stopped",
948950 "container_id", event.id,
949951 "container_name", event.name)
950-
952+
951953 // Call the stop callback if provided
952954 if stopCallback != nil {
953955 logger.Debug("Calling container stop callback",
A pkg/scripts/container_diagnostic/diagnostic.sh +171 -0
@@ -0,0 +1,171 @@
1+#!/bin/bash
2+
3+# Gordon Container Diagnostic Script
4+# Usage: podman exec -it gordon /bin/sh /diagnostic.sh
5+
6+echo "----- Gordon System Information -----"
7+echo "Date: $(date)"
8+echo "Hostname: $(hostname)"
9+echo "Container ID: $(hostname)"
10+
11+echo "----- Network Interfaces -----"
12+ip addr show
13+
14+echo "----- Network Routes -----"
15+ip route show
16+
17+echo "----- Listening Ports -----"
18+netstat -tuln
19+
20+echo "----- Process Listening On Ports -----"
21+netstat -tulnp
22+
23+echo "----- HTTP Server Test -----"
24+curl -v http://localhost:8080/admin/ping 2>&1 | grep -v '^{.*}'
25+
26+echo "----- HTTPS/HTTP Binding Test -----"
27+# Try to create listeners on ports 80, 443, and 8080 to test if they're available
28+# More reliable port check using timeout
29+check_port() {
30+ local port=$1
31+ local service=$2
32+ # Try to bind to the port briefly
33+ timeout 1 bash -c "nc -l -p $port >/dev/null 2>&1" >/dev/null 2>&1
34+ if [ $? -eq 124 ] || [ $? -eq 1 ]; then
35+ # Timeout or error means port is likely in use
36+ echo "✅ Port $port is in use - $service server is likely running"
37+ echo "Process using port $port:"
38+ # Multiple ways to check what's using the port
39+ lsof -i :$port 2>/dev/null || \
40+ ss -tulpn | grep ":$port" 2>/dev/null || \
41+ netstat -tulpn | grep ":$port" 2>/dev/null || \
42+ ls -l /proc/*/fd/* 2>/dev/null | grep -i "0.0.0.0:$port" | head -5
43+ else
44+ echo "❌ WARNING: Port $port is NOT in use - $service server is not running!"
45+ # Get the Gordon PID
46+ GORDON_PID=$(pgrep -f "/gordon serve" || pgrep -f "gordon$" || echo "")
47+ if [ -n "$GORDON_PID" ]; then
48+ echo "Gordon process found (PID: $GORDON_PID)"
49+
50+ # Check if anything is preventing binding to the port
51+ echo "Checking process capabilities:"
52+ grep Cap /proc/$GORDON_PID/status || echo "Could not check process capabilities"
53+
54+ # Check if the process tried to bind but failed (search logs)
55+ echo "Checking for binding errors in Gordon logs for port $port:"
56+ grep -i "error\|fail\|warn" /proc/1/fd/1 | grep -i "server\|listen\|bind\|port $port" | tail -10 || echo "No specific binding errors found in logs"
57+
58+ # Try to get socket information
59+ echo "Socket status for port $port:"
60+ ss -tulpen | grep ":$port" || echo "No socket listening on port $port"
61+
62+ # Check port availability externally
63+ echo "Testing if port can be bound explicitly:"
64+ timeout 2 nc -l -p $port -v 2>&1 || echo "Could not bind to port $port"
65+ else
66+ echo "Gordon process not found"
67+ fi
68+ fi
69+ echo ""
70+}
71+
72+# Check main ports
73+check_port 80 "HTTP"
74+check_port 443 "HTTPS"
75+check_port 8080 "Main HTTP"
76+
77+# Additional detailed diagnostics for HTTPS/TLS
78+echo "----- Detailed TLS Server Diagnostics -----"
79+# Check for TLS configuration issues
80+echo "TLS configuration in logs:"
81+grep -i "TLS\|cert\|getCertificate\|fallback" /proc/1/fd/1 | tail -15 || echo "No TLS configuration logs found"
82+
83+# Check for listener creation logs
84+echo "Listener creation logs:"
85+grep -i "listener\|starting.*server\|bound to" /proc/1/fd/1 | tail -15 || echo "No listener creation logs found"
86+
87+# Check for server startup sequence issues
88+echo "Server startup sequence:"
89+grep -i "starting\|init\|ready\|signal" /proc/1/fd/1 | grep -v "DEBUG" | tail -20 || echo "No server startup sequence logs found"
90+
91+# Check port binding status in container
92+echo "Port mapping configuration:"
93+cat /proc/1/environ | tr '\0' '\n' | grep -i "PORT\|GORDON_PROXY" || echo "No port mapping environment variables found"
94+
95+echo "----- Privileges and Capabilities Check -----"
96+# Check process privileges
97+echo "Process privileges:"
98+id || echo "Could not get process ID"
99+
100+# Check capabilities
101+echo "Process capabilities:"
102+if command -v capsh >/dev/null 2>&1; then
103+ capsh --print || echo "Could not run capsh"
104+else
105+ grep Cap /proc/self/status || echo "Could not check capabilities via proc"
106+fi
107+
108+# Check container security context
109+echo "Container security context:"
110+if [ -f "/.dockerenv" ] || [ -f "/run/.containerenv" ]; then
111+ echo "Running in a container"
112+ ls -la /proc/1/ns/ || echo "Could not check namespaces"
113+else
114+ echo "Not running in a container"
115+fi
116+
117+echo "----- Running Gordon Processes -----"
118+ps aux | grep -i gordon | grep -v grep
119+
120+echo "----- DNS Resolution Test -----"
121+# Try to resolve our own domain
122+DOMAIN=$(grep GORDON_HTTP_DOMAIN /proc/1/environ | cut -d= -f2 | tr -d '\0')
123+SUBDOMAIN=$(grep GORDON_HTTP_SUBDOMAIN /proc/1/environ | cut -d= -f2 | tr -d '\0')
124+if [ -n "$DOMAIN" ] && [ -n "$SUBDOMAIN" ]; then
125+ FQDN="${SUBDOMAIN}.${DOMAIN}"
126+ echo "Testing DNS resolution for $FQDN"
127+ dig $FQDN +short
128+ host $FQDN
129+else
130+ echo "Domain information not found in environment"
131+fi
132+
133+echo "----- TLS Certificate Status -----"
134+CERT_DIR=/certs
135+if [ -d "$CERT_DIR" ]; then
136+ echo "Certificate directory contents:"
137+ ls -la $CERT_DIR
138+
139+ # Check for certificates for our domain
140+ if [ -n "$FQDN" ]; then
141+ echo "Looking for certificates for $FQDN:"
142+ find $CERT_DIR -name "*$FQDN*" -o -name "cert-$FQDN"
143+ fi
144+else
145+ echo "Certificate directory $CERT_DIR not found"
146+fi
147+
148+echo "----- Gordon HTTPS Server Logs -----"
149+echo "Last 20 log entries related to HTTPS server initialization:"
150+grep -i "server\|tls\|https\|Certificate\|net\." /proc/1/fd/1 | tail -20
151+
152+echo "----- Goroutines Status -----"
153+# Check if GOMAXPROCS is set too high
154+echo "GOMAXPROCS setting:"
155+grep -i "GOMAXPROCS" /proc/1/environ | tr '\0' '\n' || echo "GOMAXPROCS not explicitly set"
156+
157+# Check the number of threads used by gordon
158+echo "Number of threads used by Gordon process:"
159+pid=$(pgrep -f "^/gordon")
160+if [ -n "$pid" ]; then
161+ ls -l /proc/$pid/task | wc -l
162+else
163+ echo "Gordon process not found"
164+fi
165+
166+echo "----- TCP Connection Status -----"
167+# Check existing TCP connections and states
168+echo "TCP connection states:"
169+netstat -nat | awk '{print $6}' | sort | uniq -c
170+
171+echo "----- End of Diagnostic -----" ⏎̸
M tailwind.config.js +39 -0
@@ -14,7 +14,46 @@ module.exports = {
1414 lbeige: "#FFDCB3",
1515 mbeige: "#D7B49F",
1616 },
17+ // Custom color scheme
18+ indigo: {
19+ 50: "#eef2ff",
20+ 100: "#e0e7ff",
21+ 200: "#c7d2fe",
22+ 300: "#a5b4fc",
23+ 400: "#818cf8",
24+ 500: "#6366f1",
25+ 600: "#4f46e5",
26+ 700: "#4338ca",
27+ 800: "#3730a3",
28+ 900: "#312e81",
29+ 950: "#1e1b4b",
30+ },
31+ red: {
32+ 50: "#fef2f2",
33+ 100: "#fee2e2",
34+ 200: "#fecaca",
35+ 300: "#fca5a5",
36+ 400: "#f87171",
37+ 500: "#ef4444",
38+ 600: "#dc2626",
39+ 700: "#b91c1c",
40+ 800: "#991b1b",
41+ 900: {
42+ DEFAULT: "#7f1d1d",
43+ 20: "rgba(127, 29, 29, 0.2)",
44+ },
45+ 950: "#450a0a",
46+ },
1747 },
1848 },
1949 },
50+ plugins: [require("daisyui")],
51+ daisyui: {
52+ themes: ["light", "dark", "gordon", "gordon-dark"],
53+ darkTheme: "gordon-dark",
54+ base: true,
55+ styled: true,
56+ utils: true,
57+ logs: true,
58+ },
2059 };
A tools.go +7 -0
@@ -0,0 +1,7 @@
1+// +build tools
2+
3+package tools
4+
5+import (
6+ _ "github.com/a-h/templ/cmd/templ"
7+)⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close