Init
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Do not end with a summary or explanation
|
||||||
373
.github/go.instructions.md
vendored
Normal 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 you’re 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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
19
deploy/estus-shots.service
Normal 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
@@ -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
|
||||||
189
internal/server/server.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
129
internal/server/server_test.go
Normal 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
|
||||||
|
}
|
||||||
84
internal/server/template_data.go
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
This is a demo media file served from the configurable media handler.
|
||||||
8
web/embed.go
Normal 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
BIN
web/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf
Normal file
BIN
web/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf
Normal file
72
web/static/lib/tuicss/fonts/dos437.txt
Normal 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^
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
BIN
web/static/lib/tuicss/images/bg-blue-black.png
Normal file
|
After Width: | Height: | Size: 166 B |
BIN
web/static/lib/tuicss/images/bg-blue-white.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/bg-cyan-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/bg-cyan-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
web/static/lib/tuicss/images/bg-green-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/bg-green-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
web/static/lib/tuicss/images/bg-orange-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/bg-orange-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
web/static/lib/tuicss/images/bg-purple-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/bg-purple-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
web/static/lib/tuicss/images/bg-red-black.png
Normal file
|
After Width: | Height: | Size: 166 B |
BIN
web/static/lib/tuicss/images/bg-red-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
web/static/lib/tuicss/images/bg-yellow-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/bg-yellow-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
web/static/lib/tuicss/images/scroll-blue.png
Normal file
|
After Width: | Height: | Size: 166 B |
BIN
web/static/lib/tuicss/images/scroll-cyan.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/scroll-green.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
web/static/lib/tuicss/images/scroll-orange.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
web/static/lib/tuicss/images/scroll-purple.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/scroll-red.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
web/static/lib/tuicss/images/scroll-white.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
web/static/lib/tuicss/images/scroll-yellow.png
Normal file
|
After Width: | Height: | Size: 167 B |
2704
web/static/lib/tuicss/tuicss.css
Normal file
256
web/static/lib/tuicss/tuicss.js
Normal 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
1
web/static/lib/tuicss/tuicss.min.js
vendored
Normal 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
@@ -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
@@ -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
@@ -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}}
|
||||||
25
web/templates/menubar.html
Normal 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}}
|
||||||