New /dashboard route serves the authenticated home screen with a server-rendered payload (no skeleton→fetch waterfall, per design audit §2.3). / now redirects authenticated visitors to /dashboard and keeps the marketing landing for anonymous visitors. - DashboardService aggregates deadline + matter summaries, the next 7d of Fristen/Termine, and the last 10 akten_events, all scoped by the standard office-visibility predicate. - Dashboard handler splices the JSON payload into dist/dashboard.html as window.__PALIAD_DASHBOARD__ so the client paints on first frame; client re-fetches /api/dashboard every 60s to stay current. - Sidebar gains an "Übersicht" group with the Dashboard entry at the top; DE/EN i18n keys + traffic-light card styles added. - Empty-state copy, onboarding hint, and 503 handling keep the page intact when DATABASE_URL is unset.
91 lines
3.1 KiB
Go
91 lines
3.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// The dashboard shell is pre-rendered by bun (`renderDashboard()` → dist/dashboard.html)
|
|
// and contains the placeholder token below. On each request we splice in a
|
|
// JSON blob as `window.__PALIAD_DASHBOARD__` so the client can paint the real
|
|
// data on first frame — no skeleton + /api/dashboard waterfall.
|
|
const dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
|
|
|
var (
|
|
dashboardShellOnce sync.Once
|
|
dashboardShellBytes []byte
|
|
dashboardShellErr error
|
|
)
|
|
|
|
// loadDashboardShell reads dist/dashboard.html once. Server restarts on new
|
|
// builds (container-level) so a lifetime cache is safe.
|
|
func loadDashboardShell() ([]byte, error) {
|
|
dashboardShellOnce.Do(func() {
|
|
path := filepath.Join("dist", "dashboard.html")
|
|
dashboardShellBytes, dashboardShellErr = os.ReadFile(path)
|
|
if dashboardShellErr != nil {
|
|
return
|
|
}
|
|
if !bytes.Contains(dashboardShellBytes, []byte(dashboardDataPlaceholder)) {
|
|
log.Printf("warning: dashboard.html is missing the data placeholder — client will fall back to /api/dashboard")
|
|
}
|
|
})
|
|
return dashboardShellBytes, dashboardShellErr
|
|
}
|
|
|
|
// serveDashboardShell writes dist/dashboard.html with the JSON payload spliced
|
|
// into the placeholder. A nil payload disables server-side hydration; the
|
|
// client then falls back to fetching /api/dashboard on mount.
|
|
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte) {
|
|
shell, err := loadDashboardShell()
|
|
if err != nil {
|
|
http.Error(w, "dashboard shell unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var body []byte
|
|
if len(payload) > 0 {
|
|
// JSON is wrapped so the script block is self-contained even when the
|
|
// payload contains `</script>` sequences (defensive: our data is
|
|
// server-owned, but future event.description fields could contain
|
|
// arbitrary text).
|
|
inline := append([]byte("window.__PALIAD_DASHBOARD__="), escapeForScript(payload)...)
|
|
inline = append(inline, ';')
|
|
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder), inline, 1)
|
|
} else {
|
|
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder),
|
|
[]byte("window.__PALIAD_DASHBOARD__=null;"), 1)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// escapeForScript makes a JSON blob safe to embed directly in an inline
|
|
// <script>. JSON strings may contain `</script>` or U+2028/U+2029, both of
|
|
// which terminate script blocks in some parsers.
|
|
func escapeForScript(b []byte) []byte {
|
|
b = bytes.ReplaceAll(b, []byte(`</`), []byte(`<\/`))
|
|
b = bytes.ReplaceAll(b, []byte("\u2028"), []byte(`\u2028`))
|
|
b = bytes.ReplaceAll(b, []byte("\u2029"), []byte(`\u2029`))
|
|
return b
|
|
}
|
|
|
|
// mustJSON encodes v, falling back to null on error (logged). Used only for
|
|
// server-side hydration where a broken payload is non-fatal — the client will
|
|
// fetch /api/dashboard as a fallback.
|
|
func mustJSON(v any) []byte {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
log.Printf("dashboard hydration encode: %v", err)
|
|
return nil
|
|
}
|
|
return b
|
|
}
|