716 lines
26 KiB
Plaintext
716 lines
26 KiB
Plaintext
package seasonsview
|
||
|
||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||
import "fmt"
|
||
import "sort"
|
||
import "time"
|
||
|
||
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats, position int, totalTeams int) {
|
||
{{
|
||
team := twr.Team
|
||
season := twr.Season
|
||
league := twr.League
|
||
}}
|
||
@baseview.Layout(fmt.Sprintf("%s - %s - %s", team.Name, league.Name, season.Name)) {
|
||
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||
<!-- Header Section -->
|
||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||
<div class="flex items-center gap-4">
|
||
if team.Color != "" {
|
||
<div
|
||
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
|
||
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||
></div>
|
||
}
|
||
<div>
|
||
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
|
||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||
{ team.ShortName }
|
||
</span>
|
||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||
{ team.AltShortName }
|
||
</span>
|
||
<span class="text-subtext1 text-sm">
|
||
{ season.Name } — { league.Name }
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<a
|
||
href={ templ.SafeURL(fmt.Sprintf("/teams/%d", team.ID)) }
|
||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||
bg-surface0 border border-surface1 hover:bg-surface1 text-subtext0 hover:text-text transition text-sm"
|
||
>
|
||
View All Seasons
|
||
</a>
|
||
<a
|
||
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
|
||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||
bg-surface1 hover:bg-surface2 text-text transition"
|
||
>
|
||
Back to Teams
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Content -->
|
||
<div class="bg-crust p-6">
|
||
{{
|
||
// Split fixtures into upcoming and completed
|
||
var upcoming []*db.Fixture
|
||
var completed []*db.Fixture
|
||
for _, f := range fixtures {
|
||
if _, hasResult := resultMap[f.ID]; hasResult {
|
||
completed = append(completed, f)
|
||
} else {
|
||
upcoming = append(upcoming, f)
|
||
}
|
||
}
|
||
// Sort completed by scheduled time descending (most recent first)
|
||
sort.Slice(completed, func(i, j int) bool {
|
||
ti := time.Time{}
|
||
tj := time.Time{}
|
||
if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil {
|
||
ti = *si.ScheduledTime
|
||
}
|
||
if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil {
|
||
tj = *sj.ScheduledTime
|
||
}
|
||
return ti.After(tj)
|
||
})
|
||
// Limit to 5 most recent results
|
||
recentResults := completed
|
||
if len(recentResults) > 5 {
|
||
recentResults = recentResults[:5]
|
||
}
|
||
}}
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<!-- Top Left: Team Standing -->
|
||
@teamRecordCard(record, position, totalTeams)
|
||
<!-- Top Right: Results -->
|
||
@teamResultsSection(twr.Team, recentResults, resultMap)
|
||
<!-- Bottom Left: Roster -->
|
||
@TeamRosterSection(twr, available)
|
||
<!-- Bottom Right: Upcoming -->
|
||
@teamUpcomingSection(twr.Team, upcoming, scheduleMap)
|
||
</div>
|
||
<!-- Player Stats (full width) -->
|
||
<div class="mt-6">
|
||
@playerStatsSection(playerStats)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script src="/static/vendored/sortablejs@1.15.6.min.js"></script>
|
||
}
|
||
}
|
||
|
||
// TeamRosterSection renders the roster section — exported so it can be used for HTMX swaps
|
||
templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
|
||
{{
|
||
permCache := contexts.Permissions(ctx)
|
||
canManagePlayers := permCache.HasPermission(permissions.TeamsManagePlayers)
|
||
|
||
// Build the non-manager player list for display
|
||
rosterPlayers := []*db.Player{}
|
||
for _, p := range twr.Players {
|
||
if p != nil && (twr.Manager == nil || p.ID != twr.Manager.ID) {
|
||
rosterPlayers = append(rosterPlayers, p)
|
||
}
|
||
}
|
||
hasRoster := twr.Manager != nil || len(rosterPlayers) > 0
|
||
}}
|
||
<section
|
||
id="team-roster-section"
|
||
x-data="{ showManageRosterModal: false }"
|
||
>
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-2xl font-bold text-text">Roster</h2>
|
||
if canManagePlayers {
|
||
<button
|
||
@click="showManageRosterModal = true"
|
||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||
bg-blue hover:bg-blue/80 text-mantle transition"
|
||
>
|
||
Manage Players
|
||
</button>
|
||
}
|
||
</div>
|
||
if !hasRoster {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||
<p class="text-subtext0 text-lg">No players on this roster yet.</p>
|
||
if canManagePlayers {
|
||
<p class="text-subtext1 text-sm mt-2">Click "Manage Players" to add players to this team.</p>
|
||
}
|
||
</div>
|
||
} else {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||
if twr.Manager != nil {
|
||
<div class="px-4 py-3 flex items-center justify-between">
|
||
<span class="font-medium">
|
||
@links.PlayerLink(twr.Manager)
|
||
</span>
|
||
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
|
||
★ Manager
|
||
</span>
|
||
</div>
|
||
}
|
||
for _, player := range rosterPlayers {
|
||
<div class="px-4 py-3">
|
||
@links.PlayerLink(player)
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
if canManagePlayers {
|
||
@manageRosterModal(twr, available, rosterPlayers)
|
||
}
|
||
</section>
|
||
}
|
||
|
||
templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPlayers []*db.Player) {
|
||
<div
|
||
x-show="showManageRosterModal"
|
||
@keydown.escape.window="showManageRosterModal = false"
|
||
class="fixed inset-0 z-50 overflow-y-auto"
|
||
style="display: none;"
|
||
x-data="rosterManager()"
|
||
x-init="$watch('showManageRosterModal', val => { if (val) $nextTick(() => init()) })"
|
||
>
|
||
<!-- Backdrop -->
|
||
<div
|
||
class="fixed inset-0 bg-crust/80 transition-opacity"
|
||
@click="showManageRosterModal = false"
|
||
></div>
|
||
<!-- Modal -->
|
||
<div class="flex min-h-full items-center justify-center p-4">
|
||
<div
|
||
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-4xl w-full p-6"
|
||
@click.stop
|
||
>
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h3 class="text-2xl font-bold text-text">Manage Players</h3>
|
||
<button
|
||
@click="showManageRosterModal = false"
|
||
class="text-subtext0 hover:text-text transition hover:cursor-pointer"
|
||
>
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<!-- Two column layout -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<!-- Left: Available Players -->
|
||
<div>
|
||
<h4 class="text-lg font-semibold text-text mb-3">Available Players</h4>
|
||
<input
|
||
type="text"
|
||
x-model="search"
|
||
@input="applySearch()"
|
||
placeholder="Search players..."
|
||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||
focus:border-blue focus:outline-none mb-3 text-sm"
|
||
/>
|
||
<div
|
||
id="available-players-list"
|
||
class="bg-base border border-surface1 rounded-lg p-2 min-h-48 max-h-80 overflow-y-auto space-y-1"
|
||
>
|
||
for _, player := range available {
|
||
<div
|
||
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
|
||
text-text text-sm cursor-grab hover:bg-surface1 transition"
|
||
data-id={ fmt.Sprint(player.ID) }
|
||
data-name={ player.Name }
|
||
>
|
||
{ player.Name }
|
||
</div>
|
||
}
|
||
</div>
|
||
<p class="text-subtext1 text-xs mt-2">Drag players to the team roster</p>
|
||
</div>
|
||
<!-- Right: Team Roster -->
|
||
<div>
|
||
<h4 class="text-lg font-semibold text-text mb-3">Team Roster</h4>
|
||
<!-- Manager slot -->
|
||
<div class="mb-4">
|
||
<label class="text-sm font-medium text-yellow mb-2 flex items-center gap-1">
|
||
★ Manager
|
||
</label>
|
||
<div
|
||
id="manager-drop-zone"
|
||
class="bg-base border-2 border-dashed border-yellow/40 rounded-lg p-2 min-h-12
|
||
flex items-center justify-center"
|
||
>
|
||
if twr.Manager != nil {
|
||
<div
|
||
class="roster-player-chip px-3 py-2 bg-yellow/10 border border-yellow/30 rounded
|
||
text-text text-sm cursor-grab w-full"
|
||
data-id={ fmt.Sprint(twr.Manager.ID) }
|
||
data-name={ twr.Manager.Name }
|
||
>
|
||
{ twr.Manager.Name }
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
<!-- Players list -->
|
||
<div>
|
||
<label class="text-sm font-medium text-subtext0 mb-2 block">Players</label>
|
||
<div
|
||
id="roster-drop-zone"
|
||
class="bg-base border-2 border-dashed border-surface1 rounded-lg p-2 min-h-40
|
||
max-h-60 overflow-y-auto space-y-1"
|
||
>
|
||
for _, player := range rosterPlayers {
|
||
<div
|
||
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
|
||
text-text text-sm cursor-grab hover:bg-surface1 transition"
|
||
data-id={ fmt.Sprint(player.ID) }
|
||
data-name={ player.Name }
|
||
>
|
||
{ player.Name }
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Footer -->
|
||
<div class="flex justify-between items-center mt-6 pt-4 border-t border-surface1">
|
||
<p
|
||
x-show="!hasManager"
|
||
class="text-sm text-red"
|
||
>
|
||
A manager is required
|
||
</p>
|
||
<div class="flex gap-3 ml-auto">
|
||
<button
|
||
type="button"
|
||
@click="showManageRosterModal = false"
|
||
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<form
|
||
id="roster-submit-form"
|
||
hx-post="/teams/manage_roster"
|
||
hx-swap="none"
|
||
class="inline"
|
||
>
|
||
<input type="hidden" name="season_id" value={ fmt.Sprint(twr.Season.ID) }/>
|
||
<input type="hidden" name="league_id" value={ fmt.Sprint(twr.League.ID) }/>
|
||
<input type="hidden" name="team_id" value={ fmt.Sprint(twr.Team.ID) }/>
|
||
<input type="hidden" name="manager_id" value="0"/>
|
||
<!-- player_ids inputs are added dynamically by submitRoster() -->
|
||
<button
|
||
type="button"
|
||
@click="submitRoster()"
|
||
:disabled="!hasManager"
|
||
class="px-4 py-2 rounded-lg text-mantle transition"
|
||
:class="hasManager ? 'bg-green hover:bg-green/75 hover:cursor-pointer' : 'bg-green/40 cursor-not-allowed'"
|
||
>
|
||
Save Roster
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
function rosterManager() {
|
||
return {
|
||
search: '',
|
||
hasManager: false,
|
||
sortableInstances: [],
|
||
updateHasManager() {
|
||
const zone = document.getElementById('manager-drop-zone');
|
||
this.hasManager = zone ? zone.querySelectorAll('.roster-player-chip').length > 0 : false;
|
||
},
|
||
init() {
|
||
if (typeof Sortable === 'undefined') return;
|
||
|
||
// Destroy any previous instances
|
||
this.sortableInstances.forEach(s => s.destroy());
|
||
this.sortableInstances = [];
|
||
|
||
const self = this;
|
||
|
||
this.sortableInstances.push(new Sortable(
|
||
document.getElementById('available-players-list'), {
|
||
group: { name: 'roster', pull: true, put: true },
|
||
sort: false,
|
||
animation: 150,
|
||
onAdd(evt) { self.applySearch(); self.updateHasManager(); },
|
||
onRemove(evt) { self.applySearch(); self.updateHasManager(); }
|
||
}));
|
||
|
||
this.sortableInstances.push(new Sortable(
|
||
document.getElementById('manager-drop-zone'), {
|
||
group: { name: 'roster', pull: true, put: function(to) {
|
||
return to.el.querySelectorAll('.roster-player-chip').length === 0;
|
||
}},
|
||
sort: false,
|
||
animation: 150,
|
||
onAdd(evt) {
|
||
// Style the chip for manager zone
|
||
evt.item.classList.remove('bg-surface0', 'border-surface1');
|
||
evt.item.classList.add('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||
self.updateHasManager();
|
||
},
|
||
onRemove(evt) {
|
||
// Revert style
|
||
evt.item.classList.add('bg-surface0', 'border-surface1');
|
||
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||
self.updateHasManager();
|
||
}
|
||
}));
|
||
|
||
this.sortableInstances.push(new Sortable(
|
||
document.getElementById('roster-drop-zone'), {
|
||
group: { name: 'roster', pull: true, put: true },
|
||
sort: true,
|
||
animation: 150,
|
||
onAdd(evt) {
|
||
// Ensure surface styling
|
||
evt.item.classList.add('bg-surface0', 'border-surface1');
|
||
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||
}
|
||
}));
|
||
|
||
this.updateHasManager();
|
||
this.applySearch();
|
||
},
|
||
applySearch() {
|
||
const s = this.search.toLowerCase();
|
||
const list = document.getElementById('available-players-list');
|
||
if (!list) return;
|
||
const chips = list.querySelectorAll('.roster-player-chip');
|
||
chips.forEach(chip => {
|
||
const name = (chip.dataset.name || '').toLowerCase();
|
||
if (s === '' || name.includes(s)) {
|
||
chip.style.display = '';
|
||
} else {
|
||
chip.style.display = 'none';
|
||
}
|
||
});
|
||
},
|
||
submitRoster() {
|
||
if (!this.hasManager) return;
|
||
|
||
const managerZone = document.getElementById('manager-drop-zone');
|
||
const rosterZone = document.getElementById('roster-drop-zone');
|
||
|
||
const managerChip = managerZone ? managerZone.querySelector('.roster-player-chip') : null;
|
||
const managerID = managerChip ? managerChip.dataset.id : '0';
|
||
|
||
const rosterChips = rosterZone ? rosterZone.querySelectorAll('.roster-player-chip') : [];
|
||
const playerIDs = Array.from(rosterChips).map(el => el.dataset.id);
|
||
|
||
// Build a hidden form dynamically and submit via HTMX
|
||
const form = document.getElementById('roster-submit-form');
|
||
// Clear previous dynamic inputs
|
||
form.querySelectorAll('.dynamic-input').forEach(el => el.remove());
|
||
|
||
// Set manager ID
|
||
form.querySelector('[name="manager_id"]').value = managerID;
|
||
|
||
// Add player_ids as multiple hidden inputs
|
||
playerIDs.forEach(id => {
|
||
const input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = 'player_ids';
|
||
input.value = id;
|
||
input.className = 'dynamic-input';
|
||
form.appendChild(input);
|
||
});
|
||
|
||
// Trigger HTMX request on the form
|
||
htmx.trigger(form, 'submit');
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
}
|
||
|
||
templ teamResultsSection(team *db.Team, recentResults []*db.Fixture, resultMap map[int]*db.FixtureResult) {
|
||
<section>
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-2xl font-bold text-text">
|
||
Results
|
||
<span class="text-sm font-normal text-subtext0">(last 5)</span>
|
||
</h2>
|
||
</div>
|
||
if len(recentResults) == 0 {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||
<p class="text-subtext0 text-lg">No results yet.</p>
|
||
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
|
||
</div>
|
||
} else {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||
for _, fixture := range recentResults {
|
||
@teamResultRow(team, fixture, resultMap)
|
||
}
|
||
</div>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
templ teamUpcomingSection(team *db.Team, upcoming []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||
<section>
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-2xl font-bold text-text">Upcoming</h2>
|
||
</div>
|
||
if len(upcoming) == 0 {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
|
||
</div>
|
||
} else {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||
for _, fixture := range upcoming {
|
||
@teamFixtureRow(team, fixture, scheduleMap)
|
||
}
|
||
</div>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||
{{
|
||
isHome := fixture.HomeTeamID == team.ID
|
||
var opponent string
|
||
if isHome {
|
||
opponent = fixture.AwayTeam.Name
|
||
} else {
|
||
opponent = fixture.HomeTeam.Name
|
||
}
|
||
sched, hasSchedule := scheduleMap[fixture.ID]
|
||
_ = sched
|
||
}}
|
||
<a
|
||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
|
||
>
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
|
||
GW{ fmt.Sprint(*fixture.GameWeek) }
|
||
</span>
|
||
if isHome {
|
||
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
|
||
HOME
|
||
</span>
|
||
} else {
|
||
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
|
||
AWAY
|
||
</span>
|
||
}
|
||
<span class="text-sm text-subtext0 shrink-0">vs</span>
|
||
<span class="text-text font-medium truncate">
|
||
{ opponent }
|
||
</span>
|
||
</div>
|
||
if hasSchedule && sched.ScheduledTime != nil {
|
||
<span class="text-xs text-green font-medium shrink-0">
|
||
@localtime(sched.ScheduledTime, "short")
|
||
</span>
|
||
} else {
|
||
<span class="text-xs text-subtext1 shrink-0">
|
||
TBD
|
||
</span>
|
||
}
|
||
</a>
|
||
}
|
||
|
||
templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.FixtureResult) {
|
||
{{
|
||
isHome := fixture.HomeTeamID == team.ID
|
||
var opponent string
|
||
if isHome {
|
||
opponent = fixture.AwayTeam.Name
|
||
} else {
|
||
opponent = fixture.HomeTeam.Name
|
||
}
|
||
res := resultMap[fixture.ID]
|
||
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
|
||
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
|
||
_ = lost
|
||
isForfeit := res.IsForfeit
|
||
isMutualForfeit := isForfeit && res.ForfeitType != nil && *res.ForfeitType == "mutual"
|
||
}}
|
||
<a
|
||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
|
||
>
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
if isMutualForfeit {
|
||
<span class="text-xs font-bold px-2 py-0.5 bg-peach/20 text-peach rounded shrink-0">FF</span>
|
||
} else if won {
|
||
<span class="text-xs font-bold px-2 py-0.5 bg-green/20 text-green rounded shrink-0">W</span>
|
||
} else if lost {
|
||
<span class="text-xs font-bold px-2 py-0.5 bg-red/20 text-red rounded shrink-0">L</span>
|
||
} else {
|
||
<span class="text-xs font-bold px-2 py-0.5 bg-surface1 text-subtext0 rounded shrink-0">D</span>
|
||
}
|
||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
|
||
GW{ fmt.Sprint(*fixture.GameWeek) }
|
||
</span>
|
||
if isHome {
|
||
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
|
||
HOME
|
||
</span>
|
||
} else {
|
||
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
|
||
AWAY
|
||
</span>
|
||
}
|
||
<span class="text-sm text-subtext0 shrink-0">vs</span>
|
||
<span class="text-text font-medium truncate">
|
||
{ opponent }
|
||
</span>
|
||
</div>
|
||
if isForfeit {
|
||
<span class="flex items-center gap-2 shrink-0">
|
||
if isMutualForfeit {
|
||
<span class="px-2 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||
Mutual Forfeit
|
||
</span>
|
||
} else {
|
||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||
Forfeit
|
||
</span>
|
||
}
|
||
</span>
|
||
} else {
|
||
<span class="flex items-center gap-2 shrink-0">
|
||
if res.Winner == "home" {
|
||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||
<span class="text-xs text-subtext0">–</span>
|
||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
|
||
} else if res.Winner == "away" {
|
||
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
|
||
<span class="text-xs text-subtext0">–</span>
|
||
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||
} else {
|
||
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||
<span class="text-xs text-subtext0">–</span>
|
||
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||
}
|
||
</span>
|
||
}
|
||
</a>
|
||
}
|
||
|
||
templ teamRecordCard(record *db.TeamRecord, position int, totalTeams int) {
|
||
<section>
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-2xl font-bold text-text">Standing</h2>
|
||
</div>
|
||
if record.Played == 0 {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||
<p class="text-subtext0 text-lg">No games played yet.</p>
|
||
</div>
|
||
} else {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||
<!-- Position & Points Header -->
|
||
<div class="flex items-center justify-between px-6 py-5 border-b border-surface1">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-4xl font-bold text-text">{ ordinal(position) }</span>
|
||
<div>
|
||
<p class="text-xs text-subtext0 uppercase font-medium">Position</p>
|
||
<p class="text-sm text-subtext1">of { fmt.Sprint(totalTeams) } teams</p>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<p class="text-xs text-subtext0 uppercase font-medium">Points</p>
|
||
<p class="text-3xl font-bold text-blue">{ fmt.Sprint(record.Points) }</p>
|
||
</div>
|
||
</div>
|
||
<!-- Record Grid -->
|
||
<div class="grid grid-cols-4 divide-x divide-surface1">
|
||
@statCell("W", fmt.Sprint(record.Wins), "text-green")
|
||
@statCell("OTW", fmt.Sprint(record.OvertimeWins), "text-teal")
|
||
@statCell("OTL", fmt.Sprint(record.OvertimeLosses), "text-peach")
|
||
@statCell("L", fmt.Sprint(record.Losses), "text-red")
|
||
</div>
|
||
<!-- Goals Row -->
|
||
<div class="grid grid-cols-3 divide-x divide-surface1 border-t border-surface1">
|
||
@statCell("Played", fmt.Sprint(record.Played), "")
|
||
@statCell("GF", fmt.Sprint(record.GoalsFor), "")
|
||
@statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
|
||
</div>
|
||
</div>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
templ playerStatsSection(playerStats []*db.AggregatedPlayerStats) {
|
||
<section>
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-2xl font-bold text-text">Player Stats</h2>
|
||
</div>
|
||
if len(playerStats) == 0 {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||
<p class="text-subtext0 text-lg">No player stats yet.</p>
|
||
<p class="text-subtext1 text-sm mt-2">Player statistics will appear here once games are played.</p>
|
||
</div>
|
||
} else {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||
<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-left text-xs font-semibold text-text">Player</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-surface1">
|
||
for _, ps := range playerStats {
|
||
<tr class="hover:bg-surface1 transition-colors">
|
||
<td class="px-3 py-2 text-sm">
|
||
@links.PlayerLinkFromStats(ps.PlayerID, ps.PlayerName)
|
||
</td>
|
||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.PeriodsPlayed) }</td>
|
||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
templ statCell(label string, value string, valueColor string) {
|
||
<div class="px-4 py-3 text-center">
|
||
<p class="text-xs text-subtext0 font-medium uppercase mb-1">{ label }</p>
|
||
<p class={ "text-lg font-bold", templ.KV("text-text", valueColor == ""), templ.KV(valueColor, valueColor != "") }>
|
||
{ value }
|
||
</p>
|
||
</div>
|
||
}
|