Suppression easteregg + fix captcha

This commit is contained in:
Esteban
2026-07-09 11:54:37 +02:00
parent 6164f7eaae
commit d7dc48454c
18 changed files with 65 additions and 2609 deletions
+12 -9
View File
@@ -180,16 +180,19 @@ $spamSubjects = [
['text' => 'CONFIRMEZ VOTRE IDENTITÉ POUR RECEVOIR VOTRE ARGENT', 'reason' => 'Promesse dargent contre des données personnelles'],
];
// Liste des expéditeurs spam
// Liste des expéditeurs spam : mêmes formats d'affichage (nom, pas d'adresse brute) que les
// expéditeurs normaux ci-dessus, pour qu'il ne soit pas possible de repérer les mails
// frauduleux d'un simple coup d'œil sur l'expéditeur. La seule façon de les distinguer doit
// être de lire le contenu (sujet), comme un vrai email de phishing usurpant une identité connue.
$spamSenders = [
'service-urgent@compte-bloque-now.biz',
'cadeau-gratuit@super-offre-win.click',
'banque-alerte@verification-securite.top',
'colis-stop@paiement-obligatoire.help',
'admin-compte@action-immediate.world',
'support-prime@argent-rapide-fast.support',
'verification@motdepasse-danger.win',
'alerte@compte-fermeture.click'
'Sécurité du compte',
'Service client',
'Banque familiale',
'Livraison Colis',
'Support technique',
'Service facturation',
'Assurance Habitation',
'Mutuelle Santé'
];
// Liste des aperçus normaux
+3 -29
View File
@@ -62,7 +62,7 @@ CREATE TABLE IF NOT EXISTS schools (
PRIMARY KEY (id)
);
-- Réglages du site (clé / valeur), utilisés pour la notification de la page d'accueil
-- Réglages du site (clé / valeur), utilisés pour la notification de la page d'accueil et le captcha
CREATE TABLE IF NOT EXISTS settings (
name VARCHAR(50) NOT NULL,
value TEXT NULL,
@@ -72,7 +72,8 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT INTO settings (name, value) VALUES
('site_notice_title', 'Des bugs peuvent encore se produire.'),
('site_notice_text', 'Si le CSS ou les images s''affichent mal, pensez à vider le cache de votre navigateur (Ctrl + F5 ou Cmd + Shift + R) pour voir les dernières modifications.'),
('site_notice_enabled', '1')
('site_notice_enabled', '1'),
('captcha_enabled', '1')
ON DUPLICATE KEY UPDATE value = VALUES(value);
-- items : un item par ligne, au format "classes-icone|Texte" (icône optionnelle)
@@ -134,30 +135,3 @@ CREATE TABLE IF NOT EXISTS school_pdfs (
PRIMARY KEY (school_id),
CONSTRAINT fk_school_pdfs_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
);
-- Jeu "Presse-Citron" (easter egg, code Konami) : comptes séparés des comptes admin ci-dessus.
-- game_state (JSON) stocke bâtiments/améliorations possédés ; toute la logique du jeu vit côté JS.
CREATE TABLE IF NOT EXISTS lemon_players (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
password VARCHAR(255) NOT NULL,
score DOUBLE NOT NULL DEFAULT 0,
lifetime_score DOUBLE NOT NULL DEFAULT 0,
prestige_level INT UNSIGNED NOT NULL DEFAULT 0,
prestige_bonus DOUBLE NOT NULL DEFAULT 0,
game_state LONGTEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_played DATETIME NULL,
PRIMARY KEY (id),
UNIQUE INDEX idx_lemon_username (username)
);
-- Photo de profil des joueurs "Presse-Citron", table à part pour la même raison que les PDF
CREATE TABLE IF NOT EXISTS lemon_avatars (
player_id INT UNSIGNED NOT NULL,
avatar_data LONGBLOB NOT NULL,
avatar_mime VARCHAR(50) NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (player_id),
CONSTRAINT fk_lemon_avatars_player FOREIGN KEY (player_id) REFERENCES lemon_players(id) ON DELETE CASCADE
);
-120
View File
@@ -74,125 +74,5 @@ 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
@@ -1,66 +0,0 @@
<?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
@@ -1,15 +0,0 @@
<?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;
}
}
+29
View File
@@ -52,6 +52,9 @@ $noticeTitle = recupererSetting('site_notice_title') ?? '';
$noticeText = recupererSetting('site_notice_text') ?? '';
$noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
// Valeur actuelle du captcha anti-robot (activé par défaut)
$captchaEnabled = recupererSetting('captcha_enabled') ?? '1';
// Page du panneau d'administration
?>
@@ -71,6 +74,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<a href="#admin-competences"><i class="fa-solid fa-code"></i> Compétences</a>
<a href="#admin-logos"><i class="fa-solid fa-images"></i> Logos</a>
<a href="#admin-notification"><i class="fa-solid fa-bell"></i> Notification</a>
<a href="#admin-securite"><i class="fa-solid fa-shield-halved"></i> Sécurité</a>
<a href="#admin-utilisateurs"><i class="fa-solid fa-users"></i> Utilisateurs</a>
<a href="#admin-donnees"><i class="fa-solid fa-database"></i> Données</a>
</nav>
@@ -382,6 +386,7 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="notice">
<label>Titre (affiché en gras)</label>
<input type="text" name="notice_title" value="<?php echo htmlspecialchars($noticeTitle); ?>">
@@ -402,6 +407,30 @@ $noticeEnabled = recupererSetting('site_notice_enabled') ?? '1';
</div>
</section>
<!-- ========== SECTION SÉCURITÉ ========== -->
<section id="admin-securite" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-shield-halved"></i> Sécurité</h2>
<div class="project-container">
<div class="project-form-card">
<!-- on envoie les données à updateSettings.php -->
<form method="POST" action="../scripts/updateSettings.php">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars(csrfToken()); ?>">
<input type="hidden" name="settings_group" value="captcha">
<!-- Checkbox pour activer / désactiver le test anti-robot à l'entrée du site -->
<label class="checkbox-row">
<input type="checkbox" name="captcha_enabled" value="1" <?php echo $captchaEnabled === '1' ? 'checked' : ''; ?>>
Activer le test anti-robot (captcha) à l'entrée du site
</label>
<input type="submit" name="submitsettings" value="Enregistrer" class="btn-submit">
</form>
</div>
</div>
</section>
<!-- ========== SECTION UTILISATEURS ========== -->
<section id="admin-utilisateurs" class="admin-section">
<h2 class="admin-section-title"><i class="fa-solid fa-users"></i> Utilisateurs</h2>
-1092
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
<?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
@@ -1,29 +0,0 @@
<?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
@@ -1,51 +0,0 @@
<?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
@@ -1,51 +0,0 @@
<?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
@@ -1,61 +0,0 @@
<?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
@@ -1,11 +0,0 @@
<?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
@@ -1,68 +0,0 @@
<?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
@@ -1,67 +0,0 @@
<?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.']);
}
+6 -1
View File
@@ -1,6 +1,11 @@
<?php
if (!isset($_SESSION['captcha_valid']) || $_SESSION['captcha_valid'] !== true) {
require_once(__DIR__ . '/../functions/listSettings.php');
// Le captcha peut être désactivé depuis le panel Admin (section Sécurité)
$captchaEnabled = recupererSetting('captcha_enabled') ?? '1';
if ($captchaEnabled === '1' && (!isset($_SESSION['captcha_valid']) || $_SESSION['captcha_valid'] !== true)) {
header('Location: ../captcha.php');
exit;
}
+15 -5
View File
@@ -23,11 +23,21 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// On récupère les données pour les mettres en variables locales
// La checkbox n'est envoyée que si elle est cochée
$reglages = [
'site_notice_title' => trim($_POST['notice_title'] ?? ''),
'site_notice_text' => trim($_POST['notice_text'] ?? ''),
'site_notice_enabled' => isset($_POST['notice_enabled']) ? '1' : '0'
];
// settings_group : évite qu'un formulaire écrase les réglages d'un autre (ex : la case
// "notice_enabled" non cochée ne doit pas désactiver le captcha, et inversement)
$groupeReglages = $_POST['settings_group'] ?? 'notice';
if ($groupeReglages === 'captcha') {
$reglages = [
'captcha_enabled' => isset($_POST['captcha_enabled']) ? '1' : '0'
];
} else {
$reglages = [
'site_notice_title' => trim($_POST['notice_title'] ?? ''),
'site_notice_text' => trim($_POST['notice_text'] ?? ''),
'site_notice_enabled' => isset($_POST['notice_enabled']) ? '1' : '0'
];
}
try { // On essaie d'executer une requête qui est préparée pour chaque réglage
// ON DUPLICATE KEY UPDATE : crée le réglage s'il n'existe pas encore, le met à jour sinon
-897
View File
@@ -1,897 +0,0 @@
/* ---- Presse-Citron : easter egg Konami code (mini-jeu incrémental) ---- */
:root {
--lemon-hue: 48; /* dérive lentement avec la progression (voir JS) */
--lemon: hsl(var(--lemon-hue), 92%, 56%);
--lemon-light: hsl(var(--lemon-hue), 100%, 74%);
--lemon-pale: hsl(var(--lemon-hue), 100%, 88%);
--lemon-dark: hsl(calc(var(--lemon-hue) - 6), 82%, 40%);
--leaf: hsl(100, 45%, 42%);
--bg: hsl(calc(var(--lemon-hue) + 8), 38%, 8%);
--bg-soft: hsl(calc(var(--lemon-hue) + 8), 30%, 11%);
--surface: hsl(calc(var(--lemon-hue) + 8), 24%, 14%);
--surface-2: hsl(calc(var(--lemon-hue) + 8), 20%, 19%);
--surface-3: hsl(calc(var(--lemon-hue) + 8), 18%, 24%);
--border: hsl(calc(var(--lemon-hue) + 8), 18%, 26%);
--accent-soft: hsla(var(--lemon-hue), 92%, 56%, 0.14);
--text: #fbf8ef;
--text-muted: #b9b3a2;
--radius: 16px;
--radius-sm: 11px;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.45);
--font: 'Inter', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family: var(--font);
background:
radial-gradient(circle at 18% -10%, hsla(var(--lemon-hue), 80%, 45%, 0.25), transparent 45%),
radial-gradient(circle at 85% 110%, hsla(calc(var(--lemon-hue) + 40), 70%, 40%, 0.18), transparent 50%),
var(--bg);
color: var(--text);
overflow-x: hidden;
transition: background 0.6s ease;
}
::selection {
background: var(--lemon);
color: #2a1c00;
}
::-webkit-scrollbar {
width: 9px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 999px;
}
.citron-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ---- Le citron dessiné en CSS (aucun emoji) : logo, écran d'accueil, cible cliquable ---- */
.lemon-mark {
display: inline-block;
width: 26px;
height: 22px;
border-radius: 52% 52% 46% 46% / 62% 62% 38% 38%;
background: radial-gradient(circle at 34% 28%, var(--lemon-pale) 0%, var(--lemon) 48%, var(--lemon-dark) 100%);
position: relative;
box-shadow: inset -3px -3px 5px rgba(0, 0, 0, 0.2), inset 2px 2px 4px rgba(255, 255, 255, 0.35);
vertical-align: middle;
}
.lemon-mark-leaf {
position: absolute;
top: -7px;
left: 50%;
width: 10px;
height: 15px;
background: linear-gradient(135deg, #8bc34a, var(--leaf));
border-radius: 0 100% 0 100%;
transform: translateX(-50%) rotate(-18deg);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.25);
}
.lemon-mark-big {
width: 68px;
height: 58px;
margin: 0 auto 10px;
display: block;
animation: citron-bob 2.6s ease-in-out infinite;
}
.lemon-mark-big .lemon-mark-leaf {
width: 22px;
height: 32px;
top: -16px;
}
@keyframes citron-bob {
0%, 100% { transform: translateY(0) rotate(-4deg); }
50% { transform: translateY(-8px) rotate(4deg); }
}
/* ---- En-tête ---- */
.citron-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 22px;
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
gap: 10px;
}
.citron-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 19px;
font-weight: 800;
letter-spacing: -0.02em;
}
.citron-player-info {
display: flex;
align-items: center;
gap: 14px;
font-size: 13.5px;
color: var(--text-muted);
}
.citron-player-info i {
color: var(--lemon-light);
}
.citron-player-info strong {
color: var(--text);
}
/* ---- Photo de profil (en-tête du jeu) ---- */
.citron-avatar-wrap {
position: relative;
width: 32px;
height: 32px;
flex: none;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
background: transparent;
}
.citron-avatar-fallback,
.citron-avatar-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 50%;
}
.citron-avatar-fallback {
background: linear-gradient(135deg, var(--lemon-light), var(--lemon-dark));
color: #2a1c00;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13px;
}
.citron-avatar-img {
object-fit: cover;
border: 2px solid var(--surface-2);
}
.citron-avatar-edit {
position: absolute;
inset: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 11px;
opacity: 0;
transition: opacity 0.15s ease;
}
.citron-avatar-wrap:hover .citron-avatar-edit {
opacity: 1;
}
.citron-btn {
border: none;
border-radius: 999px;
padding: 9px 16px;
font-family: var(--font);
font-weight: 700;
font-size: 13px;
cursor: pointer;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
gap: 7px;
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.citron-btn:hover {
background: var(--surface-3);
transform: translateY(-1px);
}
.citron-btn.primary {
background: linear-gradient(135deg, var(--lemon-light), var(--lemon) 60%, var(--lemon-dark));
color: #2a1c00;
box-shadow: 0 8px 20px var(--accent-soft);
}
.citron-btn.primary:hover {
filter: brightness(1.06);
transform: translateY(-1px);
}
.citron-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
/* ---- Écran d'authentification ---- */
.citron-auth {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 30px 16px;
}
.citron-auth-card {
width: 100%;
max-width: 380px;
background: linear-gradient(180deg, var(--surface), var(--bg-soft));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 30px 28px;
text-align: center;
box-shadow: var(--shadow);
}
.citron-auth-card h1 {
margin: 0 0 4px;
font-size: 21px;
letter-spacing: -0.01em;
}
.citron-auth-card p.subtitle {
margin: 0 0 22px;
color: var(--text-muted);
font-size: 13px;
line-height: 1.5;
}
.citron-auth-tabs {
display: flex;
gap: 6px;
margin-bottom: 18px;
background: var(--surface-2);
border-radius: 999px;
padding: 4px;
border: 1px solid var(--border);
}
.citron-auth-tabs button {
flex: 1;
border: none;
background: transparent;
color: var(--text-muted);
font-family: var(--font);
font-weight: 700;
font-size: 12.5px;
padding: 9px;
border-radius: 999px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.citron-auth-tabs button.active {
background: linear-gradient(135deg, var(--lemon-light), var(--lemon));
color: #2a1c00;
}
.citron-field {
text-align: left;
margin-bottom: 13px;
}
.citron-field label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 5px;
}
.citron-field label i {
color: var(--lemon-light);
width: 12px;
}
.citron-field input {
width: 100%;
padding: 11px 13px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-family: var(--font);
font-size: 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.citron-field input:focus {
outline: none;
border-color: var(--lemon);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.citron-auth-error {
background: rgba(220, 60, 40, 0.16);
border: 1px solid rgba(220, 60, 40, 0.4);
color: #ffb3a8;
border-radius: var(--radius-sm);
padding: 9px 12px;
font-size: 12.5px;
margin-bottom: 12px;
display: none;
text-align: left;
}
.citron-auth-error.visible {
display: block;
}
.citron-auth-submit {
width: 100%;
justify-content: center;
padding: 13px;
font-size: 14px;
margin-top: 4px;
}
/* ---- Écran de jeu ---- */
.citron-game {
flex: 1;
display: none;
flex-direction: column;
}
.citron-game.visible {
display: flex;
}
.citron-score-bar {
text-align: center;
padding: 18px 12px 4px;
}
.citron-score {
font-size: clamp(32px, 6vw, 48px);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--lemon-light);
text-shadow: 0 4px 20px var(--accent-soft);
font-variant-numeric: tabular-nums;
}
.citron-score-label {
font-size: 12.5px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.09em;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.citron-score-label i {
color: var(--lemon);
}
.citron-cps {
margin-top: 5px;
font-size: 13px;
color: var(--text-muted);
}
.citron-main {
flex: 1;
display: grid;
/* minmax(0, ...) plutôt que 1fr/340px seuls : sans le "0", une piste de grille ne
peut jamais devenir plus étroite que le plus large de ses enfants, ce qui fait
déborder toute la page sur mobile dès qu'un item de liste est un peu large */
grid-template-columns: minmax(0, 1fr) minmax(0, 340px);
gap: 18px;
padding: 10px 18px 24px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 820px) {
.citron-main {
grid-template-columns: minmax(0, 1fr);
}
}
/* ---- Le citron cliquable (dessiné en CSS) ---- */
.citron-click-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 18px;
position: relative;
}
.citron-press {
position: relative;
width: min(44vw, 230px);
height: min(38vw, 200px);
border: none;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
padding: 0;
}
.citron-press::before {
content: '';
position: absolute;
inset: -14%;
border-radius: 50%;
background: radial-gradient(circle, var(--accent-soft), transparent 70%);
animation: lemon-pulse 3.2s ease-in-out infinite;
z-index: 0;
}
@keyframes lemon-pulse {
0%, 100% { transform: scale(0.92); opacity: 0.55; }
50% { transform: scale(1.05); opacity: 1; }
}
.lemon-visual {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: 100%;
border-radius: 50% 50% 47% 47% / 60% 60% 40% 40%;
background: radial-gradient(circle at 32% 26%, var(--lemon-pale) 0%, var(--lemon) 46%, var(--lemon-dark) 100%);
box-shadow:
0 22px 46px rgba(0, 0, 0, 0.4),
inset -14px -18px 30px rgba(0, 0, 0, 0.16),
inset 10px 12px 22px rgba(255, 255, 255, 0.3);
transition: transform 0.08s ease;
}
.lemon-shine {
position: absolute;
top: 15%;
left: 18%;
width: 24%;
height: 16%;
background: rgba(255, 255, 255, 0.55);
border-radius: 50%;
filter: blur(3px);
}
.lemon-leaf {
position: absolute;
top: -7%;
left: 50%;
width: 16%;
height: 25%;
background: linear-gradient(135deg, #8bc34a, var(--leaf));
border-radius: 0 100% 0 100%;
transform: translateX(-50%) rotate(-18deg);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.25);
}
.citron-press:active .lemon-visual,
.citron-press.squish .lemon-visual {
transform: scale(0.87) rotate(-4deg);
}
.citron-press-hint {
margin-top: 14px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
.citron-float {
position: absolute;
left: 50%;
top: 50%;
pointer-events: none;
font-weight: 800;
color: var(--lemon-light);
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
animation: citron-float-up 0.9s ease-out forwards;
white-space: nowrap;
font-size: 18px;
z-index: 2;
}
@keyframes citron-float-up {
0% { opacity: 1; transform: translate(-50%, -50%) scale(0.8); }
100% { opacity: 0; transform: translate(-50%, -160%) scale(1.15); }
}
.citron-drop {
position: absolute;
left: 50%;
top: 50%;
pointer-events: none;
font-size: 15px;
color: hsl(200, 90%, 65%);
animation: citron-drop-fly 0.7s ease-out forwards;
z-index: 2;
}
@keyframes citron-drop-fly {
0% { opacity: 1; transform: translate(-50%, -50%) translate(0, 0) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) translate(var(--dx), var(--dy)) scale(0.4); }
}
/* ---- Prestige ---- */
.citron-prestige {
margin-top: 20px;
text-align: center;
}
.citron-prestige-btn {
background: linear-gradient(135deg, #ffd93d, #ff9d3d, #ff5e5e);
color: #2a1c00;
font-size: 14px;
padding: 13px 24px;
border-radius: 999px;
box-shadow: 0 12px 28px rgba(255, 150, 30, 0.4);
}
.citron-prestige-note {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
max-width: 320px;
line-height: 1.5;
}
/* ---- Colonnes latérales ---- */
.citron-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-height: 0;
max-height: 72vh;
box-shadow: var(--shadow);
}
.citron-panel-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.citron-panel-tabs button {
flex: 1;
min-width: 0;
padding: 12px 4px;
background: transparent;
border: none;
color: var(--text-muted);
font-family: var(--font);
font-weight: 700;
font-size: 11.5px;
cursor: pointer;
border-bottom: 2px solid transparent;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.15s ease, border-color 0.15s ease;
}
.citron-panel-tabs button i {
font-size: 13px;
}
.citron-panel-tabs button.active {
color: var(--lemon-light);
border-bottom-color: var(--lemon);
}
.citron-panel-body {
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.citron-item {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
cursor: pointer;
text-align: left;
font-family: var(--font);
color: var(--text);
transition: background-color 0.15s ease, transform 0.1s ease, opacity 0.15s ease, border-color 0.15s ease;
}
.citron-item:hover:not(:disabled) {
background: var(--surface-3);
border-color: var(--lemon-dark);
transform: translateX(2px);
}
.citron-item:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.citron-item .icon {
flex: none;
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
background: linear-gradient(145deg, var(--surface-3), var(--surface-2));
color: var(--lemon-light);
box-shadow: inset 0 0 0 1px var(--border);
}
.citron-item .infos {
flex: 1;
min-width: 0;
}
.citron-item .name {
display: block;
font-weight: 700;
font-size: 13.5px;
}
.citron-item .desc {
display: block;
font-size: 11.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.citron-item .cost {
font-size: 12px;
font-weight: 700;
color: var(--lemon-light);
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.citron-item .cost i {
font-size: 10px;
}
.citron-item .owned {
flex: none;
min-width: 28px;
text-align: center;
font-weight: 800;
background: var(--lemon-dark);
color: #fff8e0;
border-radius: 8px;
padding: 4px 7px;
font-size: 12.5px;
}
.citron-empty {
text-align: center;
color: var(--text-muted);
font-size: 13px;
padding: 24px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.citron-empty i {
font-size: 22px;
color: var(--lemon-dark);
}
/* ---- Classement ---- */
.citron-leaderboard-row {
display: grid;
grid-template-columns: 26px 36px 1fr auto;
align-items: center;
gap: 11px;
padding: 10px 11px;
background: var(--surface-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.citron-leaderboard-row.lb-me {
border-color: var(--lemon);
background: var(--accent-soft);
}
.lb-rank {
text-align: center;
font-size: 15px;
}
.lb-rank-number {
color: var(--text-muted);
font-weight: 800;
font-size: 12.5px;
}
.lb-gold { color: #ffd700; }
.lb-silver { color: #d6d6e0; }
.lb-bronze { color: #cd7f32; }
.lb-avatar-wrap {
position: relative;
width: 34px;
height: 34px;
flex: none;
}
.lb-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, var(--lemon-light), var(--lemon-dark));
color: #2a1c00;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13.5px;
flex: none;
}
.lb-avatar-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.lb-main {
min-width: 0;
}
.lb-name {
display: flex;
align-items: center;
gap: 7px;
font-weight: 700;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-prestige {
flex: none;
font-size: 10.5px;
font-weight: 800;
background: var(--lemon-dark);
color: #fff8e0;
padding: 2px 7px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 3px;
}
.lb-sub {
margin-top: 2px;
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-sub i {
color: var(--lemon-dark);
}
.lb-score {
text-align: right;
flex: none;
}
.lb-score-value {
font-weight: 800;
color: var(--lemon-light);
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.lb-score-label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ---- Toast ---- */
.citron-toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--surface);
border: 1px solid var(--lemon);
color: var(--text);
padding: 11px 20px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 600;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
}
.citron-toast i {
color: var(--lemon-light);
}
.citron-toast.visible {
opacity: 1;
transform: translate(-50%, -6px);
}
@media (prefers-reduced-motion: reduce) {
.lemon-mark-big,
.citron-press::before,
.citron-float,
.citron-drop {
animation: none;
}
}