Files
projax/web/admin.go
mAi 5dcacff520 feat(phase 4b): dark/light theme toggle + file-upload permanently out-of-scope
## Slice A — explicit dark/light toggle

projax now ships with two palettes and a 1y cookie to remember the choice.
Dark is the new default; ☀ button in the header nav flips to light and
writes projax_theme=light. Server reads the cookie via themeFromRequest(r)
and injects Theme + ThemeColor into every template via the centralised
render(w, r, …) path, so first paint never flashes the wrong theme. Inline
JS in layout.tmpl handles the toggle without a server roundtrip.

Every panel colour now lives in a CSS variable under
:root[data-theme=dark|light]; the only hardcoded hex values left are
inside those two :root blocks. A future palette tweak is one edit, not
30 selectors. Graph node colours, kind-badges, highlights and warn/ok/bad
all have parallel dark/light values picked for contrast.

Standalone SVG download bakes the light palette inline because the
downloaded asset has no parent :root providing vars — m's existing
snapshots stay print-friendly regardless of his current cookie.

Login page keeps its embedded dark CSS — it's the gateway, intentionally
always dark.

Tests: TestThemeDefaultIsDark, TestThemeCookieRoundTrips,
TestThemeCookieUnknownFallsBackToDark, TestThemeTogglePagesShareSameTheme,
TestThemeToggleScriptPresent, TestThemeColorMetaHelper. Full suite green.

## Slice B — file-upload permanently out of scope (m, 2026-05-17)

docs/design.md moves "File uploads / in-projax storage" from the §3c
parked list to a permanent "Out of scope (decided 2026-05-17)" clause
with the rationale: PER is the cross-reference index, not the file
system. docs/standards/per.md gains the same explicit clause so future
shifts working from the PER standard see the constraint where they
look. Memory note filed so future workers don't re-propose multipart
uploads, attachments tables, or documents buckets.

## docs/design.md §13 Theming

Documents the toggle approach, cookie semantics, palette table, the
standalone-SVG carve-out, the login-page exception, and the 4b
out-of-scope (prefers-color-scheme detection, per-page overrides,
transitions on swap).
2026-05-17 18:14:08 +02:00

233 lines
7.0 KiB
Go

package web
import (
"context"
"net/http"
"sync"
"time"
)
// adminHealth is one upstream's reachability snapshot for the system panel.
// `OK` reflects the last probe; `URL` is what was probed (BaseURL of the
// underlying client, not the full /api/v1 path).
type adminHealth struct {
Name string
URL string
OK bool
}
// adminCard is a tile on the admin index. Count is a lazily-computed int that
// the handler resolves before rendering; -1 means "unknown / not applicable".
type adminCard struct {
Title string
Href string
Description string
Count int
CountLabel string // e.g. "orphans" / "calendars" / "items"
Disabled bool // true → grey out + skip the link (integration off)
DisabledMsg string
}
// adminSystemPanel bundles the bottom-of-page system info.
type adminSystemPanel struct {
Version string // build-time -ldflags injection; "" when unset
MigrationLast string // most recent applied migration filename
Healths []adminHealth
MCPEnabled bool
}
// adminCacheTTL is how long admin-health probes are cached. The system panel
// hammers DAV / Gitea / Supabase once per render — caching for 30 s keeps the
// cost negligible even if m refreshes hard.
const adminCacheTTL = 30 * time.Second
// adminHealthCache holds the last health probe + when it was built.
type adminHealthCache struct {
mu sync.Mutex
at time.Time
hs []adminHealth
}
func newAdminHealthCache() *adminHealthCache { return &adminHealthCache{} }
func (c *adminHealthCache) get(now time.Time) ([]adminHealth, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.hs == nil || now.Sub(c.at) > adminCacheTTL {
return nil, false
}
return c.hs, true
}
func (c *adminHealthCache) set(hs []adminHealth, now time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
c.hs = hs
c.at = now
}
// handleAdminIndex renders the admin landing page: three cards (Classify,
// CalDAV, Bulk) with live counts + a system panel (version, migration,
// upstream health, MCP status).
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Counts: orphans + total items run against the local DB (cheap).
orphanCount, _ := s.orphanCount(ctx)
itemCount, _ := s.itemCount(ctx)
classify := adminCard{
Title: "Classify orphans",
Href: "/admin/classify",
Description: "Mai-managed items that landed at root and need a parent. Pick one for each.",
Count: orphanCount,
CountLabel: "orphans",
}
caldav := adminCard{
Title: "CalDAV calendars",
Href: "/admin/caldav",
Description: "Link/unlink CalDAV lists to projax items. The Tasks + Events cards on /dashboard fan out across these.",
}
if s.CalDAV == nil {
caldav.Disabled = true
caldav.DisabledMsg = "DAV_URL not configured"
} else {
// Count calendars; per-render fetch is bounded by the existing client
// timeout (~5s) and shares the integration cache the detail page already
// uses. If the upstream is down the card surfaces "?" rather than 0.
if n, err := s.calendarCount(ctx); err == nil {
caldav.Count = n
caldav.CountLabel = "calendars"
} else {
caldav.Count = -1
caldav.CountLabel = "calendars (probe failed)"
}
}
bulk := adminCard{
Title: "Bulk edit",
Href: "/admin/bulk",
Description: "Multi-row tag/management/status apply across the filtered set. One transaction, undoable item-by-item.",
Count: itemCount,
CountLabel: "items in scope",
}
cards := []adminCard{classify, caldav, bulk}
panel := adminSystemPanel{
Version: s.Version,
MigrationLast: s.migrationLast(ctx),
Healths: s.gatherHealth(ctx),
MCPEnabled: s.MCP != nil,
}
s.render(w, r, "admin", map[string]any{
"Title": "admin",
"Cards": cards,
"Panel": panel,
})
}
// orphanCount reports how many mai-managed items live at root and need
// classifying — same predicate as MaiOrphans, just a count.
func (s *Server) orphanCount(ctx context.Context) (int, error) {
var n int
err := s.Store.Pool.QueryRow(ctx, `
select count(*) from projax.items
where deleted_at is null
and cardinality(parent_ids) = 0
and 'mai' = any(management)`).Scan(&n)
return n, err
}
// itemCount reports the total number of live, non-archived items.
func (s *Server) itemCount(ctx context.Context) (int, error) {
var n int
err := s.Store.Pool.QueryRow(ctx, `
select count(*) from projax.items
where deleted_at is null and archived = false`).Scan(&n)
return n, err
}
// calendarCount fetches the calendar list from dav.msbls.de and returns the
// total. Surfaces only the count — discovery itself happens on /admin/caldav.
func (s *Server) calendarCount(ctx context.Context) (int, error) {
cals, err := s.CalDAV.Client.ListCalendars(ctx)
if err != nil {
return 0, err
}
return len(cals), nil
}
// migrationLast reads the most-recent row from projax.schema_migrations so
// the system panel can show "0013_orphan_item_links_cleanup.sql · 2026-05-15".
// Returns "" if the table doesn't exist (fresh DB) or on error.
func (s *Server) migrationLast(ctx context.Context) string {
var name string
err := s.Store.Pool.QueryRow(ctx,
`select name from projax.schema_migrations order by applied_at desc limit 1`).Scan(&name)
if err != nil {
return ""
}
return name
}
// gatherHealth probes each configured upstream in parallel with a 1 s
// timeout. Results are cached for adminCacheTTL so /admin renders stay
// cheap even if m hammers refresh.
func (s *Server) gatherHealth(ctx context.Context) []adminHealth {
now := time.Now()
if cached, ok := s.adminHealth.get(now); ok {
return cached
}
type target struct {
name string
url string
}
targets := []target{}
if s.CalDAV != nil {
targets = append(targets, target{"CalDAV", s.CalDAV.Client.BaseURL})
}
if s.Gitea != nil {
targets = append(targets, target{"Gitea", s.Gitea.Client.BaseURL})
}
if s.Auth != nil {
targets = append(targets, target{"Supabase", s.Auth.SupabaseURL})
}
if len(targets) == 0 {
return nil
}
out := make([]adminHealth, len(targets))
var wg sync.WaitGroup
probeCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
client := &http.Client{Timeout: time.Second}
for i, t := range targets {
wg.Add(1)
go func(i int, t target) {
defer wg.Done()
out[i] = adminHealth{Name: t.name, URL: t.url, OK: probeURL(probeCtx, client, t.url)}
}(i, t)
}
wg.Wait()
s.adminHealth.set(out, now)
return out
}
// probeURL issues a quick GET against the given URL and reports whether the
// upstream returned ANY HTTP status. Connection-level failures (DNS,
// refused, TLS) count as down. Non-2xx but valid HTTP counts as UP — the
// admin panel cares about reachability, not authorisation. CalDAV returns
// 401 unauth on a bare GET, which is "alive". Same for Gitea / Supabase.
func probeURL(ctx context.Context, client *http.Client, u string) bool {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false
}
resp, err := client.Do(req)
if err != nil {
return false
}
_ = resp.Body.Close()
return true
}