generated from hantim/static-site-template
This commit is contained in:
@@ -0,0 +1,493 @@
|
|||||||
|
# thekims-dashboard — Claude Code Plan
|
||||||
|
|
||||||
|
A DAKboard-style family dashboard served at `https://thekims.family/dashboard`, rendered on a Raspberry Pi 5 driving a 1440p portrait monitor via Firefox kiosk mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ Raspberry Pi 5 │ │ hantim (Vultr VPS) │
|
||||||
|
│ Firefox kiosk mode │──────▶│ nginx ─► Go binary (Docker) │
|
||||||
|
│ 1440x2560 portrait │ │ ├─ /dashboard │
|
||||||
|
└─────────────────────┘ │ ├─ /dashboard/admin │
|
||||||
|
│ └─ /dashboard/api/* │
|
||||||
|
│ │
|
||||||
|
│ config.json (Docker volume) │
|
||||||
|
│ themes/*.css (Docker volume) │
|
||||||
|
└───────┬──────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
┌────────────┘ └────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────┐ ┌─────────────────┐
|
||||||
|
│ External Services │ │ Home Server │
|
||||||
|
│ - api.weather.gov │ │ (argento) │
|
||||||
|
│ - CalDAV servers │ │ - Immich API │
|
||||||
|
│ (incl Google) │ │ │
|
||||||
|
└───────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key decisions:**
|
||||||
|
- Go backend does ALL external fetching. Client never talks to third-party services.
|
||||||
|
- Client receives lightweight JSON + proxied images. Vanilla JS: poll, swap DOM, CSS transitions.
|
||||||
|
- Server caches aggressively. Client polls server on staggered intervals.
|
||||||
|
- No frameworks (JS or Go). Vanilla JS. Go `html/template`. CSS custom properties for theming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go Concepts You'll Encounter, By Phase
|
||||||
|
|
||||||
|
This is not a Go tutorial — you'll pick up syntax from Claude Code and the spec. This section maps *engineering concepts you already know* to *Go's way of expressing them*, so you can pattern-match instead of cargo-culting.
|
||||||
|
|
||||||
|
### The things that'll feel familiar
|
||||||
|
- Statically typed, compiled, C-family syntax.
|
||||||
|
- Structs instead of classes. Methods are functions with a receiver argument.
|
||||||
|
- Explicit error returns (`val, err := doThing()`) instead of exceptions. You'll write `if err != nil` hundreds of times. This is normal.
|
||||||
|
- Interfaces are satisfied implicitly — if your struct has the right method signatures, it implements the interface. No `implements` keyword.
|
||||||
|
|
||||||
|
### The things that'll trip you up
|
||||||
|
|
||||||
|
**Packages, not files.** All `.go` files in a directory share the same package namespace. There are no file-scoped imports or per-file visibility. If you declare `func helper()` in `a.go` and `func Handler()` in `b.go` in the same package, `Handler` can call `helper` directly. Capitalization controls visibility: `Exported` (public) vs `unexported` (package-private).
|
||||||
|
|
||||||
|
**No constructors.** You'll see a convention of `NewThing()` functions that return initialized structs. That's just a convention, not language machinery.
|
||||||
|
|
||||||
|
**Goroutines and channels.** Go's concurrency primitive. `go doSomething()` spawns a lightweight thread. Channels (`chan T`) are typed pipes between goroutines. You'll use these for the cache refresh tickers and background photo list fetching. The `sync` package (`sync.Mutex`, `sync.RWMutex`) is the alternative when channels are overkill.
|
||||||
|
|
||||||
|
**`context.Context`.** Passed as the first argument to almost every function that does I/O. It carries deadlines, cancellation signals, and request-scoped values. The HTTP handler gives you one via `r.Context()`. Thread it through to all downstream calls.
|
||||||
|
|
||||||
|
**Dependency injection via struct fields.** Go has no DI framework. You wire dependencies manually:
|
||||||
|
```go
|
||||||
|
type Server struct {
|
||||||
|
config *config.Config
|
||||||
|
photos *photos.Manager
|
||||||
|
weather *weather.Client
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Construct it in `main()`, pass it around. This will feel tedious at first. It's intentional — the dependency graph is always explicit.
|
||||||
|
|
||||||
|
**`defer`.** Schedules a function call to run when the enclosing function returns. Used for cleanup: `defer file.Close()`, `defer mutex.Unlock()`. Runs in LIFO order. You'll use it constantly.
|
||||||
|
|
||||||
|
**Zero values.** Uninitialized variables get the zero value for their type: `0` for ints, `""` for strings, `nil` for pointers/slices/maps. This matters because Go has no `null` in the Java/JS sense — but `nil` on a slice or map will still panic if you write to it without initializing.
|
||||||
|
|
||||||
|
**Slices vs arrays.** You almost never use arrays directly. Slices (`[]T`) are the standard dynamic-length sequence type. `append()` grows them. They're reference types (like Java's ArrayList, but built-in).
|
||||||
|
|
||||||
|
**Error wrapping.** `fmt.Errorf("failed to fetch weather: %w", err)` wraps an error with context while preserving the original for `errors.Is()` / `errors.As()` checks downstream. Always wrap, never swallow.
|
||||||
|
|
||||||
|
### Project layout conventions
|
||||||
|
|
||||||
|
Go doesn't enforce directory structure, but the community follows a loose standard:
|
||||||
|
|
||||||
|
```
|
||||||
|
thekims-dashboard/
|
||||||
|
├── main.go # Entry point only: wire deps, start server
|
||||||
|
├── go.mod # Module name + dependencies (like package.json)
|
||||||
|
├── go.sum # Lock file (committed to git)
|
||||||
|
│
|
||||||
|
├── internal/ # Private to this module — can't be imported externally
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── config.go
|
||||||
|
│ ├── server/
|
||||||
|
│ │ └── server.go # HTTP server struct, router setup, middleware
|
||||||
|
│ ├── handler/
|
||||||
|
│ │ ├── pages.go
|
||||||
|
│ │ ├── api_config.go
|
||||||
|
│ │ ├── api_photo.go
|
||||||
|
│ │ ├── api_calendar.go
|
||||||
|
│ │ └── api_weather.go
|
||||||
|
│ ├── service/
|
||||||
|
│ │ ├── photo/
|
||||||
|
│ │ │ ├── manager.go # Rotation logic, queue, index tracking
|
||||||
|
│ │ │ └── immich.go # Immich API client
|
||||||
|
│ │ ├── calendar/
|
||||||
|
│ │ │ └── caldav.go # CalDAV client, event parsing/merging
|
||||||
|
│ │ └── weather/
|
||||||
|
│ │ └── nws.go # NWS API client, zip-to-latlon
|
||||||
|
│ └── cache/
|
||||||
|
│ └── cache.go # Generic TTL cache
|
||||||
|
│
|
||||||
|
├── templates/
|
||||||
|
│ ├── dashboard.html
|
||||||
|
│ └── admin.html
|
||||||
|
│
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── dashboard.css
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── dashboard.js
|
||||||
|
│ │ └── admin.js
|
||||||
|
│ └── themes/
|
||||||
|
│ └── default.css
|
||||||
|
│
|
||||||
|
├── data/ # Docker volume mount (gitignored)
|
||||||
|
│ ├── config.json
|
||||||
|
│ └── themes/
|
||||||
|
│
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The `internal/` directory is special in Go — the compiler enforces that nothing outside this module can import packages under it. It's the idiomatic way to say "these are implementation details."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"weather": {
|
||||||
|
"zip_code": "22030",
|
||||||
|
"lat": 38.8462,
|
||||||
|
"lon": -77.3064,
|
||||||
|
"grid_office": "LWX",
|
||||||
|
"grid_x": 90,
|
||||||
|
"grid_y": 78,
|
||||||
|
"observation_station": "KDAA"
|
||||||
|
},
|
||||||
|
"photo_sources": [
|
||||||
|
{
|
||||||
|
"id": "immich-main",
|
||||||
|
"type": "immich",
|
||||||
|
"name": "Family Photos",
|
||||||
|
"url": "https://photos.example.com",
|
||||||
|
"api_key": "...",
|
||||||
|
"album_id": "optional-album-uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"active_photo_source": "immich-main",
|
||||||
|
"calendar_sources": [
|
||||||
|
{
|
||||||
|
"id": "family-cal",
|
||||||
|
"name": "Family",
|
||||||
|
"url": "https://caldav.example.com/dav/calendars/...",
|
||||||
|
"username": "...",
|
||||||
|
"password": "...",
|
||||||
|
"color": "#e74c3c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "google-tim",
|
||||||
|
"name": "Tim's Google",
|
||||||
|
"url": "https://apidata.googleusercontent.com/caldav/v2/.../events",
|
||||||
|
"username": "tim@gmail.com",
|
||||||
|
"password": "app-specific-password",
|
||||||
|
"color": "#3498db"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"active_theme": "default",
|
||||||
|
"photo_interval_seconds": 10,
|
||||||
|
"calendar_refresh_seconds": 60,
|
||||||
|
"weather_refresh_seconds": 600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Weather grid resolution:** On admin save of a zip code, the backend geocodes zip → lat/lon, calls `GET https://api.weather.gov/points/{lat},{lon}` to resolve `gridId`, `gridX`, `gridY`, and nearest `observationStation`, then persists all resolved values so subsequent weather fetches skip re-resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All prefixed with `/dashboard/api`. All return `application/json` unless noted.
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `/config` | GET | Display-relevant config (timezone, theme, intervals). No secrets. |
|
||||||
|
| `/config` | POST | Full config update from admin. Triggers cache invalidation. |
|
||||||
|
| `/photo/current` | GET | `{imageUrl, sourceUrl, index, total}` |
|
||||||
|
| `/photo/next` | GET | Same shape, for preloading. |
|
||||||
|
| `/photo/image/{index}` | GET | Proxied image binary. Resized server-side to ≤2560px tall. |
|
||||||
|
| `/calendar/today` | GET | `{events: [{title, start, end, color, calendarName}]}` |
|
||||||
|
| `/calendar/month` | GET | `{startDate, endDate, events: [...]}` — 4-week window |
|
||||||
|
| `/weather` | GET | `{current: {temp, humidity, condition, icon}, today: {high, low}}` |
|
||||||
|
| `/themes` | GET | List available theme filenames. |
|
||||||
|
| `/themes/{name}` | GET | Serve CSS file. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Notes
|
||||||
|
|
||||||
|
### Display page DOM
|
||||||
|
|
||||||
|
```html
|
||||||
|
<body class="dashboard" data-theme="default">
|
||||||
|
<div id="photo-layer">
|
||||||
|
<img id="photo-a" class="photo active" />
|
||||||
|
<img id="photo-b" class="photo" />
|
||||||
|
</div>
|
||||||
|
<div id="gradient-overlay"></div>
|
||||||
|
<div id="overlay-top-left" class="overlay">
|
||||||
|
<canvas id="qr-code"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="overlay-top-center" class="overlay">
|
||||||
|
<div id="clock-time"></div>
|
||||||
|
<div id="clock-date"></div>
|
||||||
|
</div>
|
||||||
|
<div id="overlay-top-right" class="overlay">
|
||||||
|
<h2>Today</h2>
|
||||||
|
<ul id="today-events"></ul>
|
||||||
|
</div>
|
||||||
|
<div id="overlay-weather" class="overlay">
|
||||||
|
<div id="weather-temp"></div>
|
||||||
|
<div id="weather-condition"></div>
|
||||||
|
<div id="weather-range"></div>
|
||||||
|
<div id="weather-humidity"></div>
|
||||||
|
</div>
|
||||||
|
<div id="calendar-month">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<span>S</span><span>M</span><span>T</span><span>W</span>
|
||||||
|
<span>T</span><span>F</span><span>S</span>
|
||||||
|
</div>
|
||||||
|
<div id="calendar-grid"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Photo crossfade:** Two `<img>` stacked via `position: absolute`. Toggle `.active` class, CSS `transition: opacity 1.5s ease`. After transition completes, preload next image into the hidden element.
|
||||||
|
|
||||||
|
**Staggered polling:** `setInterval` with offsets — photos at t+0s, calendar at t+3s, weather at t+7s.
|
||||||
|
|
||||||
|
**QR code:** `qrcode-generator` library (~4 KB, no deps, renders to canvas). Updates on each photo swap.
|
||||||
|
|
||||||
|
### Admin page
|
||||||
|
|
||||||
|
Vanilla HTML form. Sections: timezone select (auto-detect via `Intl.DateTimeFormat`), zip code, photo sources (dynamic add/remove), calendar sources (dynamic with color picker), theme upload + selector. `admin.js` serializes → `POST /api/config`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
Each phase introduces Go concepts incrementally. Complete each end-to-end (compile, run, test in browser) before moving to the next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1 — Hello World Web Server
|
||||||
|
|
||||||
|
**Goal:** Go toolchain, first compiled binary, first HTTP response.
|
||||||
|
|
||||||
|
**Go concepts introduced:** `go mod init`, packages, `net/http`, `http.HandleFunc`, `http.ListenAndServe`, `log` package, `html/template`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `go mod init git.timothykim.net/timothy/thekims-dashboard`
|
||||||
|
2. `main.go`: register one route (`GET /dashboard`), return a hardcoded HTML string. Run with `go run .`
|
||||||
|
3. Add `templates/dashboard.html` with the full DOM structure from above (hardcoded placeholder content). Parse and execute via `html/template`.
|
||||||
|
4. Serve `static/` via `http.FileServer`. Add `dashboard.css` with the portrait 1440×2560 layout. Add empty `dashboard.js`.
|
||||||
|
5. Dockerfile + docker-compose.yml. Build, run, verify.
|
||||||
|
|
||||||
|
**End state:** `http://localhost:8080/dashboard` renders the full layout skeleton with placeholder text and styling. No dynamic data.
|
||||||
|
|
||||||
|
**Claude Code prompt hint:** *"Set up a Go HTTP server that serves an HTML template at /dashboard and static files from /static/. Use Go 1.22+ stdlib routing. Show me idiomatic project structure."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — Config System
|
||||||
|
|
||||||
|
**Goal:** JSON persistence, struct serialization, first custom package.
|
||||||
|
|
||||||
|
**Go concepts introduced:** Structs with JSON tags, `encoding/json`, `os.ReadFile`/`os.WriteFile`, `internal/` packages, exported vs unexported, pointer receivers.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Define `Config` struct in `internal/config/config.go` with all JSON tags.
|
||||||
|
2. `Load(path) (*Config, error)` — read file, unmarshal. Return defaults if file missing.
|
||||||
|
3. `Save(path) error` — marshal indented, write atomically (temp file + `os.Rename`).
|
||||||
|
4. Wire into `main.go`: load on startup, pass to handlers.
|
||||||
|
5. `GET /dashboard/api/config` — return sanitized config (strip credentials).
|
||||||
|
6. `POST /dashboard/api/config` — accept JSON, validate, save, reload in-memory state.
|
||||||
|
|
||||||
|
**Atomic write note:** `os.Rename` is atomic on Linux (same filesystem). Write to `config.json.tmp`, then rename. Prevents corruption on crash.
|
||||||
|
|
||||||
|
**End state:** POST config from curl, restart server, GET returns persisted values.
|
||||||
|
|
||||||
|
**Claude Code prompt hint:** *"Create a config package under internal/ that loads/saves JSON. Show idiomatic error handling and struct tags. Use atomic file writes."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — Admin Page
|
||||||
|
|
||||||
|
**Goal:** Functional admin UI that reads/writes config.
|
||||||
|
|
||||||
|
**Go concepts introduced:** Template data passing, `http.ServeFile`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `templates/admin.html` — full form, inject current config values via template.
|
||||||
|
2. `static/js/admin.js` — form → JSON → `POST /api/config`, inline success/error feedback.
|
||||||
|
3. Timezone dropdown (from IANA list).
|
||||||
|
4. Dynamic add/remove rows for photo and calendar sources.
|
||||||
|
5. Theme file upload: `POST /dashboard/api/themes/upload` (multipart form, save to `data/themes/`).
|
||||||
|
|
||||||
|
**End state:** Fill form, save, refresh — values persist. Basic auth is nginx config (Phase 8), not app logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4 — Weather Service
|
||||||
|
|
||||||
|
**Goal:** First external HTTP client, first background goroutine, first cache.
|
||||||
|
|
||||||
|
**Go concepts introduced:** `net/http` client, `json.Decoder`, `context.Context`, goroutines, `time.Ticker`, `sync.RWMutex`, generics.
|
||||||
|
|
||||||
|
This is the simplest external integration (no auth, well-documented API) — a good place to learn the HTTP client patterns you'll reuse in calendar and photo services.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `internal/service/weather/nws.go`:
|
||||||
|
- `FetchCurrentConditions(ctx, station) → Observation`
|
||||||
|
- `FetchForecast(ctx, office, gridX, gridY) → Forecast`
|
||||||
|
- Both: `http.NewRequestWithContext`, set `User-Agent` header (NWS policy), decode JSON.
|
||||||
|
2. `internal/cache/cache.go` — generic TTL cache using Go 1.18+ generics:
|
||||||
|
```go
|
||||||
|
type Cache[T any] struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
data T
|
||||||
|
exp time.Time
|
||||||
|
ttl time.Duration
|
||||||
|
fetch func(context.Context) (T, error)
|
||||||
|
}
|
||||||
|
func (c *Cache[T]) Get(ctx context.Context) (T, error)
|
||||||
|
```
|
||||||
|
`Get()` returns cached value if fresh, else calls fetch function under write lock.
|
||||||
|
3. Wire in `main.go`: weather cache with 10-min TTL.
|
||||||
|
4. `GET /dashboard/api/weather`.
|
||||||
|
5. Weather rendering in `dashboard.js` — fetch on load, poll every 600s with stagger offset.
|
||||||
|
6. Zip → lat/lon resolution on admin save: embed a lookup table or call Census Geocoder API. Also resolve NWS grid point + observation station and persist to config.
|
||||||
|
|
||||||
|
**End state:** Dashboard shows live weather. Change zip in admin → weather updates on next poll.
|
||||||
|
|
||||||
|
**Claude Code prompt hint:** *"Write an HTTP client for the NWS weather.gov API. Show context usage, custom headers, JSON decoding. Then build a generic TTL cache with sync.RWMutex and generics."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5 — Calendar Service
|
||||||
|
|
||||||
|
**Goal:** CalDAV integration, multi-source parallel fetch and merge.
|
||||||
|
|
||||||
|
**Go concepts introduced:** Third-party deps (`go get`), interfaces, `sync.WaitGroup` for parallel fan-out, `time.LoadLocation`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `go get github.com/emersion/go-webdav`
|
||||||
|
2. `internal/service/calendar/caldav.go`:
|
||||||
|
- `FetchEvents(ctx, source, start, end) → []Event`
|
||||||
|
- CalDAV `REPORT` with time-range filter, parse iCal response.
|
||||||
|
3. Multi-source merge: fan out with goroutines, collect with `WaitGroup`:
|
||||||
|
```go
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([][]Event, len(sources))
|
||||||
|
for i, src := range sources {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, src CalDAVConfig) {
|
||||||
|
defer wg.Done()
|
||||||
|
events, err := FetchEvents(ctx, src, start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("calendar %s: %v", src.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results[i] = events
|
||||||
|
}(i, src)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
```
|
||||||
|
4. Cache merged result (1-min TTL).
|
||||||
|
5. `GET /api/calendar/today` — filter to today.
|
||||||
|
6. `GET /api/calendar/month` — 4-week window (-1 week through +2 weeks).
|
||||||
|
7. Client-side: render today overlay + 4-week grid.
|
||||||
|
|
||||||
|
**Google Calendar CalDAV:** URL format is `https://apidata.googleusercontent.com/caldav/v2/{calendarId}/events`, auth via basic auth with Gmail address + app-specific password. Note this in admin form help text.
|
||||||
|
|
||||||
|
**End state:** Dashboard shows real events from all configured CalDAV sources, color-coded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6 — Photo Service
|
||||||
|
|
||||||
|
**Goal:** Immich API integration, image proxying, binary streaming.
|
||||||
|
|
||||||
|
**Go concepts introduced:** Interfaces (for future extensibility), `io.Reader`/`io.Writer` streaming, `image` package.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Define `PhotoSource` interface (even with only Immich, this keeps the door open for adding sources later without refactoring):
|
||||||
|
```go
|
||||||
|
type PhotoSource interface {
|
||||||
|
ListPhotos(ctx context.Context) ([]PhotoMeta, error)
|
||||||
|
FetchPhoto(ctx context.Context, id string) (io.ReadCloser, string, error)
|
||||||
|
SourceURL(id string) string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. `ImmichSource` (`immich.go`):
|
||||||
|
- List: `GET /api/assets` or `GET /api/albums/{id}`, `x-api-key` header.
|
||||||
|
- Fetch: `GET /api/assets/{id}/original` — stream body directly, don't buffer.
|
||||||
|
- Source URL: `{base_url}/photos/{id}`
|
||||||
|
3. `Manager` (`manager.go`):
|
||||||
|
- On init/config change: `ListPhotos()`, shuffle, store list.
|
||||||
|
- Track `currentIndex` (use `sync/atomic`).
|
||||||
|
- `ServeImage(w, r, index)` — fetch, resize to ≤2560px height, stream response.
|
||||||
|
- Background goroutine refreshes photo list every 5 min.
|
||||||
|
4. Image resize: `go get github.com/disintegration/imaging`. Decode → resize → JPEG encode → write. Cache current + next resized images in memory (~10-20 MB for two 1440p JPEGs).
|
||||||
|
5. API endpoints: `/photo/current`, `/photo/next`, `/photo/image/{index}`.
|
||||||
|
6. Client-side: crossfade, preload, QR code via `qrcode-generator`.
|
||||||
|
|
||||||
|
**Streaming vs buffering:** For proxying without resize, stream directly via `io.Copy(w, reader)`. For resize, you must buffer (decode `image.Image`, resize, re-encode). Cache the resized result to avoid reprocessing.
|
||||||
|
|
||||||
|
**End state:** Photos cycle from Immich with smooth crossfade and QR code linking to the photo in the Immich web UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7 — Theming + Polish
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. CSS custom properties in `dashboard.css` for all colors, fonts, spacing, overlay positions.
|
||||||
|
2. Theme CSS files override variables. Active theme loaded via `<link>` tag, swapped by JS.
|
||||||
|
3. Admin: theme upload + dropdown selector.
|
||||||
|
4. Clock overlay: `setInterval(1000)`, format via `Intl.DateTimeFormat` with configured timezone.
|
||||||
|
5. Error indicator: 3 consecutive fetch failures → show "offline" badge. Auto-clear on recovery.
|
||||||
|
6. Stagger all polling start times so no two intervals fire in the same second.
|
||||||
|
7. Graceful shutdown: `signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)` + `server.Shutdown(ctx)`.
|
||||||
|
8. HTTP server timeouts: `ReadTimeout`, `WriteTimeout`, `IdleTimeout`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8 — Deployment
|
||||||
|
|
||||||
|
1. Multi-stage Dockerfile: build in `golang:1.24-alpine`, run in `alpine:3.21`.
|
||||||
|
2. `docker-compose.yml`: `shared` network, port 8080, volume for `data/`.
|
||||||
|
3. nginx on hantim:
|
||||||
|
```nginx
|
||||||
|
location /dashboard {
|
||||||
|
proxy_pass http://thekims-dashboard:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
location /dashboard/admin {
|
||||||
|
auth_basic "Dashboard Admin";
|
||||||
|
auth_basic_user_file /etc/nginx/htpasswd.dashboard;
|
||||||
|
proxy_pass http://thekims-dashboard:8080;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. SSL via existing certbot.
|
||||||
|
5. Raspberry Pi autostart:
|
||||||
|
```bash
|
||||||
|
unclutter -idle 0 &
|
||||||
|
xset s off && xset -dpms
|
||||||
|
firefox-esr --kiosk https://thekims.family/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Libraries
|
||||||
|
|
||||||
|
| Purpose | Library | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| HTTP routing | stdlib `net/http` (Go 1.22+) | New pattern matching makes third-party routers unnecessary here. |
|
||||||
|
| CalDAV | `github.com/emersion/go-webdav` | Maintained, clean, handles REPORT. |
|
||||||
|
| Image resize | `github.com/disintegration/imaging` | Simple API, pure Go. |
|
||||||
|
| JSON | stdlib `encoding/json` | Sufficient at this scale. |
|
||||||
|
| Logging | stdlib `log/slog` (Go 1.21+) | Structured logging, built-in. |
|
||||||
|
|
||||||
|
Avoid `gin`, `echo`, `fiber`, `gorm`, or any heavy framework. The stdlib is more capable than you'd expect coming from other ecosystems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Photo cropping:** Landscape on portrait = heavy crop (`object-fit: cover`) or letterbox (`contain`). Recommend `cover` + `object-position: center` as default, configurable per-theme.
|
||||||
|
- **Multi-day calendar events:** v1 lists them in each day cell. Spanning banners (like DAKboard) are complex layout — defer to a later enhancement.
|
||||||
|
- **Credential storage:** Plaintext in `config.json`. Acceptable for single-user dashboard behind basic auth. Bitwarden Secrets Manager integration possible later.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUCKET="$(basename "$(cd "$(dirname "$0")" && pwd)")"
|
||||||
|
ENDPOINT="https://s3.hantim.net"
|
||||||
|
LOCAL_DIR="static/media"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 <pull|push>"
|
||||||
|
echo " pull download media from garage to $LOCAL_DIR/"
|
||||||
|
echo " push upload media from $LOCAL_DIR/ to garage"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -eq 1 ]] || usage
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
pull)
|
||||||
|
aws --endpoint-url "$ENDPOINT" s3 sync "s3://$BUCKET/" "$LOCAL_DIR/"
|
||||||
|
;;
|
||||||
|
push)
|
||||||
|
aws --endpoint-url "$ENDPOINT" s3 sync "$LOCAL_DIR/" "s3://$BUCKET/"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user