Compare commits

...

4 Commits

Author SHA1 Message Date
93c517de47 Merge branch 'homepage' into development 2026-03-07 14:34:04 +11:00
8b33d57d59 added kofi link 2026-03-07 14:33:13 +11:00
3e0ad128a2 removed placeholders from footer 2026-03-07 14:23:05 +11:00
d530b41520 home page added 2026-03-07 14:18:06 +11:00
7 changed files with 346 additions and 16 deletions

View File

@@ -20,6 +20,7 @@
--container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem;
--container-6xl: 72rem;
--container-7xl: 80rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
@@ -266,6 +267,9 @@
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
@@ -335,6 +339,9 @@
.-mt-3 {
margin-top: calc(var(--spacing) * -3);
}
.mt-0 {
margin-top: calc(var(--spacing) * 0);
}
.mt-0\.5 {
margin-top: calc(var(--spacing) * 0.5);
}
@@ -368,12 +375,12 @@
.mt-12 {
margin-top: calc(var(--spacing) * 12);
}
.mt-16 {
margin-top: calc(var(--spacing) * 16);
}
.mt-24 {
margin-top: calc(var(--spacing) * 24);
}
.mt-25 {
margin-top: calc(var(--spacing) * 25);
}
.mt-auto {
margin-top: auto;
}
@@ -395,6 +402,9 @@
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
.mb-12 {
margin-bottom: calc(var(--spacing) * 12);
}
.mb-auto {
margin-bottom: auto;
}
@@ -582,6 +592,9 @@
.max-w-5xl {
max-width: var(--container-5xl);
}
.max-w-6xl {
max-width: var(--container-6xl);
}
.max-w-7xl {
max-width: var(--container-7xl);
}
@@ -621,12 +634,22 @@
.flex-1 {
flex: 1;
}
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.shrink-0 {
flex-shrink: 0;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -711,6 +734,9 @@
.place-content-center {
place-content: center;
}
.items-baseline {
align-items: baseline;
}
.items-center {
align-items: center;
}
@@ -732,6 +758,9 @@
.justify-end {
justify-content: flex-end;
}
.gap-0 {
gap: calc(var(--spacing) * 0);
}
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
@@ -963,6 +992,9 @@
.border-overlay0 {
border-color: var(--overlay0);
}
.border-peach {
border-color: var(--peach);
}
.border-peach\/50 {
border-color: var(--peach);
@supports (color: color-mix(in lab, red, red)) {
@@ -1080,6 +1112,9 @@
.bg-mauve {
background-color: var(--mauve);
}
.bg-overlay0 {
background-color: var(--overlay0);
}
.bg-overlay0\/10 {
background-color: var(--overlay0);
@supports (color: color-mix(in lab, red, red)) {
@@ -1227,6 +1262,9 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
@@ -1399,6 +1437,9 @@
.whitespace-pre-wrap {
white-space: pre-wrap;
}
.text-base {
color: var(--base);
}
.text-blue {
color: var(--blue);
}
@@ -1516,6 +1557,10 @@
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
@@ -1578,6 +1623,13 @@
}
}
}
.group-hover\:text-blue {
&:is(:where(.group):hover *) {
@media (hover: hover) {
color: var(--blue);
}
}
}
.group-hover\:opacity-100 {
&:is(:where(.group):hover *) {
@media (hover: hover) {
@@ -2633,6 +2685,11 @@
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-blur {
syntax: "*";
inherits: false;
@@ -2735,6 +2792,7 @@
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;

View File

@@ -1,22 +1,85 @@
package handlers
import (
"context"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
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
// 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(
func(w http.ResponseWriter, r *http.Request) {
if 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)
},
)
}

View File

@@ -39,7 +39,7 @@ func addRoutes(
{
Path: "/",
Method: hws.MethodGET,
Handler: handlers.Index(s),
Handler: handlers.Index(s, conn),
},
}

View File

@@ -26,6 +26,15 @@ templ Footer() {
</div>
</div>
</footer>
<script src="https://storage.ko-fi.com/cdn/scripts/overlay-widget.js"></script>
<script>
kofiWidgetOverlay.draw('haelnorr', {
'type': 'floating-chat',
'floating-chat.donateButton.text': 'Support me',
'floating-chat.donateButton.background-color': '#313244',
'floating-chat.donateButton.text-color': '#ccd5f3'
});
</script>
}
templ backToTopButton() {
@@ -56,12 +65,9 @@ templ backToTopButton() {
templ footerBranding() {
<div>
<div class="flex justify-center text-text lg:justify-start">
<div class="flex justify-center text-text lg:justify-start pb-4">
<span class="text-2xl">OSL Stats</span>
</div>
<p class="mx-auto max-w-md text-center leading-relaxed text-subtext0">
placeholder text
</p>
</div>
}
@@ -86,7 +92,7 @@ templ footerLinks(items []FooterItem) {
templ footerCopyright() {
<div>
<p class="mt-4 text-center text-sm text-overlay0">
by Haelnorr | placeholder text
by Haelnorr
</p>
</div>
}

View 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>
}

View File

@@ -1,13 +1,32 @@
package homeview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
// Page content for the index page
templ IndexPage() {
@baseview.Layout("OSL Stats") {
<div class="text-center mt-25">
<div class="text-4xl lg:text-6xl">OSL Stats</div>
<div>Placeholder text</div>
templ IndexPage(season *db.Season, standings []LeagueStandings) {
@baseview.Layout("Oceanic Slapshot League") {
<div class="max-w-screen-2xl mx-auto px-2">
<div class="mt-8 mb-12">
<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>
}
}

View 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>
}