Browse Source

feat(ui): add responsive day navigation shell

main
Domagoj Zecevic 3 days ago
parent
commit
4890660c72
  1. 38
      .ai/HANDOFF.md
  2. 42
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 6
      README.md
  5. 137
      internal/web/handler.go
  6. 9
      internal/web/router.go
  7. 121
      internal/web/router_test.go
  8. 72
      internal/web/templates/base.html
  9. 16
      internal/web/templates/day.html
  10. 2
      internal/web/templates/templates.go

38
.ai/HANDOFF.md

@ -2,6 +2,18 @@
Append-only role handoff log. Each role adds one entry when its step is complete.
### T-004 — review — 2026-06-18T13:25:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-004 UI shell; all 5 web tests pass including responsive class assertions, month grouping, and tab counts; four nit-level findings only — no blockers. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./internal/web/... -v`, `go test -race ./...` all pass. |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-003 — review — 2026-06-18T11:05:00Z
@ -163,3 +175,29 @@ Append-only role handoff log. Each role adds one entry when its step is complete
| Next Role | none |
---
### T-004 — implement — 2026-06-18T13:10:37Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added the responsive dark UI shell, month-grouped day navigation, mobile drawer and bottom tabs, and day overview counts. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, internal/web/handler.go, internal/web/router.go, internal/web/router_test.go, internal/web/templates/base.html, internal/web/templates/day.html, internal/web/templates/templates.go |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./internal/web/...` passed; `go test ./...` passed; `go test -race ./...` passed |
| Commit | `feat(ui): add responsive day navigation shell` |
| Next Role | review |
---
### T-004 — implement — 2026-06-18T13:19:26Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-004 responsive UI shell after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(ui): add responsive day navigation shell` |
| Next Role | none |
---

42
.ai/REVIEW.md

@ -166,3 +166,45 @@ None.
- Silent error discard in `rescan()` (finding #1) is low risk for correctness but could delay diagnosis of a footage mount problem in production.
---
## T-004 — UI shell & day navigation
**Verdict:** PASS_WITH_NOTES
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | minor | `internal/web/handler.go` (lines 81–84) | `render()` sets `Content-Type` header then calls `ExecuteTemplate` directly to `w`. If template execution fails after writing any bytes, the subsequent `http.Error(w, ...)` call is a no-op — headers are already sent and the HTTP status code cannot be changed. Fix: render into a `bytes.Buffer` first, check error, then write in one shot. | no |
| 2 | nit | `internal/web/handler.go` (line 48–51) | Empty-footage case sets `Date: "No footage"` in `DayPageData` and renders the day template, which shows `"No footage"` as the date heading. Minor UX rough edge; a dedicated empty-state template or prose would be cleaner. | no |
| 3 | nit | `internal/web/handler.go` (line 24) | `ShellData.ActiveTab` field is declared but never written or read in current templates. Scaffolded for T-005/T-006. Harmless. | no |
| 4 | nit | `internal/web/templates/admin_users.html` | Admin users page still does not extend `base.html` — it has no sidebar or mobile nav. Acceptable for current scope; T-004 plan does not require refactoring auth pages. | no |
### Required fixes
None.
### Verification
**Steps performed:**
1. Read all new/changed files: `internal/web/handler.go`, `internal/web/router.go`, `internal/web/router_test.go`, `internal/web/templates/base.html`, `internal/web/templates/day.html`, `internal/web/templates/templates.go`.
2. Cross-checked against `.ai/PLAN.md` Phase 4 scope.
3. Ran `go fmt ./...` — clean.
4. Ran `go vet ./...` — clean.
5. Ran `go test ./internal/web/... -v` — all 5 tests PASS.
6. Ran `go test -race ./...` — PASS, no data races.
**Findings:**
- All acceptance criteria met: `hidden ... md:block` on `<aside>` hides sidebar at <768 px and shows at 768 px; `data-mobile-drawer` `<details>/<summary>` provides hamburger drawer on mobile; bottom `<nav>` with `md:hidden` is the mobile tab bar; `TestDayOverviewRendersShellNavigationAndCounts` asserts both responsive class names, month grouping, sidebar day links, image/video counts, and tab links.
- `{{define "base"}}` / `{{template "content" .}}` pattern is a sound Go template composition approach — functionally equivalent to `{{block "content" .}}`.
- `groupDaysByMonth` correctly preserves newest-first order within each month group (days come from `DayList()` which is already sorted).
- Mobile hamburger uses pure `<details>`/`<summary>` — no JavaScript required for open/close. ✅
- `render()` pulls `User` from context and `Months` from the live index on every request — sidebar always fresh. ✅
- `Index` handler nil-safe (`h.index == nil` guard). ✅
- `Day()` returns `http.NotFound` for unknown dates. ✅
- Tests cover: redirect to newest day on `/`, day overview with all shell assertions, 404 for missing day, and existing auth/health routes still pass.
**Risks:**
- Finding #1 (buffer-less render) will become more visible when T-005/T-006 handlers reuse the same `render()` pattern with more dynamic data; recommend fixing before T-006 lands.
---

2
.ai/TASKS.md

@ -23,6 +23,6 @@ Command expectations:
| T-001 | Project scaffold: Go module, chi router, health endpoint, multi-stage Dockerfile (debian:slim + ffmpeg), docker-compose.yml, .env.example, README.md | done | `docker compose build` succeeds; `GET /health` returns 200; `go vet ./...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `docker compose build`, `curl -i http://127.0.0.1:18080/health` passed | none |
| T-002 | Auth: SQLite schema (users + sessions), bcrypt login/logout, session cookie middleware, first-run admin bootstrap, admin user-management page | done | Login page renders at `/login`; valid credentials set session cookie and redirect; invalid credentials return 401; admin can add/delete users at `/admin/users`; all non-login routes return 302 without cookie; `go test ./internal/auth/...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `go test ./internal/auth/...` passed after template rework | none |
| T-003 | Footage scanner: walk FOOTAGE_ROOT, build in-memory index (date → images + videos), filename parser, periodic rescan goroutine | done | Scanner correctly indexes fixture directory with known dates/files; `DayList()` returns sorted dates; `DayEntry` contains sorted images and videos; periodic rescan runs without data race (`go test -race ./internal/footage/...` passes) | `go fmt ./...`, `go vet ./...`, `go test ./internal/footage/...`, `go test ./...`, `go test -race ./...` passed | none |
| T-004 | UI shell & day navigation: dark Tailwind CSS base layout, sidebar (desktop) with date list grouped by month, mobile drawer + bottom tab bar, day overview page with Images/Videos tabs | ready_for_implement | Desktop layout shows sidebar at ≥768 px; mobile layout hides sidebar and shows drawer trigger at <768 px; day overview page renders images and videos tab counts; `go vet ./...` passes | n/a | implement |
| T-004 | UI shell & day navigation: dark Tailwind CSS base layout, sidebar (desktop) with date list grouped by month, mobile drawer + bottom tab bar, day overview page with Images/Videos tabs | done | Desktop layout shows sidebar at ≥768 px; mobile layout hides sidebar and shows drawer trigger at <768 px; day overview page renders images and videos tab counts; `go vet ./...` passes | `go fmt ./...`, `go vet ./...`, `go test ./internal/web/...`, `go test ./...`, `go test -race ./...` passed | none |
| T-005 | Image browser: raw JPEG serving, in-memory LRU thumbnail cache (160×90), thumbnail strip, full-image viewer, arrow navigation (keyboard + on-screen), deep-linkable `?idx=N` | ready_for_implement | Thumbnail endpoint returns JPEG ≤30 KB for a test image; raw endpoint serves original file unchanged; arrow nav advances and wraps correctly; keyboard left/right works; strip highlights active item; `go test ./internal/image/...` passes | n/a | implement |
| T-006 | Video browser: on-demand ffmpeg remux stream (.265→MP4 via pipe), in-memory LRU video thumbnail (first-frame extract), thumbnail strip, HTML5 player, arrow navigation, deep-linkable `?idx=N` | ready_for_implement | Stream endpoint returns `Content-Type: video/mp4` and non-empty body for a test .265 file; thumbnail endpoint returns a JPEG; player page renders with `<video>` element; arrow nav works; ffmpeg process is killed on client disconnect; `go test ./internal/video/...` passes | n/a | implement |

6
README.md

@ -53,6 +53,12 @@ The login form posts credentials to `/login`. Successful login sets an HTTP-only
Admin users can manage accounts at `/admin/users`. New users are created with bcrypt-hashed passwords, and deleting a user also removes their sessions through the SQLite foreign key cascade.
## Browsing Footage
After login, `/` redirects to the newest indexed day's image browser at `/day/YYYYMMDD/images`. The day overview at `/day/YYYYMMDD` shows the available image and video counts with links to `/day/YYYYMMDD/images` and `/day/YYYYMMDD/videos`.
On desktop widths, the app shows a dark sidebar with indexed days grouped by month. On smaller screens, the sidebar is replaced by a drawer trigger in the header and a fixed bottom tab bar for switching between Images and Videos.
## Footage Layout
Footage is mounted read-only at `/footage` in the container. CamMonitor scans this tree on startup and then rescans it on the `SCAN_INTERVAL` schedule. Days are indexed from `YYYYMMDD` directories, with images and videos sorted chronologically inside each day.

137
internal/web/handler.go

@ -0,0 +1,137 @@
package web
import (
"html/template"
"net/http"
"sort"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/footage"
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates"
"github.com/go-chi/chi/v5"
)
type Handler struct {
index *footage.Index
}
type ShellData struct {
Title string
User *auth.User
Months []MonthGroup
CurrentDate string
ActiveTab string
Content any
}
type MonthGroup struct {
Label string
Days []string
}
type DayPageData struct {
Date string
ImageCount int
VideoCount int
}
func NewHandler(index *footage.Index) *Handler {
return &Handler{index: index}
}
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
days := h.dayList()
if len(days) == 0 {
h.render(w, r, ShellData{
Title: "CamMonitor",
Content: DayPageData{
Date: "No footage",
},
})
return
}
http.Redirect(w, r, "/day/"+days[0]+"/images", http.StatusFound)
}
func (h *Handler) DayOverview(w http.ResponseWriter, r *http.Request) {
date := chi.URLParam(r, "date")
day, ok := h.day(date)
if !ok {
http.NotFound(w, r)
return
}
h.render(w, r, ShellData{
Title: "CamMonitor " + date,
CurrentDate: date,
Content: DayPageData{
Date: date,
ImageCount: len(day.Images),
VideoCount: len(day.Videos),
},
})
}
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) {
data.User, _ = auth.UserFromContext(r.Context())
data.Months = groupDaysByMonth(h.dayList())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := shellTemplate.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "render page failed", http.StatusInternalServerError)
}
}
func (h *Handler) dayList() []string {
if h.index == nil {
return nil
}
return h.index.DayList()
}
func (h *Handler) day(date string) (*footage.DayEntry, bool) {
if h.index == nil {
return nil, false
}
return h.index.Day(date)
}
func groupDaysByMonth(days []string) []MonthGroup {
groups := make(map[string][]string)
labels := make([]string, 0)
for _, day := range days {
if len(day) != len("20060102") {
continue
}
parsed, err := time.Parse("20060102", day)
if err != nil {
continue
}
label := parsed.Format("2006-01")
if _, ok := groups[label]; !ok {
labels = append(labels, label)
}
groups[label] = append(groups[label], day)
}
sort.Sort(sort.Reverse(sort.StringSlice(labels)))
monthGroups := make([]MonthGroup, 0, len(labels))
for _, label := range labels {
monthGroups = append(monthGroups, MonthGroup{
Label: label,
Days: groups[label],
})
}
return monthGroups
}
var shellTemplate = template.Must(template.ParseFS(
webtemplates.FS,
webtemplates.Base,
webtemplates.Day,
))

9
internal/web/router.go

@ -11,7 +11,7 @@ import (
"github.com/go-chi/chi/v5"
)
func NewRouter(cfg *config.Config, database *sql.DB, _ *footage.Index) chi.Router {
func NewRouter(cfg *config.Config, database *sql.DB, index *footage.Index) chi.Router {
router := chi.NewRouter()
router.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
@ -26,6 +26,7 @@ func NewRouter(cfg *config.Config, database *sql.DB, _ *footage.Index) chi.Route
store := auth.NewStore(database)
authHandler := auth.NewHandler(store, cfg.SessionTTL)
webHandler := NewHandler(index)
router.Get("/login", authHandler.LoginPage)
router.Post("/login", authHandler.Login)
@ -34,10 +35,8 @@ func NewRouter(cfg *config.Config, database *sql.DB, _ *footage.Index) chi.Route
router.Group(func(protected chi.Router) {
protected.Use(auth.RequireAuth(store))
protected.Get("/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("CamMonitor"))
})
protected.Get("/", webHandler.Index)
protected.Get("/day/{date}", webHandler.DayOverview)
protected.Group(func(admin chi.Router) {
admin.Use(auth.RequireAdmin)

121
internal/web/router_test.go

@ -4,12 +4,16 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/db"
"github.com/domagojzecevic/cammonitor/internal/footage"
)
func TestHealthReturnsOK(t *testing.T) {
@ -60,3 +64,120 @@ func TestAdminUsersRedirectsWithoutSessionCookie(t *testing.T) {
t.Fatalf("expected redirect to /login, got %q", location)
}
}
func TestIndexRedirectsToNewestDayImages(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, response.Code)
}
if location := response.Header().Get("Location"); location != "/day/20260102/images" {
t.Fatalf("expected redirect to newest day images, got %q", location)
}
}
func TestDayOverviewRendersShellNavigationAndCounts(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/day/20260101", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, response.Code)
}
body := response.Body.String()
for _, want := range []string{
`https://cdn.tailwindcss.com`,
`class="hidden w-56 shrink-0 border-r border-slate-800 bg-slate-950 md:block"`,
`data-mobile-drawer`,
`class="fixed inset-x-0 bottom-0 z-20 grid grid-cols-2 border-t border-slate-800 bg-slate-950/95 md:hidden"`,
`2026-01`,
`href="/day/20260102"`,
`href="/day/20260101"`,
`Images (2)`,
`Videos (2)`,
`href="/day/20260101/images"`,
`href="/day/20260101/videos"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body)
}
}
}
func TestDayOverviewReturnsNotFoundForMissingDay(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/day/20260103", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, response.Code)
}
}
func newAuthenticatedRouter(t *testing.T) (http.Handler, *http.Cookie) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "cammonitor.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
})
store := auth.NewStore(database)
if err := store.EnsureAdmin("admin", "secret"); err != nil {
t.Fatalf("ensure admin: %v", err)
}
index := footage.NewIndex(filepath.Join("..", "..", "testdata", "footage"), time.Hour)
t.Cleanup(index.Close)
router := NewRouter(&config.Config{SessionTTL: time.Hour}, database, index)
cookie := login(t, router)
return router, cookie
}
func login(t *testing.T, router http.Handler) *http.Cookie {
t.Helper()
form := url.Values{
"username": {"admin"},
"password": {"secret"},
}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected login status %d, got %d", http.StatusSeeOther, response.Code)
}
for _, cookie := range response.Result().Cookies() {
if cookie.Name == "session" && cookie.Value != "" {
return cookie
}
}
t.Fatalf("expected login response to set session cookie")
return nil
}

72
internal/web/templates/base.html

@ -0,0 +1,72 @@
{{define "base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-900 text-slate-100">
<div class="flex min-h-screen">
<aside class="hidden w-56 shrink-0 border-r border-slate-800 bg-slate-950 md:block">
<div class="px-4 py-4">
<a href="/" class="text-base font-semibold text-slate-100">CamMonitor</a>
</div>
<nav class="px-3 pb-6">
{{range .Months}}
<div class="mt-5 first:mt-0">
<div class="px-2 text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div>
<div class="mt-2 space-y-1">
{{range .Days}}
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a>
{{end}}
</div>
</div>
{{end}}
</nav>
</aside>
<main class="flex min-h-screen flex-1 flex-col pb-16 md:pb-0">
<header class="sticky top-0 z-10 border-b border-slate-800 bg-slate-900/95">
<div class="flex h-14 items-center justify-between px-4 md:px-6">
<div class="flex items-center gap-3">
<details data-mobile-drawer class="relative md:hidden">
<summary class="flex h-9 w-9 cursor-pointer list-none items-center justify-center rounded-md border border-slate-700 text-slate-100">&#9776;</summary>
<div class="absolute left-0 top-11 z-30 w-72 border border-slate-800 bg-slate-950 p-4 shadow-xl">
{{range .Months}}
<div class="mt-5 first:mt-0">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div>
<div class="mt-2 space-y-1">
{{range .Days}}
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a>
{{end}}
</div>
</div>
{{end}}
</div>
</details>
<a href="/" class="text-base font-semibold text-slate-100 md:hidden">CamMonitor</a>
</div>
<div class="flex items-center gap-3 text-sm text-slate-300">
{{with .User}}<span>{{.Username}}</span>{{end}}
<form method="post" action="/logout">
<button type="submit" class="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-100 hover:bg-slate-800">Logout</button>
</form>
</div>
</div>
</header>
<section class="flex-1 px-4 py-6 md:px-8">
{{template "content" .}}
</section>
</main>
</div>
<nav class="fixed inset-x-0 bottom-0 z-20 grid grid-cols-2 border-t border-slate-800 bg-slate-950/95 md:hidden">
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/images{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Images</a>
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/videos{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Videos</a>
</nav>
</body>
</html>
{{end}}

16
internal/web/templates/day.html

@ -0,0 +1,16 @@
{{define "content"}}
{{with .Content}}
<div class="mx-auto max-w-5xl">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-50">{{.Date}}</h1>
<p class="mt-1 text-sm text-slate-400">Footage overview</p>
</div>
<div class="flex rounded-md border border-slate-800 bg-slate-950 p-1">
<a href="/day/{{.Date}}/images" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Images ({{.ImageCount}})</a>
<a href="/day/{{.Date}}/videos" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Videos ({{.VideoCount}})</a>
</div>
</div>
</div>
{{end}}
{{end}}

2
internal/web/templates/templates.go

@ -5,6 +5,8 @@ import "embed"
const (
Login = "login.html"
AdminUsers = "admin_users.html"
Base = "base.html"
Day = "day.html"
)
// FS exposes file-backed HTML templates for handlers that render web pages.

Loading…
Cancel
Save