This commit is contained in:
2025-10-15 10:01:47 +02:00
commit 2433b865da
48 changed files with 4359 additions and 0 deletions

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
- Do not end with a summary or explanation

373
.github/go.instructions.md vendored Normal file
View File

@@ -0,0 +1,373 @@
---
description: 'Instructions for writing Go code following idiomatic Go practices and community standards'
applyTo: '**/*.go,**/go.mod,**/go.sum'
---
# Go Development Instructions
Follow idiomatic Go practices and community standards when writing Go code. These instructions are based on [Effective Go](https://go.dev/doc/effective_go), [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments), and [Google's Go Style Guide](https://google.github.io/styleguide/go/).
## General Instructions
- Write simple, clear, and idiomatic Go code
- Favor clarity and simplicity over cleverness
- Follow the principle of least surprise
- Keep the happy path left-aligned (minimize indentation)
- Return early to reduce nesting
- Prefer early return over if-else chains; use `if condition { return }` pattern to avoid else blocks
- Make the zero value useful
- Write self-documenting code with clear, descriptive names
- Document exported types, functions, methods, and packages
- Use Go modules for dependency management
- Leverage the Go standard library instead of reinventing the wheel (e.g., use `strings.Builder` for string concatenation, `filepath.Join` for path construction)
- Prefer standard library solutions over custom implementations when functionality exists
- Write comments in English by default; translate only upon user request
- Avoid using emoji in code and comments
## Naming Conventions
### Packages
- Use lowercase, single-word package names
- Avoid underscores, hyphens, or mixedCaps
- Choose names that describe what the package provides, not what it contains
- Avoid generic names like `util`, `common`, or `base`
- Package names should be singular, not plural
#### Package Declaration Rules (CRITICAL):
- **NEVER duplicate `package` declarations** - each Go file must have exactly ONE `package` line
- When editing an existing `.go` file:
- **PRESERVE** the existing `package` declaration - do not add another one
- If you need to replace the entire file content, start with the existing package name
- When creating a new `.go` file:
- **BEFORE writing any code**, check what package name other `.go` files in the same directory use
- Use the SAME package name as existing files in that directory
- If it's a new directory, use the directory name as the package name
- Write **exactly one** `package <name>` line at the very top of the file
- When using file creation or replacement tools:
- **ALWAYS verify** the target file doesn't already have a `package` declaration before adding one
- If replacing file content, include only ONE `package` declaration in the new content
- **NEVER** create files with multiple `package` lines or duplicate declarations
### Variables and Functions
- Use mixedCaps or MixedCaps (camelCase) rather than underscores
- Keep names short but descriptive
- Use single-letter variables only for very short scopes (like loop indices)
- Exported names start with a capital letter
- Unexported names start with a lowercase letter
- Avoid stuttering (e.g., avoid `http.HTTPServer`, prefer `http.Server`)
### Interfaces
- Name interfaces with -er suffix when possible (e.g., `Reader`, `Writer`, `Formatter`)
- Single-method interfaces should be named after the method (e.g., `Read``Reader`)
- Keep interfaces small and focused
### Constants
- Use MixedCaps for exported constants
- Use mixedCaps for unexported constants
- Group related constants using `const` blocks
- Consider using typed constants for better type safety
## Code Style and Formatting
### Formatting
- Always use `gofmt` to format code
- Use `goimports` to manage imports automatically
- Keep line length reasonable (no hard limit, but consider readability)
- Add blank lines to separate logical groups of code
### Comments
- Strive for self-documenting code; prefer clear variable names, function names, and code structure over comments
- Write comments only when necessary to explain complex logic, business rules, or non-obvious behavior
- Write comments in complete sentences in English by default
- Translate comments to other languages only upon specific user request
- Start sentences with the name of the thing being described
- Package comments should start with "Package [name]"
- Use line comments (`//`) for most comments
- Use block comments (`/* */`) sparingly, mainly for package documentation
- Document why, not what, unless the what is complex
- Avoid emoji in comments and code
### Error Handling
- Check errors immediately after the function call
- Don't ignore errors using `_` unless you have a good reason (document why)
- Wrap errors with context using `fmt.Errorf` with `%w` verb
- Create custom error types when you need to check for specific errors
- Place error returns as the last return value
- Name error variables `err`
- Keep error messages lowercase and don't end with punctuation
## Architecture and Project Structure
### Package Organization
- Follow standard Go project layout conventions
- Keep `main` packages in `cmd/` directory
- Put reusable packages in `pkg/` or `internal/`
- Use `internal/` for packages that shouldn't be imported by external projects
- Group related functionality into packages
- Avoid circular dependencies
### Dependency Management
- Use Go modules (`go.mod` and `go.sum`)
- Keep dependencies minimal
- Regularly update dependencies for security patches
- Use `go mod tidy` to clean up unused dependencies
- Vendor dependencies only when necessary
## Type Safety and Language Features
### Type Definitions
- Define types to add meaning and type safety
- Use struct tags for JSON, XML, database mappings
- Prefer explicit type conversions
- Use type assertions carefully and check the second return value
- Prefer generics over unconstrained types; when an unconstrained type is truly needed, use the predeclared alias `any` instead of `interface{}` (Go 1.18+)
### Pointers vs Values
- Use pointer receivers for large structs or when you need to modify the receiver
- Use value receivers for small structs and when immutability is desired
- Use pointer parameters when you need to modify the argument or for large structs
- Use value parameters for small structs and when you want to prevent modification
- Be consistent within a type's method set
- Consider the zero value when choosing pointer vs value receivers
### Interfaces and Composition
- Accept interfaces, return concrete types
- Keep interfaces small (1-3 methods is ideal)
- Use embedding for composition
- Define interfaces close to where they're used, not where they're implemented
- Don't export interfaces unless necessary
## Concurrency
### Goroutines
- Be cautious about creating goroutines in libraries; prefer letting the caller control concurrency
- If you must create goroutines in libraries, provide clear documentation and cleanup mechanisms
- Always know how a goroutine will exit
- Use `sync.WaitGroup` or channels to wait for goroutines
- Avoid goroutine leaks by ensuring cleanup
### Channels
- Use channels to communicate between goroutines
- Don't communicate by sharing memory; share memory by communicating
- Close channels from the sender side, not the receiver
- Use buffered channels when you know the capacity
- Use `select` for non-blocking operations
### Synchronization
- Use `sync.Mutex` for protecting shared state
- Keep critical sections small
- Use `sync.RWMutex` when you have many readers
- Choose between channels and mutexes based on the use case: use channels for communication, mutexes for protecting state
- Use `sync.Once` for one-time initialization
- WaitGroup usage by Go version:
- If `go >= 1.25` in `go.mod`, use the new `WaitGroup.Go` method ([documentation](https://pkg.go.dev/sync#WaitGroup)):
```go
var wg sync.WaitGroup
wg.Go(task1)
wg.Go(task2)
wg.Wait()
```
- If `go < 1.25`, use the classic `Add`/`Done` pattern
## Error Handling Patterns
### Creating Errors
- Use `errors.New` for simple static errors
- Use `fmt.Errorf` for dynamic errors
- Create custom error types for domain-specific errors
- Export error variables for sentinel errors
- Use `errors.Is` and `errors.As` for error checking
### Error Propagation
- Add context when propagating errors up the stack
- Don't log and return errors (choose one)
- Handle errors at the appropriate level
- Consider using structured errors for better debugging
## API Design
### HTTP Handlers
- Use `http.HandlerFunc` for simple handlers
- Implement `http.Handler` for handlers that need state
- Use middleware for cross-cutting concerns
- Set appropriate status codes and headers
- Handle errors gracefully and return appropriate error responses
- Router usage by Go version:
- If `go >= 1.22`, prefer the enhanced `net/http` `ServeMux` with pattern-based routing and method matching
- If `go < 1.22`, use the classic `ServeMux` and handle methods/paths manually (or use a third-party router when justified)
### JSON APIs
- Use struct tags to control JSON marshaling
- Validate input data
- Use pointers for optional fields
- Consider using `json.RawMessage` for delayed parsing
- Handle JSON errors appropriately
### HTTP Clients
- Keep the client struct focused on configuration and dependencies only (e.g., base URL, `*http.Client`, auth, default headers). It must not store per-request state
- Do not store or cache `*http.Request` inside the client struct, and do not persist request-specific state across calls; instead, construct a fresh request per method invocation
- Methods should accept `context.Context` and input parameters, assemble the `*http.Request` locally (or via a short-lived builder/helper created per call), then call `c.httpClient.Do(req)`
- If request-building logic is reused, factor it into unexported helper functions or a per-call builder type; never keep `http.Request` (URL params, body, headers) as fields on the long-lived client
- Ensure the underlying `*http.Client` is configured (timeouts, transport) and is safe for concurrent use; avoid mutating `Transport` after first use
- Always set headers on the request instance youre sending, and close response bodies (`defer resp.Body.Close()`), handling errors appropriately
## Performance Optimization
### Memory Management
- Minimize allocations in hot paths
- Reuse objects when possible (consider `sync.Pool`)
- Use value receivers for small structs
- Preallocate slices when size is known
- Avoid unnecessary string conversions
### I/O: Readers and Buffers
- Most `io.Reader` streams are consumable once; reading advances state. Do not assume a reader can be re-read without special handling
- If you must read data multiple times, buffer it once and recreate readers on demand:
- Use `io.ReadAll` (or a limited read) to obtain `[]byte`, then create fresh readers via `bytes.NewReader(buf)` or `bytes.NewBuffer(buf)` for each reuse
- For strings, use `strings.NewReader(s)`; you can `Seek(0, io.SeekStart)` on `*bytes.Reader` to rewind
- For HTTP requests, do not reuse a consumed `req.Body`. Instead:
- Keep the original payload as `[]byte` and set `req.Body = io.NopCloser(bytes.NewReader(buf))` before each send
- Prefer configuring `req.GetBody` so the transport can recreate the body for redirects/retries: `req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(buf)), nil }`
- To duplicate a stream while reading, use `io.TeeReader` (copy to a buffer while passing through) or write to multiple sinks with `io.MultiWriter`
- Reusing buffered readers: call `(*bufio.Reader).Reset(r)` to attach to a new underlying reader; do not expect it to “rewind” unless the source supports seeking
- For large payloads, avoid unbounded buffering; consider streaming, `io.LimitReader`, or on-disk temporary storage to control memory
- Use `io.Pipe` to stream without buffering the whole payload:
- Write to `*io.PipeWriter` in a separate goroutine while the reader consumes
- Always close the writer; use `CloseWithError(err)` on failures
- `io.Pipe` is for streaming, not rewinding or making readers reusable
- **Warning:** When using `io.Pipe` (especially with multipart writers), all writes must be performed in strict, sequential order. Do not write concurrently or out of order—multipart boundaries and chunk order must be preserved. Out-of-order or parallel writes can corrupt the stream and result in errors.
- Streaming multipart/form-data with `io.Pipe`:
- `pr, pw := io.Pipe()`; `mw := multipart.NewWriter(pw)`; use `pr` as the HTTP request body
- Set `Content-Type` to `mw.FormDataContentType()`
- In a goroutine: write all parts to `mw` in the correct order; on error `pw.CloseWithError(err)`; on success `mw.Close()` then `pw.Close()`
- Do not store request/in-flight form state on a long-lived client; build per call
- Streamed bodies are not rewindable; for retries/redirects, buffer small payloads or provide `GetBody`
### Profiling
- Use built-in profiling tools (`pprof`)
- Benchmark critical code paths
- Profile before optimizing
- Focus on algorithmic improvements first
- Consider using `testing.B` for benchmarks
## Testing
### Test Organization
- Keep tests in the same package (white-box testing)
- Use `_test` package suffix for black-box testing
- Name test files with `_test.go` suffix
- Place test files next to the code they test
### Writing Tests
- Use table-driven tests for multiple test cases
- Name tests descriptively using `Test_functionName_scenario`
- Use subtests with `t.Run` for better organization
- Test both success and error cases
- Consider using `testify` or similar libraries when they add value, but don't over-complicate simple tests
### Test Helpers
- Mark helper functions with `t.Helper()`
- Create test fixtures for complex setup
- Use `testing.TB` interface for functions used in tests and benchmarks
- Clean up resources using `t.Cleanup()`
## Security Best Practices
### Input Validation
- Validate all external input
- Use strong typing to prevent invalid states
- Sanitize data before using in SQL queries
- Be careful with file paths from user input
- Validate and escape data for different contexts (HTML, SQL, shell)
### Cryptography
- Use standard library crypto packages
- Don't implement your own cryptography
- Use crypto/rand for random number generation
- Store passwords using bcrypt, scrypt, or argon2 (consider golang.org/x/crypto for additional options)
- Use TLS for network communication
## Documentation
### Code Documentation
- Prioritize self-documenting code through clear naming and structure
- Document all exported symbols with clear, concise explanations
- Start documentation with the symbol name
- Write documentation in English by default
- Use examples in documentation when helpful
- Keep documentation close to code
- Update documentation when code changes
- Avoid emoji in documentation and comments
### README and Documentation Files
- Include clear setup instructions
- Document dependencies and requirements
- Provide usage examples
- Document configuration options
- Include troubleshooting section
## Tools and Development Workflow
### Essential Tools
- `go fmt`: Format code
- `go vet`: Find suspicious constructs
- `golangci-lint`: Additional linting (golint is deprecated)
- `go test`: Run tests
- `go mod`: Manage dependencies
- `go generate`: Code generation
### Development Practices
- Run tests before committing
- Use pre-commit hooks for formatting and linting
- Keep commits focused and atomic
- Write meaningful commit messages
- Review diffs before committing
## Common Pitfalls to Avoid
- Not checking errors
- Ignoring race conditions
- Creating goroutine leaks
- Not using defer for cleanup
- Modifying maps concurrently
- Not understanding nil interfaces vs nil pointers
- Forgetting to close resources (files, connections)
- Using global variables unnecessarily
- Over-using unconstrained types (e.g., `any`); prefer specific types or generic type parameters with constraints. If an unconstrained type is required, use `any` rather than `interface{}`
- Not considering the zero value of types
- **Creating duplicate `package` declarations** - this is a compile error; always check existing files before adding package declarations

107
.github/used-prompts/deploying.md vendored Normal file
View File

@@ -0,0 +1,107 @@
## Overview
Deploy `estus-shots` to the personal Fedora server manually—no CI/CD required. The application runs behind Nginx via a reverse proxy and is supervised by `systemd`.
## Prerequisites
- Fedora (server) with SSH access for a user that can `sudo` without a passwordless setup.
- Nim toolchain installed locally (for `nim`/`nimble`).
- Server already provisioned with Nim runtime dependencies.
- Existing Nginx site definition that proxies traffic to the `estus-shots` service port (defaults to `127.0.0.1:9000`).
## One-time server setup
1. Create deployment directories on the server:
```fish
ssh user@server 'sudo mkdir -p /opt/estus-shots/bin /opt/estus-shots/config && sudo chown ${USER}:${USER} /opt/estus-shots -R'
```
2. Copy the `systemd` unit and enable it:
```fish
printf '%s\n' "[Unit]" \
"Description=estus-shots web service" \
"After=network.target" \
"" \
"[Service]" \
"Type=simple" \
"ExecStart=/opt/estus-shots/bin/estus-shots" \
"Restart=on-failure" \
"RestartSec=5" \
"User=www-data" \
"WorkingDirectory=/opt/estus-shots" \
"Environment=LOG_LEVEL=info" \
"" \
"[Install]" \
"WantedBy=multi-user.target" \
| ssh user@server 'sudo tee /etc/systemd/system/estus-shots.service'
ssh user@server 'sudo systemctl daemon-reload && sudo systemctl enable estus-shots.service'
```
3. Ensure Nginx reverse proxy points to `127.0.0.1:9000` and reload it once configured:
```fish
ssh user@server 'sudo systemctl restart nginx'
```
## Deployment script
Save the following as `scripts/deploy.fish` (or another preferred location) and make it executable with `chmod +x scripts/deploy.fish`.
```fish
#!/usr/bin/env fish
set -e
set SERVER user@server
set TARGET_DIR /opt/estus-shots
set BIN_NAME estus-shots
echo 'Building project locally (release)...'
nimble build -y
echo 'Uploading binary...'
scp bin/$BIN_NAME $SERVER:$TARGET_DIR/bin/
echo 'Uploading static assets...'
scp -r src/static $SERVER:$TARGET_DIR/
echo 'Restarting services...'
ssh $SERVER 'sudo systemctl restart estus-shots.service'
ssh $SERVER 'sudo systemctl restart nginx'
echo 'Deployment finished.'
```
> The script prompts for the SSH password and any required `sudo` password on the server.
## Manual deployment steps
1. Build the project locally:
```fish
nimble build -y
```
2. Copy the new build output and static assets:
```fish
scp bin/estus-shots user@server:/opt/estus-shots/bin/
scp -r src/static user@server:/opt/estus-shots/
```
3. Restart the service and, if needed, Nginx:
```fish
ssh user@server 'sudo systemctl restart estus-shots.service'
ssh user@server 'sudo systemctl restart nginx'
```
## Verification
- Check service status:
```fish
ssh user@server 'systemctl status estus-shots.service --no-pager'
```
- Review recent logs:
```fish
ssh user@server 'journalctl -u estus-shots.service -n 50 --no-pager'
```
## Troubleshooting tips
- If the service fails, run it manually on the server to capture stdout/stderr: `bin/estus-shots`.
- Ensure SELinux contexts allow Nginx to proxy (`setsebool -P httpd_can_network_connect 1`).
- Validate reverse proxy config: `sudo nginx -t`.

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Build outputs
bin/
build/
# Go tool artifacts
*.test
coverage.out
# Compiled binaries and objects
*.exe
*.dll
*.lib
*.obj
*.o
*.so
*.dylib
*.a
*.pdb
*.ilk
*.map
# Logs and temporary files
*.log
*.out
*.err
*.tmp
*.temp
# Editor and tool artifacts
*.swp
*~
.DS_Store
Thumbs.db
.idea/
.vscode/

119
cmd/estus-shots/main.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"estus-shots/internal/server"
)
type appConfig struct {
MediaDir string `json:"media_dir"`
}
func main() {
cfg := resolveConfig()
srv, err := server.New(server.Config{MediaDir: cfg.MediaDir})
if err != nil {
log.Fatalf("failed to configure server: %v", err)
}
httpServer := &http.Server{
Addr: srv.Address(),
Handler: srv.Router(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf(
"Estus Shots web server running on http://127.0.0.1%s (media: %s)",
srv.Address(),
cfg.MediaDir,
)
go func() {
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
} else {
log.Println("server stopped")
}
}
func resolveConfig() appConfig {
mediaDirFlag := flag.String("media-dir", "", "path to the media directory served at /media")
configPath := flag.String("config", "", "optional path to a JSON config file")
flag.Parse()
cfg := appConfig{}
if *configPath != "" {
fileCfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("failed to read config file: %v", err)
}
cfg = fileCfg
}
if *mediaDirFlag != "" {
cfg.MediaDir = *mediaDirFlag
}
if cfg.MediaDir == "" {
cfg.MediaDir = defaultMediaDir()
}
return cfg
}
func loadConfig(path string) (appConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return appConfig{}, err
}
cfg := appConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return appConfig{}, err
}
return cfg, nil
}
func defaultMediaDir() string {
if cwd, err := os.Getwd(); err == nil {
candidate := filepath.Join(cwd, "media")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), "media")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
return filepath.Join(".", "media")
}

119
cmd/luxtools/main.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"estus-shots/internal/server"
)
type appConfig struct {
MediaDir string `json:"media_dir"`
}
func main() {
cfg := resolveConfig()
srv, err := server.New(server.Config{MediaDir: cfg.MediaDir})
if err != nil {
log.Fatalf("failed to configure server: %v", err)
}
httpServer := &http.Server{
Addr: srv.Address(),
Handler: srv.Router(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf(
"Estus Shots web server running on http://127.0.0.1%s (media: %s)",
srv.Address(),
cfg.MediaDir,
)
go func() {
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
} else {
log.Println("server stopped")
}
}
func resolveConfig() appConfig {
mediaDirFlag := flag.String("media-dir", "", "path to the media directory served at /media")
configPath := flag.String("config", "", "optional path to a JSON config file")
flag.Parse()
cfg := appConfig{}
if *configPath != "" {
fileCfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("failed to read config file: %v", err)
}
cfg = fileCfg
}
if *mediaDirFlag != "" {
cfg.MediaDir = *mediaDirFlag
}
if cfg.MediaDir == "" {
cfg.MediaDir = defaultMediaDir()
}
return cfg
}
func loadConfig(path string) (appConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return appConfig{}, err
}
cfg := appConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return appConfig{}, err
}
return cfg, nil
}
func defaultMediaDir() string {
if cwd, err := os.Getwd(); err == nil {
candidate := filepath.Join(cwd, "media")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), "media")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
return filepath.Join(".", "media")
}

View File

@@ -0,0 +1,19 @@
[Unit]
Description=estus-shots web service
After=network.target
[Service]
Type=simple
ExecStart=/opt/estus-shots/bin/estus-shots
WorkingDirectory=/opt/estus-shots
Restart=on-failure
RestartSec=5
User=estus-shots
Group=estus-shots
Environment=LOG_LEVEL=info
Environment=PORT=9000
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

19
deploy/luxtools.service Normal file
View File

@@ -0,0 +1,19 @@
[Unit]
Description=estus-shots web service
After=network.target
[Service]
Type=simple
ExecStart=/opt/estus-shots/bin/estus-shots
WorkingDirectory=/opt/estus-shots
Restart=on-failure
RestartSec=5
User=estus-shots
Group=estus-shots
Environment=LOG_LEVEL=info
Environment=PORT=9000
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module estus-shots
go 1.22.0

189
internal/server/server.go Normal file
View File

@@ -0,0 +1,189 @@
package server
import (
"fmt"
"html"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
webbundle "estus-shots/web"
)
const (
address = ":5000"
htmlContentType = "text/html; charset=utf-8"
notFoundTemplate = `
<div class="tui-window">
<fieldset class="tui-fieldset">
<legend>Error</legend>
<p>Route %s not found.</p>
</fieldset>
</div>
`
)
// Config contains the runtime options for the HTTP server.
type Config struct {
MediaDir string
}
// Server hosts the Estus Shots HTTP handlers.
type Server struct {
mux *http.ServeMux
templates *template.Template
staticFS fs.FS
mediaFS http.FileSystem
counterMu sync.Mutex
clickCount int
}
// New constructs a configured Server ready to serve requests.
func New(cfg Config) (*Server, error) {
if strings.TrimSpace(cfg.MediaDir) == "" {
return nil, fmt.Errorf("media directory is required")
}
mediaDir := filepath.Clean(cfg.MediaDir)
info, err := os.Stat(mediaDir)
if err != nil {
return nil, fmt.Errorf("media directory check failed: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("media directory must be a directory: %s", mediaDir)
}
// Parse all templates
tmpl, err := template.ParseFS(webbundle.Content, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}
staticFS, err := fs.Sub(webbundle.Content, "static")
if err != nil {
return nil, fmt.Errorf("failed to prepare static assets: %w", err)
}
s := &Server{
mux: http.NewServeMux(),
templates: tmpl,
staticFS: staticFS,
mediaFS: http.Dir(mediaDir),
}
s.registerRoutes()
return s, nil
}
// Router exposes the configured http.Handler for the server.
func (s *Server) Router() http.Handler {
return s.mux
}
// Address returns the default listen address for the server.
func (s *Server) Address() string {
return address
}
func (s *Server) registerRoutes() {
staticHandler := http.FileServer(http.FS(s.staticFS))
mediaHandler := http.FileServer(s.mediaFS)
s.mux.Handle("/static/", http.StripPrefix("/static/", allowGetHead(staticHandler)))
s.mux.Handle("/media/", http.StripPrefix("/media/", allowGetHead(mediaHandler)))
s.mux.HandleFunc("/", s.handleRequest)
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
data := DefaultMenuBar()
s.renderTemplate(w, http.StatusOK, "index.html", data)
case "/admin":
data := AdminMenuBar()
data.Title = "Admin Panel"
s.renderTemplate(w, http.StatusOK, "admin.html", data)
case "/counter":
s.handleCounter(w, r)
case "/time":
s.sendHTMLString(w, http.StatusOK, renderTime(time.Now()))
default:
s.sendHTMLString(w, http.StatusNotFound, fmt.Sprintf(notFoundTemplate, html.EscapeString(r.URL.Path)))
}
}
func (s *Server) handleCounter(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost || r.Method == http.MethodPut {
s.counterMu.Lock()
s.clickCount++
s.counterMu.Unlock()
}
s.counterMu.Lock()
count := s.clickCount
s.counterMu.Unlock()
s.sendHTMLString(w, http.StatusOK, renderCounter(count))
}
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data interface{}) {
w.Header().Set("Content-Type", htmlContentType)
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template execution failed: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) sendHTML(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", htmlContentType)
w.WriteHeader(status)
if len(body) > 0 {
if _, err := w.Write(body); err != nil {
log.Printf("write response failed: %v", err)
}
}
}
func (s *Server) sendHTMLString(w http.ResponseWriter, status int, body string) {
s.sendHTML(w, status, []byte(body))
}
func renderCounter(count int) string {
return fmt.Sprintf(`
<div id="counter" class="tui-panel tui-panel-inline">
<p><strong>Clicks:</strong> %d</p>
<button class="tui-button" hx-post="/counter" hx-target="#counter" hx-swap="outerHTML">
Increment
</button>
</div>
`, count)
}
func renderTime(now time.Time) string {
return fmt.Sprintf(`
<div id="server-time" class="tui-panel tui-panel-inline">
<p><strong>Server time:</strong> %s</p>
</div>
`, now.Format("2006-01-02 15:04:05"))
}
func allowGetHead(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead:
next.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}

View File

@@ -0,0 +1,129 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRenderCounter(t *testing.T) {
html := renderCounter(7)
if !strings.Contains(html, "Clicks:") {
t.Fatalf("expected counter markup to include label")
}
if !strings.Contains(html, "> 7<") {
t.Fatalf("expected counter value in markup, got %q", html)
}
}
func TestRenderTime(t *testing.T) {
now := time.Date(2024, time.August, 1, 12, 34, 56, 0, time.UTC)
html := renderTime(now)
expected := "2024-08-01 12:34:56"
if !strings.Contains(html, expected) {
t.Fatalf("expected formatted timestamp %s in markup", expected)
}
}
func TestCounterEndpoint(t *testing.T) {
srv := mustNewServer(t, nil)
req := httptest.NewRequest(http.MethodPost, "/counter", nil)
resp := httptest.NewRecorder()
srv.Router().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.Code)
}
body := resp.Body.String()
if !strings.Contains(body, "> 1<") {
t.Fatalf("expected counter to increment to 1, body: %q", body)
}
}
func TestNotFound(t *testing.T) {
srv := mustNewServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/unknown", nil)
resp := httptest.NewRecorder()
srv.Router().ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Route /unknown not found.") {
t.Fatalf("expected not found message, got %q", resp.Body.String())
}
}
func TestStaticFileServing(t *testing.T) {
srv := mustNewServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/static/lib/htmx.2.0.7.min.js", nil)
resp := httptest.NewRecorder()
srv.Router().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected static asset to load, got status %d", resp.Code)
}
if resp.Body.Len() == 0 {
t.Fatalf("expected static asset response body")
}
}
func TestMediaServing(t *testing.T) {
var demoName string
srv := mustNewServer(t, func(dir string) {
demoName = filepath.Join(dir, "demo.txt")
if err := os.WriteFile(demoName, []byte("demo media file"), 0o644); err != nil {
t.Fatalf("failed to seed media file: %v", err)
}
})
req := httptest.NewRequest(http.MethodGet, "/media/demo.txt", nil)
resp := httptest.NewRecorder()
srv.Router().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected public asset to load, got status %d", resp.Code)
}
body := resp.Body.String()
if !strings.Contains(body, "demo media file") {
t.Fatalf("unexpected asset body: %q", body)
}
}
func TestNewRequiresMediaDir(t *testing.T) {
if _, err := New(Config{}); err == nil {
t.Fatalf("expected error when media dir is empty")
}
missing := filepath.Join(t.TempDir(), "missing")
if _, err := New(Config{MediaDir: missing}); err == nil {
t.Fatalf("expected error when media dir does not exist")
}
}
func mustNewServer(t *testing.T, setup func(string)) *Server {
t.Helper()
mediaDir := t.TempDir()
if setup != nil {
setup(mediaDir)
}
srv, err := New(Config{MediaDir: mediaDir})
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
return srv
}

View File

@@ -0,0 +1,84 @@
package server
// This file defines the data structures and helper functions for the template system.
//
// The template system uses Go's html/template package to provide:
// - Dynamic menu bars that can be customized per page
// - Template inheritance via layout.html
// - Type-safe data passing via structs
//
// To create a new page with a custom menu:
// 1. Create a PageData struct with your menu configuration
// 2. Call s.renderTemplate(w, status, "yourpage.html", data)
// 3. In yourpage.html, use {{template "layout" .}} and define content blocks
//
// See TEMPLATE_GUIDE.md for detailed examples and usage patterns.
// PageData holds the data passed to page templates.
type PageData struct {
Title string
MenuGroups []MenuGroup
ShowClock bool
}
// MenuGroup represents a dropdown menu in the navbar.
type MenuGroup struct {
Label string
Items []MenuItem
}
// MenuItem represents a single menu item.
type MenuItem struct {
Label string
URL string
IsDivider bool
}
// DefaultMenuBar returns the standard menu configuration.
func DefaultMenuBar() PageData {
return PageData{
ShowClock: true,
MenuGroups: []MenuGroup{
{
Label: "File",
Items: []MenuItem{
{Label: "New", URL: "#!"},
{Label: "Open", URL: "#!"},
{Label: "Save", URL: "#!"},
{Label: "Save As", URL: "#!"},
{IsDivider: true},
{Label: "Exit", URL: "#!"},
},
},
{
Label: "Edit",
Items: []MenuItem{
{Label: "Cut", URL: "#!"},
{Label: "Copy", URL: "#!"},
{Label: "Paste", URL: "#!"},
},
},
{
Label: "Help",
Items: []MenuItem{
{Label: "Documentation", URL: "#!"},
{Label: "About", URL: "#!"},
},
},
},
}
}
// AdminMenuBar returns a menu configuration with admin options.
func AdminMenuBar() PageData {
data := DefaultMenuBar()
data.MenuGroups = append(data.MenuGroups, MenuGroup{
Label: "Admin",
Items: []MenuItem{
{Label: "Users", URL: "/admin/users"},
{Label: "Settings", URL: "/admin/settings"},
{Label: "Logs", URL: "/admin/logs"},
},
})
return data
}

1
media/demo.txt Normal file
View File

@@ -0,0 +1 @@
This is a demo media file served from the configurable media handler.

8
web/embed.go Normal file
View File

@@ -0,0 +1,8 @@
package web
import "embed"
// Content holds the HTML templates and static assets for the web UI.
//
//go:embed templates/* static
var Content embed.FS

1
web/static/lib/htmx.2.0.7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,72 @@
/
/(_____________ ____
\ /______)\ | |
:\ | / \:| |:::::::::: : .. . : .. . . :. .
\_____| / | \| |______
___ / ________ \... . . .
\______________ \ | | /.. . . . . .
\ |__| /
--x--x-----x----\______ |-/_____/-x--x-xx--x-- - -x -- - - -- - - -
. . . . . . . . . . . .\____|. . . . . .
-------------------------------------------------------------------------------
>> perfect dos vga 437 - general information >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
-------------------------------------------------------------------------------
"Perfect DOS VGA 437" and "Perfect DOS VGA 437 Win" are truetype fonts
designed to emulate the MS-DOS/Text mode standard font, used on VGA monitors,
with the 437 Codepage (standard US/International). This is a "bitmap" font,
meaning it emulates a bitmap font and can only be used at a given size (8 or
multiples of it like 16, 24, 32, etc). It's optimized for Flash too, so it
won't produce antialias if used at round positions.
There are two fonts available. "Perfect DOS VGA 437" uses the original DOS
codepage 437. It should be used, for example, if you're opening DOS ASCII
files on notepad or another windows-based editor. Since it's faithful to the
original DOS codes, it won't accent correctly in windows ("é" would produce
something different, not an "e" with an acute).
There's also "Perfect DOS VGA 437 Win" which is the exactly same font adapted
to a windows codepage. This should use accented characters correctly but won't
work if you're opening a DOS-based text file.
UPDATE: this is a new version, updated in august/2008. It has fixed leading
metrics for Mac systems.
-------------------------------------------------------------------------------
>> perfect dos vga 437 - creation process >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
-------------------------------------------------------------------------------
This font was created to be used on a Flash-based ANSi viewer I'm working. To
create it, I created a small Quick Basic program to write all characters on
screen,
CLS
FOR l = 0 TO 255
charWrite 1 + (l MOD 20), 1 + (l \ 20) * 6 + (l MOD 2), LTRIM$(RTRIM$(STR$(l))) + CHR$(l)
NEXT
SUB charWrite (lin, col, char$)
DEF SEG = &HB800
FOR i = 1 TO LEN(char$)
POKE ((lin - 1) * 160) + ((col - 2 + i) * 2), ASC(MID$(char$, i, 1))
IF (i = LEN(char$)) THEN POKE ((lin - 1) * 160) + ((col - 2 + i) * 2) + 1, 113
NEXT
END SUB
Then captured the text screen using SCREEN THIEF (a very, very old screen
capture TSR program which converts text screens to images accurately). I then
recreated the font polygon by polygon on Fontlab, while looking at the image
on Photoshop. No conversion took place.
-------------------------------------------------------------------------------
>> author >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
-------------------------------------------------------------------------------
zeh fernando remembers the old days. SMASH DAH FUCKING ENTAH.
http://www.fatorcaos.com.br
rorshack ^ maiden brazil
-------------------------------------------------------------------------------
^zehPULLSdahTRICK^kudosOUTtoWHOkeepsITreal^smashDAHfuckingENTAH!!!^lowres4ever^
-------------------------------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
/**
* Replacement for jQuery's $(document).ready() function.
* This is handy in making sure stuff fires after the DOM is ready to be touched.
* Stolen from:https://stackoverflow.com/a/53601942/344028
*
* @param fn Callback.
*/
function domReady(fn) {
// If we're early to the party
document.addEventListener('DOMContentLoaded', fn);
// If late; I mean on time.
if (document.readyState === 'interactive' || document.readyState === 'complete') {
fn();
}
}
/**
* TuiTabs controller
*/
function tabsController() {
// Get all the tab elements (typically li tags).
const tabs = document.getElementsByClassName('tui-tab');
if (!tabs.length) {
// No tabs found, return early and save a couple CPU cycles.
return;
}
for (const tab of tabs) {
// Add click listeners to them.
tab.addEventListener('click', function (e) {
// Check if the clicked tab is disabled
if(e.target.classList.contains("disabled")) {
return;
}
// Remove the 'active' class from any and all tabs.
for (const otherTab of tabs) {
otherTab.classList.remove('active');
}
// Get the content element.
const tabContents = document.getElementsByClassName('tui-tab-content');
if (tabContents) {
for (const tabContent of tabContents) {
// Hide all tab contents.
tabContent.style.display = 'none';
}
} else {
throw 'No tab content elements found.'
}
// Get the id of the tab contents we want to show.
const tabContentId = e.target.getAttribute('data-tab-content');
if (tabContentId) {
const tabContent = document.getElementById(tabContentId);
if (tabContent) {
// Show the tab contents.
tabContent.style.display = 'block';
} else {
throw 'No tab content element with id "' + tabContentId + '" found.';
}
}
// We are not going to throw an error here, since we could make the tab do something else that a tab
// normally wouldn't do.
// Set the clicked tab to have the 'active' class so we can use it in the next part.
e.target.classList.add('active');
});
}
// Grab the first tab with the active class.
const activeTab = document.querySelector('.tui-tab.active');
if (activeTab) {
// Now click it 'click' it.
activeTab.click();
} else {
// Nothing found, just click the first tab foud.
tabs[0].click()
}
}
/**
* Date/time field controller
*/
function datetimeController() {
// Get date/time elements.
const clocks = document.getElementsByClassName('tui-datetime');
if (!clocks.length) {
// No date time elements found, return early and save a couple CPU cycles.
return;
}
// Kick off our clock interval stuff.
datetimeInterval();
// Synchronize time and set interval to control the clocks
setTimeout(() => {
setInterval(datetimeInterval, 1000);
}, 1000 - new Date().getMilliseconds());
function datetimeInterval() {
for (const clock of clocks) {
if (clock === null) {
continue;
}
// Get the format we want to display in the element.
let format = clock.getAttribute('data-format');
// parse out the date and time into constants.
const today = new Date();
const month = (today.getMonth() + 1).toString().padStart(2, '0');
const day = today.getDate().toString().padStart(2, '0');
const dayOfWeek = (today.getDay() + 1).toString().padStart(2, '0');
const year = today.getFullYear().toString();
const hour = today.getHours().toString().padStart(2, '0');
const hour12 = (parseInt(hour) + 24) % '12' || '12';
const minute = today.getMinutes().toString().padStart(2, '0');
const second = today.getSeconds().toString().padStart(2, '0');
const ampm = parseInt(hour) >= 12 ? 'PM' : 'AM';
// Replace based on the format.
format = format.replace('M', month);
format = format.replace('d', day);
format = format.replace('e', dayOfWeek);
format = format.replace('y', year);
format = format.replace('H', hour);
format = format.replace('h', hour12);
format = format.replace('m', minute);
format = format.replace('s', second);
format = format.replace('a', ampm);
// Show it in the element.
clock.innerHTML = format;
}
}
}
/**
* Sidenav Controller
* There should only side navigation element at the moment.
*/
function sidenavController() {
// Get the side navigation button (there should be only one, but if not, we are getting the first one).
const sideNavButton = document.querySelector('.tui-sidenav-button');
if (!sideNavButton) {
// No side navigation button found, return early and save a couple CPU cycles.
return;
}
// Add the click event listener to the buttons.
sideNavButton.addEventListener('click', () => {
// Get the side navigation element (there should be only one, but if not, we are getting the first one).
const sideNav = document.querySelector('.tui-sidenav');
if (sideNav) {
if (sideNav.classList.contains('active')) {
sideNav.classList.remove('active');
} else {
sideNav.classList.add('active');
}
} else {
throw 'No sidenav element found.'
}
});
}
/**
* Modal controller
*/
function modalController() {
// Get the overlap (overlay) element (there should be only one, but if not, we are getting the first one).
const tuiOverlap = document.querySelector('.tui-overlap');
if (!tuiOverlap) {
// No overlap found element, return early and save a couple CPU cycles.
return;
}
// Find modal buttons.
const modalButtons = document.getElementsByClassName('tui-modal-button');
for (const modalButton of modalButtons) {
// Add the click event listener to the buttons.
modalButton.addEventListener('click', (e) => {
// Show the overlap.
tuiOverlap.classList.add('active');
// Get the display element for the modal.
const modalId = e.target.getAttribute('data-modal');
if (modalId) {
const modal = document.getElementById(modalId);
if (modal) {
// Show it.
modal.classList.add('active');
} else {
throw 'No modal element with id of "' + modalId + '" found.';
}
} else {
throw 'Modal close button data-modal attribute is empty or not set.'
}
});
}
// Find the close modal buttons.
const modalCloseButtons = document.getElementsByClassName('tui-modal-close-button');
if (modalButtons.length > 0 && !modalCloseButtons.length) {
// A modal without a close button, is a bad modal.
throw 'No modal close buttons found.'
}
for (const modalCloseButton of modalCloseButtons) {
// Add the click event listener to the buttons.
modalCloseButton.addEventListener('click', (e) => {
// Hide the the overlap.
tuiOverlap.classList.remove('active');
// Get the display element id for the modal.
const modalId = e.target.getAttribute('data-modal');
if (modalId) {
// Get the modal element.
const modal = document.getElementById(modalId);
if (modal) {
// Hide it.
modal.classList.remove('active');
} else {
throw 'No modal element with id of "' + modalId + '" found.';
}
} else {
throw 'Modal close button data-modal attribute is empty or not set.'
}
});
}
}
/**
* Init: This is at the bottom to make sure it is fired correctly.
*/
domReady(function () {
tabsController();
datetimeController();
sidenavController();
modalController();
});

1
web/static/lib/tuicss/tuicss.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
web/static/lib/tuicss/tuicss.min.js vendored Normal file
View File

@@ -0,0 +1 @@
function domReady(t){document.addEventListener("DOMContentLoaded",t),"interactive"!==document.readyState&&"complete"!==document.readyState||t()}function tabsController(){const t=document.getElementsByClassName("tui-tab");if(!t.length)return;for(const e of t)e.addEventListener("click",function(e){if(e.target.classList.contains("disabled"))return;for(const e of t)e.classList.remove("active");const o=document.getElementsByClassName("tui-tab-content");if(!o)throw"No tab content elements found.";for(const t of o)t.style.display="none";const n=e.target.getAttribute("data-tab-content");if(n){const t=document.getElementById(n);if(!t)throw'No tab content element with id "'+n+'" found.';t.style.display="block"}e.target.classList.add("active")});const e=document.querySelector(".tui-tab.active");e?e.click():t[0].click()}function datetimeController(){const t=document.getElementsByClassName("tui-datetime");function e(){for(const e of t){if(null===e)continue;let t=e.getAttribute("data-format");const o=new Date,n=(o.getMonth()+1).toString().padStart(2,"0"),a=o.getDate().toString().padStart(2,"0"),c=(o.getDay()+1).toString().padStart(2,"0"),s=o.getFullYear().toString(),i=o.getHours().toString().padStart(2,"0"),l=(parseInt(i)+24)%"12"||"12",r=o.getMinutes().toString().padStart(2,"0"),d=o.getSeconds().toString().padStart(2,"0"),u=parseInt(i)>=12?"PM":"AM";t=(t=(t=(t=(t=(t=(t=(t=(t=t.replace("M",n)).replace("d",a)).replace("e",c)).replace("y",s)).replace("H",i)).replace("h",l)).replace("m",r)).replace("s",d)).replace("a",u),e.innerHTML=t}}t.length&&(e(),setTimeout(()=>{setInterval(e,1e3)},1e3-(new Date).getMilliseconds()))}function sidenavController(){const t=document.querySelector(".tui-sidenav-button");t&&t.addEventListener("click",()=>{const t=document.querySelector(".tui-sidenav");if(!t)throw"No sidenav element found.";t.classList.contains("active")?t.classList.remove("active"):t.classList.add("active")})}function modalController(){const t=document.querySelector(".tui-overlap");if(!t)return;const e=document.getElementsByClassName("tui-modal-button");for(const o of e)o.addEventListener("click",e=>{t.classList.add("active");const o=e.target.getAttribute("data-modal");if(!o)throw"Modal close button data-modal attribute is empty or not set.";{const t=document.getElementById(o);if(!t)throw'No modal element with id of "'+o+'" found.';t.classList.add("active")}});const o=document.getElementsByClassName("tui-modal-close-button");if(e.length>0&&!o.length)throw"No modal close buttons found.";for(const e of o)e.addEventListener("click",e=>{t.classList.remove("active");const o=e.target.getAttribute("data-modal");if(!o)throw"Modal close button data-modal attribute is empty or not set.";{const t=document.getElementById(o);if(!t)throw'No modal element with id of "'+o+'" found.';t.classList.remove("active")}})}domReady(function(){tabsController(),datetimeController(),sidenavController(),modalController()});

28
web/templates/admin.html Normal file
View File

@@ -0,0 +1,28 @@
{{template "layout" .}}
{{define "title"}}Admin Panel · Estus Shots{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Admin Panel</legend>
<p class="tui-text-white">
This is an example admin page with a different menu bar that includes admin-specific options.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-yellow">System Status</h2>
<div class="tui-panel">
<p><strong>Server:</strong> Running</p>
<p><strong>Users:</strong> 42 active</p>
<p><strong>Uptime:</strong> 7 days, 3 hours</p>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-yellow">Quick Actions</h2>
<button class="tui-button">View Logs</button>
<button class="tui-button">Manage Users</button>
<button class="tui-button">Settings</button>
</section>
</fieldset>
{{end}}

45
web/templates/index.html Executable file
View File

@@ -0,0 +1,45 @@
{{template "layout" .}}
{{define "title"}}Estus Shots · Go + HTMX demo{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Estus Shots control panel</legend>
<p class="tui-text-white">
Estus Shots demonstrates a Go backend compiled into a single binary. The UI
uses <strong>TUI.CSS</strong> for a retro feel and <strong>HTMX</strong> for
partial page updates without JavaScript glue code.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Interactive counter</h2>
<p class="tui-text-silver">
Click the button to trigger an <code>hx-post</code> request. The response
replaces only the counter panel.
</p>
<div
id="counter"
hx-get="/counter"
hx-trigger="load"
hx-swap="outerHTML"
>
<div class="tui-panel tui-panel-inline">Loading…</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Server time</h2>
<p class="tui-text-silver">
A periodic <code>hx-get</code> refresh keeps this panel in sync with the
server clock.
</p>
<div
id="server-time"
hx-get="/time"
hx-trigger="load, every 5s"
>
<div class="tui-panel tui-panel-inline">Loading…</div>
</div>
</section>
</fieldset>
{{end}}

19
web/templates/layout.html Normal file
View File

@@ -0,0 +1,19 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{block "title" .}}Estus Shots{{end}}</title>
<link rel="stylesheet" href="/static/lib/tuicss/tuicss.min.css" />
<script src="/static/lib/htmx.2.0.7.min.js" defer></script>
<script src="/static/lib/tuicss/tuicss.min.js" defer></script>
</head>
<body class="tui-bg-black">
{{block "menubar" .}}{{end}}
<main class="tui-container tui-window" style="margin-top: 2rem;">
{{block "content" .}}{{end}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,25 @@
{{define "menubar"}}
<nav class="tui-nav">
<ul>
{{range .MenuGroups}}
<li class="tui-dropdown">
<span class="red-168-text">{{slice .Label 0 1}}</span>{{slice .Label 1}}
<div class="tui-dropdown-content">
<ul>
{{range .Items}}
{{if .IsDivider}}
<li class="tui-divider"></li>
{{else}}
<li><a href="{{.URL}}"><span class="red-168-text">{{slice .Label 0 1}}</span>{{slice .Label 1}}</a></li>
{{end}}
{{end}}
</ul>
</div>
</li>
{{end}}
</ul>
{{if .ShowClock}}
<span class="tui-datetime" data-format="h:m:s a"></span>
{{end}}
</nav>
{{end}}