Ajout d'un easteregg

This commit is contained in:
Esteban
2026-07-03 10:56:54 +02:00
parent 542910a15d
commit 6164f7eaae
15 changed files with 2626 additions and 141 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Config Claude Code (local, pas utile au dépôt)
# Config Claude Code
.claude/
# macOS
+60 -140
View File
@@ -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;
-- 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
);
+120
View File
@@ -74,5 +74,125 @@ if (isset($_GET['success']) && $_GET['success'] === '1') {
})();
</script>
<!-- Easter egg : code Konami (↑↑↓↓←→←→BA) => ouvre le mini-jeu "Presse-Citron" -->
<style>
#konami-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 3vh 3vw;
animation: konami-fade-in 0.2s ease;
}
@keyframes konami-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.konami-modal {
position: relative;
width: min(1100px, 100%);
height: min(760px, 94vh);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
}
.konami-modal iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
.konami-close {
position: absolute;
top: 10px;
right: 10px;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
}
.konami-close:hover {
background: rgba(0, 0, 0, 0.8);
}
</style>
<script>
(function () {
// Séquence classique : Haut Haut Bas Bas Gauche Droite Gauche Droite B A
var sequence = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
var progres = 0;
document.addEventListener('keydown', function (e) {
// Ignore la saisie dans un champ de formulaire (évite de déclencher pendant qu'on tape)
var cible = e.target;
if (cible && (cible.tagName === 'INPUT' || cible.tagName === 'TEXTAREA' || cible.isContentEditable)) return;
var touche = e.key.length === 1 ? e.key.toLowerCase() : e.key;
var attendue = sequence[progres];
if (touche === attendue) {
progres++;
if (progres === sequence.length) {
progres = 0;
ouvrirPresseCitron();
}
} else {
// Permet de relancer la séquence même si elle recommence par la même touche
progres = (touche === sequence[0]) ? 1 : 0;
}
});
function ouvrirPresseCitron() {
if (document.getElementById('konami-overlay')) return;
var overlay = document.createElement('div');
overlay.id = 'konami-overlay';
overlay.innerHTML =
'<div class="konami-modal">' +
'<button class="konami-close" aria-label="Fermer"><i class="fa-solid fa-xmark"></i></button>' +
'<iframe src="./../pages/citron.php" title="Presse-Citron"></iframe>' +
'</div>';
document.body.appendChild(overlay);
overlay.querySelector('.konami-close').addEventListener('click', fermerPresseCitron);
overlay.addEventListener('click', function (e) {
if (e.target === overlay) fermerPresseCitron();
});
document.addEventListener('keydown', echapFerme);
}
function echapFerme(e) {
if (e.key === 'Escape') fermerPresseCitron();
}
function fermerPresseCitron() {
var overlay = document.getElementById('konami-overlay');
if (overlay) overlay.remove();
document.removeEventListener('keydown', echapFerme);
}
// Le jeu (dans l'iframe) peut aussi demander la fermeture (touche Échap captée à
// l'intérieur de l'iframe, qui ne reçoit pas les événements clavier de la page parente)
window.addEventListener('message', function (e) {
if (e.data === 'citron-close') fermerPresseCitron();
});
})();
</script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<?php
// Fonction utilitaire de validation/lecture d'une photo de profil envoyée par formulaire
// (jeu "Presse-Citron"). Même esprit que functions/pdfUpload.php.
// Taille max acceptée côté serveur : généreuse, mais le client redimensionne déjà l'image
// avant l'envoi (voir pages/citron.php), donc en pratique un envoi normal fait quelques
// dizaines de Ko. Cette limite est un filet de sécurité si le redimensionnement côté
// navigateur est contourné (requête forgée, JS désactivé, etc.)
define('AVATAR_MAX_SIZE', 3 * 1024 * 1024); // 3 Mo
// Dimensions max acceptées (en pixels) : évite qu'une image immense (mais légère une fois
// compressée) fasse exploser le temps de décodage côté navigateur pour les autres joueurs
define('AVATAR_MAX_DIMENSION', 4000);
// Types d'image acceptés (liste blanche : pas de SVG, qui peut contenir du JavaScript)
const AVATAR_MIME_AUTORISES = ['image/jpeg', 'image/png', 'image/webp'];
// Vérifie et lit une image de profil envoyée en upload ($_FILES)
// Retourne :
// - null si aucun fichier n'a été envoyé (ce n'est pas une erreur)
// - une chaîne de texte si le fichier est invalide (message d'erreur à afficher)
// - ['mime' => ..., '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
];
}
+15
View File
@@ -0,0 +1,15 @@
<?php
// Session du jeu "Presse-Citron" (easter egg code Konami) : totalement indépendante
// des comptes admin du portfolio. Utilise des clés de session différentes
// ($_SESSION['lemon_player_id'] / ['lemon_username']), jamais $_SESSION['role'] ou
// ['user_id'] : se connecter au jeu ne donne et ne retire aucun droit sur le portfolio.
// Vérifie qu'un joueur est connecté au jeu, sinon répond en JSON et arrête le script
function requireLemonAuth() {
if (empty($_SESSION['lemon_player_id'])) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Non connecté.']);
exit;
}
}
+1092
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
<?php
// Sert la photo de profil d'un joueur "Presse-Citron", stockée en base
// (voir migration Juillet 2026 (8)). Public : pas besoin d'être connecté pour voir
// les avatars affichés dans le classement.
require_once('../functions/databaseConnection.php');
$playerId = $_GET['player_id'] ?? '';
if (!is_numeric($playerId)) {
http_response_code(404);
exit;
}
try {
$stmt = $dbh->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;
+29
View File
@@ -0,0 +1,29 @@
<?php
// Suppression de la photo de profil du joueur "Presse-Citron" connecté (retour à
// l'avatar par défaut : cercle avec l'initiale du pseudo).
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
require_once('../functions/lemonSession.php');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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.']);
}
+51
View File
@@ -0,0 +1,51 @@
<?php
// Upload / remplacement de la photo de profil du joueur "Presse-Citron" connecté.
// Le fichier est déjà redimensionné et compressé côté navigateur (voir pages/citron.php),
// mais la validation ici ne fait aucune confiance à ça : elle revérifie tout elle-même.
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
require_once('../functions/lemonSession.php');
require_once('../functions/avatarUpload.php');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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.']);
}
+51
View File
@@ -0,0 +1,51 @@
<?php
// Classement public du jeu "Presse-Citron" (les 15 meilleurs scores cumulés), avec un
// peu plus de détail que le simple score : nombre de bâtiments possédés (calculé depuis
// game_state), niveau de prestige, date de dernière partie. Lecture seule, sans connexion.
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
header('Content-Type: application/json; charset=utf-8');
try {
$stmt = $dbh->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);
+61
View File
@@ -0,0 +1,61 @@
<?php
// Connexion au jeu "Presse-Citron" (easter egg). Comptes totalement séparés
// des comptes admin du portfolio (table lemon_players).
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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'],
]);
+11
View File
@@ -0,0 +1,11 @@
<?php
// Déconnexion du jeu "Presse-Citron" uniquement : ne touche jamais à une éventuelle
// session admin du portfolio (clés de session complètement séparées)
require_once(__DIR__ . '/../functions/security.php');
header('Content-Type: application/json; charset=utf-8');
unset($_SESSION['lemon_player_id'], $_SESSION['lemon_username']);
echo json_encode(['ok' => true]);
+68
View File
@@ -0,0 +1,68 @@
<?php
// Création d'un compte pour le jeu "Presse-Citron" (easter egg).
// Comptes totalement séparés des comptes admin du portfolio (table lemon_players).
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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.']);
}
+67
View File
@@ -0,0 +1,67 @@
<?php
// Sauvegarde de la partie en cours pour le joueur "Presse-Citron" connecté.
// Toute la logique du jeu vit côté JS : ce script se contente de valider
// grossièrement les données reçues (nombres positifs, JSON de taille raisonnable)
// et de les enregistrer telles quelles pour le joueur de la session en cours.
require_once(__DIR__ . '/../functions/security.php');
require_once('../functions/databaseConnection.php');
require_once('../functions/lemonSession.php');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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.']);
}
+897
View File
@@ -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;
}
}