feat(dashboard): Phase G — logged-in landing page
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.
This commit is contained in:
90
internal/handlers/dashboard_shell.go
Normal file
90
internal/handlers/dashboard_shell.go
Normal file
@@ -0,0 +1,90 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user