feat(service): landing chooser at /, shared header + footer (refs #451)

Post-merge, "/" was the admin console with a small text link to the
player. This makes "/" a neutral chooser and unifies the chrome across
all three surfaces (landing, player, admin).

- "/" now serves a lean chooser page (web/landing.html): a calm, self-
  contained page (no framework, inline CSS) that routes to the Player
  (/app) or the Admin & Setup console (/admin), with the console framed
  as the privileged surface. API/speaker clients (non-HTML Accept) still
  get the version JSON from "/" unchanged.
- The admin console moved to /admin (HandleAdmin); its assets and APIs
  are absolute, so it works unchanged at the new path.
- New persisted setting default_landing (chooser|app|admin): when set to
  app or admin, "/" 302-redirects straight there. Exposed in the admin
  Settings tab; defaults to the chooser.
- Shared header: all three carry the same accent bar (braille mark +
  "AfterTouch" + "Bose SoundTouch Toolkit"); the mark is the home link
  back to "/". Shared footer: all three show the same version line
  (the landing fetches /api/setup/version with a tiny vanilla script).

Light/dark and mobile refinements are deliberately left for a later
pass; the admin keeps its existing light-only styling for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-07 15:08:26 +02:00
co-authored by Claude Opus 4.8
parent b861c11d37
commit 86878cd23b
11 changed files with 472 additions and 13 deletions
+1
View File
@@ -1175,6 +1175,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/admin", server.HandleAdmin)
r.Get("/health", server.HandleHealth)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// The favicon lives in the embedded web/img bundle, not under
+1
View File
@@ -34,6 +34,7 @@ GET /accounts/{account}/devices/{device}/presets handlers.(
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /admin handlers.(*Server).HandleAdmin-fm
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
+9
View File
@@ -2627,6 +2627,15 @@ type Settings struct {
// is passed through verbatim; AfterTouch does not validate the
// individual format tokens.
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
// DefaultLanding selects what the root path "/" serves to a browser:
// "chooser" (or empty) — the neutral landing page that links to the
// player and the admin/setup console;
// "app" — redirect straight to the player UI (/app);
// "admin" — redirect straight to the admin console (/admin).
// API/speaker clients (non-HTML Accept) always get the version JSON
// regardless of this setting.
DefaultLanding string `json:"default_landing,omitempty"`
}
// GetSettings retrieves the global service settings.
@@ -0,0 +1,114 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newLandingServer(t *testing.T, landing string) *Server {
t.Helper()
ds := datastore.NewDataStore(t.TempDir())
if landing != "" {
if err := ds.SaveSettings(datastore.Settings{
ServerURL: "http://127.0.0.1:8000",
DefaultLanding: landing,
}); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
}
return NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
}
func htmlGet(path string) *http.Request {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Accept", "text/html")
return req
}
// TestHandleRootChooser: with no configured default, a browser at "/"
// gets the neutral chooser linking to both surfaces.
func TestHandleRootChooser(t *testing.T) {
server := newLandingServer(t, "")
rec := httptest.NewRecorder()
server.HandleRoot(rec, htmlGet("/"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`href="/app"`, `href="/admin"`, "Player"} {
if !strings.Contains(body, want) {
t.Errorf("chooser body missing %q", want)
}
}
// The chooser is not the admin console.
if strings.Contains(body, "tab-settings") {
t.Error("chooser unexpectedly served the admin console")
}
}
// TestHandleRootRedirects: default_landing app/admin redirects "/" to the
// matching surface for browsers.
func TestHandleRootRedirects(t *testing.T) {
for landing, want := range map[string]string{"app": "/app", "admin": "/admin"} {
server := newLandingServer(t, landing)
rec := httptest.NewRecorder()
server.HandleRoot(rec, htmlGet("/"))
if rec.Code != http.StatusFound {
t.Errorf("landing=%q: status = %d; want 302", landing, rec.Code)
}
if got := rec.Header().Get("Location"); got != want {
t.Errorf("landing=%q: Location = %q; want %q", landing, got, want)
}
}
}
// TestHandleRootJSONIgnoresLanding: API/speaker clients (non-HTML Accept)
// always get the version JSON, never a landing redirect, regardless of
// the configured default.
func TestHandleRootJSONIgnoresLanding(t *testing.T) {
server := newLandingServer(t, "app")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
server.HandleRoot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200 (not a redirect)", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q; want application/json", ct)
}
}
// TestHandleAdminServesConsole: /admin serves the admin console itself.
func TestHandleAdminServesConsole(t *testing.T) {
server := newLandingServer(t, "")
rec := httptest.NewRecorder()
server.HandleAdmin(rec, htmlGet("/admin"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "tab-settings") {
t.Error("admin body did not contain the console markup")
}
}
+36
View File
@@ -13,6 +13,9 @@ import (
//go:embed web/index.html
var indexHTML []byte
//go:embed web/landing.html
var landingHTML []byte
//go:embed web/css/* web/js/* web/img/favicon-braille* web/img/favicon*
var webFS embed.FS
@@ -113,10 +116,43 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
return
}
// HTML branch: a browser hitting "/". Honour the configured default
// landing surface, otherwise serve the neutral chooser. The admin
// console itself now lives at /admin (served by HandleAdmin).
switch s.defaultLanding() {
case "app":
http.Redirect(w, r, "/app", http.StatusFound)
return
case "admin":
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(landingHTML)
}
// HandleAdmin serves the admin / setup console. It used to live at "/";
// the chooser landing page took that spot, so the console moved here.
// Its assets (/web/*) and APIs (/setup, /mgmt) are absolute, so the page
// works unchanged at the new path.
func (s *Server) HandleAdmin(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(indexHTMLVersioned)
}
// defaultLanding returns the configured root-path behaviour for browsers
// ("chooser", "app", or "admin"), defaulting to "chooser" when unset or
// unreadable.
func (s *Server) defaultLanding() string {
persisted, err := s.ds.GetSettings()
if err != nil || persisted.DefaultLanding == "" {
return "chooser"
}
return persisted.DefaultLanding
}
// HandleWeb returns a handler for serving web resources.
func (s *Server) HandleWeb() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+14
View File
@@ -189,6 +189,8 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsRunning, actualBind := s.GetDNSRunning()
defaultLanding := s.defaultLanding()
var serverURLResolvedIP, serverURLResolveError string
if ip, err := s.resolveServerURLIP(serverURL); err == nil {
@@ -266,6 +268,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"tts_language": ttsLanguage,
"tts_voice": ttsVoice,
"tts_volume": ttsVolume,
"default_landing": defaultLanding,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -296,12 +299,22 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
TTSVoice string `json:"tts_voice"`
TTSVolume int `json:"tts_volume"`
TLSExtraHosts *[]string `json:"tls_extra_hosts"`
DefaultLanding string `json:"default_landing"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Normalise + validate the landing choice. Empty means "chooser".
defaultLanding := strings.ToLower(strings.TrimSpace(settings.DefaultLanding))
switch defaultLanding {
case "", "chooser", "app", "admin":
default:
http.Error(w, "Invalid default_landing: must be chooser, app, or admin", http.StatusBadRequest)
return
}
if settings.DNSEnabled && settings.DNSUpstream == "" {
// No strict requirement for DNSUpstream here as SetDNSSettings will
// try to fall back to system DNS. We only log it if both are empty later.
@@ -420,6 +433,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
TTSVoice: s.ttsVoice,
TTSVolume: s.ttsVolume,
TLSExtraHosts: resolvedTLSExtraHosts,
DefaultLanding: defaultLanding,
})
dnsEnabled := s.dnsEnabled
+29
View File
@@ -1,4 +1,33 @@
body { font-family: sans-serif; margin: 20px; }
/* Shared accent bar (matches the app navbar and the landing page). It
breaks out of the body's 20px margin to sit full-bleed at the top. */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 52px;
padding: 0 1.25rem;
margin: -20px -20px 16px;
background: #1a1a1a;
color: #fff;
}
.topbar .brand { display: flex; align-items: center; gap: .5rem; text-decoration: none; color: inherit; }
.topbar .brand img { width: 24px; height: 24px; filter: brightness(0) invert(1); }
.topbar .brand-text { display: flex; flex-direction: column; line-height: 1.1; }
.topbar .brand-name { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }
.topbar .brand-subtitle { font-size: .7rem; font-weight: 400; opacity: .7; }
.topbar .bar-links a {
color: #fff;
font-size: .85rem;
opacity: .85;
text-decoration: none;
padding: .35rem .5rem;
border-radius: 4px;
}
.topbar .bar-links a:hover { opacity: 1; background: rgba(255, 255, 255, .12); }
@media (max-width: 480px) { .topbar .brand-subtitle { display: none; } }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
+26 -12
View File
@@ -8,18 +8,20 @@
<link rel="stylesheet" href="/web/css/style.css"/>
</head>
<body>
<h1><img src="/web/img/favicon-braille.svg" alt="AfterTouch Logo" class="logo"/>AfterTouch</h1>
<p style="margin-top: -10px; font-style: italic; color: #666">
Bose SoundTouch Toolkit
</p>
<p style="margin-top: -4px">
<a href="/app" style="font-weight: bold">🎵 Open the Player (Web UI) →</a>
<span style="font-size: 0.85em; color: #666; margin-left: 8px">
Control playback, volume, presets, zones, and browse TuneIn / RadioBrowser.
</span>
</p>
<p style="margin-top: 2px; font-size: 0.85em; color: #888">
This page is the admin / setup console (migration, settings, diagnostics).
<header class="topbar">
<a class="brand" href="/" title="AfterTouch home">
<img src="/web/img/favicon-braille.svg" alt=""/>
<span class="brand-text">
<span class="brand-name">AfterTouch</span>
<span class="brand-subtitle">Bose SoundTouch Toolkit</span>
</span>
</a>
<nav class="bar-links">
<a href="/app">Open the Player &rarr;</a>
</nav>
</header>
<p style="margin: 0 0 16px; font-size: 0.9em; color: #888">
Admin &amp; Setup console: migration, settings, accounts, and diagnostics.
</p>
<div class="tabs">
@@ -174,6 +176,18 @@
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
</div>
<div style="margin-bottom: 20px">
<label for="default-landing">Landing page (<code>/</code>):</label>
<select id="default-landing" style="margin-left: 4px">
<option value="chooser">Chooser (pick Player or Admin)</option>
<option value="app">Go straight to the Player</option>
<option value="admin">Go straight to Admin &amp; Setup</option>
</select>
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
What a browser sees at the root URL. The Player and Admin pages
stay reachable at <code>/app</code> and <code>/admin</code> either way.
</div>
</div>
<div style="margin-bottom: 20px">
<strong>TLS extra hosts:</strong>
<span class="info-toggle" onclick="toggleInfo('tls-extra-hosts-info')"></span>
+4
View File
@@ -322,6 +322,9 @@ async function fetchSettings() {
if (settings.discovery_enabled !== undefined) {
document.getElementById("discovery-enabled").checked = settings.discovery_enabled;
}
if (settings.default_landing) {
document.getElementById("default-landing").value = settings.default_landing;
}
if (settings.dns_enabled !== undefined) {
document.getElementById("dns-enabled").checked = settings.dns_enabled;
}
@@ -460,6 +463,7 @@ async function updateLoggingSettings() {
async function updateSettings() {
const settings = {
server_url: document.getElementById("target-domain").value,
default_landing: document.getElementById("default-landing").value,
discovery_interval: document.getElementById("discovery-interval").value,
discovery_enabled: document.getElementById("discovery-enabled").checked,
dns_enabled: document.getElementById("dns-enabled").checked,
+237
View File
@@ -0,0 +1,237 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>AfterTouch</title>
<meta name="description" content="AfterTouch — a local replacement for the Bose SoundTouch cloud. Open the player or the admin console."/>
<link rel="icon" href="/web/img/favicon-braille.svg" type="image/svg+xml"/>
<style>
:root {
/* Tinted neutrals (OKLCH), a hair warm so the page reads calm.
One live-green accent marks the everyday player. The accent
bar mirrors the app navbar (dark bar / light text). */
--bg: oklch(0.972 0.004 95);
--surface: oklch(0.995 0.003 95);
--border: oklch(0.905 0.006 95);
--text: oklch(0.255 0.012 95);
--text-dim: oklch(0.520 0.012 95);
--hover: oklch(0.955 0.006 95);
--live: oklch(0.640 0.150 150);
--accent: oklch(0.220 0.010 95);
--accent-fg: oklch(0.980 0.003 95);
--radius: 12px;
--logo-filter: brightness(0) invert(1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(0.175 0.005 95);
--surface: oklch(0.215 0.006 95);
--border: oklch(0.305 0.008 95);
--text: oklch(0.925 0.008 95);
--text-dim: oklch(0.660 0.012 95);
--hover: oklch(0.255 0.008 95);
--live: oklch(0.720 0.150 150);
--accent: oklch(0.860 0.006 95);
--accent-fg: oklch(0.200 0.005 95);
--logo-filter: none;
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
background: var(--bg);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
min-height: 100%;
}
a { color: inherit; text-decoration: none; }
/* ── Shared accent bar (mirrors the app navbar) ──────────────── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 52px;
padding: 0 1.25rem;
background: var(--accent);
color: var(--accent-fg);
}
.brand { display: flex; align-items: center; gap: 0.5rem; }
.brand img { width: 24px; height: 24px; filter: var(--logo-filter); }
.brand-text { display: flex; flex-direction: column; line-height: 1.1; }
.brand-name { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
.brand-subtitle { font-size: 0.7rem; font-weight: 400; opacity: 0.7; }
.bar-links a {
font-size: 0.85rem;
opacity: 0.8;
padding: 0.35rem 0.5rem;
border-radius: 4px;
transition: opacity 0.15s, background 0.15s;
}
.bar-links a:hover { opacity: 1; background: rgba(127, 127, 127, 0.18); }
/* ── Chooser ─────────────────────────────────────────────────── */
main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
max-width: 32rem;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.lead {
font-size: 1.05rem;
color: var(--text-dim);
margin-bottom: 1.75rem;
max-width: 28rem;
}
nav.dest-list {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.dest {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.2rem 1rem;
padding: 1.15rem 1.25rem;
transition: background 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.dest + .dest { border-top: 1px solid var(--border); }
.dest:hover { background: var(--hover); }
.dest:focus-visible { outline: 2px solid var(--live); outline-offset: -2px; }
.dest .label {
display: flex;
align-items: baseline;
gap: 0.55rem;
font-size: 1.15rem;
font-weight: 600;
}
.dest .desc { grid-column: 1; font-size: 0.88rem; color: var(--text-dim); }
.dest .arrow {
grid-row: 1 / span 2;
grid-column: 2;
font-size: 1.2rem;
color: var(--text-dim);
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), color 0.18s;
}
.dest:hover .arrow { transform: translateX(4px); }
/* The player is the everyday surface: its arrow carries the live
accent so the eye lands there first. */
.dest--player:hover .arrow { color: var(--live); }
/* The console is the privileged surface: a key glyph and a quiet
tag mark it as setup / diagnostics rather than daily use. */
.tag {
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
/* ── Footer (version line, like the app and admin) ───────────── */
footer {
max-width: 32rem;
margin: 0 auto;
padding: 1.5rem;
font-size: 0.8rem;
color: var(--text-dim);
}
footer a { text-decoration: underline; text-underline-offset: 2px; }
footer a:hover { color: var(--text); }
.dot { opacity: 0.5; padding: 0 0.15rem; }
</style>
</head>
<body>
<header class="topbar">
<a class="brand" href="/" title="AfterTouch home">
<img src="/web/img/favicon-braille.svg" alt=""/>
<span class="brand-text">
<span class="brand-name">AfterTouch</span>
<span class="brand-subtitle">Bose SoundTouch Toolkit</span>
</span>
</a>
<nav class="bar-links">
<a href="https://gesellix.github.io/Bose-SoundTouch/" target="_blank" rel="noopener">Documentation</a>
</nav>
</header>
<main>
<p class="lead">
Your SoundTouch speakers, kept playing on your own network
after the Bose cloud shutdown. Where to?
</p>
<nav class="dest-list" aria-label="Choose a surface">
<a class="dest dest--player" href="/app">
<span class="label">Player</span>
<span class="desc">Playback, volume, presets, zones, and TuneIn / RadioBrowser.</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</a>
<a class="dest dest--admin" href="/admin">
<span class="label">
Admin &amp; Setup
<span class="tag">console</span>
</span>
<span class="desc">Migrate speakers, settings, accounts, and diagnostics.</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
</a>
</nav>
</main>
<footer>
<span id="version-info">AfterTouch</span>
<span class="dot">&middot;</span>
<a href="https://gesellix.github.io/Bose-SoundTouch/" target="_blank" rel="noopener">Documentation</a>
</footer>
<script>
// Mirror the version line the app and admin footers show. Same
// /api/setup/version payload, kept to a tiny vanilla fetch so the
// landing page carries no framework.
(async function () {
try {
const data = await (await fetch('/api/setup/version')).json();
if (!data || !data.version) return;
let v = data.version;
if (data.release_url) {
v = `<a href="${data.release_url}" target="_blank" rel="noopener">${data.version}</a>`;
}
let line = `AfterTouch ${v}`;
if (data.commit && data.commit !== 'unknown') {
const short = data.commit.substring(0, 7);
const c = data.commit_url
? `<a href="${data.commit_url}" target="_blank" rel="noopener">${short}</a>`
: short;
line += ` (${c})`;
}
if (data.date && data.date !== 'unknown') {
line += ` &bull; ${data.date}`;
}
document.getElementById('version-info').innerHTML = line;
} catch (e) {
/* leave the static "AfterTouch" fallback in place */
}
})();
</script>
</body>
</html>
+1 -1
View File
@@ -170,7 +170,7 @@ function App() {
return html`
<div class="app">
<nav class="navbar">
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
<a class="brand" href="/" title="AfterTouch home">
<img src="/app/static/img/logo.svg" alt="AfterTouch" class="nav-logo" />
<div class="brand-text">
<span class="brand-name">AfterTouch</span>