diff --git a/.gitignore b/.gitignore index 30efe56..66f1018 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Config Claude Code (local, pas utile au dépôt) +# Config Claude Code .claude/ # macOS diff --git a/database/scriptDatabase.sql b/database/scriptDatabase.sql index dc12e84..93813da 100644 --- a/database/scriptDatabase.sql +++ b/database/scriptDatabase.sql @@ -1,16 +1,11 @@ --- On créer la base de donnée si elle n'existe déjà aps -CREATE DATABASE IF NOT EXISTS portfolio +CREATE DATABASE IF NOT EXISTS portfolio; +USE portfolio; --- On créer un utilisateur avec un mot de passe fort qui est à changer après execution dus script (voir bitwarden) -CREATE USER 'srv_portfolio'@'%' IDENTIFIED BY 'mdp'; - --- On met les droits au compte sur la base portfolio à l'utilisateur +-- Mot de passe à changer après exécution du script (voir Bitwarden) +CREATE USER IF NOT EXISTS 'srv_portfolio'@'%' IDENTIFIED BY 'mdp'; GRANT ALL ON portfolio.* TO 'srv_portfolio'@'%'; - --- On actualise les permissions FLUSH PRIVILEGES; --- Création de la table user : (Généré par Beekeeper Studio : équivalent à dbGate) CREATE TABLE IF NOT EXISTS users ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, @@ -18,63 +13,69 @@ CREATE TABLE IF NOT EXISTS users ( role ENUM('admin', 'user') NOT NULL DEFAULT 'user', created_at DATETIME NULL DEFAULT NULL, last_login DATETIME NULL DEFAULT NULL, - PRIMARY KEY (id), UNIQUE INDEX idx_username (username) ); --- Création de la table projects : (Généré par Beekeeper Studio) +INSERT IGNORE INTO users (id, created_at, last_login, password, role, username) +VALUES (11, '2026-06-08 11:10:21', '2026-06-08 13:10:41', '$2y$12$x01fDqrHQEYIyj7MDHa72eRyl2j/n5cNq9sv3.FIiQCaRsB50Q4iK', 'admin', 'admin'); + CREATE TABLE IF NOT EXISTS projects ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL, + status VARCHAR(100) NULL, tags VARCHAR(255) NULL, description TEXT NOT NULL, image_url VARCHAR(255) NOT NULL, + markdown LONGTEXT NULL, + link_url VARCHAR(255) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); --- Création de la table projects : (Généré par Beekeeper Studio) CREATE TABLE IF NOT EXISTS experiences ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL, + status VARCHAR(100) NULL, xptype VARCHAR(100) NOT NULL, tags VARCHAR(255) NULL, description TEXT NOT NULL, duration VARCHAR(100) NOT NULL, image_url VARCHAR(255) NOT NULL, + markdown LONGTEXT NULL, + link_url VARCHAR(255) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); --- Compte de test (PLUS A JOUR METTRE UN HASH) --- INSERT INTO users (username, password, role) --- VALUES ('admin', 'admin', 'admin'); -insert into `users` (`created_at`, `id`, `last_login`, `password`, `role`, `username`) -values ('2026-06-08 11:10:21', 11, '2026-06-08 13:10:41', '$2y$12$x01fDqrHQEYIyj7MDHa72eRyl2j/n5cNq9sv3.FIiQCaRsB50Q4iK', 'admin', 'admin'); +CREATE TABLE IF NOT EXISTS schools ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(100) NOT NULL, + status VARCHAR(100) NULL, + tags VARCHAR(255) NULL, + description TEXT NOT NULL, + duration VARCHAR(100) NOT NULL, + image_url VARCHAR(255) NOT NULL, + markdown LONGTEXT NULL, + link_url VARCHAR(255) NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); --- Ajout d'une collonne status pour les projets et les expériences pour pouvoir différencier les projets/expériences en cours, terminés ou à venir -ALTER TABLE projects -ADD COLUMN status VARCHAR(100) NULL AFTER title; -ALTER TABLE experiences -ADD COLUMN status VARCHAR(100) NULL AFTER title; - --- Table de réglages du site (clé / valeur) : utilisée pour la notification de la page d'accueil +-- Réglages du site (clé / valeur), utilisés pour la notification de la page d'accueil CREATE TABLE IF NOT EXISTS settings ( name VARCHAR(50) NOT NULL, value TEXT NULL, PRIMARY KEY (name) ); --- Valeurs par défaut de la notification (modifiables ensuite depuis le panel Admin) 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') ON DUPLICATE KEY UPDATE value = VALUES(value); --- Table des compétences : une ligne = une catégorie (carte sur la page Compétences) --- items : un item par ligne, au format "classes-icone-fontawesome|Texte" (l'icône est optionnelle : "Texte" seul fonctionne aussi) +-- items : un item par ligne, au format "classes-icone|Texte" (icône optionnelle) CREATE TABLE IF NOT EXISTS skills ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL, @@ -85,7 +86,6 @@ CREATE TABLE IF NOT EXISTS skills ( PRIMARY KEY (id) ); --- Compétences actuelles (reprises du CV) INSERT INTO skills (title, icon, items, position) VALUES ('Systèmes d''exploitation', 'fa-solid fa-computer', 'fa-brands fa-microsoft|Windows (Serveur/XP/8/10/11)\nfa-brands fa-linux|Linux (Debian/Ubuntu)\nfa-brands fa-apple|macOS', 1), ('Réseaux', 'fa-solid fa-network-wired', 'fa-solid fa-location-dot|Adressage IP\nfa-solid fa-diagram-project|VLAN\nfa-solid fa-shield-halved|Pare-feu (iptables/ufw)\nfa-solid fa-globe|DNS (Bind9)\nfa-solid fa-server|DHCP (ISC DHCP)\nfa-solid fa-arrows-left-right|Proxy (Squid)', 2), @@ -94,108 +94,23 @@ INSERT INTO skills (title, icon, items, position) VALUES ('Développement web', 'fa-solid fa-code', 'fa-brands fa-html5|HTML\nfa-brands fa-css3-alt|CSS\nfa-brands fa-js|JavaScript\nfa-brands fa-php|PHP', 5), ('Virtualisation & Outils', 'fa-solid fa-layer-group', 'fa-solid fa-server|Proxmox\nfa-solid fa-box|VirtualBox\nfa-brands fa-git-alt|Git (GitHub, Gitea)', 6); --- Table des écoles / formations : affichée sous les expériences professionnelles sur la page Expériences -CREATE TABLE IF NOT EXISTS schools ( - id INT UNSIGNED NOT NULL AUTO_INCREMENT, - title VARCHAR(100) NOT NULL, - status VARCHAR(100) NULL, - tags VARCHAR(255) NULL, - description TEXT NOT NULL, - duration VARCHAR(100) NOT NULL, - image_url VARCHAR(255) NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id) -); - - --- markdown : contenu de la page détaillée (LONGTEXT = ~4 Go, taille pratiquement illimitée) --- link_url : lien externe utilisé à la place du markdown si celui-ci est vide --- Les deux sont facultatifs : si les deux sont vides, la carte n'est pas cliquable -ALTER TABLE projects -ADD COLUMN markdown LONGTEXT NULL, -ADD COLUMN link_url VARCHAR(255) NULL; - -ALTER TABLE experiences -ADD COLUMN markdown LONGTEXT NULL, -ADD COLUMN link_url VARCHAR(255) NULL; - -ALTER TABLE schools -ADD COLUMN markdown LONGTEXT NULL, -ADD COLUMN link_url VARCHAR(255) NULL; - --- ============================================================ --- Juillet 2026 (4) : bandeaux de logos sur la page d'accueil --- ("Outils que je sais manipuler" et "Ils m'ont fait confiance"), --- gérés depuis le panel Admin --- ============================================================ - --- Une seule table pour les deux bandeaux, distingués par la colonne category : --- - 'tool' => bandeau "Outils que je sais manipuler" --- - 'partner' => bandeau "Ils m'ont fait confiance" --- link_url est facultatif : si renseigné, le logo devient cliquable (nouvel onglet) +-- category : 'tool' (bandeau "Outils que je sais manipuler") ou 'partner' (bandeau "Ils m'ont fait confiance") +-- icon : classes CSS Devicon, prioritaire sur logo_url si les deux sont renseignés (au moins un des deux requis) CREATE TABLE IF NOT EXISTS logo_banners ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, category ENUM('tool', 'partner') NOT NULL, name VARCHAR(100) NOT NULL, - logo_url VARCHAR(255) NOT NULL, + icon VARCHAR(100) NULL, + logo_url VARCHAR(255) NULL, link_url VARCHAR(255) NULL, position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); --- Table créée vide volontairement : les logos sont ajoutés depuis le panel Admin --- (section Logos) pour éviter d'afficher des images devinées/potentiellement cassées - --- ============================================================ --- Juillet 2026 (3) : PDF en drag & drop pour les projets, --- expériences et écoles, lu depuis un mini lecteur intégré --- ============================================================ - --- pdf_data : contenu binaire du PDF (LONGBLOB = ~4 Go, largement suffisant) --- pdf_filename : nom original du fichier (affiché dans le panel admin et le lecteur) --- Priorité d'affichage sur la page de détails : markdown > pdf > lien externe -ALTER TABLE projects -ADD COLUMN pdf_data LONGBLOB NULL, -ADD COLUMN pdf_filename VARCHAR(255) NULL; - -ALTER TABLE experiences -ADD COLUMN pdf_data LONGBLOB NULL, -ADD COLUMN pdf_filename VARCHAR(255) NULL; - -ALTER TABLE schools -ADD COLUMN pdf_data LONGBLOB NULL, -ADD COLUMN pdf_filename VARCHAR(255) NULL; - --- ============================================================ --- Juillet 2026 (5) : icône Devicon en alternative à l'image pour --- le bandeau de logos (surtout utile pour "Outils que je sais --- manipuler" : Devicon est un équivalent gratuit de Font Awesome --- dédié aux logos d'outils/langages/frameworks, cf. devicon.dev) --- ============================================================ - --- icon : classes CSS Devicon (ex: "devicon-docker-plain colored"), prioritaire sur logo_url --- si les deux sont renseignés. logo_url devient facultatif : il faut au moins l'un des deux --- (validé côté PHP), pour les outils absents de Devicon (Proxmox, GLPI, FOG...) -ALTER TABLE logo_banners -ADD COLUMN icon VARCHAR(100) NULL AFTER name, -MODIFY COLUMN logo_url VARCHAR(255) NULL; - --- ============================================================ --- Juillet 2026 (6) : sortie des PDF des tables projects/experiences/schools --- vers des tables associées dédiées, pour que les pages qui LISTENT ces --- éléments (admin.php, xp.php, projects.php...) n'aient plus à charger les --- PDF (jusqu'à 15 Mo chacun) en mémoire à chaque affichage. C'était la --- cause du ralentissement d'admin.php et de xp.php : ces pages faisaient --- un SELECT * qui remontait aussi tout le contenu binaire des PDF alors --- que seul le nom de fichier est utilisé dans les listes. --- --- ÉTAPE 1/2 (non destructive) : crée les nouvelles tables et copie les PDF --- existants dedans. Les colonnes pdf_data / pdf_filename des tables --- d'origine ne sont PAS touchées à cette étape : aucune perte de donnée --- possible, elles restent en place comme copie de sécurité. --- ============================================================ - +-- PDF de projects/experiences/schools : tables associées plutôt qu'une colonne sur la table +-- parente, pour que les pages qui LISTENT ces éléments n'aient jamais à charger le contenu +-- binaire des PDF en mémoire (seul le nom de fichier y est utilisé) CREATE TABLE IF NOT EXISTS project_pdfs ( project_id INT UNSIGNED NOT NULL, pdf_filename VARCHAR(255) NOT NULL, @@ -220,24 +135,29 @@ CREATE TABLE IF NOT EXISTS school_pdfs ( CONSTRAINT fk_school_pdfs_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE ); --- Copie des PDF déjà en base vers les nouvelles tables (ne copie que les lignes qui ont --- effectivement un PDF ; ré-exécutable sans doublon grâce à la clé primaire = id du parent) -INSERT IGNORE INTO project_pdfs (project_id, pdf_filename, pdf_data) -SELECT id, pdf_filename, pdf_data FROM projects WHERE pdf_data IS NOT NULL; +-- 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) +); -INSERT IGNORE INTO experience_pdfs (experience_id, pdf_filename, pdf_data) -SELECT id, pdf_filename, pdf_data FROM experiences WHERE pdf_data IS NOT NULL; - -INSERT IGNORE INTO school_pdfs (school_id, pdf_filename, pdf_data) -SELECT id, pdf_filename, pdf_data FROM schools WHERE pdf_data IS NOT NULL; - --- ============================================================ --- ÉTAPE 2/2 (destructive, À NE PAS EXÉCUTER MAINTENANT) : à lancer plus --- tard, une fois que le site a été vérifié (listes, modification, suppression, --- lecteur PDF) et qu'on est sûr à 100% que tout fonctionne avec les --- nouvelles tables. Supprime les anciennes colonnes devenues inutiles. --- ============================================================ - --- ALTER TABLE projects DROP COLUMN pdf_data, DROP COLUMN pdf_filename; --- ALTER TABLE experiences DROP COLUMN pdf_data, DROP COLUMN pdf_filename; --- ALTER TABLE schools DROP COLUMN pdf_data, DROP COLUMN pdf_filename; \ No newline at end of file +-- 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 0713a3f..b635f8a 100644 --- a/elements/footer.php +++ b/elements/footer.php @@ -74,5 +74,125 @@ if (isset($_GET['success']) && $_GET['success'] === '1') { })(); + + + + \ No newline at end of file diff --git a/functions/avatarUpload.php b/functions/avatarUpload.php new file mode 100644 index 0000000..6e04e0e --- /dev/null +++ b/functions/avatarUpload.php @@ -0,0 +1,66 @@ + ..., '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 new file mode 100644 index 0000000..f825b01 --- /dev/null +++ b/functions/lemonSession.php @@ -0,0 +1,15 @@ + 'Non connecté.']); + exit; + } +} diff --git a/pages/citron.php b/pages/citron.php new file mode 100644 index 0000000..6bcb2c3 --- /dev/null +++ b/pages/citron.php @@ -0,0 +1,1092 @@ +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 new file mode 100644 index 0000000..61e0ee4 --- /dev/null +++ b/scripts/lemonAvatar.php @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..36b9de4 --- /dev/null +++ b/scripts/lemonAvatarRemove.php @@ -0,0 +1,29 @@ + '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 new file mode 100644 index 0000000..ab6655e --- /dev/null +++ b/scripts/lemonAvatarUpload.php @@ -0,0 +1,51 @@ + '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 new file mode 100644 index 0000000..b89f694 --- /dev/null +++ b/scripts/lemonLeaderboard.php @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..c413f97 --- /dev/null +++ b/scripts/lemonLogin.php @@ -0,0 +1,61 @@ + '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 new file mode 100644 index 0000000..dbe650b --- /dev/null +++ b/scripts/lemonLogout.php @@ -0,0 +1,11 @@ + true]); diff --git a/scripts/lemonRegister.php b/scripts/lemonRegister.php new file mode 100644 index 0000000..5813830 --- /dev/null +++ b/scripts/lemonRegister.php @@ -0,0 +1,68 @@ + '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 new file mode 100644 index 0000000..588ef2e --- /dev/null +++ b/scripts/lemonSave.php @@ -0,0 +1,67 @@ + '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/styles/citron.css b/styles/citron.css new file mode 100644 index 0000000..babc40b --- /dev/null +++ b/styles/citron.css @@ -0,0 +1,897 @@ +/* ---- 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; + } +}