mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Opus 4.8
parent
b861c11d37
commit
86878cd23b
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 →</a>
|
||||
</nav>
|
||||
</header>
|
||||
<p style="margin: 0 0 16px; font-size: 0.9em; color: #888">
|
||||
Admin & 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 & 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">→</span>
|
||||
</a>
|
||||
<a class="dest dest--admin" href="/admin">
|
||||
<span class="label">
|
||||
Admin & Setup
|
||||
<span class="tag">console</span>
|
||||
</span>
|
||||
<span class="desc">Migrate speakers, settings, accounts, and diagnostics.</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span id="version-info">AfterTouch</span>
|
||||
<span class="dot">·</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 += ` • ${data.date}`;
|
||||
}
|
||||
document.getElementById('version-info').innerHTML = line;
|
||||
} catch (e) {
|
||||
/* leave the static "AfterTouch" fallback in place */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user