999047f8cbdd0f58401b87d61ee62a7d63123294

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add web server skeleton with embedded templates and static assets

- server.go: HTTP server, in-memory Game session, html/template via go:embed
- main.go: -web and -port flags to launch the web UI (TUI remains default)
- web/templates: layout + home page
- web/static: vendored htmx.min.js, base dark-theme style.css

Diff

This diff is truncated to protect this page.

  1diff --git a/main.go b/main.go
  2index 46174d318846d1f677504639d5c05e10efe6448d..25f421d50aaf902ed31ec52b1262e7c702f6dc9c 100644
  3--- a/main.go
  4+++ b/main.go
  5@@ -3,6 +3,7 @@ package main
  6 import (
  7 	"flag"
  8 	"fmt"
  9+	"net/http"
 10 	"os"
 11 
 12 	tea "github.com/charmbracelet/bubbletea"
 13@@ -12,6 +13,8 @@ func main() {
 14 	dir := flag.String("dir", "/mnt/media/music", "directory to scan for music files")
 15 	blacklist := flag.String("blacklist", "blacklist", "path to blacklist file (one directory per line, fully excluded)")
 16 	rounds := flag.Int("rounds", 10, "number of rounds")
 17+	web := flag.Bool("web", false, "run as a web server instead of the TUI")
 18+	port := flag.Int("port", 8080, "port for the web server")
 19 	flag.Parse()
 20 
 21 	catalog, err := buildCatalog(*dir, *blacklist)
 22@@ -28,6 +31,21 @@ func main() {
 23 		*rounds = len(catalog)
 24 	}
 25 
 26+	if *web {
 27+		srv, err := newServer(catalog, *rounds)
 28+		if err != nil {
 29+			fmt.Fprintf(os.Stderr, "error starting web server: %v\n", err)
 30+			os.Exit(1)
 31+		}
 32+		addr := fmt.Sprintf(":%d", *port)
 33+		fmt.Printf("Muse Guessr web server listening on http://localhost:%d\n", *port)
 34+		if err := http.ListenAndServe(addr, srv.handler()); err != nil {
 35+			fmt.Fprintf(os.Stderr, "server error: %v\n", err)
 36+			os.Exit(1)
 37+		}
 38+		return
 39+	}
 40+
 41 	m := newModel(catalog, *rounds)
 42 	p := tea.NewProgram(m, tea.WithAltScreen())
 43 	if _, err := p.Run(); err != nil {
 44diff --git a/server.go b/server.go
 45new file mode 100644
 46index 0000000000000000000000000000000000000000..6ae9a8fa90b243d4d22cbce699ec1973b85699c1
 47--- /dev/null
 48+++ b/server.go
 49@@ -0,0 +1,146 @@
 50+package main
 51+
 52+import (
 53+	"embed"
 54+	"html/template"
 55+	"io/fs"
 56+	"net/http"
 57+	"strings"
 58+	"sync"
 59+)
 60+
 61+//go:embed web/templates/*.html
 62+var templateFS embed.FS
 63+
 64+//go:embed web/static/*
 65+var staticFS embed.FS
 66+
 67+// Game holds the mutable state of a single-player game session. All access is
 68+// guarded by mu because HTTP handlers run concurrently.
 69+type Game struct {
 70+	mu       sync.Mutex
 71+	catalog  []Song
 72+	order    []int
 73+	pos      int
 74+	rounds   int
 75+	played   int
 76+	score    int
 77+	answer   Song
 78+	startSec int
 79+	segment  []byte // WAV bytes for the current round's clip
 80+	hinted   bool
 81+	done     bool
 82+	result   *RoundResult
 83+}
 84+
 85+// RoundResult captures the outcome of a submitted guess, ready for rendering.
 86+type RoundResult struct {
 87+	Points       int
 88+	Parts        []string
 89+	Guesses      []string
 90+	FieldCorrect []bool
 91+	FieldPartial []bool
 92+	ResultMsg    string
 93+	Hinted       bool
 94+}
 95+
 96+func newGame(catalog []Song, rounds int) *Game {
 97+	g := &Game{
 98+		catalog: catalog,
 99+		rounds:  rounds,
100+		order:   shuffled(len(catalog)),
101+	}
102+	g.beginRound()
103+	return g
104+}
105+
106+// beginRound selects the next song and resets the per-round state. Audio
107+// generation is wired in by a later stage.
108+func (g *Game) beginRound() {
109+	g.answer = g.nextSong()
110+	g.hinted = false
111+	g.result = nil
112+	g.startSec = -1
113+	g.segment = nil
114+}
115+
116+func (g *Game) nextSong() Song {
117+	if g.pos >= len(g.order) {
118+		g.pos = 0
119+		g.order = shuffled(len(g.catalog))
120+	}
121+	idx := g.order[g.pos]
122+	g.pos++
123+	return g.catalog[idx]
124+}
125+
126+// viewData is the render model passed to the templates.
127+type viewData struct {
128+	Score       int
129+	MaxScore    int
130+	Round       int
131+	TotalRounds int
132+	Answer      Song
133+	Hinted      bool
134+	Done        bool
135+	Result      *RoundResult
136+}
137+
138+func (g *Game) viewData() viewData {
139+	return viewData{
140+		Score:       g.score,
141+		MaxScore:    g.rounds * pointsPerRound,
142+		Round:       g.played + 1,
143+		TotalRounds: g.rounds,
144+		Answer:      g.answer,
145+		Hinted:      g.hinted,
146+		Done:        g.done,
147+		Result:      g.result,
148+	}
149diff --git a/web/static/htmx.min.js b/web/static/htmx.min.js
150new file mode 100644
151index 0000000000000000000000000000000000000000..de5f0f1a6173a84e0eacda6067c22d763d4dfebd
152--- /dev/null
153+++ b/web/static/htmx.min.js
154@@ -0,0 +1 @@
155diff --git a/web/static/style.css b/web/static/style.css
156new file mode 100644
157index 0000000000000000000000000000000000000000..bb7fa35426dc8a3540f9b9fedbd2498a879bf8fb
158--- /dev/null
159+++ b/web/static/style.css
160@@ -0,0 +1,56 @@
161+:root {
162+  --bg: #2e3440;
163+  --bg-2: #3b4252;
164+  --fg: #d8dee9;
165+  --dim: #7b889a;
166+  --accent: #a89984;
167+  --green: #a3be5c;
168+  --red: #bf616a;
169+  --yellow: #e5c07b;
170+  --orange: #d08a52;
171+}
172+
173+* { box-sizing: border-box; }
174+
175+html, body { height: 100%; }
176+
177+body {
178+  margin: 0;
179+  min-height: 100vh;
180+  display: flex;
181+  align-items: center;
182+  justify-content: center;
183+  padding: 24px;
184+  background: radial-gradient(120% 120% at 50% 0%, var(--bg-2), var(--bg) 60%);
185+  color: var(--fg);
186+  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
187+}
188+
189+.game {
190+  width: min(92vw, 560px);
191+}
192+
193+.hud {
194+  display: flex;
195+  align-items: baseline;
196+  justify-content: space-between;
197+  gap: 12px;
198+}
199+
200+h1 {
201+  margin: 0;
202+  font-size: 1.9rem;
203+  letter-spacing: 0.5px;
204+  color: var(--accent);
205+}
206+
207+.score {
208+  font-size: 1.05rem;
209+  color: var(--dim);
210+  white-space: nowrap;
211+}
212+
213+.round {
214+  color: var(--dim);
215+  margin-top: 4px;
216+}
217diff --git a/web/templates/home.html b/web/templates/home.html
218new file mode 100644
219index 0000000000000000000000000000000000000000..9fede01a1bb59be3a6fbb94f9751094ab11e5ee1
220--- /dev/null
221+++ b/web/templates/home.html
222@@ -0,0 +1,7 @@
223+{{define "content"}}
224+<header class="hud">
225+  <h1>Muse Guessr</h1>
226+  <div class="score">Score: {{.Score}}/{{.MaxScore}}</div>
227+</header>
228+<p class="round">Round {{.Round}} of {{.TotalRounds}}</p>
229+{{end}}
230diff --git a/web/templates/layout.html b/web/templates/layout.html
231new file mode 100644
232index 0000000000000000000000000000000000000000..abe4071c6ee5e76a9082c1589459ba0258c6fb88
233--- /dev/null
234+++ b/web/templates/layout.html
235@@ -0,0 +1,17 @@
236+{{define "page"}}
237+<!DOCTYPE html>
238+<html lang="en">
239+<head>
240+  <meta charset="utf-8">
241+  <meta name="viewport" content="width=device-width, initial-scale=1">
242+  <title>Muse Guessr</title>
243+  <link rel="stylesheet" href="/static/style.css">
244+  <script src="/static/htmx.min.js"></script>
245+</head>
246+<body>
247+  <main class="game">
248+    {{template "content" .}}
249+  </main>
250+</body>
251+</html>
252+{{end}}