Files
team-tryouts/app/templates/pages/calendar.html

463 lines
18 KiB
HTML

{% extends "layouts/base.html" %}
{% block title %}{{ _('Calendar') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Calendar') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-alt"></i> {{ _('Schedule') }}</h3>
<div class="header-actions">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="dayGridMonth">
<i class="fas fa-calendar"></i> {{ _('Month') }}
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridWeek">
<i class="fas fa-calendar-week"></i> {{ _('Week') }}
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="timeGridDay">
<i class="fas fa-calendar-day"></i> {{ _('Day') }}
</button>
<button type="button" class="btn btn-sm btn-outline" data-action="change-view" data-view="listMonth">
<i class="fas fa-list"></i> {{ _('List') }}
</button>
</div>
</div>
</div>
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
<!-- Create Event Modal (for clicking empty days) -->
<div id="createEventModal" class="modal hidden">
<div class="modal-backdrop" data-action="hide-create-event"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-plus-circle"></i> {{ _('Create New Event') }}</h3>
<button class="modal-close" data-action="hide-create-event">&times;</button>
</div>
<div class="modal-body">
<p class="mb-3"><strong>{{ _('Date:') }}</strong> <span id="createEventDate"></span></p>
<input type="hidden" id="createEventDateInput"/>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-futbol"></i> {{ _('Schedule Tryout Match') }}</h5>
<p class="text-muted small">{{ _('Add a scrim/match inside an existing tryout') }}</p>
<div class="form-inline">
<select id="createTryoutSelect" class="form-select" style="flex:1;">
<option value="">{{ _('-- Select a tryout --') }}</option>
</select>
<button class="btn btn-sm btn-primary ml-2" data-action="go-tryout-match">
<i class="fas fa-arrow-right"></i> {{ _('Go') }}
</button>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-users"></i> {{ _('Schedule Team Match') }}</h5>
<p class="text-muted small">{{ _('Regular season match for an org team') }}</p>
<div class="form-inline">
<select id="createTeamSelect" class="form-select" style="flex:1;">
<option value="">{{ _('-- Select a team --') }}</option>
</select>
<button class="btn btn-sm btn-success ml-2" data-action="go-team-match">
<i class="fas fa-arrow-right"></i> {{ _('Go') }}
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h5><i class="fas fa-calendar-plus"></i> {{ _('Create New Tryout') }}</h5>
<p class="text-muted small">{{ _('Create a brand new tryout event') }}</p>
<button class="btn btn-sm btn-info" data-action="go-create-tryout">
<i class="fas fa-plus"></i> {{ _('Create Tryout') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div id="eventModal" class="modal hidden">
<div class="modal-backdrop" data-action="hide-event-modal"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">{{ _('Event Details') }}</h3>
<button class="modal-close" data-action="hide-event-modal">&times;</button>
</div>
<div class="modal-body">
<div id="modalContent"></div>
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;">
<button class="btn btn-sm btn-outline" id="calPresenceBtn">{{ _('Confirm') }}</button>
</div>
<div id="modalActions" class="form-actions mt-3" style="display: none;">
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
<i class="fas fa-trash"></i> {{ _('Delete Match') }}
</button>
<button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;">
<i class="fas fa-edit"></i> {{ _('Edit Match') }}
</button>
<button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;">
<i class="fas fa-eye"></i> {{ _('View Tryout') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{# The stylesheet that used to sit here — index.global.min.css — does not
exist. FullCalendar 6 bundles its styles into the JS, and that file is not
in the published package: the link had been answering 404 on every calendar
load since the upgrade. A failed stylesheet is silent in the browser, which
is why it survived.
Integrity pins the bundle (QUA-004): this file is executable script from a
third party, on the page that shows every match in the club. See the note
in layouts/base.html for what SRI does and does not promise. #}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"
integrity="sha384-WfE/vOHqht3KDj6FvpwQUf3UxEPUHoGJ3w1yZ8rhpLWnVigt8HjXL2zXqtcfS7mf"
crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script nonce="{{ csp_nonce }}">
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
},
events: '/matches/api/events',
eventClick: function(info) {
showEventModal(info.event);
},
dateClick: function(info) {
if (canScheduleMatches) {
var dateStr = info.dateStr;
showCreateEventModal(dateStr);
}
},
selectable: true,
select: function(info) {
if (canScheduleMatches) {
var dateStr = info.startStr;
showCreateEventModal(dateStr);
calendar.unselect();
}
},
slotMinTime: '12:00:00',
slotMaxTime: '24:00:00'
});
calendar.render();
window.fcCalendar = calendar;
// Pre-load tryout and team options for the create modal
if (canScheduleMatches) {
fetchTryoutOptions();
fetchTeamOptions();
}
});
function changeView(viewName) {
if (window.fcCalendar) {
window.fcCalendar.changeView(viewName);
}
}
// --- Create Event Modal ---
function showCreateEventModal(dateStr) {
document.getElementById('createEventDate').textContent = dateStr;
document.getElementById('createEventDateInput').value = dateStr;
// Reset dropdowns
document.getElementById('createTryoutSelect').value = '';
document.getElementById('createTeamSelect').value = '';
document.getElementById('createEventModal').classList.remove('hidden');
}
function hideCreateEventModal() {
document.getElementById('createEventModal').classList.add('hidden');
}
function goToTryoutMatch() {
var tryoutId = document.getElementById('createTryoutSelect').value;
if (!tryoutId) { alert('Please select a tryout.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/matches/create/' + tryoutId + '?date=' + date;
}
function goToTeamMatch() {
var teamId = document.getElementById('createTeamSelect').value;
if (!teamId) { alert('Please select a team.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/team-matches/' + teamId + '/create?date=' + date;
}
function goToCreateTryout() {
window.location.href = '/tryouts/create';
}
// Pre-fetch data for dropdowns
function fetchTryoutOptions() {
fetch('/matches/api/manageable-tryouts')
.then(function(r) { return r.json(); })
.then(function(data) {
// new Option() sets the label as text; concatenating it into
// innerHTML let a tryout title carry markup into the page.
var sel = document.getElementById('createTryoutSelect');
sel.replaceChildren(new Option('-- Select a tryout --', ''));
var today = new Date().toISOString().split('T')[0];
data.forEach(function(t) {
// Only show tryouts that haven't ended
var tryoutEndDate = t.end_date || t.date;
if (tryoutEndDate >= today) {
sel.appendChild(new Option(t.title + ' (' + t.date + ')', t.id));
}
});
})
.catch(function() {});
}
function fetchTeamOptions() {
fetch('/team-matches/api/manageable-teams')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = document.getElementById('createTeamSelect');
sel.replaceChildren(new Option('-- Select a team --', ''));
data.forEach(function(t) {
sel.appendChild(new Option(t.name, t.id));
});
})
.catch(function() {});
}
// ---------------------------------------------------------------------------
// Safe DOM builders
// ---------------------------------------------------------------------------
// Everything rendered in the event modal originates from a JSON endpoint,
// where no HTML escaping applies. Text therefore goes through textContent,
// never through innerHTML.
function makeEl(tag, className, text) {
var node = document.createElement(tag);
if (className) { node.className = className; }
if (text !== undefined && text !== null) { node.textContent = text; }
return node;
}
function detailItem(label, valueNode, fullWidth) {
var item = makeEl('div', 'detail-item' + (fullWidth ? ' full-width' : ''));
item.appendChild(makeEl('span', 'detail-label', label));
var value = makeEl('span', 'detail-value');
value.appendChild(valueNode);
item.appendChild(value);
return item;
}
// Renders newlines as <br> without letting any other markup through.
function multilineNode(text) {
var fragment = document.createDocumentFragment();
String(text).split('\n').forEach(function(line, index) {
if (index > 0) { fragment.appendChild(document.createElement('br')); }
fragment.appendChild(document.createTextNode(line));
});
return fragment;
}
// A team block: its name, plus an optional list of player names.
function teamNode(name, players) {
var team = makeEl('div', 'match-team');
team.appendChild(makeEl('span', 'team-name', name));
if (players) {
var list = makeEl('ul', 'team-players-list');
players.forEach(function(player) {
list.appendChild(makeEl('li', null, player));
});
team.appendChild(list);
}
return team;
}
function versusNode(left, right) {
var wrap = makeEl('div', 'match-teams');
wrap.appendChild(left);
wrap.appendChild(makeEl('div', 'match-vs', 'vs'));
wrap.appendChild(right);
return wrap;
}
function buildTeamsNode(props) {
var sides = props.participants.split(' vs ');
if (props.match_type === 'team_vs_team' && sides.length >= 2) {
return versusNode(teamNode(sides[0]), teamNode(sides[1]));
}
if (props.match_type === 'player_vs_player' && sides.length >= 2) {
return versusNode(
teamNode('Team 1', sides[0].split(', ')),
teamNode('Team 2', sides[1].split(', '))
);
}
return document.createTextNode(props.participants);
}
function showEventModal(event) {
var props = event.extendedProps;
var title = event.title;
var type = props.type;
var date = event.start ? event.start.toDateString() : '';
// Built as DOM nodes rather than concatenated HTML. Every value below —
// match title, location, description, and above all the participant list,
// which is made of user-chosen usernames — comes from a JSON API and has
// never been HTML-escaped. Assigning it to innerHTML executed it.
var grid = makeEl('div', 'detail-grid');
var typeLabel = props.match_type === 'team_vs_team' ? 'Team Match'
: (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim');
var typeBadgeClass = (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player')
? 'success' : 'warning';
grid.appendChild(detailItem('Type', makeEl('span', 'badge badge-' + typeBadgeClass, typeLabel)));
grid.appendChild(detailItem('Title', document.createTextNode(title)));
grid.appendChild(detailItem('Date', document.createTextNode(date)));
grid.appendChild(detailItem('Location', document.createTextNode(props.location || 'TBD')));
var status = props.status || 'scheduled';
grid.appendChild(detailItem('Status', makeEl('span', 'badge badge-' + status, status)));
if (type === 'match' && props.participants) {
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
}
if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
}
var modalContent = document.getElementById('modalTitle');
modalContent.textContent = 'Match Details';
var target = document.getElementById('modalContent');
target.textContent = '';
target.appendChild(grid);
// Reset buttons
document.getElementById('deleteMatchBtn').style.display = 'none';
document.getElementById('editMatchBtn').style.display = 'none';
document.getElementById('viewTryoutBtn').style.display = 'none';
// Show action buttons for matches (coaches and above)
if (type === 'match' && canScheduleMatches) {
document.getElementById('modalActions').style.display = 'flex';
document.getElementById('editMatchBtn').style.display = 'inline-flex';
document.getElementById('editMatchBtn').onclick = function() {
window.location.href = '/matches/' + props.match_id + '/edit';
};
document.getElementById('deleteMatchBtn').style.display = 'inline-flex';
document.getElementById('deleteMatchBtn').onclick = function() {
if (confirm('Are you sure you want to delete this match?')) {
deleteCalendarMatch(props.match_id);
}
};
} else {
document.getElementById('modalActions').style.display = 'none';
}
// Show presence toggle for matches where user is a participant
var presenceDiv = document.getElementById('modalPresenceToggle');
if (type === 'match' && props.user_participant_id) {
presenceDiv.style.display = 'flex';
var confirmed = props.user_attendance_confirmed || false;
var toggleBtn = document.getElementById('calPresenceBtn');
toggleBtn.textContent = confirmed ? '✅ Confirmed' : 'Confirm';
toggleBtn.className = 'btn btn-sm ' + (confirmed ? 'btn-success' : 'btn-outline');
toggleBtn.onclick = function() {
toggleCalendarPresence(props.match_id, props.user_participant_id, toggleBtn);
};
} else {
presenceDiv.style.display = 'none';
}
document.getElementById('eventModal').classList.remove('hidden');
}
function deleteCalendarMatch(matchId) {
fetch('/matches/' + matchId + '/delete', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json().catch(function() { return {}; }); })
.then(function() {
hideEventModal();
if (window.fcCalendar) {
window.fcCalendar.refetchEvents();
}
})
.catch(function(err) {
console.error('Error deleting match:', err);
alert('Failed to delete match.');
});
}
function toggleCalendarPresence(matchId, participantId, btn) {
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.attendance_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.textContent = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.textContent = 'Confirm';
}
})
.catch(function(err) {
console.error('Error toggling presence:', err);
});
}
function hideEventModal() {
document.getElementById('eventModal').classList.add('hidden');
}
// Behaviours declared in the markup, dispatched by the delegated listener
// in main.js. Inline onclick attributes cannot be authorised by a CSP nonce.
registerActions({
'change-view': function (element) {
changeView(element.getAttribute('data-view'));
},
'hide-create-event': hideCreateEventModal,
'go-tryout-match': goToTryoutMatch,
'go-team-match': goToTeamMatch,
'go-create-tryout': goToCreateTryout,
'hide-event-modal': hideEventModal,
});
</script>
{% endblock %}