parent
b0a45e4faf
commit
e128569a29
@ -0,0 +1,224 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"track-gopher/derby"
|
||||
)
|
||||
|
||||
//go:embed templates static
|
||||
var content embed.FS
|
||||
|
||||
// Server represents the web server for the derby clock
|
||||
type Server struct {
|
||||
router *chi.Mux
|
||||
clock *derby.DerbyClock
|
||||
templates *template.Template
|
||||
raceEvents chan derby.Event
|
||||
clients map[chan string]bool
|
||||
port int
|
||||
}
|
||||
|
||||
// NewServer creates a new web server
|
||||
func NewServer(clock *derby.DerbyClock, port int) (*Server, error) {
|
||||
// Parse templates
|
||||
templates, err := template.ParseFS(content, "templates/*.html", "templates/**/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse templates: %w", err)
|
||||
}
|
||||
|
||||
// Create server
|
||||
s := &Server{
|
||||
router: chi.NewRouter(),
|
||||
clock: clock,
|
||||
templates: templates,
|
||||
raceEvents: make(chan derby.Event, 10),
|
||||
clients: make(map[chan string]bool),
|
||||
port: port,
|
||||
}
|
||||
|
||||
// Set up routes
|
||||
s.routes()
|
||||
|
||||
// Start event forwarder
|
||||
go s.forwardEvents()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// routes sets up the HTTP routes
|
||||
func (s *Server) routes() {
|
||||
// Middleware
|
||||
s.router.Use(middleware.Logger)
|
||||
s.router.Use(middleware.Recoverer)
|
||||
s.router.Use(middleware.Timeout(10 * time.Second))
|
||||
|
||||
// Static files
|
||||
staticFS, _ := fs.Sub(content, "static")
|
||||
s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Pages
|
||||
s.router.Get("/", s.handleIndex())
|
||||
|
||||
// API endpoints
|
||||
s.router.Post("/api/reset", s.handleReset())
|
||||
s.router.Post("/api/force-end", s.handleForceEnd())
|
||||
s.router.Get("/api/status", s.handleStatus())
|
||||
s.router.Get("/api/events", s.handleEvents())
|
||||
}
|
||||
|
||||
// Start starts the web server
|
||||
func (s *Server) Start() error {
|
||||
fmt.Printf("Starting web server on port %d...\n", s.port)
|
||||
return http.ListenAndServe(fmt.Sprintf(":%d", s.port), s.router)
|
||||
}
|
||||
|
||||
// forwardEvents forwards derby clock events to the web clients
|
||||
func (s *Server) forwardEvents() {
|
||||
for event := range s.clock.Events() {
|
||||
// Store the event for new clients
|
||||
s.raceEvents <- event
|
||||
|
||||
// Create the SSE message based on the event type
|
||||
var message string
|
||||
switch event.Type {
|
||||
case derby.EventRaceStart:
|
||||
message = "event: race-start\ndata: {\"status\": \"running\"}\n\n"
|
||||
|
||||
case derby.EventLaneFinish:
|
||||
result := event.Result
|
||||
message = fmt.Sprintf("event: lane-finish\ndata: {\"lane\": %d, \"place\": %d, \"time\": %.4f}\n\n",
|
||||
result.Lane, result.FinishPlace, result.Time)
|
||||
|
||||
case derby.EventRaceComplete:
|
||||
message = "event: race-complete\ndata: {\"status\": \"finished\"}\n\n"
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
for clientChan := range s.clients {
|
||||
select {
|
||||
case clientChan <- message:
|
||||
// Message sent successfully
|
||||
default:
|
||||
// Client is not receiving, remove it
|
||||
delete(s.clients, clientChan)
|
||||
close(clientChan)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleIndex handles the index page
|
||||
func (s *Server) handleIndex() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s.templates.ExecuteTemplate(w, "index.html", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// handleReset handles the reset API endpoint
|
||||
func (s *Server) handleReset() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.clock.Reset(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to reset clock: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status": "reset"}`))
|
||||
}
|
||||
}
|
||||
|
||||
// handleForceEnd handles the force end API endpoint
|
||||
func (s *Server) handleForceEnd() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.clock.ForceEnd(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to force end race: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status": "forced"}`))
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatus handles the status API endpoint
|
||||
func (s *Server) handleStatus() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.clock.Status()
|
||||
|
||||
var statusStr string
|
||||
switch status {
|
||||
case derby.StatusIdle:
|
||||
statusStr = "idle"
|
||||
case derby.StatusRunning:
|
||||
statusStr = "running"
|
||||
case derby.StatusFinished:
|
||||
statusStr = "finished"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(fmt.Sprintf(`{"status": "%s"}`, statusStr)))
|
||||
}
|
||||
}
|
||||
|
||||
// handleEvents handles the SSE events endpoint
|
||||
func (s *Server) handleEvents() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set headers for SSE
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Create a channel for this client
|
||||
clientChan := make(chan string, 10)
|
||||
s.clients[clientChan] = true
|
||||
|
||||
// Clean up when the client disconnects
|
||||
defer func() {
|
||||
delete(s.clients, clientChan)
|
||||
close(clientChan)
|
||||
}()
|
||||
|
||||
// Send initial status
|
||||
status := s.clock.Status()
|
||||
var statusStr string
|
||||
switch status {
|
||||
case derby.StatusIdle:
|
||||
statusStr = "idle"
|
||||
case derby.StatusRunning:
|
||||
statusStr = "running"
|
||||
case derby.StatusFinished:
|
||||
statusStr = "finished"
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "event: status\ndata: {\"status\": \"%s\"}\n\n", statusStr)
|
||||
w.(http.Flusher).Flush()
|
||||
|
||||
// Keep the connection open
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-clientChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Send the message to the client
|
||||
fmt.Fprint(w, message)
|
||||
w.(http.Flusher).Flush()
|
||||
|
||||
case <-r.Context().Done():
|
||||
// Client disconnected
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
/* Additional custom styles can go here */
|
||||
.place-1st {
|
||||
color: gold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.place-2nd {
|
||||
color: silver;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.place-3rd {
|
||||
color: #cd7f32; /* bronze */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.place-4th {
|
||||
color: #666;
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pinewood Derby Race Manager</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||
<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.lane {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.lane.finished {
|
||||
background-color: #d1fae5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-center text-blue-800">Pinewood Derby Race Manager</h1>
|
||||
</header>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">Race Control</h2>
|
||||
<div id="race-status" class="px-4 py-2 rounded-full text-white bg-gray-500">
|
||||
Connecting...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4 justify-center">
|
||||
<button
|
||||
hx-post="/api/reset"
|
||||
hx-swap="none"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
|
||||
>
|
||||
Reset Race
|
||||
</button>
|
||||
<button
|
||||
hx-post="/api/force-end"
|
||||
hx-swap="none"
|
||||
class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
|
||||
>
|
||||
Force End
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Race Results</h2>
|
||||
|
||||
<div id="lanes-container" class="space-y-4">
|
||||
<div id="lane-1" class="lane p-4 border rounded-lg flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center text-white font-bold">1</div>
|
||||
<span class="ml-4 font-medium">Lane 1</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="place hidden"></span>
|
||||
<span class="time text-lg font-mono">--.--.---</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lane-2" class="lane p-4 border rounded-lg flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold">2</div>
|
||||
<span class="ml-4 font-medium">Lane 2</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="place hidden"></span>
|
||||
<span class="time text-lg font-mono">--.--.---</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lane-3" class="lane p-4 border rounded-lg flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center text-white font-bold">3</div>
|
||||
<span class="ml-4 font-medium">Lane 3</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="place hidden"></span>
|
||||
<span class="time text-lg font-mono">--.--.---</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lane-4" class="lane p-4 border rounded-lg flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center text-white font-bold">4</div>
|
||||
<span class="ml-4 font-medium">Lane 4</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="place hidden"></span>
|
||||
<span class="time text-lg font-mono">--.--.---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSE Events Source -->
|
||||
<div hx-ext="sse" sse-connect="/api/events">
|
||||
<div sse-swap="race-start" hx-swap-oob="true">
|
||||
<script>
|
||||
document.getElementById('race-status').textContent = 'Race Running';
|
||||
document.getElementById('race-status').className = 'px-4 py-2 rounded-full text-white bg-green-600';
|
||||
|
||||
// Reset all lanes
|
||||
document.querySelectorAll('.lane').forEach(lane => {
|
||||
lane.classList.remove('finished');
|
||||
lane.querySelector('.time').textContent = '--.--.---';
|
||||
lane.querySelector('.place').classList.add('hidden');
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div sse-swap="lane-finish" hx-swap-oob="true">
|
||||
<script>
|
||||
const laneFinishData = JSON.parse(event.data);
|
||||
const lane = document.getElementById(`lane-${laneFinishData.lane}`);
|
||||
if (lane) {
|
||||
lane.classList.add('finished');
|
||||
lane.querySelector('.time').textContent = laneFinishData.time.toFixed(4);
|
||||
|
||||
const placeEl = lane.querySelector('.place');
|
||||
placeEl.textContent = `${getOrdinal(laneFinishData.place)} Place`;
|
||||
placeEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function getOrdinal(n) {
|
||||
const s = ["th", "st", "nd", "rd"];
|
||||
const v = n % 100;
|
||||
return n + (s[(v-20)%10] || s[v] || s[0]);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div sse-swap="race-complete" hx-swap-oob="true">
|
||||
<script>
|
||||
document.getElementById('race-status').textContent = 'Race Complete';
|
||||
document.getElementById('race-status').className = 'px-4 py-2 rounded-full text-white bg-purple-600';
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div sse-swap="status" hx-swap-oob="true">
|
||||
<script>
|
||||
const statusData = JSON.parse(event.data);
|
||||
let statusText = 'Unknown';
|
||||
let statusClass = 'bg-gray-500';
|
||||
|
||||
if (statusData.status === 'idle') {
|
||||
statusText = 'Ready';
|
||||
statusClass = 'bg-blue-600';
|
||||
|
||||
// Reset all lanes
|
||||
document.querySelectorAll('.lane').forEach(lane => {
|
||||
lane.classList.remove('finished');
|
||||
lane.querySelector('.time').textContent = '--.--.---';
|
||||
lane.querySelector('.place').classList.add('hidden');
|
||||
});
|
||||
} else if (statusData.status === 'running') {
|
||||
statusText = 'Race Running';
|
||||
statusClass = 'bg-green-600';
|
||||
} else if (statusData.status === 'finished') {
|
||||
statusText = 'Race Complete';
|
||||
statusClass = 'bg-purple-600';
|
||||
}
|
||||
|
||||
document.getElementById('race-status').textContent = statusText;
|
||||
document.getElementById('race-status').className = `px-4 py-2 rounded-full text-white ${statusClass}`;
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Handle button states based on race status
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.path === '/api/reset' || event.detail.path === '/api/force-end') {
|
||||
// Refresh the page status after reset or force end
|
||||
fetch('/api/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update UI based on status
|
||||
console.log('Race status updated:', data.status);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in new issue