added team view to season_leagues
This commit is contained in:
470
internal/view/seasonsview/season_league_team_detail.templ
Normal file
470
internal/view/seasonsview/season_league_team_detail.templ
Normal file
@@ -0,0 +1,470 @@
|
||||
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) {
|
||||
{{
|
||||
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)
|
||||
</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) {
|
||||
<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)
|
||||
}
|
||||
</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) {
|
||||
{{
|
||||
isHome := fixture.HomeTeamID == team.ID
|
||||
var opponent string
|
||||
if isHome {
|
||||
opponent = fixture.AwayTeam.Name
|
||||
} else {
|
||||
opponent = fixture.HomeTeam.Name
|
||||
}
|
||||
}}
|
||||
<div class="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<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>
|
||||
<span class="text-xs text-subtext1 shrink-0">
|
||||
TBD
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
Reference in New Issue
Block a user