Files
team-tryouts/app/static/js/main.js
T
GGThedandClaude Opus 5 09453199b8 refactor(csp): migrer dix gabarits vers les comportements declaratifs
OPS-010, suite. 76 gestionnaires en ligne -> 52, dans 5 gabarits au lieu de
15. Le cliquet de tests/test_csp.py est abaisse en consequence.

Six motifs recurrents, generalises dans main.js plutot que traites un a un
  data-action        clic, resolu par un ecouteur delegue
  data-change        changement -- attribut distinct du clic, sans quoi un
                     <select> declencherait son gestionnaire des le clic
                     qui l'ouvre
  data-confirm       confirmation avant un envoi destructeur, en
                     remplacement de onsubmit="return confirm(...)". Le
                     texte reste dans le markup, donc traduisible.
  data-navigate      navigation sur selection, {value} etant encode
  remove-element     suppression d'un ancetre designe par data-remove
  history-back       retour arriere

registerActions()
  Les fonctions propres a une page vivent dans son bloc de script et ne
  peuvent donc pas figurer dans la table globale. Chaque page declare les
  siennes, l'ecouteur delegue reste unique.

Cas particulier, coach_availability
  Le gestionnaire y etait construit dans une chaine JavaScript, au moment
  de generer la grille de creneaux. Le markup portait deja data-day et
  data-time : toggleSlot lit desormais ses arguments depuis l'element, ce
  qui supprime a la fois l'attribut en ligne et la concatenation.

Gabarits migres : my_teams, register, users, view_user, one_on_one,
coach_availability, profile, team_matches, notes, contracts.

Restent, par ordre decroissant : match_form 13, calendar 11, teams 11,
evaluate_player 9, view_tryout 8.

Syntaxe JavaScript de chaque bloc modifie verifiee par node --check.

A noter : la traduction de ces dix gabarits reste a faire. Seules les deux
chaines devenues visibles dans le markup au cours de cette migration -- les
messages de confirmation de suppression -- sont balisees et traduites.

192 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:51:07 -04:00

560 lines
20 KiB
JavaScript

/**
* Team Tryouts - Main JavaScript Module
*
* This module provides core UI functionality including:
* - Dark mode toggle and persistence
* - Sidebar mobile toggle
* - Draggable dashboard blocks
* - Auto-dismissing alerts
*/
/**
* Toggle dark mode theme.
*
* Switches between light and dark themes, updates the toggle button icon,
* and persists the preference in localStorage.
*/
function toggleDarkMode() {
const body = document.documentElement;
const isDark = body.getAttribute('data-theme') === 'dark';
const toggle = document.getElementById('darkModeToggle');
if (isDark) {
body.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
toggle.innerHTML = '<i class="fas fa-moon"></i>';
} else {
body.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
toggle.innerHTML = '<i class="fas fa-sun"></i>';
}
}
/**
* Load saved theme preference from localStorage.
*
* Called on page load to restore the user's preferred theme.
*/
function loadTheme() {
const savedTheme = localStorage.getItem('theme');
const toggle = document.getElementById('darkModeToggle');
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
if (toggle) {
toggle.innerHTML = '<i class="fas fa-sun"></i>';
}
}
}
/**
* Toggle sidebar visibility on mobile devices.
*
* Adds/removes 'open' class on sidebar to show/hide it.
*/
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('open');
}
/**
* Handle click outside sidebar to close it on mobile.
*
* Listens for document clicks and closes sidebar when clicking
* outside of it, but only on screens smaller than 768px.
*/
document.addEventListener('click', function(event) {
const sidebar = document.getElementById('sidebar');
const toggle = document.getElementById('sidebarToggle');
if (window.innerWidth <= 768) {
if (!sidebar.contains(event.target) && !toggle.contains(event.target)) {
sidebar.classList.remove('open');
}
}
});
/**
* Initialize confirmation dialogs for elements with data-confirm attribute.
*
* Adds click handler to show confirmation dialog before submitting forms.
*/
document.querySelectorAll('[data-confirm]').forEach(function(el) {
el.addEventListener('click', function(e) {
if (!confirm(this.getAttribute('data-confirm'))) {
e.preventDefault();
}
});
});
/**
* Initialize draggable blocks functionality on dashboard pages.
*
* Adds drag handles to card headers and enables drag-and-drop
* reordering of cards. Saves layout to localStorage.
*/
function initDraggableBlocks() {
const grids = document.querySelectorAll('.dashboard-grid');
grids.forEach(function(grid) {
const cards = grid.querySelectorAll('.card');
// Add drag handles to card headers
cards.forEach(function(card, index) {
const header = card.querySelector('.card-header');
if (header) {
// Add unique ID to card if not present
if (!card.id) {
card.id = 'block-' + index;
}
// Create drag handle
const dragHandle = document.createElement('div');
dragHandle.className = 'drag-handle';
dragHandle.innerHTML = '<i class="fas fa-grip-vertical"></i>';
dragHandle.title = 'Drag to reorder';
// Add draggable class to header
header.classList.add('draggable');
header.prepend(dragHandle);
// Make header draggable
makeHeaderDraggable(header, card, grid);
}
});
// Load saved layout
loadLayout(grid);
});
}
/**
* Make a card header draggable.
*
* @param {HTMLElement} header - The card header element
* @param {HTMLElement} card - The card element being dragged
* @param {HTMLElement} grid - The parent grid container
*/
function makeHeaderDraggable(header, card, grid) {
let draggedElement = null;
let placeholder = null;
let ghost = null;
let animationFrame = null;
header.addEventListener('mousedown', function(e) {
// Only start drag from drag handle
if (!e.target.closest('.drag-handle')) return;
e.preventDefault();
draggedElement = card;
// Get card dimensions
const rect = card.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
// Store original data-position to restore later
const originalDataPosition = card.getAttribute('data-position');
// Remove data-position during drag to allow free placement
if (originalDataPosition) {
card.removeAttribute('data-position');
}
// Create ghost element (floating preview)
ghost = document.createElement('div');
ghost.className = 'sortable-ghost';
ghost.style.position = 'fixed';
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '99999';
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
ghost.style.backgroundColor = 'var(--card-bg, white)';
ghost.style.border = '2px solid var(--primary)';
ghost.style.borderRadius = 'var(--radius)';
ghost.style.opacity = '0.9';
ghost.style.boxShadow = 'var(--shadow-lg)';
ghost.style.cursor = 'grabbing';
document.body.appendChild(ghost);
// Create placeholder element (shows drop position)
placeholder = document.createElement('div');
placeholder.className = 'sortable-placeholder';
placeholder.style.minHeight = rect.height + 'px';
card.parentNode.insertBefore(placeholder, card);
// Keep original card visible but add dragging style
card.classList.add('dragging');
card.style.opacity = '0.5';
card.style.transform = 'scale(0.98)';
// Initial position
ghost.style.top = (e.clientY - offsetY) + 'px';
ghost.style.left = (e.clientX - offsetX) + 'px';
/**
* Update ghost position during drag.
* @param {number} clientY - Mouse Y coordinate
* @param {number} clientX - Mouse X coordinate
*/
function updateGhostPosition(clientY, clientX) {
// Position ghost directly at cursor position
ghost.style.top = (clientY - offsetY) + 'px';
ghost.style.left = (clientX - offsetX) + 'px';
}
// Get grid layout info for 2D position tracking
const gridRect = grid.getBoundingClientRect();
const gridStyle = window.getComputedStyle(grid);
const gridGap = parseInt(gridStyle.gap) || 20;
/**
* Calculate which column the X position falls into.
* @param {number} x - X coordinate relative to grid
* @returns {number} Column index
*/
function getColumnFromX(x) {
// Calculate which column the x position falls into
const relativeX = x - gridRect.left;
const colWidth = (gridRect.width + gridGap) / Math.max(1, Math.floor(gridRect.width / 300)); // Estimate columns based on min 300px width
return Math.floor(relativeX / (colWidth + gridGap));
}
/**
* Handle mouse movement during drag.
* @param {MouseEvent} e - Mouse event
*/
function onMouseMove(e) {
e.preventDefault();
// Cancel any pending animation frame
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
// Use requestAnimationFrame for smooth updates
animationFrame = requestAnimationFrame(function() {
updateGhostPosition(e.clientY, e.clientX);
// Use a point slightly offset from cursor to avoid ghost interference
// This ensures we detect the card under the cursor, not the ghost
const checkX = e.clientX;
const checkY = e.clientY + 10; // 10px below cursor for better detection
// Find the element under the offset point
const elementUnderCursor = document.elementFromPoint(checkX, checkY);
// Check if we're directly over the placeholder (skip processing to avoid flicker)
if (elementUnderCursor && elementUnderCursor.closest('.sortable-placeholder')) {
return; // Already over placeholder, don't change position
}
// Find the card that contains or is the element under cursor
let targetCard = null;
if (elementUnderCursor) {
targetCard = elementUnderCursor.closest('.card');
// Make sure it's not the dragged card
if (targetCard && targetCard.classList.contains('dragging')) {
targetCard = null;
}
}
// If we found a valid target card, determine insert position
if (targetCard) {
const targetRect = targetCard.getBoundingClientRect();
const targetMiddleY = targetRect.top + targetRect.height / 2;
// If cursor is above the card's middle, insert before it
// If cursor is below, insert after it
// Use checkY for consistent comparison with detection point
if (checkY < targetMiddleY) {
// Check if placeholder is already in the correct position
if (targetCard.nextElementSibling !== placeholder) {
grid.insertBefore(placeholder, targetCard);
}
} else {
// Insert after the target card
const nextCard = targetCard.nextElementSibling;
// If next sibling is the placeholder or the dragged element, it's already in the right position
if (nextCard === placeholder || nextCard === draggedElement) {
// Already in correct position, don't move
} else if (nextCard && nextCard.classList.contains('card')) {
grid.insertBefore(placeholder, nextCard);
} else {
grid.appendChild(placeholder);
}
}
} else {
// No target card found - check if we should move to end
// This handles the case where we're dragging over empty space or the grid background
// If cursor is within the grid bounds, move to end
if (e.clientX >= gridRect.left && e.clientX <= gridRect.right &&
e.clientY >= gridRect.top && e.clientY <= gridRect.bottom) {
// Check if we're over the dragged element (which is hidden but still in DOM)
const isOverDraggedElement = elementUnderCursor &&
(elementUnderCursor.closest('.card.dragging') ||
elementUnderCursor.closest('.sortable-placeholder'));
if (!isOverDraggedElement) {
// Move placeholder to end if not already there
const lastCard = grid.querySelector('.card:last-of-type');
if (lastCard && lastCard.nextElementSibling !== placeholder) {
grid.appendChild(placeholder);
}
}
}
}
});
}
/**
* Handle mouse release to complete drag.
* @param {MouseEvent} e - Mouse event
*/
function onMouseUp(e) {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
if (draggedElement) {
draggedElement.classList.remove('dragging');
// Restore original card styles
draggedElement.style.opacity = '';
draggedElement.style.transform = '';
// Ensure the card is placed in the correct position
// The placeholder shows where the card should go
if (placeholder && placeholder.parentNode) {
// Move the dragged element to the placeholder position
placeholder.parentNode.replaceChild(draggedElement, placeholder);
} else {
// If no placeholder exists, append to the end
grid.appendChild(draggedElement);
}
// Save layout
saveLayout(grid);
}
if (ghost && ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.removeChild(placeholder);
}
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
/**
* Save the current card layout order to localStorage.
*
* @param {HTMLElement} grid - The grid container to save layout for
*/
function saveLayout(grid) {
const pageKey = getPageKey();
const cards = grid.querySelectorAll('.card');
const order = Array.from(cards).map(card => card.id);
localStorage.setItem('blockLayout_' + pageKey, JSON.stringify(order));
}
/**
* Load saved card layout from localStorage.
*
* @param {HTMLElement} grid - The grid container to load layout for
*/
function loadLayout(grid) {
const pageKey = getPageKey();
const saved = localStorage.getItem('blockLayout_' + pageKey);
if (saved) {
try {
const order = JSON.parse(saved);
const cards = Array.from(grid.querySelectorAll('.card'));
// Sort cards according to saved order
order.forEach(function(id) {
const card = cards.find(c => c.id === id);
if (card) {
grid.appendChild(card);
}
});
} catch (e) {
console.error('Failed to load layout:', e);
}
}
}
/**
* Get the current page key for layout storage.
*
* @returns {string} Page identifier based on URL path
*/
function getPageKey() {
const path = window.location.pathname;
if (path.includes('/tryouts/')) {
return 'tryout';
}
if (path.includes('/profile')) {
return 'profile';
}
return 'dashboard';
}
/**
* Initialize on DOM ready.
*
* Loads theme preference and initializes draggable blocks.
*/
document.addEventListener('DOMContentLoaded', function() {
// Load theme preference
loadTheme();
// Initialize draggable blocks
initDraggableBlocks();
/**
* Auto-dismiss flash alerts after 5 seconds.
*/
const alerts = document.querySelectorAll('.alert-dismissible');
alerts.forEach(function(alert) {
setTimeout(function() {
if (alert.parentElement) {
alert.style.opacity = '0';
alert.style.transition = 'opacity 0.3s ease';
setTimeout(function() {
if (alert.parentElement) {
alert.remove();
}
}, 300);
}
}, 5000);
});
});
/* =========================================================================
Declarative behaviours — replacing inline event handlers
=========================================================================
A Content Security Policy without 'unsafe-inline' blocks `onclick="..."`
attributes, and a nonce does not help: nonces apply to <script> elements,
never to event handler attributes. Dropping 'unsafe-inline' therefore
requires removing every one of them first.
Rather than one listener per widget, behaviours are declared in the
markup with a data-action attribute and dispatched from a single
delegated listener. New markup gets the behaviour for free, and nothing
has to be re-bound after content is replaced dynamically.
<button data-action="toggle-sidebar">
<button data-action="dismiss-alert">
<div data-action="hide-modal" data-modal-id="confirmDelete">
Migration status is tracked by tests/test_csp.py.
========================================================================= */
const DATA_ACTIONS = {
'toggle-sidebar': function () {
toggleSidebar();
},
'toggle-dark-mode': function () {
toggleDarkMode();
},
'dismiss-alert': function (element) {
const alert = element.closest('.alert');
if (alert) {
alert.remove();
}
},
'hide-modal': function (element) {
const id = element.getAttribute('data-modal-id');
if (id && typeof hideModal === 'function') {
hideModal(id);
}
},
'history-back': function (element, event) {
event.preventDefault();
history.back();
},
// Removes the nearest ancestor matching data-remove, or the parent.
'remove-element': function (element) {
const selector = element.getAttribute('data-remove');
const target = selector ? element.closest(selector) : element.parentElement;
if (target) {
target.remove();
}
},
};
/**
* Register behaviours defined by a single page.
*
* Page-local functions live in that page's script block, so they cannot be
* listed in DATA_ACTIONS above. Each page declares its own:
*
* registerActions({ 'clear-availability': clearAllAvailability });
*
* @param {Object} map - action name to handler(element, event).
*/
function registerActions(map) {
Object.assign(DATA_ACTIONS, map);
}
function dispatchAction(attribute, event) {
const trigger = event.target.closest('[' + attribute + ']');
if (!trigger) {
return;
}
const handler = DATA_ACTIONS[trigger.getAttribute(attribute)];
if (handler) {
handler(trigger, event);
}
}
document.addEventListener('click', function (event) {
dispatchAction('data-action', event);
});
// Separate attribute rather than one shared with click: a <select> would
// otherwise fire its handler on the click that opens it.
document.addEventListener('change', function (event) {
dispatchAction('data-change', event);
});
/**
* Confirmation before a destructive submit.
*
* <form data-confirm="Delete this match?">
*
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
* markup where it can be translated.
*/
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
/**
* Navigate on selection.
*
* <select data-navigate="/team-matches/{value}/create">
*
* {value} is replaced by the chosen option, URL-encoded. An empty
* selection navigates nowhere.
*/
document.addEventListener('change', function (event) {
const select = event.target.closest('[data-navigate]');
if (!select || !select.value) {
return;
}
window.location.href = select.getAttribute('data-navigate')
.replace('{value}', encodeURIComponent(select.value));
});