Compare commits

...

9 Commits

15 changed files with 505 additions and 308 deletions

View File

@@ -14,6 +14,7 @@ type Flags struct {
GenEnv string GenEnv string
EnvFile string EnvFile string
DevMode bool DevMode bool
Staging bool
// Database reset (destructive) // Database reset (destructive)
ResetDB bool ResetDB bool
@@ -36,6 +37,7 @@ func SetupFlags() (*Flags, error) {
genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)") genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration") envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration")
devMode := flag.Bool("dev", false, "Run the server in dev mode") devMode := flag.Bool("dev", false, "Run the server in dev mode")
staging := flag.Bool("staging", false, "Show a staging banner")
// Database reset (destructive) // Database reset (destructive)
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)") resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
@@ -92,6 +94,7 @@ func SetupFlags() (*Flags, error) {
GenEnv: *genEnv, GenEnv: *genEnv,
EnvFile: *envfile, EnvFile: *envfile,
DevMode: *devMode, DevMode: *devMode,
Staging: *staging,
ResetDB: *resetDB, ResetDB: *resetDB,
MigrateUp: *migrateUp, MigrateUp: *migrateUp,
MigrateRollback: *migrateRollback, MigrateRollback: *migrateRollback,

View File

@@ -13,4 +13,5 @@ func DevMode(ctx context.Context) DevInfo {
type DevInfo struct { type DevInfo struct {
WebsocketBase string WebsocketBase string
HTMXLog bool HTMXLog bool
StagingBanner bool
} }

View File

@@ -129,72 +129,29 @@
} }
/* Custom Scrollbar Styles - Catppuccin Theme */ /* Custom Scrollbar Styles - Catppuccin Theme */
/* Only applied to specific elements (viewport, dropdowns, modals) to avoid
overriding the browser default scrollbar on the html/body level */
/* Firefox */ /* Main content viewport */
* { #page-viewport {
--scrollbar-thumb: var(--overlay0);
--scrollbar-track: var(--base);
scrollbar-width: normal;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* Multi-select dropdowns */
.multi-select-dropdown {
--scrollbar-thumb: var(--surface2);
--scrollbar-track: var(--base);
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--surface1) var(--mantle); scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
} }
/* Webkit browsers (Chrome, Safari, Edge) */ /* Modal content */
::-webkit-scrollbar { .modal-scrollable {
width: 8px; --scrollbar-thumb: var(--surface1);
height: 8px; --scrollbar-track: var(--base);
} scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
::-webkit-scrollbar-track {
background: var(--mantle);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
border: 2px solid var(--mantle);
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
}
::-webkit-scrollbar-thumb:active {
background: var(--overlay0);
}
/* Specific styling for multi-select dropdowns */
.multi-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.multi-select-dropdown::-webkit-scrollbar-track {
background: var(--base);
border-radius: 3px;
}
.multi-select-dropdown::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 3px;
border: 1px solid var(--base);
}
.multi-select-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--overlay0);
}
/* Specific styling for modal content */
.modal-scrollable::-webkit-scrollbar {
width: 8px;
}
.modal-scrollable::-webkit-scrollbar-track {
background: var(--base);
}
.modal-scrollable::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
}
.modal-scrollable::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
} }

View File

@@ -341,9 +341,6 @@
.mt-1 { .mt-1 {
margin-top: calc(var(--spacing) * 1); margin-top: calc(var(--spacing) * 1);
} }
.mt-1\.5 {
margin-top: calc(var(--spacing) * 1.5);
}
.mt-2 { .mt-2 {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }
@@ -570,9 +567,6 @@
.w-80 { .w-80 {
width: calc(var(--spacing) * 80); width: calc(var(--spacing) * 80);
} }
.w-fit {
width: fit-content;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@@ -726,6 +720,9 @@
.items-start { .items-start {
align-items: flex-start; align-items: flex-start;
} }
.items-stretch {
align-items: stretch;
}
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
@@ -2322,9 +2319,9 @@
display: flex; display: flex;
} }
} }
.lg\:inline { .lg\:w-auto {
@media (width >= 64rem) { @media (width >= 64rem) {
display: inline; width: auto;
} }
} }
.lg\:grid-cols-2 { .lg\:grid-cols-2 {
@@ -2347,11 +2344,6 @@
align-items: flex-end; align-items: flex-end;
} }
} }
.lg\:items-start {
@media (width >= 64rem) {
align-items: flex-start;
}
}
.lg\:justify-between { .lg\:justify-between {
@media (width >= 64rem) { @media (width >= 64rem) {
justify-content: space-between; justify-content: space-between;
@@ -2471,56 +2463,23 @@
font-weight: 700; font-weight: 700;
font-style: italic; font-style: italic;
} }
* { #page-viewport {
--scrollbar-thumb: var(--overlay0);
--scrollbar-track: var(--base);
scrollbar-width: normal;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.multi-select-dropdown {
--scrollbar-thumb: var(--surface2);
--scrollbar-track: var(--base);
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--surface1) var(--mantle); scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
} }
::-webkit-scrollbar { .modal-scrollable {
width: 8px; --scrollbar-thumb: var(--surface1);
height: 8px; --scrollbar-track: var(--base);
} scrollbar-width: thin;
::-webkit-scrollbar-track { scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
background: var(--mantle);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
border: 2px solid var(--mantle);
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
}
::-webkit-scrollbar-thumb:active {
background: var(--overlay0);
}
.multi-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.multi-select-dropdown::-webkit-scrollbar-track {
background: var(--base);
border-radius: 3px;
}
.multi-select-dropdown::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 3px;
border: 1px solid var(--base);
}
.multi-select-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--overlay0);
}
.modal-scrollable::-webkit-scrollbar {
width: 8px;
}
.modal-scrollable::-webkit-scrollbar-track {
background: var(--base);
}
.modal-scrollable::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
}
.modal-scrollable::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
} }
@property --tw-translate-x { @property --tw-translate-x {
syntax: "*"; syntax: "*";

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -20,8 +21,7 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
// FixtureDetailPage renders the fixture detail page with scheduling UI, history, // FixtureDetailPage redirects to the default tab (overview)
// result display, and team rosters
func FixtureDetailPage( func FixtureDetailPage(
s *hws.Server, s *hws.Server,
conn *db.DB, conn *db.DB,
@@ -33,9 +33,230 @@ func FixtureDetailPage(
return return
} }
activeTab := r.URL.Query().Get("tab") http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
if activeTab == "" { })
activeTab = "overview" }
// FixtureDetailOverviewPage renders the overview tab of the fixture detail page
func FixtureDetailOverviewPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var currentSchedule *db.FixtureSchedule
var canSchedule bool
var userTeamID int
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
currentSchedule, err = db.GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetCurrentFixtureSchedule")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err = fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailOverviewPage(
fixture, currentSchedule, canSchedule, userTeamID,
result, rosters, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailOverviewContent(
fixture, currentSchedule, canSchedule, userTeamID,
result, rosters, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
}
})
}
// FixtureDetailPreviewPage renders the match preview tab of the fixture detail page
func FixtureDetailPreviewPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
return true, nil
}); !ok {
return
}
// If finalized, redirect to analysis instead
if result != nil && result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/analysis", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/analysis", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailPreviewPage(
fixture, result, rosters, previewData,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailPreviewContent(
fixture, rosters, previewData,
), s, r, w)
}
})
}
// FixtureDetailAnalysisPage renders the match analysis tab of the fixture detail page
func FixtureDetailAnalysisPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
return true, nil
}); !ok {
return
}
// If not finalized, redirect to preview instead
if result == nil || !result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/preview", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/preview", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailAnalysisPage(
fixture, result, rosters, previewData,
), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailAnalysisContent(
fixture, result, rosters, previewData,
), s, r, w)
}
})
}
// FixtureDetailSchedulePage renders the schedule tab of the fixture detail page
func FixtureDetailSchedulePage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
} }
var fixture *db.Fixture var fixture *db.Fixture
@@ -44,10 +265,6 @@ func FixtureDetailPage(
var canSchedule bool var canSchedule bool
var userTeamID int var userTeamID int
var result *db.FixtureResult var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent
var previewData *db.MatchPreviewData
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error var err error
@@ -72,48 +289,34 @@ func FixtureDetailPage(
if err != nil { if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule") return false, errors.Wrap(err, "fixture.CanSchedule")
} }
// Fetch fixture result if it exists
result, err = db.GetFixtureResult(ctx, tx, fixtureID) result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult") return false, errors.Wrap(err, "db.GetFixtureResult")
} }
// Fetch team rosters with play status
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
// Fetch free agent nominations for this fixture
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
// Fetch available free agents for nomination (if user can schedule or manage fixtures)
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
}
// Fetch match preview data for preview and analysis tabs
if activeTab == "preview" || activeTab == "analysis" {
previewData, err = db.ComputeMatchPreview(ctx, tx, fixture)
if err != nil {
return false, errors.Wrap(err, "db.ComputeMatchPreview")
}
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
renderSafely(seasonsview.FixtureDetailPage( // If finalized, redirect to overview (scheduling tab is hidden)
if result != nil && result.Finalized {
if r.Method == "GET" {
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
} else {
respond.HXRedirect(w, "/fixtures/%d/overview", fixtureID)
}
return
}
if r.Method == "GET" {
renderSafely(seasonsview.FixtureDetailSchedulePage(
fixture, currentSchedule, history, canSchedule, userTeamID, fixture, currentSchedule, history, canSchedule, userTeamID,
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
previewData,
), s, r, w) ), s, r, w)
} else {
renderSafely(seasonsview.FixtureDetailScheduleContent(
fixture, currentSchedule, history, canSchedule, userTeamID,
), s, r, w)
}
}) })
} }

View File

@@ -54,14 +54,14 @@ func Register(
store.ClearRedirectTrack(r, "/register") store.ClearRedirectTrack(r, "/register")
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(authview.RegisterPage(details.DiscordUser.Username), s, r, w) renderSafely(authview.RegisterPage(""), s, r, w)
return return
} }
username := r.FormValue("username") username := r.FormValue("username")
unique := false unique := false
var user *db.User var user *db.User
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user) audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username) unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.IsUsernameUnique") return false, errors.Wrap(err, "db.IsUsernameUnique")
@@ -79,6 +79,7 @@ func Register(
} }
return true, nil return true, nil
}); !ok { }); !ok {
throw.InternalServiceError(s, w, r, "Registration failed", err)
return return
} }
if !unique { if !unique {

View File

@@ -44,17 +44,21 @@ func addMiddleware(
func devMode(cfg *config.Config) hws.Middleware { func devMode(cfg *config.Config) hws.Middleware {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.Flags.DevMode && !cfg.Flags.Staging {
next.ServeHTTP(w, r)
return
}
devInfo := contexts.DevInfo{}
if cfg.Flags.DevMode { if cfg.Flags.DevMode {
devInfo := contexts.DevInfo{ devInfo.WebsocketBase = "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10)
WebsocketBase: "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10), devInfo.HTMXLog = true
HTMXLog: true, }
if cfg.Flags.Staging {
devInfo.StagingBanner = true
} }
ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo) ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo)
req := r.WithContext(ctx) req := r.WithContext(ctx)
next.ServeHTTP(w, req) next.ServeHTTP(w, req)
return
}
next.ServeHTTP(w, r)
}, },
) )
} }

View File

@@ -219,6 +219,27 @@ func addRoutes(
Method: hws.MethodDELETE, Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)), Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
}, },
// Fixture detail tab routes
{
Path: "/fixtures/{fixture_id}/overview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailOverviewPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/preview",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailPreviewPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/analysis",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailAnalysisPage(s, conn),
},
{
Path: "/fixtures/{fixture_id}/scheduling",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.FixtureDetailSchedulePage(s, conn),
},
// Fixture scheduling routes // Fixture scheduling routes
{ {
Path: "/fixtures/{fixture_id}/schedule", Path: "/fixtures/{fixture_id}/schedule",

View File

@@ -29,14 +29,14 @@ templ RegisterFormForm(username string) {
}, },
handleSubmit() { handleSubmit() {
this.isSubmitting = true; this.isSubmitting = true;
this.buttontext = 'Loading...'; this.buttontext = "Loading...";
// Set timeout for 10 seconds // Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => { this.submitTimeout = setTimeout(() => {
this.isSubmitting = false; this.isSubmitting = false;
this.buttontext = 'Register'; this.buttontext = "Register";
this.errorMessage = 'Request timed out. Please try again.'; this.errorMessage = "Request timed out. Please try again.";
}, 10000); }, 10000);
} },
}; };
} }
</script> </script>
@@ -60,12 +60,11 @@ templ RegisterFormForm(username string) {
value={ username } value={ username }
@input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }" @input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }"
hx-post="/htmx/isusernameunique" hx-post="/htmx/isusernameunique"
hx-trigger="load delay:100ms, input changed delay:500ms" hx-trigger="input changed delay:500ms"
hx-swap="none" hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { isEmpty=true; return; } isEmpty=false; isChecking=true; isUnique=false; errorMessage=''" @htmx:before-request="if($el.value.trim() === '') { isEmpty=true; return; } isEmpty=false; isChecking=true; isUnique=false; errorMessage=''"
@htmx:after-request="isChecking=false; if($event.detail.successful) { isUnique=true; canSubmit=true; } else if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; canSubmit=false; }" @htmx:after-request="isChecking=false; if($event.detail.successful) { isUnique=true; canSubmit=true; } else if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; canSubmit=false; }"
/> />
<p <p
class="text-center text-xs text-red mt-2" class="text-center text-xs text-red mt-2"
id="username-error" id="username-error"

View File

@@ -15,7 +15,7 @@ func getFooterItems() []FooterItem {
// Returns the template fragment for the Footer // Returns the template fragment for the Footer
templ Footer() { templ Footer() {
<footer class="bg-mantle mt-10"> <footer class="bg-mantle mt-10">
<div class="relative mx-auto max-w-screen-xl px-4 py-8 sm:px-6 lg:px-8"> <div class="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
@backToTopButton() @backToTopButton()
<div class="lg:flex lg:items-end lg:justify-between"> <div class="lg:flex lg:items-end lg:justify-between">
@footerBranding() @footerBranding()
@@ -23,7 +23,6 @@ templ Footer() {
</div> </div>
<div class="lg:flex lg:items-end lg:justify-between"> <div class="lg:flex lg:items-end lg:justify-between">
@footerCopyright() @footerCopyright()
@themeSelector()
</div> </div>
</div> </div>
</footer> </footer>
@@ -31,10 +30,10 @@ templ Footer() {
templ backToTopButton() { templ backToTopButton() {
<div class="absolute end-4 top-4 sm:end-6 lg:end-8"> <div class="absolute end-4 top-4 sm:end-6 lg:end-8">
<a <button
class="inline-block rounded-full bg-teal p-2 text-crust class="inline-block rounded-full bg-teal p-2 text-crust
shadow-sm transition hover:bg-teal/75" shadow-sm transition hover:bg-teal/75 hover:cursor-pointer"
href="#main-content" onclick="document.getElementById('page-viewport').scrollTo({ top: 0, behavior: 'smooth' })"
> >
<span class="sr-only">Back to top</span> <span class="sr-only">Back to top</span>
<svg <svg
@@ -51,7 +50,7 @@ templ backToTopButton() {
clip-rule="evenodd" clip-rule="evenodd"
></path> ></path>
</svg> </svg>
</a> </button>
</div> </div>
} }
@@ -91,38 +90,3 @@ templ footerCopyright() {
</p> </p>
</div> </div>
} }
templ themeSelector() {
<div>
<div class="mt-2 text-center">
<label for="theme-select" class="hidden lg:inline">Theme</label>
<select
name="ThemeSelect"
id="theme-select"
class="mt-1.5 inline rounded-lg bg-surface0 p-2 w-fit"
x-model="theme"
>
<template
x-for="themeopt in [
'dark',
'light',
'system',
]"
>
<option
x-text="displayThemeName(themeopt)"
:value="themeopt"
:selected="theme === themeopt"
></option>
</template>
</select>
<script>
const displayThemeName = (value) => {
if (value === "dark") return "Dark (Mocha)";
if (value === "light") return "Light (Latte)";
if (value === "system") return "System";
};
</script>
</div>
</div>
}

View File

@@ -11,13 +11,8 @@ templ Layout(title string) {
<!DOCTYPE html> <!DOCTYPE html>
<html <html
lang="en" lang="en"
x-data="{ theme: localStorage.getItem('theme') || 'system'}"
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
> >
<head> <head>
<script src="/static/js/theme.js"></script>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title> <title>{ title }</title>
@@ -34,7 +29,7 @@ templ Layout(title string) {
} }
</head> </head>
<body <body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden" class="bg-base dark text-text ubuntu-mono-regular overflow-hidden h-screen"
hx-ext="ws" hx-ext="ws"
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" } ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
> >
@@ -43,21 +38,34 @@ templ Layout(title string) {
@popup.ConfirmModal() @popup.ConfirmModal()
<div <div
id="main-content" id="main-content"
class="flex flex-col h-screen justify-between" class="flex flex-col h-screen"
> >
if devInfo.StagingBanner {
@stagingBanner()
}
@Navbar() @Navbar()
if previewRole != nil { if previewRole != nil {
@previewModeBanner(previewRole) @previewModeBanner(previewRole)
} }
<div id="page-content" class="mb-auto md:px-5 md:pt-5"> <div id="page-viewport" class="flex-1 overflow-y-auto overflow-x-hidden">
<div id="page-content" class="min-h-full flex flex-col justify-between">
<div class="mb-auto md:px-5 md:pt-5">
{ children... } { children... }
</div> </div>
@Footer() @Footer()
</div> </div>
</div>
</div>
</body> </body>
</html> </html>
} }
templ stagingBanner() {
<div class="bg-peach text-crust text-center text-xs font-bold py-1 tracking-wider uppercase">
Staging Environment - For Testing Only
</div>
}
// Preview mode banner (private helper) // Preview mode banner (private helper)
templ previewModeBanner(previewRole *db.Role) { templ previewModeBanner(previewRole *db.Role) {
<div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3"> <div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3">

View File

@@ -240,6 +240,7 @@ templ mobileNav(navItems []NavItem, user *db.User) {
<div <div
x-show="open" x-show="open"
x-transition x-transition
@click.outside="open = false"
class="absolute w-full bg-mantle sm:hidden z-10" class="absolute w-full bg-mantle sm:hidden z-10"
> >
<div class="px-4 py-6"> <div class="px-4 py-6">

View File

@@ -9,39 +9,12 @@ import "fmt"
import "sort" import "sort"
import "strings" import "strings"
templ FixtureDetailPage( // FixtureDetailLayout renders the fixture detail page layout with header and
fixture *db.Fixture, // tab navigation. Tab content is rendered as children.
currentSchedule *db.FixtureSchedule, templ FixtureDetailLayout(activeTab string, fixture *db.Fixture, result *db.FixtureResult) {
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
activeTab string,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
previewData *db.MatchPreviewData,
) {
{{ {{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName) backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
isFinalized := result != nil && result.Finalized isFinalized := result != nil && result.Finalized
if activeTab == "" {
activeTab = "overview"
}
// Force overview if schedule tab is hidden (result finalized)
if isFinalized && activeTab == "schedule" {
activeTab = "overview"
}
// Redirect preview → analysis once finalized
if isFinalized && activeTab == "preview" {
activeTab = "analysis"
}
// Redirect analysis → preview if not finalized
if !isFinalized && activeTab == "analysis" {
activeTab = "preview"
}
}} }}
@baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) { @baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
<div class="max-w-screen-lg mx-auto px-4 py-8"> <div class="max-w-screen-lg mx-auto px-4 py-8">
@@ -81,29 +54,24 @@ templ FixtureDetailPage(
</div> </div>
</div> </div>
<!-- Tab Navigation --> <!-- Tab Navigation -->
<nav class="bg-surface0 border-b border-surface1"> <nav class="bg-surface0 border-b border-surface1" data-tab-nav="fixture-detail-content">
<ul class="flex flex-wrap"> <ul class="flex flex-wrap">
@fixtureTabItem("overview", "Overview", activeTab, fixture) @fixtureTabItem("overview", "Overview", activeTab, fixture)
if isFinalized { if isFinalized {
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture) @fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
} else { } else {
@fixtureTabItem("preview", "Match Preview", activeTab, fixture) @fixtureTabItem("preview", "Match Preview", activeTab, fixture)
@fixtureTabItem("schedule", "Schedule", activeTab, fixture) @fixtureTabItem("scheduling", "Schedule", activeTab, fixture)
} }
</ul> </ul>
</nav> </nav>
</div> </div>
<!-- Tab Content --> <!-- Content Area -->
if activeTab == "overview" { <main id="fixture-detail-content">
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents) { children... }
} else if activeTab == "preview" && previewData != nil { </main>
@fixtureMatchPreviewTab(fixture, rosters, previewData)
} else if activeTab == "analysis" && result != nil && result.Finalized {
@fixtureMatchAnalysisTab(fixture, result, rosters, previewData)
} else if activeTab == "schedule" {
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
}
</div> </div>
<script src="/static/js/tabs.js" defer></script>
} }
} }
@@ -113,14 +81,15 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2" baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
activeClasses := "border-blue text-blue font-semibold" activeClasses := "border-blue text-blue font-semibold"
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2" inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
url := fmt.Sprintf("/fixtures/%d", fixture.ID) url := fmt.Sprintf("/fixtures/%d/%s", fixture.ID, section)
if section != "overview" {
url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section)
}
}} }}
<li class="inline-block"> <li class="inline-block">
<a <a
href={ templ.SafeURL(url) } href={ templ.SafeURL(url) }
hx-post={ url }
hx-target="#fixture-detail-content"
hx-swap="innerHTML"
hx-push-url={ url }
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) } class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
> >
{ label } { label }
@@ -128,6 +97,107 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
</li> </li>
} }
// ==================== Full page wrappers (for GET requests / direct navigation) ====================
templ FixtureDetailOverviewPage(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
@FixtureDetailLayout("overview", fixture, result) {
@FixtureDetailOverviewContent(fixture, currentSchedule, canSchedule, userTeamID, result, rosters, nominatedFreeAgents, availableFreeAgents)
}
}
templ FixtureDetailPreviewPage(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@FixtureDetailLayout("preview", fixture, result) {
@FixtureDetailPreviewContent(fixture, rosters, previewData)
}
}
templ FixtureDetailAnalysisPage(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@FixtureDetailLayout("analysis", fixture, result) {
@FixtureDetailAnalysisContent(fixture, result, rosters, previewData)
}
}
templ FixtureDetailSchedulePage(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
) {
@FixtureDetailLayout("scheduling", fixture, nil) {
@FixtureDetailScheduleContent(fixture, currentSchedule, history, canSchedule, userTeamID)
}
}
// ==================== Tab content components (for POST requests / HTMX swaps) ====================
templ FixtureDetailOverviewContent(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
canSchedule bool,
userTeamID int,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
}}
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
}
templ FixtureDetailPreviewContent(
fixture *db.Fixture,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@fixtureMatchPreviewTab(fixture, rosters, previewData)
}
templ FixtureDetailAnalysisContent(
fixture *db.Fixture,
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
previewData *db.MatchPreviewData,
) {
@fixtureMatchAnalysisTab(fixture, result, rosters, previewData)
}
templ FixtureDetailScheduleContent(
fixture *db.Fixture,
currentSchedule *db.FixtureSchedule,
history []*db.FixtureSchedule,
canSchedule bool,
userTeamID int,
) {
{{
permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
}}
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
}
// ==================== Overview Tab ==================== // ==================== Overview Tab ====================
templ fixtureOverviewTab( templ fixtureOverviewTab(
fixture *db.Fixture, fixture *db.Fixture,

View File

@@ -38,9 +38,9 @@ templ SeasonLeagueStats(
<div class="space-y-4"> <div class="space-y-4">
<h2 class="text-xl font-bold text-text text-center">Trophy Leaders</h2> <h2 class="text-xl font-bold text-text text-center">Trophy Leaders</h2>
<!-- Triangle layout: two side-by-side on wide screens, saves centered below --> <!-- Triangle layout: two side-by-side on wide screens, saves centered below -->
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6 w-full">
<!-- Top row: Goals and Assists side by side when room allows --> <!-- Top row: Goals and Assists side by side when room allows -->
<div class="flex flex-col lg:flex-row gap-6 justify-center items-center lg:items-start"> <div class="flex flex-col lg:flex-row gap-6 justify-center items-stretch w-full lg:w-auto">
@topGoalScorersTable(season, league, topGoals) @topGoalScorersTable(season, league, topGoals)
@topAssistersTable(season, league, topAssists) @topAssistersTable(season, league, topAssists)
</div> </div>
@@ -61,7 +61,7 @@ templ SeasonLeagueStats(
} }
templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) { templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.LeagueTopGoalScorer) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"> <div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden min-w-0 w-full lg:w-auto">
<div class="bg-mantle border-b border-surface1 px-4 py-3"> <div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text"> <h3 class="text-sm font-semibold text-text">
Top Goal Scorers Top Goal Scorers
@@ -79,7 +79,8 @@ templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.Leag
<p class="text-subtext0 text-sm">No goal data available yet.</p> <p class="text-subtext0 text-sm">No goal data available yet.</p>
</div> </div>
} else { } else {
<table> <div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1"> <thead class="bg-mantle border-b border-surface1">
<tr> <tr>
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th> <th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
@@ -109,12 +110,13 @@ templ topGoalScorersTable(season *db.Season, league *db.League, goals []*db.Leag
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</div> </div>
} }
templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) { templ topAssistersTable(season *db.Season, league *db.League, assists []*db.LeagueTopAssister) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"> <div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden min-w-0 w-full lg:w-auto">
<div class="bg-mantle border-b border-surface1 px-4 py-3"> <div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text"> <h3 class="text-sm font-semibold text-text">
Top Assisters Top Assisters
@@ -132,7 +134,8 @@ templ topAssistersTable(season *db.Season, league *db.League, assists []*db.Leag
<p class="text-subtext0 text-sm">No assist data available yet.</p> <p class="text-subtext0 text-sm">No assist data available yet.</p>
</div> </div>
} else { } else {
<table> <div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1"> <thead class="bg-mantle border-b border-surface1">
<tr> <tr>
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th> <th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
@@ -162,12 +165,13 @@ templ topAssistersTable(season *db.Season, league *db.League, assists []*db.Leag
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</div> </div>
} }
templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) { templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTopSaver) {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"> <div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden min-w-0 w-full lg:w-auto">
<div class="bg-mantle border-b border-surface1 px-4 py-3"> <div class="bg-mantle border-b border-surface1 px-4 py-3">
<h3 class="text-sm font-semibold text-text"> <h3 class="text-sm font-semibold text-text">
Top Saves Top Saves
@@ -185,7 +189,8 @@ templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTop
<p class="text-subtext0 text-sm">No save data available yet.</p> <p class="text-subtext0 text-sm">No save data available yet.</p>
</div> </div>
} else { } else {
<table> <div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1"> <thead class="bg-mantle border-b border-surface1">
<tr> <tr>
<th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th> <th class="px-3 py-2 text-center text-xs font-semibold text-subtext0 w-10">#</th>
@@ -215,6 +220,7 @@ templ topSaversTable(season *db.Season, league *db.League, saves []*db.LeagueTop
} }
</tbody> </tbody>
</table> </table>
</div>
} }
</div> </div>
} }

View File

@@ -14,7 +14,7 @@ default:
# Build the target binary # Build the target binary
[group('build')] [group('build')]
build target=entrypoint: tailwind (_build target) build target=entrypoint: templ tailwind (_build target)
_build target=entrypoint: tidy (generate target) _build target=entrypoint: tidy (generate target)
go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}} go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}}