home page added
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
--container-3xl: 48rem;
|
--container-3xl: 48rem;
|
||||||
--container-4xl: 56rem;
|
--container-4xl: 56rem;
|
||||||
--container-5xl: 64rem;
|
--container-5xl: 64rem;
|
||||||
|
--container-6xl: 72rem;
|
||||||
--container-7xl: 80rem;
|
--container-7xl: 80rem;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
@@ -371,9 +372,6 @@
|
|||||||
.mt-24 {
|
.mt-24 {
|
||||||
margin-top: calc(var(--spacing) * 24);
|
margin-top: calc(var(--spacing) * 24);
|
||||||
}
|
}
|
||||||
.mt-25 {
|
|
||||||
margin-top: calc(var(--spacing) * 25);
|
|
||||||
}
|
|
||||||
.mt-auto {
|
.mt-auto {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
@@ -395,6 +393,9 @@
|
|||||||
.mb-8 {
|
.mb-8 {
|
||||||
margin-bottom: calc(var(--spacing) * 8);
|
margin-bottom: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 12);
|
||||||
|
}
|
||||||
.mb-auto {
|
.mb-auto {
|
||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
}
|
}
|
||||||
@@ -582,6 +583,9 @@
|
|||||||
.max-w-5xl {
|
.max-w-5xl {
|
||||||
max-width: var(--container-5xl);
|
max-width: var(--container-5xl);
|
||||||
}
|
}
|
||||||
|
.max-w-6xl {
|
||||||
|
max-width: var(--container-6xl);
|
||||||
|
}
|
||||||
.max-w-7xl {
|
.max-w-7xl {
|
||||||
max-width: var(--container-7xl);
|
max-width: var(--container-7xl);
|
||||||
}
|
}
|
||||||
@@ -711,6 +715,9 @@
|
|||||||
.place-content-center {
|
.place-content-center {
|
||||||
place-content: center;
|
place-content: center;
|
||||||
}
|
}
|
||||||
|
.items-baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1399,6 +1406,9 @@
|
|||||||
.whitespace-pre-wrap {
|
.whitespace-pre-wrap {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
.text-base {
|
||||||
|
color: var(--base);
|
||||||
|
}
|
||||||
.text-blue {
|
.text-blue {
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
@@ -1578,6 +1588,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.group-hover\:text-blue {
|
||||||
|
&:is(:where(.group):hover *) {
|
||||||
|
@media (hover: hover) {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.group-hover\:opacity-100 {
|
.group-hover\:opacity-100 {
|
||||||
&:is(:where(.group):hover *) {
|
&:is(:where(.group):hover *) {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
|
|||||||
@@ -1,22 +1,85 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
homeview "git.haelnorr.com/h/oslstats/internal/view/homeview"
|
homeview "git.haelnorr.com/h/oslstats/internal/view/homeview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Index handles responses to the / path. Also serves a 404 Page for paths that
|
// Index handles responses to the / path. Also serves a 404 Page for paths that
|
||||||
// don't have explicit handlers
|
// don't have explicit handlers
|
||||||
func Index(s *hws.Server) http.Handler {
|
func Index(s *hws.Server, conn *db.DB) http.Handler {
|
||||||
return http.HandlerFunc(
|
return http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
renderSafely(homeview.IndexPage(), s, r, w)
|
|
||||||
|
var season *db.Season
|
||||||
|
var standings []homeview.LeagueStandings
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
// Get the most recent season
|
||||||
|
seasons, err := db.ListSeasons(ctx, tx, &db.PageOpts{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 1,
|
||||||
|
Order: bun.OrderDesc,
|
||||||
|
OrderBy: "start_date",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.ListSeasons")
|
||||||
|
}
|
||||||
|
|
||||||
|
if seasons.Total == 0 || len(seasons.Items) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
season = seasons.Items[0]
|
||||||
|
|
||||||
|
// Build leaderboards for each league in this season
|
||||||
|
standings = make([]homeview.LeagueStandings, 0, len(season.Leagues))
|
||||||
|
for _, league := range season.Leagues {
|
||||||
|
_, l, teams, err := db.GetSeasonLeagueWithTeams(ctx, tx, season.ShortName, league.ShortName)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
|
||||||
|
}
|
||||||
|
|
||||||
|
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, l.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
fixtureIDs := make([]int, len(fixtures))
|
||||||
|
for i, f := range fixtures {
|
||||||
|
fixtureIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
leaderboard := db.ComputeLeaderboard(teams, fixtures, resultMap)
|
||||||
|
|
||||||
|
standings = append(standings, homeview.LeagueStandings{
|
||||||
|
League: l,
|
||||||
|
Leaderboard: leaderboard,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(homeview.IndexPage(season, standings), s, r, w)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func addRoutes(
|
|||||||
{
|
{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Method: hws.MethodGET,
|
Method: hws.MethodGET,
|
||||||
Handler: handlers.Index(s),
|
Handler: handlers.Index(s, conn),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
internal/view/homeview/external_links.templ
Normal file
33
internal/view/homeview/external_links.templ
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package homeview
|
||||||
|
|
||||||
|
// ExternalLinks renders card tiles for external community resources
|
||||||
|
templ ExternalLinks() {
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<a
|
||||||
|
href="http://slapshot.gg/osl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition hover:cursor-pointer group"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-bold text-text group-hover:text-blue transition mb-2">
|
||||||
|
Join Our Discord
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-subtext0">
|
||||||
|
Connect with other players, find teams, and stay up to date with league announcements.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://slapshot.gg/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition hover:cursor-pointer group"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-bold text-text group-hover:text-blue transition mb-2">
|
||||||
|
Official Slapshot
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-subtext0">
|
||||||
|
Visit the official Slapshot website to learn more about the game.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
package homeview
|
package homeview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
|
||||||
// Page content for the index page
|
// Page content for the index page
|
||||||
templ IndexPage() {
|
templ IndexPage(season *db.Season, standings []LeagueStandings) {
|
||||||
@baseview.Layout("OSL Stats") {
|
@baseview.Layout("Oceanic Slapshot League") {
|
||||||
<div class="text-center mt-25">
|
<div class="max-w-screen-2xl mx-auto px-2">
|
||||||
<div class="text-4xl lg:text-6xl">OSL Stats</div>
|
<div class="mt-8 mb-12">
|
||||||
<div>Placeholder text</div>
|
<h1 class="text-5xl lg:text-6xl font-bold text-text mb-6 text-center">
|
||||||
|
Oceanic Slapshot League
|
||||||
|
</h1>
|
||||||
|
<div class="max-w-3xl mx-auto bg-surface0 border border-surface1 rounded-lg p-6">
|
||||||
|
<p class="text-base text-subtext0 leading-relaxed">
|
||||||
|
The Oceanic Slapshot League (OSL) is a community for casual and competitive play of Slapshot: Rebound.
|
||||||
|
It is managed by a small group of community members, and aims to provide a place for players in the Oceanic
|
||||||
|
region (primarily Australia and New Zealand) to compete and play in organised League competitions, as well as
|
||||||
|
casual pick-up games (RPUGs) and public matches (in-game matchmaking).
|
||||||
|
The league is open to everyone, regardless of skill level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-6xl mx-auto mb-12">
|
||||||
|
@LatestStandings(season, standings)
|
||||||
|
</div>
|
||||||
|
<div class="max-w-6xl mx-auto mb-12">
|
||||||
|
@ExternalLinks()
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
internal/view/homeview/latest_standings.templ
Normal file
151
internal/view/homeview/latest_standings.templ
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package homeview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// LeagueStandings holds the data needed to render a single league's table
|
||||||
|
type LeagueStandings struct {
|
||||||
|
League *db.League
|
||||||
|
Leaderboard []*db.LeaderboardEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestStandings renders the latest standings section with tabs to switch
|
||||||
|
// between leagues from the most recent season
|
||||||
|
templ LatestStandings(season *db.Season, standings []LeagueStandings) {
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-baseline gap-3">
|
||||||
|
<h2 class="text-2xl font-bold text-text">Latest Standings</h2>
|
||||||
|
if season != nil {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s", season.ShortName)) }
|
||||||
|
class="text-sm text-subtext0 hover:text-blue transition"
|
||||||
|
>
|
||||||
|
{ season.Name }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if season == nil || len(standings) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No standings available yet.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div x-data={ fmt.Sprintf("{ activeTab: '%s' }", standings[0].League.ShortName) }>
|
||||||
|
if len(standings) > 1 {
|
||||||
|
<div class="flex gap-1 mb-4 border-b border-surface1">
|
||||||
|
for _, s := range standings {
|
||||||
|
<button
|
||||||
|
x-on:click={ fmt.Sprintf("activeTab = '%s'", s.League.ShortName) }
|
||||||
|
class="px-4 py-2 text-sm font-medium transition hover:cursor-pointer"
|
||||||
|
x-bind:class={ fmt.Sprintf("activeTab === '%s' ? 'text-blue border-b-2 border-blue' : 'text-subtext0 hover:text-text'", s.League.ShortName) }
|
||||||
|
>
|
||||||
|
{ s.League.Name }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
for _, s := range standings {
|
||||||
|
<div x-show={ fmt.Sprintf("activeTab === '%s'", s.League.ShortName) }>
|
||||||
|
@standingsTable(season, s.League, s.Leaderboard)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ standingsTable(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
|
||||||
|
if len(leaderboard) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No teams in this league yet.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-mantle border-b border-surface1 px-4 py-2 flex items-center gap-4 text-xs text-subtext0">
|
||||||
|
<span class="font-semibold text-subtext1">Points:</span>
|
||||||
|
<span>W = { fmt.Sprint(db.PointsWin) }</span>
|
||||||
|
<span>OTW = { fmt.Sprint(db.PointsOvertimeWin) }</span>
|
||||||
|
<span>OTL = { fmt.Sprint(db.PointsOvertimeLoss) }</span>
|
||||||
|
<span>L = { fmt.Sprint(db.PointsLoss) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-text">Team</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Wins">W</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Wins">OTW</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Losses">OTL</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Losses">L</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals For">GF</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals Against">GA</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goal Differential">GD</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-blue" title="Points">PTS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, entry := range leaderboard {
|
||||||
|
@standingsRow(entry, season, league)
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ standingsRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) {
|
||||||
|
{{
|
||||||
|
r := entry.Record
|
||||||
|
goalDiff := r.GoalsFor - r.GoalsAgainst
|
||||||
|
var gdStr string
|
||||||
|
if goalDiff > 0 {
|
||||||
|
gdStr = fmt.Sprintf("+%d", goalDiff)
|
||||||
|
} else {
|
||||||
|
gdStr = fmt.Sprint(goalDiff)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-3 py-3 text-center text-sm font-medium text-subtext0">
|
||||||
|
{ fmt.Sprint(entry.Position) }
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@links.TeamLinkInSeason(entry.Team, season, league)
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-subtext0">
|
||||||
|
{ fmt.Sprint(r.Played) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-green">
|
||||||
|
{ fmt.Sprint(r.Wins) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-teal">
|
||||||
|
{ fmt.Sprint(r.OvertimeWins) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-peach">
|
||||||
|
{ fmt.Sprint(r.OvertimeLosses) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-red">
|
||||||
|
{ fmt.Sprint(r.Losses) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-text">
|
||||||
|
{ fmt.Sprint(r.GoalsFor) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-text">
|
||||||
|
{ fmt.Sprint(r.GoalsAgainst) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm">
|
||||||
|
if goalDiff > 0 {
|
||||||
|
<span class="text-green">{ gdStr }</span>
|
||||||
|
} else if goalDiff < 0 {
|
||||||
|
<span class="text-red">{ gdStr }</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-subtext0">{ gdStr }</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm font-bold text-blue">
|
||||||
|
{ fmt.Sprint(r.Points) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user