Compare commits
9 Commits
62aa9ee629
...
d6dd2b06c8
| Author | SHA1 | Date | |
|---|---|---|---|
| d6dd2b06c8 | |||
| eaaa62de43 | |||
| a1ee591e6f | |||
| c2b047723f | |||
| e27d953c2a | |||
| f8031f0523 | |||
| d77cfa1c15 | |||
| ecae24e73e | |||
| d0c426216d |
@@ -14,6 +14,7 @@ type Flags struct {
|
||||
GenEnv string
|
||||
EnvFile string
|
||||
DevMode bool
|
||||
Staging bool
|
||||
|
||||
// Database reset (destructive)
|
||||
ResetDB bool
|
||||
@@ -36,6 +37,7 @@ func SetupFlags() (*Flags, error) {
|
||||
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")
|
||||
devMode := flag.Bool("dev", false, "Run the server in dev mode")
|
||||
staging := flag.Bool("staging", false, "Show a staging banner")
|
||||
|
||||
// Database reset (destructive)
|
||||
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
|
||||
@@ -92,6 +94,7 @@ func SetupFlags() (*Flags, error) {
|
||||
GenEnv: *genEnv,
|
||||
EnvFile: *envfile,
|
||||
DevMode: *devMode,
|
||||
Staging: *staging,
|
||||
ResetDB: *resetDB,
|
||||
MigrateUp: *migrateUp,
|
||||
MigrateRollback: *migrateRollback,
|
||||
|
||||
@@ -13,4 +13,5 @@ func DevMode(ctx context.Context) DevInfo {
|
||||
type DevInfo struct {
|
||||
WebsocketBase string
|
||||
HTMXLog bool
|
||||
StagingBanner bool
|
||||
}
|
||||
|
||||
@@ -129,72 +129,29 @@
|
||||
}
|
||||
|
||||
/* 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-color: var(--surface1) var(--mantle);
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-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);
|
||||
/* Modal content */
|
||||
.modal-scrollable {
|
||||
--scrollbar-thumb: var(--surface1);
|
||||
--scrollbar-track: var(--base);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
@@ -341,9 +341,6 @@
|
||||
.mt-1 {
|
||||
margin-top: calc(var(--spacing) * 1);
|
||||
}
|
||||
.mt-1\.5 {
|
||||
margin-top: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -570,9 +567,6 @@
|
||||
.w-80 {
|
||||
width: calc(var(--spacing) * 80);
|
||||
}
|
||||
.w-fit {
|
||||
width: fit-content;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -726,6 +720,9 @@
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.items-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -2322,9 +2319,9 @@
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.lg\:inline {
|
||||
.lg\:w-auto {
|
||||
@media (width >= 64rem) {
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-2 {
|
||||
@@ -2347,11 +2344,6 @@
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
.lg\:items-start {
|
||||
@media (width >= 64rem) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
.lg\:justify-between {
|
||||
@media (width >= 64rem) {
|
||||
justify-content: space-between;
|
||||
@@ -2471,56 +2463,23 @@
|
||||
font-weight: 700;
|
||||
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-color: var(--surface1) var(--mantle);
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-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);
|
||||
}
|
||||
.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);
|
||||
.modal-scrollable {
|
||||
--scrollbar-thumb: var(--surface1);
|
||||
--scrollbar-track: var(--base);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
@property --tw-translate-x {
|
||||
syntax: "*";
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -20,8 +21,7 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
|
||||
// result display, and team rosters
|
||||
// FixtureDetailPage redirects to the default tab (overview)
|
||||
func FixtureDetailPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
@@ -33,9 +33,230 @@ func FixtureDetailPage(
|
||||
return
|
||||
}
|
||||
|
||||
activeTab := r.URL.Query().Get("tab")
|
||||
if activeTab == "" {
|
||||
activeTab = "overview"
|
||||
http.Redirect(w, r, fmt.Sprintf("/fixtures/%d/overview", fixtureID), http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -44,10 +265,6 @@ func FixtureDetailPage(
|
||||
var canSchedule bool
|
||||
var userTeamID int
|
||||
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) {
|
||||
var err error
|
||||
@@ -72,48 +289,34 @@ func FixtureDetailPage(
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||
}
|
||||
// Fetch fixture result if it exists
|
||||
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
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
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(seasonsview.FixtureDetailPage(
|
||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
|
||||
previewData,
|
||||
), s, r, w)
|
||||
// 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,
|
||||
), s, r, w)
|
||||
} else {
|
||||
renderSafely(seasonsview.FixtureDetailScheduleContent(
|
||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||
), s, r, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -54,14 +54,14 @@ func Register(
|
||||
store.ClearRedirectTrack(r, "/register")
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(authview.RegisterPage(details.DiscordUser.Username), s, r, w)
|
||||
renderSafely(authview.RegisterPage(""), s, r, w)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
unique := false
|
||||
var user *db.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)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.IsUsernameUnique")
|
||||
@@ -79,6 +79,7 @@ func Register(
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
throw.InternalServiceError(s, w, r, "Registration failed", err)
|
||||
return
|
||||
}
|
||||
if !unique {
|
||||
|
||||
@@ -44,17 +44,21 @@ func addMiddleware(
|
||||
func devMode(cfg *config.Config) hws.Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.Flags.DevMode {
|
||||
devInfo := contexts.DevInfo{
|
||||
WebsocketBase: "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10),
|
||||
HTMXLog: true,
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo)
|
||||
req := r.WithContext(ctx)
|
||||
next.ServeHTTP(w, req)
|
||||
if !cfg.Flags.DevMode && !cfg.Flags.Staging {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
devInfo := contexts.DevInfo{}
|
||||
if cfg.Flags.DevMode {
|
||||
devInfo.WebsocketBase = "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10)
|
||||
devInfo.HTMXLog = true
|
||||
}
|
||||
if cfg.Flags.Staging {
|
||||
devInfo.StagingBanner = true
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo)
|
||||
req := r.WithContext(ctx)
|
||||
next.ServeHTTP(w, req)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,6 +219,27 @@ func addRoutes(
|
||||
Method: hws.MethodDELETE,
|
||||
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
|
||||
{
|
||||
Path: "/fixtures/{fixture_id}/schedule",
|
||||
|
||||
@@ -24,19 +24,19 @@ templ RegisterFormForm(username string) {
|
||||
this.isChecking = false;
|
||||
this.isUnique = false;
|
||||
},
|
||||
enableSubmit() {
|
||||
this.canSubmit = true;
|
||||
},
|
||||
enableSubmit() {
|
||||
this.canSubmit = true;
|
||||
},
|
||||
handleSubmit() {
|
||||
this.isSubmitting = true;
|
||||
this.buttontext = 'Loading...';
|
||||
this.buttontext = "Loading...";
|
||||
// Set timeout for 10 seconds
|
||||
this.submitTimeout = setTimeout(() => {
|
||||
this.isSubmitting = false;
|
||||
this.buttontext = 'Register';
|
||||
this.errorMessage = 'Request timed out. Please try again.';
|
||||
this.buttontext = "Register";
|
||||
this.errorMessage = "Request timed out. Please try again.";
|
||||
}, 10000);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -49,7 +49,7 @@ templ RegisterFormForm(username string) {
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
x-bind:class="{
|
||||
x-bind:class="{
|
||||
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
|
||||
'border-overlay0 focus:border-blue': !isUnique && !errorMessage,
|
||||
'border-green focus:border-green': isUnique && !isChecking && !errorMessage,
|
||||
@@ -60,19 +60,18 @@ templ RegisterFormForm(username string) {
|
||||
value={ username }
|
||||
@input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }"
|
||||
hx-post="/htmx/isusernameunique"
|
||||
hx-trigger="load delay:100ms, input changed delay:500ms"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
hx-swap="none"
|
||||
@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; }"
|
||||
/>
|
||||
|
||||
<p
|
||||
class="text-center text-xs text-red mt-2"
|
||||
id="username-error"
|
||||
x-show="errorMessage && !isSubmitting"
|
||||
x-cloak
|
||||
x-text="errorMessage"
|
||||
></p>
|
||||
<p
|
||||
class="text-center text-xs text-red mt-2"
|
||||
id="username-error"
|
||||
x-show="errorMessage && !isSubmitting"
|
||||
x-cloak
|
||||
x-text="errorMessage"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -15,7 +15,7 @@ func getFooterItems() []FooterItem {
|
||||
// Returns the template fragment for the Footer
|
||||
templ Footer() {
|
||||
<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()
|
||||
<div class="lg:flex lg:items-end lg:justify-between">
|
||||
@footerBranding()
|
||||
@@ -23,7 +23,6 @@ templ Footer() {
|
||||
</div>
|
||||
<div class="lg:flex lg:items-end lg:justify-between">
|
||||
@footerCopyright()
|
||||
@themeSelector()
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -31,10 +30,10 @@ templ Footer() {
|
||||
|
||||
templ backToTopButton() {
|
||||
<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
|
||||
shadow-sm transition hover:bg-teal/75"
|
||||
href="#main-content"
|
||||
shadow-sm transition hover:bg-teal/75 hover:cursor-pointer"
|
||||
onclick="document.getElementById('page-viewport').scrollTo({ top: 0, behavior: 'smooth' })"
|
||||
>
|
||||
<span class="sr-only">Back to top</span>
|
||||
<svg
|
||||
@@ -51,7 +50,7 @@ templ backToTopButton() {
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -91,38 +90,3 @@ templ footerCopyright() {
|
||||
</p>
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -11,13 +11,8 @@ templ Layout(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
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>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{ title }</title>
|
||||
@@ -34,7 +29,7 @@ templ Layout(title string) {
|
||||
}
|
||||
</head>
|
||||
<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"
|
||||
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
|
||||
>
|
||||
@@ -43,21 +38,34 @@ templ Layout(title string) {
|
||||
@popup.ConfirmModal()
|
||||
<div
|
||||
id="main-content"
|
||||
class="flex flex-col h-screen justify-between"
|
||||
class="flex flex-col h-screen"
|
||||
>
|
||||
if devInfo.StagingBanner {
|
||||
@stagingBanner()
|
||||
}
|
||||
@Navbar()
|
||||
if previewRole != nil {
|
||||
@previewModeBanner(previewRole)
|
||||
}
|
||||
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
|
||||
{ children... }
|
||||
<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... }
|
||||
</div>
|
||||
@Footer()
|
||||
</div>
|
||||
</div>
|
||||
@Footer()
|
||||
</div>
|
||||
</body>
|
||||
</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)
|
||||
templ previewModeBanner(previewRole *db.Role) {
|
||||
<div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3">
|
||||
|
||||
@@ -240,6 +240,7 @@ templ mobileNav(navItems []NavItem, user *db.User) {
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
@click.outside="open = false"
|
||||
class="absolute w-full bg-mantle sm:hidden z-10"
|
||||
>
|
||||
<div class="px-4 py-6">
|
||||
|
||||
@@ -9,39 +9,12 @@ import "fmt"
|
||||
import "sort"
|
||||
import "strings"
|
||||
|
||||
templ FixtureDetailPage(
|
||||
fixture *db.Fixture,
|
||||
currentSchedule *db.FixtureSchedule,
|
||||
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,
|
||||
) {
|
||||
// FixtureDetailLayout renders the fixture detail page layout with header and
|
||||
// tab navigation. Tab content is rendered as children.
|
||||
templ FixtureDetailLayout(activeTab string, fixture *db.Fixture, result *db.FixtureResult) {
|
||||
{{
|
||||
permCache := contexts.Permissions(ctx)
|
||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||||
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
|
||||
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)) {
|
||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||||
@@ -81,29 +54,24 @@ templ FixtureDetailPage(
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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">
|
||||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||||
if isFinalized {
|
||||
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
|
||||
} else {
|
||||
@fixtureTabItem("preview", "Match Preview", activeTab, fixture)
|
||||
@fixtureTabItem("schedule", "Schedule", activeTab, fixture)
|
||||
@fixtureTabItem("scheduling", "Schedule", activeTab, fixture)
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
if activeTab == "overview" {
|
||||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
|
||||
} else if activeTab == "preview" && previewData != nil {
|
||||
@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)
|
||||
}
|
||||
<!-- Content Area -->
|
||||
<main id="fixture-detail-content">
|
||||
{ children... }
|
||||
</main>
|
||||
</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"
|
||||
activeClasses := "border-blue text-blue font-semibold"
|
||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||
url := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||
if section != "overview" {
|
||||
url = fmt.Sprintf("/fixtures/%d?tab=%s", fixture.ID, section)
|
||||
}
|
||||
url := fmt.Sprintf("/fixtures/%d/%s", fixture.ID, section)
|
||||
}}
|
||||
<li class="inline-block">
|
||||
<a
|
||||
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) }
|
||||
>
|
||||
{ label }
|
||||
@@ -128,6 +97,107 @@ templ fixtureTabItem(section string, label string, activeTab string, fixture *db
|
||||
</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 ====================
|
||||
templ fixtureOverviewTab(
|
||||
fixture *db.Fixture,
|
||||
|
||||
@@ -37,16 +37,16 @@ templ SeasonLeagueStats(
|
||||
if len(topGoals) > 0 || len(topAssists) > 0 || len(topSaves) > 0 {
|
||||
<div class="space-y-4">
|
||||
<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 -->
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<!-- 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">
|
||||
@topGoalScorersTable(season, league, topGoals)
|
||||
@topAssistersTable(season, league, topAssists)
|
||||
</div>
|
||||
<!-- Bottom row: Saves centered -->
|
||||
@topSaversTable(season, league, topSaves)
|
||||
<!-- Triangle layout: two side-by-side on wide screens, saves centered below -->
|
||||
<div class="flex flex-col items-center gap-6 w-full">
|
||||
<!-- Top row: Goals and Assists side by side when room allows -->
|
||||
<div class="flex flex-col lg:flex-row gap-6 justify-center items-stretch w-full lg:w-auto">
|
||||
@topGoalScorersTable(season, league, topGoals)
|
||||
@topAssistersTable(season, league, topAssists)
|
||||
</div>
|
||||
<!-- Bottom row: Saves centered -->
|
||||
@topSaversTable(season, league, topSaves)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- All Stats Section -->
|
||||
@@ -61,7 +61,7 @@ templ SeasonLeagueStats(
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 class="text-sm font-semibold text-text">
|
||||
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>
|
||||
</div>
|
||||
} else {
|
||||
<table>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 class="text-sm font-semibold text-text">
|
||||
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>
|
||||
</div>
|
||||
} else {
|
||||
<table>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 class="text-sm font-semibold text-text">
|
||||
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>
|
||||
</div>
|
||||
} else {
|
||||
<table>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-mantle border-b border-surface1">
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
2
justfile
2
justfile
@@ -14,7 +14,7 @@ default:
|
||||
|
||||
# Build the target binary
|
||||
[group('build')]
|
||||
build target=entrypoint: tailwind (_build target)
|
||||
build target=entrypoint: templ tailwind (_build target)
|
||||
|
||||
_build target=entrypoint: tidy (generate target)
|
||||
go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}}
|
||||
|
||||
Reference in New Issue
Block a user