482 lines
16 KiB
Plaintext
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">
|
|
★ 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">
|
|
★ 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>
|
|
}
|