From 29143976365625bf0af13c534370f27b8a28425b Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 22 Apr 2026 14:26:14 +0000 Subject: [PATCH] Auto commit: 2026-04-22T14:26:14.520Z --- assets/css/custom.css | 613 ++++++++++++++++++++---------------------- assets/js/main.js | 43 +-- athlete.php | 83 ++++++ athlete_new.php | 180 +++++++++++++ athletes.php | 134 +++++++++ change_password.php | 77 ++++++ forgot_password.php | 58 ++++ healthz.php | 10 + includes/app.php | 342 +++++++++++++++++++++++ index.php | 338 +++++++++++++---------- login.php | 80 ++++++ logout.php | 8 + register.php | 109 ++++++++ reset_password.php | 95 +++++++ 14 files changed, 1662 insertions(+), 508 deletions(-) create mode 100644 athlete.php create mode 100644 athlete_new.php create mode 100644 athletes.php create mode 100644 change_password.php create mode 100644 forgot_password.php create mode 100644 healthz.php create mode 100644 includes/app.php create mode 100644 login.php create mode 100644 logout.php create mode 100644 register.php create mode 100644 reset_password.php diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..54ca4c4 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,362 @@ -body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - min-height: 100vh; +:root { + --bg: #f5f7fa; + --surface: #ffffff; + --surface-muted: #f8fafc; + --border: #dbe1ea; + --border-strong: #c5ced9; + --text: #111827; + --text-muted: #667085; + --primary: #111827; + --secondary: #4b5563; + --accent: #0f172a; + --success: #166534; + --warning: #9a6700; + --danger: #b42318; + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.04); + --shadow-md: 0 12px 30px rgba(15, 23, 42, 0.06); + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --spacing: 1rem; } -.main-wrapper { - display: flex; +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +body.app-shell { + background: linear-gradient(to bottom, #f8fafc 0, #f8fafc 220px, #f5f7fa 220px, #f5f7fa 100%); +} + +a { + color: inherit; +} + +.app-navbar { + background: rgba(255, 255, 255, 0.94); + backdrop-filter: blur(10px); +} + +.navbar-brand { + display: inline-flex; + align-items: center; + gap: 0.75rem; + color: var(--primary); +} + +.brand-mark { + width: 2rem; + height: 2rem; + border-radius: var(--radius-sm); + background: var(--primary); + color: #fff; + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; + font-size: 0.75rem; + letter-spacing: 0.12em; } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +.nav-link { + color: var(--secondary); + font-weight: 500; } -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; +.nav-link:hover, +.nav-link:focus, +.link-dark:hover, +.link-dark:focus { + color: #000 !important; } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { +.hero-section { background: transparent; } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; +.eyebrow, +.section-kicker { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); +.display-title { + font-size: clamp(2.1rem, 4vw, 3.4rem); + line-height: 1.05; + letter-spacing: -0.03em; + max-width: 12ch; } -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +.hero-copy { + max-width: 62ch; + color: var(--text-muted); + font-size: 1rem; } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } +.panel-card, +.empty-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); } -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; +.panel-card { + box-shadow: var(--shadow-md); } -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; +.metric-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem; + background: var(--surface-muted); + min-height: 100%; } -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); +.metric-card span { + display: block; + color: var(--text-muted); + font-size: 0.82rem; + margin-bottom: 0.35rem; } -.chat-input-area form { - display: flex; - gap: 0.75rem; +.metric-card strong { + font-size: 1.6rem; + line-height: 1; + letter-spacing: -0.03em; } -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; +.workflow-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; margin-top: 1.5rem; } -.table th { - background: transparent; - border: none; +.workflow-grid article { + border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; + background: var(--surface-muted); } -.table td { - background: #fff; - padding: 1rem; - border: none; +.workflow-grid span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 999px; + border: 1px solid var(--border-strong); + font-size: 0.82rem; + font-weight: 700; + margin-bottom: 0.75rem; } -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; +.workflow-grid h3 { + font-size: 1rem; margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; +.workflow-grid p, +.empty-card p, +.quick-links .list-group-item, +.stack-item p, +.detail-note { + color: var(--text-muted); } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +.quick-links .list-group-item { + padding-inline: 0; + border-color: var(--border); + background: transparent; } -.header-container { +.stack-list { + display: grid; + gap: 0.9rem; +} + +.stack-item { display: flex; justify-content: space-between; align-items: center; + gap: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.95rem 1rem; + background: var(--surface-muted); } -.header-links { +.app-table { + --bs-table-bg: transparent; + --bs-table-striped-bg: #f8fafc; + margin-bottom: 0; +} + +.app-table thead th { + background: #f8fafc; + color: var(--text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom-width: 1px; +} + +.app-table td, +.app-table th { + padding: 1rem; + border-color: var(--border); +} + +.auth-wrap { + min-height: calc(100vh - 180px); display: flex; + align-items: center; +} + +.form-control, +.form-select { + min-height: 44px; + border-color: var(--border-strong); + border-radius: var(--radius-sm); + padding-inline: 0.9rem; + background-color: #fff; +} + +.form-control:focus, +.form-select:focus, +.btn:focus, +.nav-link:focus { + box-shadow: 0 0 0 0.2rem rgba(17, 24, 39, 0.12); + border-color: var(--primary); +} + +.btn { + border-radius: var(--radius-sm); + padding: 0.7rem 1rem; + font-weight: 600; +} + +.btn-lg { + padding: 0.85rem 1.25rem; +} + +.btn-dark { + background: var(--primary); + border-color: var(--primary); +} + +.btn-outline-secondary { + color: var(--secondary); + border-color: var(--border-strong); +} + +.detail-list { + display: grid; gap: 1rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.detail-list div { + display: grid; + gap: 0.25rem; + padding-bottom: 0.9rem; + border-bottom: 1px solid var(--border); } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; +.detail-list div:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.detail-list dt { + font-size: 0.78rem; font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.detail-list dd { + margin: 0; + font-size: 1rem; } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.toast { + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; +.text-bg-success { + background-color: var(--success) !important; } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.text-bg-warning { + background-color: var(--warning) !important; + color: #fff !important; } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.text-bg-danger { + background-color: var(--danger) !important; } -.history-table { - width: 100%; +.text-bg-info { + background-color: #344054 !important; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.badge.text-bg-light { + background: #f8fafc !important; + color: var(--secondary) !important; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.sticky-form-card { + position: sticky; + top: 5.5rem; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.app-footer { + background: transparent; } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +@media (max-width: 991.98px) { + .workflow-grid { + grid-template-columns: 1fr; + } + + .sticky-form-card { + position: static; + } +} + +@media (max-width: 575.98px) { + .display-title { + max-width: none; + } + + .panel-card, + .empty-card { + border-radius: var(--radius-md); + } + + .app-table td, + .app-table th { + padding: 0.8rem; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..e414550 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,12 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + document.querySelectorAll('.toast').forEach((toastEl) => { + const toast = new bootstrap.Toast(toastEl, { delay: 4500 }); + toast.show(); + }); - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - }; - - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; - - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); - } + document.querySelectorAll('input[data-uppercase="true"]').forEach((input) => { + input.addEventListener('input', () => { + input.value = input.value.toUpperCase(); }); + }); }); diff --git a/athlete.php b/athlete.php new file mode 100644 index 0000000..e74381e --- /dev/null +++ b/athlete.php @@ -0,0 +1,83 @@ +prepare('SELECT * FROM athletes WHERE id = :id AND user_id = :user_id LIMIT 1'); +$stmt->execute(['id' => $id, 'user_id' => (int) $user['id']]); +$athlete = $stmt->fetch(); + +if (!$athlete) { + http_response_code(404); + render_header('Fiche introuvable', ['description' => 'La fiche sportif demandée est introuvable.']); + ?> +
+
+

Fiche introuvable

+

Le sportif demandé n’existe pas ou n’est pas accessible avec votre compte.

+ Retour à la liste +
+
+ 'Consulter le détail complet d’un sportif dans RJLRESAKA.']); +?> +
+
+
+

Fiche détaillée

+

+

+
+ +
+ +
+
+
+

Résumé

+
+
Club actuel
+
Nationalité
+
Numéro
+
Date d’arrivée
+
Créé le
+
+
+
+
+
+
+

Performance actuelle

+ Parcours professionnel — instantané +
+
+
Matchs joués
+
Buts / points
+
Passes décisives
+
+
+
+

Parcours et distinctions

+
+

Distinctions

+

+
+
+

Note de parcours

+

+
+
+
+
+
+ diff --git a/athlete_new.php b/athlete_new.php new file mode 100644 index 0000000..f0afff2 --- /dev/null +++ b/athlete_new.php @@ -0,0 +1,180 @@ +prepare( + 'INSERT INTO athletes (user_id, first_name, last_name, sport_name, club_name, nationality, position_name, jersey_number, status, joined_on, matches_played, goals_scored, assists_count, awards, career_note) + VALUES (:user_id, :first_name, :last_name, :sport_name, :club_name, :nationality, :position_name, :jersey_number, :status, :joined_on, :matches_played, :goals_scored, :assists_count, :awards, :career_note)' + ); + $stmt->bindValue(':user_id', (int) $user['id'], PDO::PARAM_INT); + $stmt->bindValue(':first_name', $firstName); + $stmt->bindValue(':last_name', $lastName); + $stmt->bindValue(':sport_name', $sport); + $stmt->bindValue(':club_name', $club); + $stmt->bindValue(':nationality', $nationality !== '' ? $nationality : null); + $stmt->bindValue(':position_name', $position !== '' ? $position : null); + $stmt->bindValue(':jersey_number', $jerseyNumber !== '' ? (int) $jerseyNumber : null, $jerseyNumber !== '' ? PDO::PARAM_INT : PDO::PARAM_NULL); + $stmt->bindValue(':status', $status); + $stmt->bindValue(':joined_on', $joinedOn !== '' ? $joinedOn : null); + $stmt->bindValue(':matches_played', max(0, $matches), PDO::PARAM_INT); + $stmt->bindValue(':goals_scored', max(0, $goals), PDO::PARAM_INT); + $stmt->bindValue(':assists_count', max(0, $assists), PDO::PARAM_INT); + $stmt->bindValue(':awards', $awards !== '' ? $awards : null); + $stmt->bindValue(':career_note', $careerNote !== '' ? $careerNote : null); + $stmt->execute(); + + $id = (int) db()->lastInsertId(); + set_flash('success', 'Sportif créé avec succès.'); + redirect('athlete.php?id=' . $id); + } +} + +render_header('Ajouter un sportif', ['description' => 'Créer une nouvelle fiche sportive dans le registre RJLRESAKA.']); +?> +
+
+
+
+

Nouveau profil

+

Ajouter un sportif

+

Créez une fiche complète avec club actuel, indicateurs de performance et note de parcours.

+
+
+
+
+ + + +
+ +
+

Identité

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

Affectation sportive

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

Performance actuelle

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

Les données sont enregistrées en base MySQL via PDO et requêtes préparées.

+ +
+
+
+
+
+
+ diff --git a/athletes.php b/athletes.php new file mode 100644 index 0000000..6a37152 --- /dev/null +++ b/athletes.php @@ -0,0 +1,134 @@ + (int) $user['id']]; + +if ($q !== '') { + $sql .= ' AND (first_name LIKE :q OR last_name LIKE :q OR nationality LIKE :q OR position_name LIKE :q)'; + $params['q'] = '%' . $q . '%'; +} +if ($sport !== '') { + $sql .= ' AND sport_name = :sport'; + $params['sport'] = $sport; +} +if ($club !== '') { + $sql .= ' AND club_name = :club'; + $params['club'] = $club; +} +if ($status !== '') { + $sql .= ' AND status = :status'; + $params['status'] = $status; +} +$sql .= ' ORDER BY created_at DESC'; + +$stmt = db()->prepare($sql); +$stmt->execute($params); +$athletes = $stmt->fetchAll(); + +$sports = db()->prepare('SELECT DISTINCT sport_name FROM athletes WHERE user_id = :user_id ORDER BY sport_name ASC'); +$sports->execute(['user_id' => (int) $user['id']]); +$clubs = db()->prepare('SELECT DISTINCT club_name FROM athletes WHERE user_id = :user_id ORDER BY club_name ASC'); +$clubs->execute(['user_id' => (int) $user['id']]); + +render_header('Liste des sportifs', ['description' => 'Filtrer, parcourir et consulter les fiches sportifs enregistrées.']); +?> +
+
+
+

Registre principal

+

Liste des sportifs

+
+ Ajouter un sportif +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Réinitialiser +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
SportifSportClub actuelStatutStats
+ +
+
MJ · PTS · ASTVoir la fiche
+
+ +
+

Aucun résultat

+

Aucune fiche ne correspond à vos filtres actuels.

+ Créer la première fiche +
+ +
+
+ diff --git a/change_password.php b/change_password.php new file mode 100644 index 0000000..4e7c7c0 --- /dev/null +++ b/change_password.php @@ -0,0 +1,77 @@ +prepare('SELECT password_hash FROM users WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => (int) $user['id']]); + $fresh = $stmt->fetch(); + + if (!$fresh || !password_verify($current, (string) $fresh['password_hash'])) { + $errors[] = 'Le mot de passe actuel est incorrect.'; + } + if (!password_rules_ok($password)) { + $errors[] = 'Le nouveau mot de passe doit contenir au moins 8 caractères.'; + } + if ($password !== $confirm) { + $errors[] = 'La confirmation ne correspond pas.'; + } + + if (!$errors) { + $update = db()->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id'); + $update->execute([ + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + 'id' => (int) $user['id'], + ]); + set_flash('success', 'Mot de passe modifié avec succès.'); + redirect('index.php'); + } +} + +render_header('Modifier mot de passe', ['description' => 'Modifier le mot de passe du compte utilisateur RJLRESAKA.']); +?> +
+
+
+
+

Sécurité

+

Modifier le mot de passe

+ + + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ diff --git a/forgot_password.php b/forgot_password.php new file mode 100644 index 0000000..ce3f257 --- /dev/null +++ b/forgot_password.php @@ -0,0 +1,58 @@ +prepare('SELECT id, email FROM users WHERE email = :email LIMIT 1'); + $stmt->execute(['email' => $email]); + $user = $stmt->fetch(); + + if ($user) { + $token = strtoupper(substr(bin2hex(random_bytes(8)), 0, 12)); + $hash = hash('sha256', $token); + $expiresAt = date('Y-m-d H:i:s', time() + 900); + $update = db()->prepare('UPDATE users SET reset_token_hash = :hash, reset_expires_at = :expires_at WHERE id = :id'); + $update->execute([ + 'hash' => $hash, + 'expires_at' => $expiresAt, + 'id' => (int) $user['id'], + ]); + error_log('RJLRESAKA reset token for ' . $email . ': ' . $token); + set_flash('info', 'Token de démonstration généré : ' . $token . ' (également journalisé côté serveur, valide 15 min).'); + } else { + set_flash('info', 'Si ce compte existe, un token de réinitialisation a été généré.'); + } + } else { + set_flash('warning', 'Veuillez saisir un email valide.'); + } + + redirect('reset_password.php'); +} + +render_header('Mot de passe oublié', ['description' => 'Générer un token de réinitialisation pour l’accès à RJLRESAKA.']); +?> +
+
+
+
+

Réinitialisation

+

Mot de passe oublié

+

Saisissez votre email. Pour ce MVP, le token apparaît en notification et dans les logs serveur.

+
+ +
+ + +
+ +
+
+
+
+
+ diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..6f42522 --- /dev/null +++ b/healthz.php @@ -0,0 +1,10 @@ + 'ok', + 'time' => gmdate('c'), + 'php' => PHP_VERSION, +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); diff --git a/includes/app.php b/includes/app.php new file mode 100644 index 0000000..c4d95b8 --- /dev/null +++ b/includes/app.php @@ -0,0 +1,342 @@ +exec( + "CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + first_name VARCHAR(80) NOT NULL, + last_name VARCHAR(80) NOT NULL, + email VARCHAR(150) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin','manager','user') NOT NULL DEFAULT 'manager', + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME NULL, + reset_token_hash CHAR(64) NULL, + reset_expires_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_users_email (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS athletes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + first_name VARCHAR(80) NOT NULL, + last_name VARCHAR(80) NOT NULL, + sport_name VARCHAR(100) NOT NULL, + club_name VARCHAR(120) NOT NULL, + nationality VARCHAR(80) DEFAULT NULL, + position_name VARCHAR(80) DEFAULT NULL, + jersey_number INT DEFAULT NULL, + status ENUM('actif','blesse','suspendu','retraite') NOT NULL DEFAULT 'actif', + joined_on DATE DEFAULT NULL, + matches_played INT NOT NULL DEFAULT 0, + goals_scored INT NOT NULL DEFAULT 0, + assists_count INT NOT NULL DEFAULT 0, + awards VARCHAR(255) DEFAULT NULL, + career_note TEXT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_athletes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_athletes_sport (sport_name), + INDEX idx_athletes_club (club_name), + INDEX idx_athletes_status (status), + INDEX idx_athletes_name (last_name, first_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); +} + +function e(?string $value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function env_value(string $key, string $fallback = ''): string +{ + $value = $_SERVER[$key] ?? getenv($key) ?: $fallback; + return is_string($value) ? $value : $fallback; +} + +function app_name(): string +{ + return env_value('PROJECT_NAME', 'RJLRESAKA'); +} + +function page_title(string $title): string +{ + return $title . ' • ' . app_name(); +} + +function set_flash(string $type, string $message): void +{ + $_SESSION['flash'][] = ['type' => $type, 'message' => $message]; +} + +function get_flashes(): array +{ + $flashes = $_SESSION['flash'] ?? []; + unset($_SESSION['flash']); + return is_array($flashes) ? $flashes : []; +} + +function current_user(): ?array +{ + if (empty($_SESSION['user_id'])) { + return null; + } + + static $user = null; + if ($user !== null) { + return $user; + } + + $stmt = db()->prepare('SELECT id, first_name, last_name, email, role, created_at FROM users WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => (int) $_SESSION['user_id']]); + $user = $stmt->fetch() ?: null; + if (!$user) { + unset($_SESSION['user_id']); + } + return $user; +} + +function require_login(): void +{ + if (!current_user()) { + set_flash('warning', 'Veuillez vous connecter pour accéder à cette section.'); + redirect('login.php'); + } +} + +function redirect(string $path): void +{ + header('Location: ' . $path); + exit; +} + +function csrf_token(): string +{ + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(16)); + } + return $_SESSION['csrf_token']; +} + +function verify_csrf(): void +{ + $token = $_POST['csrf_token'] ?? ''; + if (!is_string($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) { + http_response_code(422); + exit('Jeton de formulaire invalide.'); + } +} + +function request_method(): string +{ + return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); +} + +function is_post(): bool +{ + return request_method() === 'POST'; +} + +function old(string $key, string $fallback = ''): string +{ + return e($_POST[$key] ?? $fallback); +} + +function password_rules_ok(string $password): bool +{ + return strlen($password) >= 8; +} + +function login_user(array $user): void +{ + session_regenerate_id(true); + $_SESSION['user_id'] = (int) $user['id']; +} + +function logout_user(): void +{ + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']); + } + session_destroy(); +} + +function format_datetime(?string $value): string +{ + if (!$value) { + return '—'; + } + return date('d/m/Y H:i', strtotime($value)); +} + +function format_date(?string $value): string +{ + if (!$value) { + return '—'; + } + return date('d/m/Y', strtotime($value)); +} + +function stat_badge_class(string $status): string +{ + return match ($status) { + 'actif' => 'success', + 'blesse' => 'warning', + 'suspendu' => 'danger', + 'retraite' => 'secondary', + default => 'secondary', + }; +} + +function fetch_dashboard_stats(?int $userId = null): array +{ + $pdo = db(); + $where = $userId ? 'WHERE user_id = :user_id' : ''; + + $stmt = $pdo->prepare("SELECT COUNT(*) AS total, COUNT(DISTINCT sport_name) AS sports, COUNT(DISTINCT club_name) AS clubs FROM athletes $where"); + $stmt->execute($userId ? ['user_id' => $userId] : []); + $base = $stmt->fetch() ?: ['total' => 0, 'sports' => 0, 'clubs' => 0]; + + $statusStmt = $pdo->prepare("SELECT status, COUNT(*) AS total FROM athletes $where GROUP BY status ORDER BY total DESC"); + $statusStmt->execute($userId ? ['user_id' => $userId] : []); + + $recentStmt = $pdo->prepare("SELECT id, first_name, last_name, sport_name, club_name, status, created_at FROM athletes $where ORDER BY created_at DESC LIMIT 5"); + $recentStmt->execute($userId ? ['user_id' => $userId] : []); + + return [ + 'total' => (int) ($base['total'] ?? 0), + 'sports' => (int) ($base['sports'] ?? 0), + 'clubs' => (int) ($base['clubs'] ?? 0), + 'status_breakdown' => $statusStmt->fetchAll(), + 'recent' => $recentStmt->fetchAll(), + ]; +} + +function top_values(string $column, int $limit = 6): array +{ + $allowed = ['sport_name', 'club_name']; + if (!in_array($column, $allowed, true)) { + return []; + } + + $stmt = db()->query("SELECT {$column} AS label, COUNT(*) AS total FROM athletes GROUP BY {$column} ORDER BY total DESC, {$column} ASC LIMIT {$limit}"); + return $stmt->fetchAll(); +} + +function render_header(string $title, array $options = []): void +{ + $pageDescription = $options['description'] ?? env_value('PROJECT_DESCRIPTION', 'Gestion professionnelle des sportifs, clubs et sports.'); + $projectImageUrl = env_value('PROJECT_IMAGE_URL', ''); + $bodyClass = $options['body_class'] ?? ''; + $user = current_user(); + $flashes = get_flashes(); + $assetVersion = (string) @filemtime(__DIR__ . '/../assets/css/custom.css'); + ?> + + + + + + <?= e(page_title($title)) ?> + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + + + 'RJLRESAKA centralise les sportifs, clubs et disciplines dans une interface nette et sécurisée.', + 'body_class' => 'app-shell', +]); ?> - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
-
- - - +
+
+
+
+
+ Gestion sportive sécurisée +

Pilotez vos sportifs, clubs et disciplines dans un espace unique.

+

Ce premier MVP livre une base réellement exploitable : authentification sécurisée, verrouillage après 5 échecs, réinitialisation par token, création de fiches sportifs, tableau de bord, liste filtrable et fiche détaillée.

+ +
+
+
+
+
+

Vue synthèse

+

Indicateurs clés

+
+ MVP initial +
+
+
+
+ Total sportifs + +
+
+
+
+ Sports suivis + +
+
+
+
+ Clubs actifs + +
+
+
+
+ Statuts suivis + +
+
+
+
+
+ + + · + + + Aucun sportif enregistré pour le moment. + +
+
+
+
+
+
+ +
+
+
+
+
+
+

Workflow livré

+

Parcours utilisateur disponible maintenant

+
+ Ouvrir le registre +
+
+
+ 01 +

Accès sécurisé

+

Inscription, connexion, verrouillage temporaire après 5 tentatives et réinitialisation via token.

+
+
+ 02 +

Création guidée

+

Ajoutez un sportif avec discipline, club, statut, statistiques clés et note de parcours.

+
+
+ 03 +

Suivi exploitable

+

Filtrez le registre, consultez la fiche détaillée et surveillez la répartition par sport et par club.

+
+
+
+
+ +
+
+ +
+
+
+
+
+

Sports les plus représentés

+ Aperçu agrégé +
+ +
+ +
+
+ +

Discipline suivie

+
+ sportifs +
+ +
+ +
+

Aucun sport disponible

+

Ajoutez votre premier sportif pour faire apparaître les disciplines suivies.

+
+ +
+
+
+
+
+

Clubs récents

+ Vue opérationnelle +
+ +
+ + + + + + + + + + + + + + + + + +
SportifClubStatut
+
+ +
+

Registre vide

+

Le tableau de bord s’enrichira dès la première fiche créée.

+
+ +
+
+
+
+
+ diff --git a/login.php b/login.php new file mode 100644 index 0000000..519ec8e --- /dev/null +++ b/login.php @@ -0,0 +1,80 @@ +prepare('SELECT * FROM users WHERE email = :email LIMIT 1'); + $stmt->execute(['email' => $email]); + $user = $stmt->fetch(); + + if (!$user) { + $error = 'Identifiants invalides.'; + } else { + $lockedUntil = $user['locked_until'] ?? null; + if ($lockedUntil && strtotime((string) $lockedUntil) > time()) { + $seconds = max(1, strtotime((string) $lockedUntil) - time()); + $error = 'Compte temporairement bloqué. Réessayez dans ' . $seconds . ' secondes.'; + } elseif (password_verify($password, (string) $user['password_hash'])) { + $reset = db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = :id'); + $reset->execute(['id' => (int) $user['id']]); + login_user($user); + set_flash('success', 'Connexion réussie.'); + redirect('index.php'); + } else { + $attempts = (int) $user['failed_attempts'] + 1; + $locked = $attempts >= 5 ? date('Y-m-d H:i:s', time() + 30) : null; + $update = db()->prepare('UPDATE users SET failed_attempts = :failed_attempts, locked_until = :locked_until WHERE id = :id'); + $update->bindValue(':failed_attempts', $attempts, PDO::PARAM_INT); + $update->bindValue(':locked_until', $locked); + $update->bindValue(':id', (int) $user['id'], PDO::PARAM_INT); + $update->execute(); + $error = $attempts >= 5 + ? '5 tentatives atteintes. Compte bloqué pendant 30 secondes.' + : 'Identifiants invalides. Tentative ' . $attempts . '/5.'; + } + } +} + +render_header('Connexion', ['description' => 'Se connecter à RJLRESAKA pour accéder au registre des sportifs.']); +?> +
+
+
+
+

Authentification

+

Connexion sécurisée

+

5 tentatives maximum, puis pause automatique de 30 secondes.

+ + + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+
+
+ diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..18f2049 --- /dev/null +++ b/logout.php @@ -0,0 +1,8 @@ +prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); + $check->execute(['email' => $email]); + if ($check->fetch()) { + $errors[] = 'Un compte existe déjà avec cet email.'; + } + } + + if (!$errors) { + $stmt = db()->prepare('INSERT INTO users (first_name, last_name, email, password_hash) VALUES (:first_name, :last_name, :email, :password_hash)'); + $stmt->execute([ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + ]); + + $userId = (int) db()->lastInsertId(); + login_user(['id' => $userId]); + set_flash('success', 'Compte créé avec succès. Bienvenue sur RJLRESAKA.'); + redirect('index.php'); + } +} + +render_header('Inscription', ['description' => 'Créer un compte pour gérer les sportifs et clubs dans RJLRESAKA.']); +?> +
+
+
+
+

Créer un compte

+

Ouvrir l’espace de gestion

+

Commencez avec un compte sécurisé pour enregistrer vos premiers sportifs.

+ + + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+

Déjà inscrit ? Connectez-vous.

+
+
+
+
+ diff --git a/reset_password.php b/reset_password.php new file mode 100644 index 0000000..009de6b --- /dev/null +++ b/reset_password.php @@ -0,0 +1,95 @@ +prepare('SELECT id, reset_token_hash, reset_expires_at FROM users WHERE email = :email LIMIT 1'); + $stmt->execute(['email' => $email]); + $user = $stmt->fetch(); + $hash = hash('sha256', $token); + + $valid = $user + && !empty($user['reset_token_hash']) + && hash_equals((string) $user['reset_token_hash'], $hash) + && !empty($user['reset_expires_at']) + && strtotime((string) $user['reset_expires_at']) >= time(); + + if (!$valid) { + $errors[] = 'Token invalide ou expiré.'; + } else { + $update = db()->prepare('UPDATE users SET password_hash = :password_hash, reset_token_hash = NULL, reset_expires_at = NULL, failed_attempts = 0, locked_until = NULL WHERE id = :id'); + $update->execute([ + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + 'id' => (int) $user['id'], + ]); + set_flash('success', 'Mot de passe réinitialisé. Vous pouvez maintenant vous connecter.'); + redirect('login.php'); + } + } +} + +render_header('Reset mot de passe', ['description' => 'Valider un token et définir un nouveau mot de passe sur RJLRESAKA.']); +?> +
+
+
+
+

Validation du token

+

Définir un nouveau mot de passe

+

Copiez le token reçu puis confirmez un nouveau mot de passe sécurisé.

+ + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+