Files
oslstats/internal/view/seasonsview/season_league_team_detail.templ

482 lines
16 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 "fmt"
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule) {
{{
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>
<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>
<!-- Content -->
<div class="bg-crust p-6">
<!-- Top row: Roster (left) + Fixtures (right) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@TeamRosterSection(twr, available)
@teamFixturesPane(twr.Team, fixtures, scheduleMap)
</div>
<!-- Stats below both -->
<div class="mt-6">
@teamStatsSection()
</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="text-text font-medium">{ twr.Manager.Name }</span>
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
&#9733; Manager
</span>
</div>
}
for _, player := range rosterPlayers {
<div class="px-4 py-3">
<span class="text-text">{ player.Name }</span>
</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">
&#9733; 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 teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
<section class="space-y-6">
<!-- Upcoming -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
if len(fixtures) == 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 fixtures {
@teamFixtureRow(team, fixture, scheduleMap)
}
</div>
}
</div>
<!-- Results -->
<div>
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Results coming soon.</p>
<p class="text-subtext1 text-sm mt-2">Match results will appear here once game data is recorded.</p>
</div>
</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 teamStatsSection() {
<section>
<div class="mb-4">
<h2 class="text-2xl font-bold text-text">Stats</h2>
</div>
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">Stats coming soon.</p>
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once game data is available.</p>
</div>
</section>
}