You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
935 lines
25 KiB
935 lines
25 KiB
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"track-gopher/db"
|
|
"track-gopher/derby"
|
|
"track-gopher/models"
|
|
"track-gopher/web/templates"
|
|
)
|
|
|
|
//go:embed static
|
|
var content embed.FS
|
|
|
|
// Server represents the web server for the derby clock
|
|
type Server struct {
|
|
router *chi.Mux
|
|
clock *derby.DerbyClock
|
|
events <-chan derby.Event
|
|
clients map[chan string]bool
|
|
clientsMux sync.Mutex
|
|
port int
|
|
server *http.Server
|
|
shutdown chan struct{}
|
|
logger *slog.Logger
|
|
db *db.DB
|
|
adminEvents chan string
|
|
}
|
|
|
|
// NewServer creates a new web server
|
|
func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string, port int, logger *slog.Logger) (*Server, error) {
|
|
// Initialize database
|
|
database, err := db.New(dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
|
}
|
|
|
|
// Create server
|
|
s := &Server{
|
|
router: chi.NewRouter(),
|
|
clock: clock,
|
|
events: events,
|
|
clients: make(map[chan string]bool),
|
|
clientsMux: sync.Mutex{},
|
|
port: port,
|
|
shutdown: make(chan struct{}),
|
|
logger: logger,
|
|
db: database,
|
|
adminEvents: make(chan string, 10),
|
|
}
|
|
|
|
// Set up routes
|
|
s.routes()
|
|
|
|
// Start event forwarder
|
|
go s.forwardEvents()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// routes sets up the routes for the server
|
|
func (s *Server) routes() {
|
|
// Middleware
|
|
s.router.Use(middleware.Logger)
|
|
s.router.Use(middleware.Recoverer)
|
|
|
|
// Create a file server for static files
|
|
staticFS, err := fs.Sub(content, "static")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Set up static file server with proper MIME types
|
|
fileServer := http.FileServer(http.FS(staticFS))
|
|
s.router.Get("/static/*", func(w http.ResponseWriter, r *http.Request) {
|
|
// Set correct MIME types based on file extension
|
|
path := r.URL.Path
|
|
if strings.HasSuffix(path, ".js") {
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
} else if strings.HasSuffix(path, ".css") {
|
|
w.Header().Set("Content-Type", "text/css")
|
|
}
|
|
|
|
// The key fix: properly handle the path
|
|
// Remove the /static prefix for the filesystem lookup
|
|
pathWithoutPrefix := strings.TrimPrefix(r.URL.Path, "/static")
|
|
|
|
// Create a new request with the modified path
|
|
r2 := new(http.Request)
|
|
*r2 = *r
|
|
r2.URL = new(url.URL)
|
|
*r2.URL = *r.URL
|
|
r2.URL.Path = pathWithoutPrefix
|
|
|
|
fileServer.ServeHTTP(w, r2)
|
|
})
|
|
|
|
// API routes
|
|
s.router.Route("/api", func(r chi.Router) {
|
|
r.Post("/reset", s.handleReset())
|
|
r.Post("/force-end", s.handleForceEnd())
|
|
r.Get("/events", s.handleEvents())
|
|
})
|
|
|
|
s.router.Get("/admin", s.handleAdmin())
|
|
s.router.Get("/register", s.handleRegister())
|
|
s.router.Get("/register/form", s.handleRegisterForm())
|
|
|
|
s.router.Route("/api/groups", func(r chi.Router) {
|
|
r.Post("/", s.handleCreateGroup())
|
|
r.Put("/{id}", s.handleUpdateGroup())
|
|
r.Delete("/{id}", s.handleDeleteGroup())
|
|
})
|
|
|
|
s.router.Route("/api/racers", func(r chi.Router) {
|
|
r.Post("/", s.handleCreateRacer())
|
|
r.Put("/{id}", s.handleUpdateRacer())
|
|
r.Delete("/{id}", s.handleDeleteRacer())
|
|
})
|
|
|
|
// Add heats page route
|
|
s.router.Get("/heats", s.handleHeats())
|
|
s.router.Get("/heats-content", s.handleHeatsContent())
|
|
|
|
// Add heats API routes
|
|
s.router.Route("/api/heats", func(r chi.Router) {
|
|
r.Post("/save", s.handleSaveHeats())
|
|
})
|
|
|
|
// Main page
|
|
s.router.Get("/", s.handleIndex())
|
|
|
|
// Add racers list route
|
|
s.router.Get("/admin/racers/list", s.handleRacersList())
|
|
|
|
// Add admin events route
|
|
s.router.Get("/api/admin-events", s.handleAdminEvents())
|
|
|
|
// Add validate car number route
|
|
s.router.Get("/api/validate/car-number", s.handleValidateCarNumber())
|
|
}
|
|
|
|
// Start starts the web server
|
|
func (s *Server) Start() error {
|
|
addr := fmt.Sprintf(":%d", s.port)
|
|
s.server = &http.Server{
|
|
Addr: addr,
|
|
Handler: s.router,
|
|
}
|
|
|
|
// Start server in a goroutine
|
|
go func() {
|
|
s.logger.Info("Web server starting", "port", s.port)
|
|
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
s.logger.Error("HTTP server error", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop gracefully shuts down the server
|
|
func (s *Server) Stop() error {
|
|
// Close database connection
|
|
if s.db != nil {
|
|
if err := s.db.Close(); err != nil {
|
|
s.logger.Error("Error closing database", "error", err)
|
|
}
|
|
}
|
|
|
|
// Create a context with timeout for shutdown
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
s.logger.Info("Shutting down web server")
|
|
|
|
// Shutdown the HTTP server
|
|
if s.server != nil {
|
|
return s.server.Shutdown(ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// forwardEvents forwards derby events to SSE clients
|
|
func (s *Server) forwardEvents() {
|
|
for {
|
|
select {
|
|
case event, ok := <-s.events:
|
|
if !ok {
|
|
return
|
|
}
|
|
// Process the event and send to clients
|
|
s.broadcastEvent(event)
|
|
case <-s.shutdown:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastEvent sends an event to all connected clients
|
|
func (s *Server) broadcastEvent(event derby.Event) {
|
|
var message string
|
|
|
|
switch event.Type {
|
|
case derby.EventRaceStart:
|
|
s.logger.Info("Broadcasting race start event")
|
|
statusMsg := struct {
|
|
Status string `json:"status"`
|
|
}{
|
|
Status: "running",
|
|
}
|
|
statusJSON, _ := json.Marshal(statusMsg)
|
|
message = fmt.Sprintf("event: status\ndata: %s", statusJSON)
|
|
|
|
case derby.EventLaneFinish:
|
|
s.logger.Info("Broadcasting lane finish event",
|
|
"lane", event.Result.Lane,
|
|
"time", event.Result.Time,
|
|
"place", event.Result.FinishPlace)
|
|
|
|
// Create a message for lane finish
|
|
laneData := struct {
|
|
Lane int `json:"lane"`
|
|
Time float64 `json:"time"`
|
|
Place int `json:"place"`
|
|
}{
|
|
Lane: event.Result.Lane,
|
|
Time: event.Result.Time,
|
|
Place: event.Result.FinishPlace,
|
|
}
|
|
laneJSON, _ := json.Marshal(laneData)
|
|
message = fmt.Sprintf("event: lane-finish\ndata: %s", laneJSON)
|
|
|
|
case derby.EventRaceComplete:
|
|
s.logger.Info("Broadcasting race complete event")
|
|
statusMsg := struct {
|
|
Status string `json:"status"`
|
|
}{
|
|
Status: "finished",
|
|
}
|
|
statusJSON, _ := json.Marshal(statusMsg)
|
|
message = fmt.Sprintf("event: status\ndata: %s", statusJSON)
|
|
}
|
|
|
|
if message == "" {
|
|
return
|
|
}
|
|
|
|
// Send to all clients
|
|
s.clientsMux.Lock()
|
|
clientCount := len(s.clients)
|
|
sentCount := 0
|
|
for clientChan := range s.clients {
|
|
select {
|
|
case clientChan <- message:
|
|
sentCount++
|
|
default:
|
|
s.logger.Warn("Client channel is full, event not sent")
|
|
}
|
|
}
|
|
s.logger.Info("Event broadcast complete",
|
|
"sentCount", sentCount,
|
|
"totalClients", clientCount,
|
|
"eventType", event.Type)
|
|
s.clientsMux.Unlock()
|
|
}
|
|
|
|
// handleIndex handles the index page
|
|
func (s *Server) handleIndex() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
templates.Index().Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
// 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 SSE events
|
|
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", "*")
|
|
|
|
// Debug message to confirm connection
|
|
fmt.Fprintf(w, "event: debug\ndata: {\"message\":\"SSE connection established\"}\n\n")
|
|
|
|
// Flush headers to ensure they're sent to the client
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
} else {
|
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create a channel for this client
|
|
clientChan := make(chan string, 10)
|
|
|
|
// Add client to map with mutex protection
|
|
s.clientsMux.Lock()
|
|
s.clients[clientChan] = true
|
|
clientCount := len(s.clients)
|
|
s.clientsMux.Unlock()
|
|
|
|
s.logger.Info("New client connected",
|
|
"clientIP", r.RemoteAddr,
|
|
"totalClients", clientCount)
|
|
|
|
// Remove client when connection is closed
|
|
defer func() {
|
|
s.clientsMux.Lock()
|
|
delete(s.clients, clientChan)
|
|
remainingClients := len(s.clients)
|
|
s.clientsMux.Unlock()
|
|
close(clientChan)
|
|
|
|
s.logger.Info("Client disconnected",
|
|
"clientIP", r.RemoteAddr,
|
|
"remainingClients", remainingClients)
|
|
}()
|
|
|
|
// Keep connection open and send events as they arrive
|
|
for {
|
|
select {
|
|
case msg, ok := <-clientChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "%s\n\n", msg)
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleAdmin renders the admin page
|
|
func (s *Server) handleAdmin() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups and racers from database
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
racers, err := s.db.GetRacers()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render template
|
|
component := templates.Admin(groups, racers)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render admin template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRegister renders the racer registration page
|
|
func (s *Server) handleRegister() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups from database
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render template
|
|
component := templates.Register(groups)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render register template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRegisterForm returns just the registration form component
|
|
func (s *Server) handleRegisterForm() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render form with isAdmin=true
|
|
component := templates.RegisterForm(groups, true)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render form", "error", err)
|
|
http.Error(w, "Failed to render form", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// API handlers for groups
|
|
|
|
// handleCreateGroup creates a new group
|
|
func (s *Server) handleCreateGroup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
// Validate
|
|
if name == "" {
|
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create group
|
|
id, err := s.db.CreateGroup(name, description)
|
|
if err != nil {
|
|
s.logger.Error("Failed to create group", "error", err)
|
|
http.Error(w, "Failed to create group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusCreated)
|
|
fmt.Fprintf(w, `{"id":%d}`, id)
|
|
}
|
|
}
|
|
|
|
// handleUpdateGroup updates a group
|
|
func (s *Server) handleUpdateGroup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
|
|
// Validate
|
|
if name == "" {
|
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update group
|
|
if err := s.db.UpdateGroup(id, name, description); err != nil {
|
|
s.logger.Error("Failed to update group", "error", err)
|
|
http.Error(w, "Failed to update group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"id":%d}`, id)
|
|
}
|
|
}
|
|
|
|
// handleDeleteGroup deletes a group
|
|
func (s *Server) handleDeleteGroup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Delete group
|
|
if err := s.db.DeleteGroup(id); err != nil {
|
|
s.logger.Error("Failed to delete group", "error", err)
|
|
http.Error(w, "Failed to delete group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"success":true}`)
|
|
}
|
|
}
|
|
|
|
// API handlers for racers
|
|
|
|
// handleCreateRacer creates a new racer
|
|
func (s *Server) handleCreateRacer() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
firstName := r.FormValue("first_name")
|
|
lastName := r.FormValue("last_name")
|
|
carNumber := r.FormValue("car_number")
|
|
carWeightStr := r.FormValue("car_weight")
|
|
groupIDStr := r.FormValue("group_id")
|
|
|
|
// Validate
|
|
if firstName == "" || lastName == "" || carNumber == "" || carWeightStr == "" || groupIDStr == "" {
|
|
http.Error(w, "All fields are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse numeric values
|
|
carWeight, err := strconv.ParseFloat(carWeightStr, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid car weight", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if car number is unique before creating
|
|
isUnique, err := s.db.IsCarNumberUnique(carNumber)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check car number uniqueness", "error", err)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<div class="alert alert-danger">Failed to validate car number</div>`))
|
|
return
|
|
}
|
|
|
|
if !isUnique {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<div class="alert alert-danger">Car number is already in use</div>`))
|
|
return
|
|
}
|
|
|
|
id, err := s.db.CreateRacer(firstName, lastName, carNumber, carWeight, groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to create racer", "error", err)
|
|
http.Error(w, "Failed to create racer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.logger.Info("Racer created", "id", id)
|
|
|
|
// Broadcast event to admin page
|
|
select {
|
|
case s.adminEvents <- "racer-added":
|
|
// Event sent
|
|
default:
|
|
// Channel full, non-blocking
|
|
}
|
|
|
|
// Return success message
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<div class="alert alert-success">Racer added successfully!</div>`))
|
|
}
|
|
}
|
|
|
|
// handleUpdateRacer updates a racer
|
|
func (s *Server) handleUpdateRacer() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse form
|
|
if err := r.ParseForm(); err != nil {
|
|
s.logger.Error("Failed to parse form", "error", err)
|
|
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get form values
|
|
firstName := r.FormValue("first_name")
|
|
lastName := r.FormValue("last_name")
|
|
carNumber := r.FormValue("car_number")
|
|
carWeightStr := r.FormValue("car_weight")
|
|
groupIDStr := r.FormValue("group_id")
|
|
|
|
// Validate
|
|
if firstName == "" || lastName == "" || carNumber == "" || carWeightStr == "" || groupIDStr == "" {
|
|
http.Error(w, "All fields are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse numeric values
|
|
carWeight, err := strconv.ParseFloat(carWeightStr, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid car weight", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update racer
|
|
if err := s.db.UpdateRacer(id, firstName, lastName, carNumber, carWeight, groupID); err != nil {
|
|
s.logger.Error("Failed to update racer", "error", err)
|
|
http.Error(w, "Failed to update racer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"id":%d}`, id)
|
|
}
|
|
}
|
|
|
|
// handleDeleteRacer deletes a racer
|
|
func (s *Server) handleDeleteRacer() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get ID from URL
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Delete racer
|
|
if err := s.db.DeleteRacer(id); err != nil {
|
|
s.logger.Error("Failed to delete racer", "error", err)
|
|
http.Error(w, "Failed to delete racer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return success
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"success":true}`)
|
|
}
|
|
}
|
|
|
|
// handleHeats renders the heats page
|
|
func (s *Server) handleHeats() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get groups from database
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get selected group ID from query parameter
|
|
selectedGroupID := int64(0)
|
|
groupIDStr := r.URL.Query().Get("group_id")
|
|
if groupIDStr != "" {
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err == nil {
|
|
selectedGroupID = groupID
|
|
}
|
|
}
|
|
|
|
// Render template
|
|
component := templates.Heats(groups, selectedGroupID, s.db)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render heats template", "error", err)
|
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleHeatsContent renders the heats content
|
|
func (s *Server) handleHeatsContent() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get selected group ID from query parameter
|
|
groupIDStr := r.URL.Query().Get("group_id")
|
|
if groupIDStr == "" {
|
|
http.Error(w, "Group ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid group ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check for saved heats
|
|
hasSavedHeats, err := s.db.HasSavedHeats(groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check for saved heats", "error", err)
|
|
http.Error(w, "Failed to check for saved heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var heats []models.Heat
|
|
if hasSavedHeats {
|
|
// Get saved heats
|
|
heats, err = s.db.GetHeats(groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get saved heats", "error", err)
|
|
http.Error(w, "Failed to get saved heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get racers for the group
|
|
racers, err := s.db.GetRacersByGroup(groupID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert models.Racer to derby.Racer
|
|
derbyRacers := make([]derby.Racer, len(racers))
|
|
for i, r := range racers {
|
|
derbyRacers[i] = derby.Racer{
|
|
ID: r.ID,
|
|
FirstName: r.FirstName,
|
|
LastName: r.LastName,
|
|
CarNumber: r.CarNumber,
|
|
}
|
|
}
|
|
|
|
// Generate and convert heats
|
|
if !hasSavedHeats && len(racers) > 0 {
|
|
derbyHeats := derby.GenerateHeats(derbyRacers)
|
|
heats = make([]models.Heat, len(derbyHeats))
|
|
for i, h := range derbyHeats {
|
|
heats[i] = models.Heat{
|
|
HeatNum: i + 1,
|
|
Lane1ID: *h.Lane1ID,
|
|
Lane2ID: *h.Lane2ID,
|
|
Lane3ID: *h.Lane3ID,
|
|
Lane4ID: *h.Lane4ID,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render template
|
|
component := templates.HeatsContent(heats, racers)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render heats content", "error", err)
|
|
http.Error(w, "Failed to render content", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleSaveHeats saves heats for a group
|
|
func (s *Server) handleSaveHeats() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Parse request body
|
|
var request struct {
|
|
GroupID int64 `json:"group_id"`
|
|
Heats []models.Heat `json:"heats"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Save heats
|
|
if err := s.db.SaveHeats(request.GroupID, request.Heats); err != nil {
|
|
s.logger.Error("Failed to save heats", "error", err)
|
|
http.Error(w, "Failed to save heats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
}
|
|
|
|
// handleRacersList renders the racers list component
|
|
func (s *Server) handleRacersList() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get racers and groups
|
|
racers, err := s.db.GetRacers()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get racers", "error", err)
|
|
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
groups, err := s.db.GetGroups()
|
|
if err != nil {
|
|
s.logger.Error("Failed to get groups", "error", err)
|
|
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Render just the racers list component
|
|
component := templates.RacersList(racers, groups)
|
|
if err := component.Render(r.Context(), w); err != nil {
|
|
s.logger.Error("Failed to render racers list", "error", err)
|
|
http.Error(w, "Failed to render racers list", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleAdminEvents handles admin events with SSE
|
|
func (s *Server) handleAdminEvents() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Set SSE headers
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
// Send events to client
|
|
for {
|
|
select {
|
|
case event := <-s.adminEvents:
|
|
fmt.Fprintf(w, "data: %s\n\n", event)
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleValidateCarNumber handles the validate car number API endpoint
|
|
func (s *Server) handleValidateCarNumber() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
carNumber := r.URL.Query().Get("car_number")
|
|
if carNumber == "" {
|
|
http.Error(w, "<div id='car-number-validation-error' class='alert alert-danger'>Car number is required</div>", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isUnique, err := s.db.IsCarNumberUnique(carNumber)
|
|
if err != nil {
|
|
s.logger.Error("Failed to check car number uniqueness", "error", err)
|
|
http.Error(w, "<div id='car-number-validation-error' class='alert alert-danger'>Failed to validate car number</div>", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if !isUnique {
|
|
w.Write([]byte("<div id='car-number-validation-error' class='alert alert-danger'>Car number is already in use</div>"))
|
|
} else {
|
|
w.Write([]byte("<div class='invalid-feedback' id='car-number-validation-error'></div>"))
|
|
}
|
|
}
|
|
}
|