diff --git a/captcha.php b/captcha.php index 4bc8f56..89694f2 100644 --- a/captcha.php +++ b/captcha.php @@ -180,16 +180,19 @@ $spamSubjects = [ ['text' => 'CONFIRMEZ VOTRE IDENTITÉ POUR RECEVOIR VOTRE ARGENT', 'reason' => 'Promesse d’argent contre des données personnelles'], ]; -// Liste des expéditeurs spam +// Liste des expéditeurs spam : mêmes formats d'affichage (nom, pas d'adresse brute) que les +// expéditeurs normaux ci-dessus, pour qu'il ne soit pas possible de repérer les mails +// frauduleux d'un simple coup d'œil sur l'expéditeur. La seule façon de les distinguer doit +// être de lire le contenu (sujet), comme un vrai email de phishing usurpant une identité connue. $spamSenders = [ - 'service-urgent@compte-bloque-now.biz', - 'cadeau-gratuit@super-offre-win.click', - 'banque-alerte@verification-securite.top', - 'colis-stop@paiement-obligatoire.help', - 'admin-compte@action-immediate.world', - 'support-prime@argent-rapide-fast.support', - 'verification@motdepasse-danger.win', - 'alerte@compte-fermeture.click' + 'Sécurité du compte', + 'Service client', + 'Banque familiale', + 'Livraison Colis', + 'Support technique', + 'Service facturation', + 'Assurance Habitation', + 'Mutuelle Santé' ]; // Liste des aperçus normaux diff --git a/database/scriptDatabase.sql b/database/scriptDatabase.sql index 93813da..69f1b0c 100644 --- a/database/scriptDatabase.sql +++ b/database/scriptDatabase.sql @@ -62,7 +62,7 @@ CREATE TABLE IF NOT EXISTS schools ( PRIMARY KEY (id) ); --- Réglages du site (clé / valeur), utilisés pour la notification de la page d'accueil +-- Réglages du site (clé / valeur), utilisés pour la notification de la page d'accueil et le captcha CREATE TABLE IF NOT EXISTS settings ( name VARCHAR(50) NOT NULL, value TEXT NULL, @@ -72,7 +72,8 @@ CREATE TABLE IF NOT EXISTS settings ( INSERT INTO settings (name, value) VALUES ('site_notice_title', 'Des bugs peuvent encore se produire.'), ('site_notice_text', 'Si le CSS ou les images s''affichent mal, pensez à vider le cache de votre navigateur (Ctrl + F5 ou Cmd + Shift + R) pour voir les dernières modifications.'), -('site_notice_enabled', '1') +('site_notice_enabled', '1'), +('captcha_enabled', '1') ON DUPLICATE KEY UPDATE value = VALUES(value); -- items : un item par ligne, au format "classes-icone|Texte" (icône optionnelle) @@ -134,30 +135,3 @@ CREATE TABLE IF NOT EXISTS school_pdfs ( PRIMARY KEY (school_id), CONSTRAINT fk_school_pdfs_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE ); - --- Jeu "Presse-Citron" (easter egg, code Konami) : comptes séparés des comptes admin ci-dessus. --- game_state (JSON) stocke bâtiments/améliorations possédés ; toute la logique du jeu vit côté JS. -CREATE TABLE IF NOT EXISTS lemon_players ( - id INT UNSIGNED NOT NULL AUTO_INCREMENT, - username VARCHAR(50) NOT NULL, - password VARCHAR(255) NOT NULL, - score DOUBLE NOT NULL DEFAULT 0, - lifetime_score DOUBLE NOT NULL DEFAULT 0, - prestige_level INT UNSIGNED NOT NULL DEFAULT 0, - prestige_bonus DOUBLE NOT NULL DEFAULT 0, - game_state LONGTEXT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_played DATETIME NULL, - PRIMARY KEY (id), - UNIQUE INDEX idx_lemon_username (username) -); - --- Photo de profil des joueurs "Presse-Citron", table à part pour la même raison que les PDF -CREATE TABLE IF NOT EXISTS lemon_avatars ( - player_id INT UNSIGNED NOT NULL, - avatar_data LONGBLOB NOT NULL, - avatar_mime VARCHAR(50) NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (player_id), - CONSTRAINT fk_lemon_avatars_player FOREIGN KEY (player_id) REFERENCES lemon_players(id) ON DELETE CASCADE -); diff --git a/elements/footer.php b/elements/footer.php index b635f8a..0713a3f 100644 --- a/elements/footer.php +++ b/elements/footer.php @@ -74,125 +74,5 @@ if (isset($_GET['success']) && $_GET['success'] === '1') { })(); - - - - \ No newline at end of file diff --git a/functions/avatarUpload.php b/functions/avatarUpload.php deleted file mode 100644 index 6e04e0e..0000000 --- a/functions/avatarUpload.php +++ /dev/null @@ -1,66 +0,0 @@ - ..., 'data' => ...] si l'image est valide -function lireUploadAvatar($champ = 'avatar') { - if (!isset($_FILES[$champ]) || $_FILES[$champ]['error'] === UPLOAD_ERR_NO_FILE) { - return null; - } - - if ($_FILES[$champ]['error'] !== UPLOAD_ERR_OK) { - return "Erreur lors de l'envoi de l'image (code " . $_FILES[$champ]['error'] . ")."; - } - - if ($_FILES[$champ]['size'] > AVATAR_MAX_SIZE) { - return 'La photo dépasse la taille maximale autorisée (3 Mo).'; - } - - // getimagesize() décode réellement l'en-tête de l'image (contrairement à une simple - // vérification d'extension) : un fichier renommé en .jpg mais qui n'est pas une vraie - // image est rejeté ici, sans avoir besoin de l'extension GD - $infos = @getimagesize($_FILES[$champ]['tmp_name']); - if ($infos === false) { - return "Le fichier envoyé n'est pas une image valide."; - } - - $largeur = $infos[0]; - $hauteur = $infos[1]; - $mime = $infos['mime']; - - if (!in_array($mime, AVATAR_MIME_AUTORISES, true)) { - return 'Format non supporté : utilise une image JPEG, PNG ou WebP.'; - } - - if ($largeur > AVATAR_MAX_DIMENSION || $hauteur > AVATAR_MAX_DIMENSION) { - return 'Image trop grande (maximum ' . AVATAR_MAX_DIMENSION . ' pixels de côté).'; - } - - $contenu = file_get_contents($_FILES[$champ]['tmp_name']); - if ($contenu === false) { - return "Impossible de lire le fichier envoyé."; - } - - return [ - 'mime' => $mime, - 'data' => $contenu - ]; -} diff --git a/functions/lemonSession.php b/functions/lemonSession.php deleted file mode 100644 index f825b01..0000000 --- a/functions/lemonSession.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Non connecté.']); - exit; - } -} diff --git a/pages/admin.php b/pages/admin.php index 1e46c36..696a1d9 100644 --- a/pages/admin.php +++ b/pages/admin.php @@ -52,6 +52,9 @@ $noticeTitle = recupererSetting('site_notice_title') ?? ''; $noticeText = recupererSetting('site_notice_text') ?? ''; $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1'; +// Valeur actuelle du captcha anti-robot (activé par défaut) +$captchaEnabled = recupererSetting('captcha_enabled') ?? '1'; + // Page du panneau d'administration ?> @@ -71,6 +74,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1'; Compétences Logos Notification + Sécurité Utilisateurs Données @@ -382,6 +386,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
+ @@ -402,6 +407,30 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1'; + +
+

Sécurité

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

Utilisateurs

diff --git a/pages/citron.php b/pages/citron.php deleted file mode 100644 index 6bcb2c3..0000000 --- a/pages/citron.php +++ /dev/null @@ -1,1092 +0,0 @@ -prepare(' - SELECT id, username, score, lifetime_score, prestige_level, prestige_bonus, game_state - FROM lemon_players WHERE id = :id - '); - $stmt->bindValue(':id', (int) $_SESSION['lemon_player_id'], PDO::PARAM_INT); - $stmt->execute(); - $joueurConnecte = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$joueurConnecte) { - // Le compte a été supprimé entre-temps : on nettoie la session - unset($_SESSION['lemon_player_id'], $_SESSION['lemon_username']); - } - } catch (PDOException $e) { - $joueurConnecte = null; - } -} - -$jetonCsrf = csrfToken(); -?> - - - - - -Presse-Citron - - - - - -
- -
-
Presse-Citron
- -
- - -
-
- -

Presse-Citron

-

Un compte à part, rien à voir avec l'admin du portfolio. Presse des citrons à l'infini.

- -
- - -
- -
- -
-
- - -
-
- - -
- -
- - -
-
- - -
- -
-
0
-
gouttes de citron
-
0 goutte/s
-
- -
- -
- -

Clique sur le citron pour le presser

- - -
- -
-
- - - -
-
- - -
- -
-
- -
- -
- - - - - - diff --git a/scripts/lemonAvatar.php b/scripts/lemonAvatar.php deleted file mode 100644 index 61e0ee4..0000000 --- a/scripts/lemonAvatar.php +++ /dev/null @@ -1,37 +0,0 @@ -prepare('SELECT avatar_data, avatar_mime FROM lemon_avatars WHERE player_id = :id'); - $stmt->bindValue(':id', (int) $playerId, PDO::PARAM_INT); - $stmt->execute(); - $row = $stmt->fetch(PDO::FETCH_ASSOC); -} catch (PDOException $e) { - http_response_code(404); - exit; -} - -if (!$row) { - http_response_code(404); - exit; -} - -header('Content-Type: ' . $row['avatar_mime']); -header('X-Content-Type-Options: nosniff'); -header('Content-Length: ' . strlen($row['avatar_data'])); -header('Content-Disposition: inline'); -// Les avatars changent rarement : cache navigateur d'une heure, comme les PDF (voir viewPdf.php) -header('Cache-Control: public, max-age=3600'); -echo $row['avatar_data']; -exit; diff --git a/scripts/lemonAvatarRemove.php b/scripts/lemonAvatarRemove.php deleted file mode 100644 index 36b9de4..0000000 --- a/scripts/lemonAvatarRemove.php +++ /dev/null @@ -1,29 +0,0 @@ - 'Méthode non autorisée.']); - exit; -} - -requireLemonAuth(); -requireCsrfToken(); - -try { - $stmt = $dbh->prepare('DELETE FROM lemon_avatars WHERE player_id = :id'); - $stmt->bindValue(':id', (int) $_SESSION['lemon_player_id'], PDO::PARAM_INT); - $stmt->execute(); - - echo json_encode(['ok' => true]); -} catch (PDOException $e) { - http_response_code(500); - echo json_encode(['error' => 'Erreur serveur.']); -} diff --git a/scripts/lemonAvatarUpload.php b/scripts/lemonAvatarUpload.php deleted file mode 100644 index ab6655e..0000000 --- a/scripts/lemonAvatarUpload.php +++ /dev/null @@ -1,51 +0,0 @@ - 'Méthode non autorisée.']); - exit; -} - -requireLemonAuth(); -requireCsrfToken(); - -$resultat = lireUploadAvatar('avatar'); - -if ($resultat === null) { - http_response_code(400); - echo json_encode(['error' => 'Aucune image envoyée.']); - exit; -} - -if (is_string($resultat)) { - http_response_code(400); - echo json_encode(['error' => $resultat]); - exit; -} - -try { - $stmt = $dbh->prepare(' - INSERT INTO lemon_avatars (player_id, avatar_data, avatar_mime, updated_at) - VALUES (:id, :data, :mime, NOW()) - ON DUPLICATE KEY UPDATE avatar_data = VALUES(avatar_data), avatar_mime = VALUES(avatar_mime), updated_at = NOW() - '); - $stmt->bindValue(':id', (int) $_SESSION['lemon_player_id'], PDO::PARAM_INT); - $stmt->bindValue(':data', $resultat['data'], PDO::PARAM_LOB); - $stmt->bindValue(':mime', $resultat['mime'], PDO::PARAM_STR); - $stmt->execute(); - - echo json_encode(['ok' => true]); -} catch (PDOException $e) { - http_response_code(500); - echo json_encode(['error' => 'Erreur serveur.']); -} diff --git a/scripts/lemonLeaderboard.php b/scripts/lemonLeaderboard.php deleted file mode 100644 index b89f694..0000000 --- a/scripts/lemonLeaderboard.php +++ /dev/null @@ -1,51 +0,0 @@ -prepare(' - SELECT id, username, score, lifetime_score, prestige_level, prestige_bonus, game_state, last_played, created_at - FROM lemon_players - ORDER BY lifetime_score DESC - LIMIT 15 - '); - $stmt->execute(); - $lignes = $stmt->fetchAll(PDO::FETCH_ASSOC); -} catch (PDOException $e) { - $lignes = []; -} - -// On calcule le nombre total de bâtiments possédés à partir du game_state (stocké en JSON) : -// une info que le classement n'affichait pas jusqu'ici, et qui donne un vrai aperçu de -// l'avancée d'un joueur au-delà du seul score -$classement = array_map(function ($ligne) { - $totalBatiments = 0; - if (!empty($ligne['game_state'])) { - $etat = json_decode($ligne['game_state'], true); - if (is_array($etat) && !empty($etat['buildings']) && is_array($etat['buildings'])) { - foreach ($etat['buildings'] as $possedes) { - $totalBatiments += (int) $possedes; - } - } - } - - return [ - 'id' => (int) $ligne['id'], - 'username' => $ligne['username'], - 'score' => (float) $ligne['score'], - 'lifetime_score' => (float) $ligne['lifetime_score'], - 'prestige_level' => (int) $ligne['prestige_level'], - 'prestige_bonus' => (float) $ligne['prestige_bonus'], - 'total_buildings' => $totalBatiments, - 'last_played' => $ligne['last_played'], - 'created_at' => $ligne['created_at'], - ]; -}, $lignes); - -echo json_encode($classement); diff --git a/scripts/lemonLogin.php b/scripts/lemonLogin.php deleted file mode 100644 index c413f97..0000000 --- a/scripts/lemonLogin.php +++ /dev/null @@ -1,61 +0,0 @@ - 'Méthode non autorisée.']); - exit; -} - -requireCsrfToken(); - -$username = trim($_POST['username'] ?? ''); -$password = $_POST['password'] ?? ''; - -try { - $stmt = $dbh->prepare(' - SELECT id, username, password, score, lifetime_score, prestige_level, prestige_bonus, game_state - FROM lemon_players WHERE username = :username LIMIT 1 - '); - $stmt->execute([':username' => $username]); - $player = $stmt->fetch(PDO::FETCH_ASSOC); -} catch (PDOException $e) { - http_response_code(500); - echo json_encode(['error' => 'Erreur serveur.']); - exit; -} - -if (!$player || !password_verify($password, $player['password'])) { - http_response_code(401); - echo json_encode(['error' => 'Nom de pressier ou mot de passe incorrect.']); - exit; -} - -// Régénère l'ID de session à la connexion : évite la fixation de session -session_regenerate_id(true); -$_SESSION['lemon_player_id'] = (int) $player['id']; -$_SESSION['lemon_username'] = $player['username']; - -try { - $stmt = $dbh->prepare('UPDATE lemon_players SET last_played = NOW() WHERE id = :id'); - $stmt->execute([':id' => $player['id']]); -} catch (PDOException $e) { - // Pas bloquant : la connexion reste valable même si cette mise à jour échoue -} - -echo json_encode([ - 'ok' => true, - 'id' => (int) $player['id'], - 'username' => $player['username'], - 'score' => (float) $player['score'], - 'lifetime_score' => (float) $player['lifetime_score'], - 'prestige_level' => (int) $player['prestige_level'], - 'prestige_bonus' => (float) $player['prestige_bonus'], - 'game_state' => $player['game_state'], -]); diff --git a/scripts/lemonLogout.php b/scripts/lemonLogout.php deleted file mode 100644 index dbe650b..0000000 --- a/scripts/lemonLogout.php +++ /dev/null @@ -1,11 +0,0 @@ - true]); diff --git a/scripts/lemonRegister.php b/scripts/lemonRegister.php deleted file mode 100644 index 5813830..0000000 --- a/scripts/lemonRegister.php +++ /dev/null @@ -1,68 +0,0 @@ - 'Méthode non autorisée.']); - exit; -} - -requireCsrfToken(); - -$username = trim($_POST['username'] ?? ''); -$password = $_POST['password'] ?? ''; - -if ($username === '' || mb_strlen($username) > 50) { - http_response_code(400); - echo json_encode(['error' => "Nom de pressier invalide (1 à 50 caractères)."]); - exit; -} - -if (mb_strlen($password) < 4) { - http_response_code(400); - echo json_encode(['error' => 'Le mot de passe doit faire au moins 4 caractères.']); - exit; -} - -try { - // On vérifie que le nom n'est pas déjà pris - $stmt = $dbh->prepare('SELECT id FROM lemon_players WHERE username = :username LIMIT 1'); - $stmt->execute([':username' => $username]); - if ($stmt->fetch()) { - http_response_code(409); - echo json_encode(['error' => 'Ce nom de pressier est déjà pris.']); - exit; - } - - $hash = password_hash($password, PASSWORD_DEFAULT); - $stmt = $dbh->prepare('INSERT INTO lemon_players (username, password, created_at) VALUES (:username, :password, NOW())'); - $stmt->execute([':username' => $username, ':password' => $hash]); - - $playerId = (int) $dbh->lastInsertId(); - - // Régénère l'ID de session à la création de compte (même logique que checkLogin.php - // pour le portfolio : évite la fixation de session) - session_regenerate_id(true); - $_SESSION['lemon_player_id'] = $playerId; - $_SESSION['lemon_username'] = $username; - - echo json_encode([ - 'ok' => true, - 'id' => $playerId, - 'username' => $username, - 'score' => 0, - 'lifetime_score' => 0, - 'prestige_level' => 0, - 'prestige_bonus' => 0, - 'game_state' => null, - ]); -} catch (PDOException $e) { - http_response_code(500); - echo json_encode(['error' => 'Erreur serveur.']); -} diff --git a/scripts/lemonSave.php b/scripts/lemonSave.php deleted file mode 100644 index 588ef2e..0000000 --- a/scripts/lemonSave.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Méthode non autorisée.']); - exit; -} - -requireLemonAuth(); -requireCsrfToken(); - -$score = $_POST['score'] ?? null; -$lifetimeScore = $_POST['lifetime_score'] ?? null; -$prestigeLevel = $_POST['prestige_level'] ?? null; -$prestigeBonus = $_POST['prestige_bonus'] ?? null; -$gameState = $_POST['game_state'] ?? null; - -// Validation basique : évite qu'une requête forgée n'enregistre n'importe quoi -if ( - !is_numeric($score) || $score < 0 || - !is_numeric($lifetimeScore) || $lifetimeScore < 0 || - !is_numeric($prestigeLevel) || $prestigeLevel < 0 || - !is_numeric($prestigeBonus) || $prestigeBonus < 0 -) { - http_response_code(400); - echo json_encode(['error' => 'Données de sauvegarde invalides.']); - exit; -} - -// game_state : JSON de taille raisonnable (20 Ko largement suffisant pour la liste -// des bâtiments/améliorations possédés), sinon on refuse plutôt que de tronquer -if ($gameState !== null && (strlen($gameState) > 20000 || json_decode($gameState) === null)) { - http_response_code(400); - echo json_encode(['error' => 'État de jeu invalide.']); - exit; -} - -try { - $stmt = $dbh->prepare(' - UPDATE lemon_players - SET score = :score, lifetime_score = :lifetime_score, prestige_level = :prestige_level, - prestige_bonus = :prestige_bonus, game_state = :game_state, last_played = NOW() - WHERE id = :id - '); - $stmt->bindValue(':score', (float) $score); - $stmt->bindValue(':lifetime_score', (float) $lifetimeScore); - $stmt->bindValue(':prestige_level', (int) $prestigeLevel, PDO::PARAM_INT); - $stmt->bindValue(':prestige_bonus', (float) $prestigeBonus); - $stmt->bindValue(':game_state', $gameState, $gameState === null ? PDO::PARAM_NULL : PDO::PARAM_STR); - $stmt->bindValue(':id', (int) $_SESSION['lemon_player_id'], PDO::PARAM_INT); - $stmt->execute(); - - echo json_encode(['ok' => true]); -} catch (PDOException $e) { - http_response_code(500); - echo json_encode(['error' => 'Erreur serveur.']); -} diff --git a/scripts/requireCaptcha.php b/scripts/requireCaptcha.php index 721445f..7a5016a 100644 --- a/scripts/requireCaptcha.php +++ b/scripts/requireCaptcha.php @@ -1,6 +1,11 @@ trim($_POST['notice_title'] ?? ''), - 'site_notice_text' => trim($_POST['notice_text'] ?? ''), - 'site_notice_enabled' => isset($_POST['notice_enabled']) ? '1' : '0' -]; +// settings_group : évite qu'un formulaire écrase les réglages d'un autre (ex : la case +// "notice_enabled" non cochée ne doit pas désactiver le captcha, et inversement) +$groupeReglages = $_POST['settings_group'] ?? 'notice'; + +if ($groupeReglages === 'captcha') { + $reglages = [ + 'captcha_enabled' => isset($_POST['captcha_enabled']) ? '1' : '0' + ]; +} else { + $reglages = [ + 'site_notice_title' => trim($_POST['notice_title'] ?? ''), + 'site_notice_text' => trim($_POST['notice_text'] ?? ''), + 'site_notice_enabled' => isset($_POST['notice_enabled']) ? '1' : '0' + ]; +} try { // On essaie d'executer une requête qui est préparée pour chaque réglage // ON DUPLICATE KEY UPDATE : crée le réglage s'il n'existe pas encore, le met à jour sinon diff --git a/styles/citron.css b/styles/citron.css deleted file mode 100644 index babc40b..0000000 --- a/styles/citron.css +++ /dev/null @@ -1,897 +0,0 @@ -/* ---- Presse-Citron : easter egg Konami code (mini-jeu incrémental) ---- */ - -:root { - --lemon-hue: 48; /* dérive lentement avec la progression (voir JS) */ - --lemon: hsl(var(--lemon-hue), 92%, 56%); - --lemon-light: hsl(var(--lemon-hue), 100%, 74%); - --lemon-pale: hsl(var(--lemon-hue), 100%, 88%); - --lemon-dark: hsl(calc(var(--lemon-hue) - 6), 82%, 40%); - --leaf: hsl(100, 45%, 42%); - --bg: hsl(calc(var(--lemon-hue) + 8), 38%, 8%); - --bg-soft: hsl(calc(var(--lemon-hue) + 8), 30%, 11%); - --surface: hsl(calc(var(--lemon-hue) + 8), 24%, 14%); - --surface-2: hsl(calc(var(--lemon-hue) + 8), 20%, 19%); - --surface-3: hsl(calc(var(--lemon-hue) + 8), 18%, 24%); - --border: hsl(calc(var(--lemon-hue) + 8), 18%, 26%); - --accent-soft: hsla(var(--lemon-hue), 92%, 56%, 0.14); - --text: #fbf8ef; - --text-muted: #b9b3a2; - --radius: 16px; - --radius-sm: 11px; - --shadow: 0 18px 44px rgba(0, 0, 0, 0.45); - --font: 'Inter', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif; -} - -* { - box-sizing: border-box; -} - -html, body { - margin: 0; - padding: 0; - height: 100%; -} - -body { - font-family: var(--font); - background: - radial-gradient(circle at 18% -10%, hsla(var(--lemon-hue), 80%, 45%, 0.25), transparent 45%), - radial-gradient(circle at 85% 110%, hsla(calc(var(--lemon-hue) + 40), 70%, 40%, 0.18), transparent 50%), - var(--bg); - color: var(--text); - overflow-x: hidden; - transition: background 0.6s ease; -} - -::selection { - background: var(--lemon); - color: #2a1c00; -} - -::-webkit-scrollbar { - width: 9px; -} - -::-webkit-scrollbar-thumb { - background: var(--surface-3); - border-radius: 999px; -} - -.citron-shell { - min-height: 100vh; - display: flex; - flex-direction: column; -} - -/* ---- Le citron dessiné en CSS (aucun emoji) : logo, écran d'accueil, cible cliquable ---- */ -.lemon-mark { - display: inline-block; - width: 26px; - height: 22px; - border-radius: 52% 52% 46% 46% / 62% 62% 38% 38%; - background: radial-gradient(circle at 34% 28%, var(--lemon-pale) 0%, var(--lemon) 48%, var(--lemon-dark) 100%); - position: relative; - box-shadow: inset -3px -3px 5px rgba(0, 0, 0, 0.2), inset 2px 2px 4px rgba(255, 255, 255, 0.35); - vertical-align: middle; -} - -.lemon-mark-leaf { - position: absolute; - top: -7px; - left: 50%; - width: 10px; - height: 15px; - background: linear-gradient(135deg, #8bc34a, var(--leaf)); - border-radius: 0 100% 0 100%; - transform: translateX(-50%) rotate(-18deg); - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.25); -} - -.lemon-mark-big { - width: 68px; - height: 58px; - margin: 0 auto 10px; - display: block; - animation: citron-bob 2.6s ease-in-out infinite; -} - -.lemon-mark-big .lemon-mark-leaf { - width: 22px; - height: 32px; - top: -16px; -} - -@keyframes citron-bob { - 0%, 100% { transform: translateY(0) rotate(-4deg); } - 50% { transform: translateY(-8px) rotate(4deg); } -} - -/* ---- En-tête ---- */ -.citron-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 22px; - background: rgba(0, 0, 0, 0.28); - backdrop-filter: blur(10px); - border-bottom: 1px solid var(--border); - flex-wrap: wrap; - gap: 10px; -} - -.citron-title { - display: flex; - align-items: center; - gap: 10px; - font-size: 19px; - font-weight: 800; - letter-spacing: -0.02em; -} - -.citron-player-info { - display: flex; - align-items: center; - gap: 14px; - font-size: 13.5px; - color: var(--text-muted); -} - -.citron-player-info i { - color: var(--lemon-light); -} - -.citron-player-info strong { - color: var(--text); -} - -/* ---- Photo de profil (en-tête du jeu) ---- */ -.citron-avatar-wrap { - position: relative; - width: 32px; - height: 32px; - flex: none; - border-radius: 50%; - border: none; - padding: 0; - cursor: pointer; - background: transparent; -} - -.citron-avatar-fallback, -.citron-avatar-img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - border-radius: 50%; -} - -.citron-avatar-fallback { - background: linear-gradient(135deg, var(--lemon-light), var(--lemon-dark)); - color: #2a1c00; - display: flex; - align-items: center; - justify-content: center; - font-weight: 800; - font-size: 13px; -} - -.citron-avatar-img { - object-fit: cover; - border: 2px solid var(--surface-2); -} - -.citron-avatar-edit { - position: absolute; - inset: 0; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.55); - color: #fff; - font-size: 11px; - opacity: 0; - transition: opacity 0.15s ease; -} - -.citron-avatar-wrap:hover .citron-avatar-edit { - opacity: 1; -} - -.citron-btn { - border: none; - border-radius: 999px; - padding: 9px 16px; - font-family: var(--font); - font-weight: 700; - font-size: 13px; - cursor: pointer; - background: var(--surface-2); - color: var(--text); - border: 1px solid var(--border); - display: inline-flex; - align-items: center; - gap: 7px; - transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; -} - -.citron-btn:hover { - background: var(--surface-3); - transform: translateY(-1px); -} - -.citron-btn.primary { - background: linear-gradient(135deg, var(--lemon-light), var(--lemon) 60%, var(--lemon-dark)); - color: #2a1c00; - box-shadow: 0 8px 20px var(--accent-soft); -} - -.citron-btn.primary:hover { - filter: brightness(1.06); - transform: translateY(-1px); -} - -.citron-btn:disabled { - opacity: 0.4; - cursor: not-allowed; - transform: none; -} - -/* ---- Écran d'authentification ---- */ -.citron-auth { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 30px 16px; -} - -.citron-auth-card { - width: 100%; - max-width: 380px; - background: linear-gradient(180deg, var(--surface), var(--bg-soft)); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 30px 28px; - text-align: center; - box-shadow: var(--shadow); -} - -.citron-auth-card h1 { - margin: 0 0 4px; - font-size: 21px; - letter-spacing: -0.01em; -} - -.citron-auth-card p.subtitle { - margin: 0 0 22px; - color: var(--text-muted); - font-size: 13px; - line-height: 1.5; -} - -.citron-auth-tabs { - display: flex; - gap: 6px; - margin-bottom: 18px; - background: var(--surface-2); - border-radius: 999px; - padding: 4px; - border: 1px solid var(--border); -} - -.citron-auth-tabs button { - flex: 1; - border: none; - background: transparent; - color: var(--text-muted); - font-family: var(--font); - font-weight: 700; - font-size: 12.5px; - padding: 9px; - border-radius: 999px; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - transition: background-color 0.15s ease, color 0.15s ease; -} - -.citron-auth-tabs button.active { - background: linear-gradient(135deg, var(--lemon-light), var(--lemon)); - color: #2a1c00; -} - -.citron-field { - text-align: left; - margin-bottom: 13px; -} - -.citron-field label { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--text-muted); - margin-bottom: 5px; -} - -.citron-field label i { - color: var(--lemon-light); - width: 12px; -} - -.citron-field input { - width: 100%; - padding: 11px 13px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--surface-2); - color: var(--text); - font-family: var(--font); - font-size: 14px; - transition: border-color 0.15s ease, box-shadow 0.15s ease; -} - -.citron-field input:focus { - outline: none; - border-color: var(--lemon); - box-shadow: 0 0 0 3px var(--accent-soft); -} - -.citron-auth-error { - background: rgba(220, 60, 40, 0.16); - border: 1px solid rgba(220, 60, 40, 0.4); - color: #ffb3a8; - border-radius: var(--radius-sm); - padding: 9px 12px; - font-size: 12.5px; - margin-bottom: 12px; - display: none; - text-align: left; -} - -.citron-auth-error.visible { - display: block; -} - -.citron-auth-submit { - width: 100%; - justify-content: center; - padding: 13px; - font-size: 14px; - margin-top: 4px; -} - -/* ---- Écran de jeu ---- */ -.citron-game { - flex: 1; - display: none; - flex-direction: column; -} - -.citron-game.visible { - display: flex; -} - -.citron-score-bar { - text-align: center; - padding: 18px 12px 4px; -} - -.citron-score { - font-size: clamp(32px, 6vw, 48px); - font-weight: 800; - letter-spacing: -0.02em; - color: var(--lemon-light); - text-shadow: 0 4px 20px var(--accent-soft); - font-variant-numeric: tabular-nums; -} - -.citron-score-label { - font-size: 12.5px; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.09em; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.citron-score-label i { - color: var(--lemon); -} - -.citron-cps { - margin-top: 5px; - font-size: 13px; - color: var(--text-muted); -} - -.citron-main { - flex: 1; - display: grid; - /* minmax(0, ...) plutôt que 1fr/340px seuls : sans le "0", une piste de grille ne - peut jamais devenir plus étroite que le plus large de ses enfants, ce qui fait - déborder toute la page sur mobile dès qu'un item de liste est un peu large */ - grid-template-columns: minmax(0, 1fr) minmax(0, 340px); - gap: 18px; - padding: 10px 18px 24px; - max-width: 1200px; - margin: 0 auto; - width: 100%; -} - -@media (max-width: 820px) { - .citron-main { - grid-template-columns: minmax(0, 1fr); - } -} - -/* ---- Le citron cliquable (dessiné en CSS) ---- */ -.citron-click-zone { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - padding-top: 18px; - position: relative; -} - -.citron-press { - position: relative; - width: min(44vw, 230px); - height: min(38vw, 200px); - border: none; - background: transparent; - cursor: pointer; - -webkit-tap-highlight-color: transparent; - padding: 0; -} - -.citron-press::before { - content: ''; - position: absolute; - inset: -14%; - border-radius: 50%; - background: radial-gradient(circle, var(--accent-soft), transparent 70%); - animation: lemon-pulse 3.2s ease-in-out infinite; - z-index: 0; -} - -@keyframes lemon-pulse { - 0%, 100% { transform: scale(0.92); opacity: 0.55; } - 50% { transform: scale(1.05); opacity: 1; } -} - -.lemon-visual { - position: relative; - z-index: 1; - display: block; - width: 100%; - height: 100%; - border-radius: 50% 50% 47% 47% / 60% 60% 40% 40%; - background: radial-gradient(circle at 32% 26%, var(--lemon-pale) 0%, var(--lemon) 46%, var(--lemon-dark) 100%); - box-shadow: - 0 22px 46px rgba(0, 0, 0, 0.4), - inset -14px -18px 30px rgba(0, 0, 0, 0.16), - inset 10px 12px 22px rgba(255, 255, 255, 0.3); - transition: transform 0.08s ease; -} - -.lemon-shine { - position: absolute; - top: 15%; - left: 18%; - width: 24%; - height: 16%; - background: rgba(255, 255, 255, 0.55); - border-radius: 50%; - filter: blur(3px); -} - -.lemon-leaf { - position: absolute; - top: -7%; - left: 50%; - width: 16%; - height: 25%; - background: linear-gradient(135deg, #8bc34a, var(--leaf)); - border-radius: 0 100% 0 100%; - transform: translateX(-50%) rotate(-18deg); - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.25); -} - -.citron-press:active .lemon-visual, -.citron-press.squish .lemon-visual { - transform: scale(0.87) rotate(-4deg); -} - -.citron-press-hint { - margin-top: 14px; - font-size: 13px; - color: var(--text-muted); - text-align: center; -} - -.citron-float { - position: absolute; - left: 50%; - top: 50%; - pointer-events: none; - font-weight: 800; - color: var(--lemon-light); - text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); - animation: citron-float-up 0.9s ease-out forwards; - white-space: nowrap; - font-size: 18px; - z-index: 2; -} - -@keyframes citron-float-up { - 0% { opacity: 1; transform: translate(-50%, -50%) scale(0.8); } - 100% { opacity: 0; transform: translate(-50%, -160%) scale(1.15); } -} - -.citron-drop { - position: absolute; - left: 50%; - top: 50%; - pointer-events: none; - font-size: 15px; - color: hsl(200, 90%, 65%); - animation: citron-drop-fly 0.7s ease-out forwards; - z-index: 2; -} - -@keyframes citron-drop-fly { - 0% { opacity: 1; transform: translate(-50%, -50%) translate(0, 0) scale(1); } - 100% { opacity: 0; transform: translate(-50%, -50%) translate(var(--dx), var(--dy)) scale(0.4); } -} - -/* ---- Prestige ---- */ -.citron-prestige { - margin-top: 20px; - text-align: center; -} - -.citron-prestige-btn { - background: linear-gradient(135deg, #ffd93d, #ff9d3d, #ff5e5e); - color: #2a1c00; - font-size: 14px; - padding: 13px 24px; - border-radius: 999px; - box-shadow: 0 12px 28px rgba(255, 150, 30, 0.4); -} - -.citron-prestige-note { - margin-top: 8px; - font-size: 12px; - color: var(--text-muted); - max-width: 320px; - line-height: 1.5; -} - -/* ---- Colonnes latérales ---- */ -.citron-panel { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - display: flex; - flex-direction: column; - min-height: 0; - max-height: 72vh; - box-shadow: var(--shadow); -} - -.citron-panel-tabs { - display: flex; - border-bottom: 1px solid var(--border); -} - -.citron-panel-tabs button { - flex: 1; - min-width: 0; - padding: 12px 4px; - background: transparent; - border: none; - color: var(--text-muted); - font-family: var(--font); - font-weight: 700; - font-size: 11.5px; - cursor: pointer; - border-bottom: 2px solid transparent; - display: flex; - align-items: center; - justify-content: center; - gap: 5px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: color 0.15s ease, border-color 0.15s ease; -} - -.citron-panel-tabs button i { - font-size: 13px; -} - -.citron-panel-tabs button.active { - color: var(--lemon-light); - border-bottom-color: var(--lemon); -} - -.citron-panel-body { - overflow-y: auto; - padding: 10px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.citron-item { - display: flex; - align-items: center; - gap: 12px; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px 12px; - cursor: pointer; - text-align: left; - font-family: var(--font); - color: var(--text); - transition: background-color 0.15s ease, transform 0.1s ease, opacity 0.15s ease, border-color 0.15s ease; -} - -.citron-item:hover:not(:disabled) { - background: var(--surface-3); - border-color: var(--lemon-dark); - transform: translateX(2px); -} - -.citron-item:disabled { - opacity: 0.42; - cursor: not-allowed; -} - -.citron-item .icon { - flex: none; - width: 40px; - height: 40px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - background: linear-gradient(145deg, var(--surface-3), var(--surface-2)); - color: var(--lemon-light); - box-shadow: inset 0 0 0 1px var(--border); -} - -.citron-item .infos { - flex: 1; - min-width: 0; -} - -.citron-item .name { - display: block; - font-weight: 700; - font-size: 13.5px; -} - -.citron-item .desc { - display: block; - font-size: 11.5px; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.citron-item .cost { - font-size: 12px; - font-weight: 700; - color: var(--lemon-light); - flex: none; - display: inline-flex; - align-items: center; - gap: 4px; -} - -.citron-item .cost i { - font-size: 10px; -} - -.citron-item .owned { - flex: none; - min-width: 28px; - text-align: center; - font-weight: 800; - background: var(--lemon-dark); - color: #fff8e0; - border-radius: 8px; - padding: 4px 7px; - font-size: 12.5px; -} - -.citron-empty { - text-align: center; - color: var(--text-muted); - font-size: 13px; - padding: 24px 10px; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -.citron-empty i { - font-size: 22px; - color: var(--lemon-dark); -} - -/* ---- Classement ---- */ -.citron-leaderboard-row { - display: grid; - grid-template-columns: 26px 36px 1fr auto; - align-items: center; - gap: 11px; - padding: 10px 11px; - background: var(--surface-2); - border: 1px solid transparent; - border-radius: var(--radius-sm); -} - -.citron-leaderboard-row.lb-me { - border-color: var(--lemon); - background: var(--accent-soft); -} - -.lb-rank { - text-align: center; - font-size: 15px; -} - -.lb-rank-number { - color: var(--text-muted); - font-weight: 800; - font-size: 12.5px; -} - -.lb-gold { color: #ffd700; } -.lb-silver { color: #d6d6e0; } -.lb-bronze { color: #cd7f32; } - -.lb-avatar-wrap { - position: relative; - width: 34px; - height: 34px; - flex: none; -} - -.lb-avatar { - width: 34px; - height: 34px; - border-radius: 50%; - background: linear-gradient(135deg, var(--lemon-light), var(--lemon-dark)); - color: #2a1c00; - display: flex; - align-items: center; - justify-content: center; - font-weight: 800; - font-size: 13.5px; - flex: none; -} - -.lb-avatar-img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.lb-main { - min-width: 0; -} - -.lb-name { - display: flex; - align-items: center; - gap: 7px; - font-weight: 700; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.lb-prestige { - flex: none; - font-size: 10.5px; - font-weight: 800; - background: var(--lemon-dark); - color: #fff8e0; - padding: 2px 7px; - border-radius: 999px; - display: inline-flex; - align-items: center; - gap: 3px; -} - -.lb-sub { - margin-top: 2px; - font-size: 11px; - color: var(--text-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.lb-sub i { - color: var(--lemon-dark); -} - -.lb-score { - text-align: right; - flex: none; -} - -.lb-score-value { - font-weight: 800; - color: var(--lemon-light); - font-size: 14px; - font-variant-numeric: tabular-nums; -} - -.lb-score-label { - font-size: 9px; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -/* ---- Toast ---- */ -.citron-toast { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background: var(--surface); - border: 1px solid var(--lemon); - color: var(--text); - padding: 11px 20px; - border-radius: 999px; - font-size: 13.5px; - font-weight: 600; - box-shadow: var(--shadow); - opacity: 0; - pointer-events: none; - transition: opacity 0.3s ease, transform 0.3s ease; - z-index: 20; - display: flex; - align-items: center; - gap: 8px; -} - -.citron-toast i { - color: var(--lemon-light); -} - -.citron-toast.visible { - opacity: 1; - transform: translate(-50%, -6px); -} - -@media (prefers-reduced-motion: reduce) { - .lemon-mark-big, - .citron-press::before, - .citron-float, - .citron-drop { - animation: none; - } -}