Compare commits

...

59 Commits

Author SHA1 Message Date
1f8127641d Transforme les sélecteurs en zones bouton 2026-04-12 20:28:54 +02:00
e791425b2b Améliore le rendu web des sélecteurs 2026-04-12 20:26:39 +02:00
9b25768a3f Remet la barre Apple en transparent 2026-04-12 20:25:26 +02:00
69bcf467bc Corrige les sélecteurs de configuration sur mobile 2026-04-12 20:23:27 +02:00
22a6fa02fd Ajoute un rafraîchissement forcé de l'application 2026-04-12 20:21:12 +02:00
b2b19f6e8b Rend le modal de résumé scrollable 2026-04-12 20:17:06 +02:00
5e8f2ba695 Corrige l'affichage des statuts en mode Time 2026-04-12 20:12:16 +02:00
8eeb359a08 Ajoute un résumé modal à la fin du cube 2026-04-12 20:09:44 +02:00
9f98168934 Ajoute un mode competition a la configuration 2026-04-12 20:04:41 +02:00
e1bfeb2b45 Replace les champs Time sur une meme ligne 2026-04-12 20:02:57 +02:00
c84aa750ae Affiche le temps joueur en mode Time 2026-04-12 19:59:05 +02:00
f5570cea25 Clarifie le libelle du temps de Block en mode Time 2026-04-12 19:57:57 +02:00
1f67ba46fb Affiche l'impact chrono apres le cube en mode Time 2026-04-12 19:56:30 +02:00
7649ba2fb9 Ajuste le mode Time et met en valeur les chronos 2026-04-12 19:51:41 +02:00
a6e007762d Ajuste le maintien de demarrage du cube 2026-04-12 19:43:31 +02:00
2dc41cc758 Ajoute un demarrage par appui long du chrono cube 2026-04-12 18:18:43 +02:00
d75234b15f Corrige la longueur des pages chrono et cube 2026-04-12 18:14:10 +02:00
e5d755980e Ajuste le calcul du viewport mobile 2026-04-12 18:09:16 +02:00
dab6a517e3 Retablit le scroll mobile sur chrono et cube 2026-04-12 18:06:16 +02:00
9444b9b64f Verrouille chrono et cube au viewport mobile 2026-04-12 18:02:26 +02:00
de962eb8a0 Renforce la transition du chrono vers cube 2026-04-12 17:46:11 +02:00
39ef9f039f Force l'étirement vertical du chrono mobile 2026-04-12 17:43:32 +02:00
62ac188c18 Restaure le plein écran du chrono mobile 2026-04-12 17:39:53 +02:00
489f6a7728 Ajuste le viewport iOS de la page chrono 2026-04-12 17:33:01 +02:00
a09b341be7 Fixe le fond plein écran sur mobile 2026-04-12 17:28:03 +02:00
89f0858bce Synchronise aussi le projet d'Ethan dans les scripts Proxmox 2026-04-12 17:24:14 +02:00
a3d3bd18a9 Integre l'application d'Ethan sous /ethan 2026-04-12 17:17:39 +02:00
df1d7cbd63 Restaure le dégradé de fond du site 2026-04-12 17:09:00 +02:00
5f9dc9ba7b Supprime la bande blanche en bas 2026-04-12 17:05:30 +02:00
e0815fc279 Aligne le chrono mobile sur la page cube 2026-04-12 16:59:09 +02:00
9b46b9f052 Rend la barre système opaque en PWA 2026-04-12 16:54:15 +02:00
198109e14e Corrige l'en-tête du chrono en PWA 2026-04-12 16:51:35 +02:00
39c563ff37 Corrige le débordement mobile plein écran 2026-04-12 16:45:00 +02:00
e6677df9ae Annule la refonte du chrono 2026-04-12 16:40:12 +02:00
795764c359 Refond le chrono en style pendule 2026-04-12 16:33:24 +02:00
d134947b14 Raccourcit la page chrono sur mobile 2026-04-12 15:36:35 +02:00
938251ad40 Bloque le scroll sur la page chrono 2026-04-12 15:31:23 +02:00
351f0bf1fe Ajoute un favicon explicite aux pages 2026-04-12 15:28:17 +02:00
c3818e6417 Ajoute le mode standalone sur iPhone 2026-04-12 15:25:28 +02:00
3bf1e93543 Réorganise les options mobiles en 2 et 3 colonnes 2026-04-12 15:12:42 +02:00
224300c1c3 Condense la page de lancement sur mobile 2026-04-12 15:10:11 +02:00
5582df99b7 Simplifie la mise à jour Proxmox par défaut 2026-04-12 15:07:11 +02:00
0da74c6484 Supprime les warnings de locale dans les scripts LXC 2026-04-12 15:05:30 +02:00
f11afb3600 Corrige le payload de mise à jour Proxmox 2026-04-12 15:02:22 +02:00
73ba5f4d3c Compacte fortement le mode mobile 2026-04-12 15:01:36 +02:00
d5db7d7ea4 Corrige le cache des mises à jour LXC 2026-04-12 14:59:12 +02:00
15528c2062 Corrige l'installation locale Proxmox 2026-04-12 14:42:28 +02:00
2c5f276f53 Allège la page cube sur téléphone 2026-04-12 14:40:10 +02:00
7330563ae6 Ajoute un mode local Proxmox sans installation hôte 2026-04-12 14:38:44 +02:00
a90b1b6d8a Remplace le terme block par partie 2026-04-12 14:37:32 +02:00
7d0be5baa5 Corrige le logo texte avec fond transparent 2026-04-12 14:34:37 +02:00
2d5a4adf99 Ajoute des scripts bootstrap pour Proxmox 2026-04-12 14:33:19 +02:00
5a287be786 Améliore la lisibilité des en-têtes de phase 2026-04-12 14:22:39 +02:00
c00e2369a8 Ajoute des scripts de déploiement Proxmox LXC 2026-04-12 14:15:30 +02:00
ec87a57712 Ajoute le lien vers l'application d'Ethan 2026-04-12 14:05:34 +02:00
ac1e20603e Supprime les pastilles de l'accueil 2026-04-12 13:49:14 +02:00
0b4f8f07a1 Supprime le texte d'introduction de l'application 2026-04-12 13:47:00 +02:00
a095f2d4fa Adoucit le dégradé du fond 2026-04-12 13:45:47 +02:00
1d71956ec9 Passe le fond du site en dégradé orange et bleu 2026-04-12 13:44:29 +02:00
28 changed files with 14021 additions and 200 deletions

View File

@@ -6,14 +6,14 @@ Application web mobile-first pour téléphone et tablette, pensée comme applica
- configure une rencontre `Twice` ou `Time` - configure une rencontre `Twice` ou `Time`
- sépare l'application en pages dédiées : configuration, phase chrono, phase cube - sépare l'application en pages dédiées : configuration, phase chrono, phase cube
- permet de définir librement le temps de block et le temps par coup - permet de définir librement le temps de partie et le temps par coup
- suit les quotas `FAST`, `FREEZE` et `MASTERS` - suit les quotas `FAST`, `FREEZE` et `MASTERS`
- orchestre la phase cube avec désignation du cube, capture des temps et préparation du block suivant - orchestre la phase cube avec désignation du cube, capture des temps et préparation de la partie suivante
- applique la logique du double coup V2 en `Twice` - applique la logique du double coup V2 en `Twice`
- applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte - applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte
- conserve un historique local dans le navigateur - conserve un historique local dans le navigateur
- propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur - propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur
- ouvre automatiquement la page cube dès que la phase chess du block est terminée - ouvre automatiquement la page cube dès que la phase chess de la partie est terminée
## Hypothèse de produit ## Hypothèse de produit
@@ -28,6 +28,94 @@ docker compose up -d --build
L'application est ensuite disponible sur `http://localhost:8080`. L'application est ensuite disponible sur `http://localhost:8080`.
## Déploiement dans un LXC Proxmox
Deux scripts Bash permettent de créer un conteneur LXC Debian sur Proxmox puis de le mettre à jour depuis Git.
Prérequis sur la machine qui lance les scripts :
- en mode distant : `ssh` et `sshpass`
- en mode local sur l'hôte Proxmox : aucun paquet supplémentaire n'est installé sur Proxmox
Le déploiement dans le LXC n'utilise pas Docker. Le script installe `nginx`, `git` et `rsync` dans le conteneur, clone le dépôt principal, synchronise aussi le projet d'Ethan, puis publie uniquement les fichiers web.
### Installer un nouveau LXC
```bash
./scripts/install-proxmox-lxc.sh \
--proxmox-host 10.0.0.2 \
--proxmox-user root@pam \
--proxmox-password 'secret'
```
Version "curl | bash" :
```bash
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/install-chesscubing-proxmox.sh)"
```
Cette version pose les questions nécessaires si les variables d'environnement ne sont pas déjà définies.
Si elle est lancée directement sur l'hôte Proxmox, elle passe automatiquement en mode local :
- elle ne demande ni serveur, ni login, ni mot de passe SSH
- elle n'installe rien sur l'hôte Proxmox
- elle crée uniquement le LXC puis installe les dépendances dans ce LXC
Valeurs par défaut utiles :
- LXC nommé `chesscubing-web`
- IP du LXC en `dhcp`
- branche Git `main`
- dépôt `https://git.jeannerot.fr/christophe/chesscubing.git`
- dépôt Ethan `https://git.jeannerot.fr/Mineloulou/Chesscubing.git`
Options utiles si besoin :
- `--ctid 120`
- `--lxc-ip 192.168.1.50/24 --gateway 192.168.1.1`
- `--template-storage local`
- `--rootfs-storage local-lvm`
- `--branch main`
- `--ethan-branch main`
À la fin, le script affiche :
- le `CTID`
- le mot de passe `root` du LXC
- l'URL probable du site
### Mettre à jour depuis Git
```bash
./scripts/update-proxmox-lxc.sh \
--proxmox-host 10.0.0.2 \
--proxmox-user root@pam \
--proxmox-password 'secret' \
--ctid 120
```
Version "curl | bash" :
```bash
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/update-chesscubing-proxmox.sh)"
```
Sur l'hôte Proxmox, cette commande met à jour le LXC local sans passer par SSH.
Par défaut, elle cible le conteneur `chesscubing-web` sans demander le `CTID`.
On peut aussi cibler le conteneur par nom si on n'a pas le `CTID` :
```bash
./scripts/update-proxmox-lxc.sh \
--proxmox-host 10.0.0.2 \
--proxmox-user root@pam \
--proxmox-password 'secret' \
--hostname chesscubing-web
```
Le script de mise à jour exécute un `git pull --ff-only` pour le dépôt principal et le dépôt d'Ethan dans le conteneur, puis republie les fichiers statiques via `nginx`, y compris la route `/ethan/`.
## Fichiers clés ## Fichiers clés
- `index.html` : page d'accueil du site - `index.html` : page d'accueil du site
@@ -38,3 +126,5 @@ L'application est ensuite disponible sur `http://localhost:8080`.
- `styles.css` : design mobile/tablette - `styles.css` : design mobile/tablette
- `app.js` : logique de match et arbitrage - `app.js` : logique de match et arbitrage
- `docker-compose.yml` + `Dockerfile` : exécution locale - `docker-compose.yml` + `Dockerfile` : exécution locale
- `scripts/install-proxmox-lxc.sh` : création et déploiement d'un LXC Proxmox
- `scripts/update-proxmox-lxc.sh` : mise à jour d'un LXC existant depuis Git

844
app.js

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,61 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#140700" />
<meta name="application-name" content="ChessCubing Arena" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
<meta <meta
name="description" name="description"
content="Application officielle de match pour ChessCubing Arena sur téléphone et tablette." content="Application officielle de match pour ChessCubing Arena sur téléphone et tablette."
/> />
<title>ChessCubing Arena | Application</title> <title>ChessCubing Arena | Application</title>
<link rel="icon" type="image/png" href="logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="styles.css" /> <link rel="shortcut icon" href="/favicon.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="site.webmanifest" />
<script>
(() => {
const assetTokenStorageKey = "chesscubing-arena-asset-token";
const pageUrl = new URL(window.location.href);
const refreshToken = pageUrl.searchParams.get("refresh");
if (refreshToken) {
try {
window.sessionStorage.setItem(assetTokenStorageKey, refreshToken);
} catch {
// Ignore storage failures in restricted browsers.
}
pageUrl.searchParams.delete("refresh");
window.history.replaceState(null, "", pageUrl.toString());
}
let assetToken = "";
try {
assetToken = window.sessionStorage.getItem(assetTokenStorageKey) || "";
} catch {
assetToken = "";
}
window.__CHESSCUBING_ASSET_TOKEN__ = assetToken;
window.__CHESSCUBING_ASSET_URL__ = (path) => {
if (!assetToken) {
return path;
}
const assetUrl = new URL(path, window.location.href);
assetUrl.searchParams.set("v", assetToken);
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
};
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.head.append(stylesheet);
})();
</script>
</head> </head>
<body data-page="setup"> <body data-page="setup">
<div class="ambient ambient-left"></div> <div class="ambient ambient-left"></div>
@@ -24,11 +71,6 @@
</a> </a>
<p class="eyebrow">Application officielle de match</p> <p class="eyebrow">Application officielle de match</p>
<h1>ChessCubing Arena</h1> <h1>ChessCubing Arena</h1>
<p class="lead">
Une version mobile et tablette organisée par phases : configuration,
page chrono ultra lisible, puis page cube dédiée qui s'ouvre
automatiquement à la fin de la phase chess.
</p>
<div class="hero-actions"> <div class="hero-actions">
<a class="button ghost" href="index.html">Accueil du site</a> <a class="button ghost" href="index.html">Accueil du site</a>
<a class="button secondary" href="reglement.html">Consulter le règlement</a> <a class="button secondary" href="reglement.html">Consulter le règlement</a>
@@ -42,7 +84,7 @@
<li>Configurer la rencontre</li> <li>Configurer la rencontre</li>
<li>Passer à la page chrono</li> <li>Passer à la page chrono</li>
<li>Basculer automatiquement sur la page cube</li> <li>Basculer automatiquement sur la page cube</li>
<li>Revenir sur la page chrono pour le block suivant</li> <li>Revenir sur la page chrono pour le Block suivant</li>
</ol> </ol>
</div> </div>
</aside> </aside>
@@ -61,7 +103,16 @@
</div> </div>
<form id="setupForm" class="setup-form"> <form id="setupForm" class="setup-form">
<label class="field span-2"> <label class="option-card competition-option span-2">
<input id="competitionMode" name="competitionMode" type="checkbox" />
<strong>Mode compétition</strong>
<span>
Affiche le nom de la rencontre, l'arbitre, le club ou l'événement
et les notes d'organisation.
</span>
</label>
<label class="field span-2" id="matchLabelField" data-competition-field hidden>
<span>Nom de la rencontre</span> <span>Nom de la rencontre</span>
<input <input
name="matchLabel" name="matchLabel"
@@ -78,7 +129,7 @@
<input type="radio" name="mode" value="twice" checked /> <input type="radio" name="mode" value="twice" checked />
<strong>ChessCubing Twice</strong> <strong>ChessCubing Twice</strong>
<span> <span>
Le gagnant du cube ouvre le block suivant et peut obtenir un Le gagnant du cube ouvre la partie suivante et peut obtenir un
double coup V2. double coup V2.
</span> </span>
</label> </label>
@@ -86,7 +137,7 @@
<input type="radio" name="mode" value="time" /> <input type="radio" name="mode" value="time" />
<strong>ChessCubing Time</strong> <strong>ChessCubing Time</strong>
<span> <span>
Même structure de blocks, avec chronos cumulés et alternance Même structure de Blocks, avec chronos cumulés et alternance
bloc - / bloc +. bloc - / bloc +.
</span> </span>
</label> </label>
@@ -94,7 +145,7 @@
</fieldset> </fieldset>
<fieldset class="field span-2"> <fieldset class="field span-2">
<legend>Cadence du block</legend> <legend>Cadence du match</legend>
<div class="option-grid preset-grid"> <div class="option-grid preset-grid">
<label class="option-card"> <label class="option-card">
<input type="radio" name="preset" value="fast" checked /> <input type="radio" name="preset" value="fast" checked />
@@ -118,7 +169,7 @@
<legend>Temps personnalisés</legend> <legend>Temps personnalisés</legend>
<div class="timing-grid"> <div class="timing-grid">
<label class="field"> <label class="field">
<span>Temps block (secondes)</span> <span id="blockSecondsLabel">Temps partie (secondes)</span>
<input <input
name="blockSeconds" name="blockSeconds"
type="number" type="number"
@@ -128,7 +179,18 @@
value="180" value="180"
/> />
</label> </label>
<label class="field"> <label class="field" id="timeInitialField" hidden>
<span>Temps de chaque joueur (minutes)</span>
<input
name="timeInitialMinutes"
type="number"
min="1"
max="180"
step="1"
value="10"
/>
</label>
<label class="field" id="moveSecondsField">
<span>Temps coup (secondes)</span> <span>Temps coup (secondes)</span>
<input <input
name="moveSeconds" name="moveSeconds"
@@ -152,7 +214,7 @@
<input name="blackName" type="text" maxlength="40" placeholder="Noir" value="Noir" /> <input name="blackName" type="text" maxlength="40" placeholder="Noir" value="Noir" />
</label> </label>
<label class="field"> <label class="field" id="arbiterField" data-competition-field hidden>
<span>Arbitre</span> <span>Arbitre</span>
<input <input
name="arbiterName" name="arbiterName"
@@ -162,7 +224,7 @@
/> />
</label> </label>
<label class="field"> <label class="field" id="eventField" data-competition-field hidden>
<span>Club / événement</span> <span>Club / événement</span>
<input <input
name="eventName" name="eventName"
@@ -172,7 +234,7 @@
/> />
</label> </label>
<label class="field span-2"> <label class="field span-2" id="notesField" data-competition-field hidden>
<span>Notes</span> <span>Notes</span>
<textarea <textarea
name="notes" name="notes"
@@ -214,7 +276,7 @@
<p> <p>
Chaque joueur dispose d'une grande zone tactile pour signaler la Chaque joueur dispose d'une grande zone tactile pour signaler la
fin de son coup, puis l'app ouvre automatiquement la phase cube fin de son coup, puis l'app ouvre automatiquement la phase cube
quand le block chess est terminé. quand le Block d'échecs est terminé.
</p> </p>
</article> </article>
<article class="rule-card"> <article class="rule-card">
@@ -243,8 +305,19 @@
</div> </div>
</aside> </aside>
</main> </main>
<div class="setup-refresh-footer">
<button class="refresh-link-button" id="refreshAppButton" type="button">
Rafraîchir l'app
</button>
</div>
</div> </div>
<script type="module" src="app.js"></script> <script>
const appScript = document.createElement("script");
appScript.type = "module";
appScript.src = window.__CHESSCUBING_ASSET_URL__("app.js");
document.body.append(appScript);
</script>
</body> </body>
</html> </html>

View File

@@ -2,17 +2,64 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#140700" />
<meta name="application-name" content="ChessCubing Arena" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
<meta <meta
name="description" name="description"
content="Phase chrono de ChessCubing Arena avec gros boutons face-à-face." content="Phase chrono de ChessCubing Arena avec gros boutons face-à-face."
/> />
<title>ChessCubing Arena | Phase Chrono</title> <title>ChessCubing Arena | Phase Chrono</title>
<link rel="icon" type="image/png" href="logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="styles.css" /> <link rel="shortcut icon" href="/favicon.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="site.webmanifest" />
<script>
(() => {
const assetTokenStorageKey = "chesscubing-arena-asset-token";
const pageUrl = new URL(window.location.href);
const refreshToken = pageUrl.searchParams.get("refresh");
if (refreshToken) {
try {
window.sessionStorage.setItem(assetTokenStorageKey, refreshToken);
} catch {
// Ignore storage failures in restricted browsers.
}
pageUrl.searchParams.delete("refresh");
window.history.replaceState(null, "", pageUrl.toString());
}
let assetToken = "";
try {
assetToken = window.sessionStorage.getItem(assetTokenStorageKey) || "";
} catch {
assetToken = "";
}
window.__CHESSCUBING_ASSET_TOKEN__ = assetToken;
window.__CHESSCUBING_ASSET_URL__ = (path) => {
if (!assetToken) {
return path;
}
const assetUrl = new URL(path, window.location.href);
assetUrl.searchParams.set("v", assetToken);
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
};
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.head.append(stylesheet);
})();
</script>
</head> </head>
<body data-page="chrono" class="phase-body"> <body data-page="chrono" class="phase-body">
<main class="phase-shell"> <main class="phase-shell chrono-stage">
<header class="phase-header"> <header class="phase-header">
<a class="brand-link brand-link-logo" href="application.html" aria-label="Retour à l'application"> <a class="brand-link brand-link-logo" href="application.html" aria-label="Retour à l'application">
<img class="brand-link-icon" src="logo.png" alt="Icône ChessCubing" /> <img class="brand-link-icon" src="logo.png" alt="Icône ChessCubing" />
@@ -30,10 +77,10 @@
<section class="status-strip"> <section class="status-strip">
<article class="status-card"> <article class="status-card">
<span>Temps block</span> <span id="blockTimerLabel">Temps Block</span>
<strong id="blockTimer">03:00</strong> <strong id="blockTimer">03:00</strong>
</article> </article>
<article class="status-card"> <article class="status-card" id="moveTimerCard">
<span>Temps coup</span> <span>Temps coup</span>
<strong id="moveTimer">00:20</strong> <strong id="moveTimer">00:20</strong>
</article> </article>
@@ -53,7 +100,7 @@
</div> </div>
<div class="zone-stats"> <div class="zone-stats">
<strong id="blackMovesChrono">0 / 6</strong> <strong id="blackMovesChrono">0 / 6</strong>
<span id="blackClockChrono"></span> <span class="player-clock" id="blackClockChrono"></span>
</div> </div>
</div> </div>
@@ -67,13 +114,13 @@
<article class="phase-spine"> <article class="phase-spine">
<div class="spine-card"> <div class="spine-card">
<p class="micro-label" id="spineLabel">État du block</p> <p class="micro-label" id="spineLabel">État du Block</p>
<strong id="spineHeadline">Prêt à démarrer</strong> <strong id="spineHeadline">Prêt à démarrer</strong>
<p id="spineText"></p> <p id="spineText"></p>
</div> </div>
<button class="button primary spine-button" id="primaryChronoButton" type="button"> <button class="button primary spine-button" id="primaryChronoButton" type="button">
Démarrer le block Démarrer le Block
</button> </button>
</article> </article>
@@ -86,7 +133,7 @@
</div> </div>
<div class="zone-stats"> <div class="zone-stats">
<strong id="whiteMovesChrono">0 / 6</strong> <strong id="whiteMovesChrono">0 / 6</strong>
<span id="whiteClockChrono"></span> <span class="player-clock" id="whiteClockChrono"></span>
</div> </div>
</div> </div>
@@ -142,6 +189,11 @@
</div> </div>
</section> </section>
<script type="module" src="app.js"></script> <script>
const appScript = document.createElement("script");
appScript.type = "module";
appScript.src = window.__CHESSCUBING_ASSET_URL__("app.js");
document.body.append(appScript);
</script>
</body> </body>
</html> </html>

116
cube.html
View File

@@ -2,14 +2,61 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#140700" />
<meta name="application-name" content="ChessCubing Arena" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
<meta <meta
name="description" name="description"
content="Phase cube de ChessCubing Arena avec gros boutons de fin." content="Phase cube de ChessCubing Arena avec gros boutons de fin."
/> />
<title>ChessCubing Arena | Phase Cube</title> <title>ChessCubing Arena | Phase Cube</title>
<link rel="icon" type="image/png" href="logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="styles.css" /> <link rel="shortcut icon" href="/favicon.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="site.webmanifest" />
<script>
(() => {
const assetTokenStorageKey = "chesscubing-arena-asset-token";
const pageUrl = new URL(window.location.href);
const refreshToken = pageUrl.searchParams.get("refresh");
if (refreshToken) {
try {
window.sessionStorage.setItem(assetTokenStorageKey, refreshToken);
} catch {
// Ignore storage failures in restricted browsers.
}
pageUrl.searchParams.delete("refresh");
window.history.replaceState(null, "", pageUrl.toString());
}
let assetToken = "";
try {
assetToken = window.sessionStorage.getItem(assetTokenStorageKey) || "";
} catch {
assetToken = "";
}
window.__CHESSCUBING_ASSET_TOKEN__ = assetToken;
window.__CHESSCUBING_ASSET_URL__ = (path) => {
if (!assetToken) {
return path;
}
const assetUrl = new URL(path, window.location.href);
assetUrl.searchParams.set("v", assetToken);
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
};
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
document.head.append(stylesheet);
})();
</script>
</head> </head>
<body data-page="cube" class="phase-body"> <body data-page="cube" class="phase-body">
<main class="phase-shell cube-shell"> <main class="phase-shell cube-shell">
@@ -30,7 +77,7 @@
<section class="status-strip"> <section class="status-strip">
<article class="status-card"> <article class="status-card">
<span>Block</span> <span id="cubeBlockLabelText">Block</span>
<strong id="cubeBlockLabel">1</strong> <strong id="cubeBlockLabel">1</strong>
</article> </article>
<article class="status-card"> <article class="status-card">
@@ -126,6 +173,65 @@
</div> </div>
</section> </section>
<script type="module" src="app.js"></script> <section class="modal hidden" id="cubeResultModal" aria-hidden="true">
<div class="modal-backdrop" data-close-cube-result-modal="true"></div>
<div class="modal-card result-modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Fin de phase cube</p>
<h2 id="cubeResultModalTitle">Résumé du cube</h2>
</div>
<button class="button ghost small" id="closeCubeResultButton" type="button">
Fermer
</button>
</div>
<p class="section-copy" id="cubeResultSummary"></p>
<div class="cube-result-overview">
<article class="result-pill-card">
<span>Vainqueur cube</span>
<strong id="cubeResultWinner">--</strong>
</article>
<article class="result-pill-card">
<span>Suite</span>
<strong id="cubeResultOutcome">--</strong>
</article>
</div>
<div class="cube-result-player-grid">
<article class="cube-result-player-card">
<span class="seat-tag light-seat">Blanc</span>
<strong id="cubeResultWhiteName">Blanc</strong>
<span id="cubeResultWhiteTime">Temps cube --</span>
<span id="cubeResultWhiteDetail">Résultat --</span>
<span id="cubeResultWhiteClock">Chrono après --</span>
</article>
<article class="cube-result-player-card">
<span class="seat-tag dark-seat">Noir</span>
<strong id="cubeResultBlackName">Noir</strong>
<span id="cubeResultBlackTime">Temps cube --</span>
<span id="cubeResultBlackDetail">Résultat --</span>
<span id="cubeResultBlackClock">Chrono après --</span>
</article>
</div>
<div class="modal-actions">
<button class="button primary" id="cubeResultActionButton" type="button">
Appliquer et ouvrir la page chrono
</button>
<button class="button ghost" id="cubeResultDismissButton" type="button">
Revenir à la phase cube
</button>
</div>
</div>
</section>
<script>
const appScript = document.createElement("script");
appScript.type = "module";
appScript.src = window.__CHESSCUBING_ASSET_URL__("app.js");
document.body.append(appScript);
</script>
</body> </body>
</html> </html>

4
ethan/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
.codex

1835
ethan/ChessClock.html Normal file

File diff suppressed because one or more lines are too long

29
ethan/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Chess Clock
Projet React/Vite reconstruit a partir d'un ancien fichier HTML monolithique.
## Scripts
- `npm install`
- `npm run dev`
- `npm run build`
- `npm run preview`
## Ouverture
- Double-clic sur `index.html` : mode autonome direct dans le navigateur via CDN React/Babel
- Serveur Vite : le meme `index.html` bascule automatiquement sur l'entree `src/main.jsx`
## Structure
- `index.html` : shell HTML minimal
- `src/main.jsx` : point d'entree React
- `src/App.jsx` : wrapper d'application
- `src/features/chess-clock/ChessClockApp.jsx` : logique de l'horloge et ecrans associes
- `src/features/chess-clock/ChessClockStandalone.jsx` : entree autonome pour ouverture directe du fichier HTML
- `src/features/chess-clock/chessClock.css` : styles extraits du HTML initial
- `legacy/ChessClock.legacy.html` : archive du fichier HTML d'origine
## Notes
Le nettoyage a separe l'enveloppe HTML, les styles et la logique React sans changer volontairement le comportement metier. Le composant principal reste encore volumineux, mais il est maintenant place dans une structure de projet standard et pret a etre decoupe plus finement si besoin.

1835
ethan/index.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

19
ethan/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "chess-clock",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@vitejs/plugin-react": "5.0.2",
"vite": "7.1.5"
}
}

5
ethan/src/App.jsx Normal file
View File

@@ -0,0 +1,5 @@
import ChessClockApp from "./features/chess-clock/ChessClockApp.jsx";
export default function App() {
return <ChessClockApp />;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,536 @@
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Oxanium:wght@400;600;700;800&display=swap');
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#060a0e;--s1:#0d1218;--s2:#131b24;--s3:#1c2632;--bd:#263040;
--gold:#e8b84b;--g2:#ffd97a;--g3:#7a5c1e;
--red:#f04040;--grn:#38e878;--blu:#4b9ee8;--pur:#a855f7;
--txt:#c8d8e8;--dim:#3e5060;
}
html,body,#root{width:100%;height:100%;overflow:hidden;background:var(--bg);
-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}
.app{width:100vw;height:100dvh;display:flex;flex-direction:column;
font-family:'Oxanium',sans-serif;background:var(--bg);position:relative;overflow:hidden;
animation:app-in .5s cubic-bezier(.16,1,.3,1) both}
@keyframes app-in{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}
.app::before{content:'';position:fixed;inset:-20%;width:140%;height:140%;
background:radial-gradient(ellipse 60% 40% at 30% 10%,rgba(232,184,75,.07) 0%,transparent 70%),
radial-gradient(ellipse 50% 50% at 70% 90%,rgba(75,158,232,.05) 0%,transparent 70%);
pointer-events:none;z-index:0;animation:mesh 14s ease-in-out infinite alternate}
@keyframes mesh{from{transform:translate(0,0)}to{transform:translate(2%,1%)}}
.app::after{content:'';position:fixed;inset:0;
background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,0,0,.06) 3px,rgba(0,0,0,.06) 4px);
pointer-events:none;z-index:999;opacity:.35}
@keyframes shimmer{0%,100%{background-position:0%}50%{background-position:100%}}
@keyframes cfg-in{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
@keyframes fade-in{from{opacity:0}to{opacity:1}}
@keyframes pop{0%{transform:scale(0);opacity:0}60%{transform:scale(1.2)}100%{transform:scale(1);opacity:1}}
/* CONFIG */
.cfg{width:100%;height:100%;display:flex;flex-direction:column;position:relative;z-index:1;overflow:hidden;animation:cfg-in .45s cubic-bezier(.16,1,.3,1) both}
.cfg-hd{padding:clamp(14px,3.5vh,24px) 20px 8px;text-align:center;flex-shrink:0}
.cfg-logo{font-family:'Bebas Neue',sans-serif;font-size:clamp(28px,7.5vw,44px);letter-spacing:8px;
background:linear-gradient(135deg,var(--g2) 0%,var(--gold) 50%,#b8882b 100%);
-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1;
background-size:200%;animation:shimmer 4s ease-in-out infinite}
.cfg-sub{font-size:9px;letter-spacing:6px;color:var(--dim);text-transform:uppercase;margin-top:3px}
.cfg-hd-row{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:8px;flex-wrap:wrap}
.icon-btn{display:flex;align-items:center;gap:5px;background:var(--s1);border:1px solid var(--bd);
border-radius:10px;padding:5px 10px;cursor:pointer;transition:all .15s}
.icon-btn:active{background:var(--s2);border-color:var(--gold)}
.icon-btn-lbl{font-size:9px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;font-weight:700}
.cfg-body{flex:1;overflow-y:auto;overflow-x:hidden;padding:8px 14px 0;-webkit-overflow-scrolling:touch}
.cfg-body::-webkit-scrollbar{display:none}
.cfg-section{font-size:9px;letter-spacing:4px;color:var(--dim);text-transform:uppercase;margin:12px 0 6px 2px}
.cfg-card{background:var(--s1);border:1px solid var(--bd);border-radius:14px;overflow:hidden;margin-bottom:8px;box-shadow:0 4px 24px rgba(0,0,0,.3)}
.cfg-row{display:flex;align-items:center;justify-content:space-between;
padding:11px 14px;border-bottom:1px solid rgba(255,255,255,.04);cursor:pointer;
position:relative;overflow:hidden;transition:background .2s}
.cfg-row:last-child{border-bottom:none}
.cfg-row:active{background:rgba(232,184,75,.04)}
.cfg-row.sel{background:linear-gradient(90deg,rgba(232,184,75,.09),rgba(232,184,75,.03));border-left:2px solid var(--gold)}
.cfg-row-lbl{font-weight:600;font-size:clamp(12px,3.5vw,15px);color:var(--txt);transition:color .2s}
.cfg-row.sel .cfg-row-lbl{color:var(--g2)}
.cfg-chk{color:var(--gold);font-size:15px;opacity:0;transform:scale(0) rotate(-45deg);transition:opacity .25s,transform .35s cubic-bezier(.34,1.56,.64,1)}
.cfg-row.sel .cfg-chk{opacity:1;transform:scale(1) rotate(0deg)}
.cfg-stepper{display:flex;align-items:center;justify-content:space-between;padding:10px 14px}
.cfg-s-lbl{font-weight:600;font-size:clamp(12px,3.3vw,14px);color:var(--txt)}
.cfg-s-val{font-family:'Bebas Neue',sans-serif;font-size:clamp(18px,5vw,26px);color:var(--gold);letter-spacing:2px;min-width:36px;text-align:center}
.cfg-btns{display:flex;gap:5px}
.cfg-btn{width:32px;height:32px;border-radius:9px;background:var(--s2);border:1px solid var(--bd);color:var(--gold);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s,transform .15s}
.cfg-btn:active{background:var(--s3);transform:scale(.88)}
.cfg-ft{padding:8px 14px clamp(14px,3.5vh,24px);flex-shrink:0}
.start-btn{width:100%;padding:clamp(14px,3.8vw,18px);border:none;border-radius:14px;font-family:'Bebas Neue',sans-serif;font-size:clamp(16px,4.5vw,20px);letter-spacing:7px;color:var(--bg);background:linear-gradient(110deg,var(--g3),var(--g2),var(--gold),var(--g2),var(--g3));background-size:250%;cursor:pointer;box-shadow:0 4px 28px rgba(232,184,75,.5),inset 0 1px 0 rgba(255,255,255,.2);text-transform:uppercase;transition:transform .18s cubic-bezier(.34,1.56,.64,1);animation:shimmer 3s ease-in-out infinite}
.start-btn:active{transform:scale(.96)}
.cfg-custom{display:flex;align-items:center;justify-content:space-between;background:var(--s1);border:1px dashed var(--g3);border-radius:12px;padding:11px 16px;margin-bottom:8px;cursor:pointer;transition:background .15s,border-color .15s}
.cfg-custom:active{background:var(--s2);border-color:var(--gold)}
.cfg-custom.c-on{border-style:solid;border-color:var(--gold);background:rgba(232,184,75,.06)}
.cfg-c-main{font-family:'Bebas Neue',sans-serif;font-size:clamp(14px,3.8vw,18px);letter-spacing:3px;color:var(--gold)}
.cfg-c-detail{font-size:9px;letter-spacing:1px;color:var(--dim);margin-top:2px}
.cfg-c-arrow{font-size:17px;color:var(--dim)}
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid rgba(255,255,255,.04)}
.toggle-row:last-child{border-bottom:none}
.toggle-lbl{display:flex;flex-direction:column;gap:2px}
.toggle-name{font-weight:600;font-size:clamp(11px,3.2vw,13px);color:var(--txt)}
.toggle-desc{font-size:9px;letter-spacing:1px;color:var(--dim)}
.toggle{width:40px;height:22px;border-radius:11px;background:var(--s3);border:1px solid var(--bd);position:relative;cursor:pointer;transition:background .25s,border-color .25s;flex-shrink:0}
.toggle.on{background:var(--grn);border-color:var(--grn)}
.toggle-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dim);transition:all .25s cubic-bezier(.34,1.56,.64,1)}
.toggle.on .toggle-knob{left:20px;background:#fff;box-shadow:0 0 8px rgba(56,232,120,.6)}
/* MODAL */
.modal-bg{position:fixed;inset:0;z-index:300;background:rgba(0,0,0,.75);backdrop-filter:blur(6px);display:flex;align-items:flex-end;justify-content:center;animation:fade-in .2s ease-out}
.modal{width:100%;max-width:480px;background:var(--s1);border-radius:22px 22px 0 0;border-top:1px solid var(--bd);padding:0 0 clamp(18px,4.5vh,32px);animation:modal-in .28s cubic-bezier(.34,1.4,.64,1)}
@keyframes modal-in{from{transform:translateY(100%)}to{transform:translateY(0)}}
.modal-handle{width:40px;height:4px;background:var(--bd);border-radius:2px;margin:12px auto 0}
.modal-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(16px,4.5vw,20px);letter-spacing:5px;color:var(--gold);text-align:center;padding:12px 18px 3px}
.modal-sub{font-size:9px;letter-spacing:3px;color:var(--dim);text-transform:uppercase;text-align:center;margin-bottom:14px}
.modal-fields{display:flex;gap:8px;padding:0 14px;margin-bottom:14px}
.modal-field{flex:1;display:flex;flex-direction:column;gap:5px}
.modal-f-lbl{font-size:9px;letter-spacing:3px;color:var(--dim);text-transform:uppercase;padding-left:2px}
.modal-spin{display:flex;align-items:center;background:var(--s2);border:1px solid var(--bd);border-radius:11px;overflow:hidden}
.modal-sb{width:38px;height:46px;display:flex;align-items:center;justify-content:center;font-size:19px;color:var(--gold);cursor:pointer;flex-shrink:0;transition:background .12s}
.modal-sb:active{background:var(--s3)}
.modal-sv{flex:1;text-align:center;font-family:'Bebas Neue',sans-serif;font-size:clamp(19px,5vw,24px);color:var(--txt);letter-spacing:2px}
.modal-su{font-size:8px;letter-spacing:2px;color:var(--dim);text-align:center;margin-top:2px}
.modal-prev{margin:0 14px 12px;background:var(--s2);border:1px solid var(--bd);border-radius:11px;padding:9px 13px;display:flex;align-items:center;justify-content:space-between}
.modal-prev-lbl{font-size:9px;letter-spacing:3px;color:var(--dim);text-transform:uppercase}
.modal-prev-val{font-family:'Bebas Neue',sans-serif;font-size:clamp(14px,3.8vw,18px);color:var(--gold);letter-spacing:2px}
.modal-actions{display:flex;flex-direction:column;gap:7px;padding:0 14px}
.modal-ok{width:100%;padding:12px;border:none;border-radius:11px;font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:4px;color:var(--bg);background:linear-gradient(135deg,var(--g2),var(--gold));cursor:pointer;box-shadow:0 4px 20px rgba(232,184,75,.4);transition:filter .15s}
.modal-ok:active{filter:brightness(.88)}
.modal-cancel{width:100%;padding:9px;border:none;background:transparent;font-size:10px;letter-spacing:3px;color:var(--dim);cursor:pointer;text-transform:uppercase}
/* GAME */
.game{width:100%;height:100%;display:flex;flex-direction:column;position:relative;z-index:1}
.panel{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;cursor:pointer;gap:clamp(5px,1.5vh,10px);overflow:hidden;transition:opacity .3s,filter .3s}
.panel.flip{transform:rotate(180deg)}
.panel.on{opacity:1}
.panel.on::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,rgba(232,184,75,.10) 0%,rgba(232,184,75,.02) 40%,transparent 70%);pointer-events:none;animation:sweep 2.4s ease-in-out infinite alternate}
@keyframes sweep{from{opacity:.6}to{opacity:1}}
.panel.off{opacity:.38;filter:brightness(.7)}
.panel.dng{animation:dng .55s ease-in-out infinite}
@keyframes dng{0%,100%{background:transparent}30%{background:rgba(240,64,64,.16)}60%{background:rgba(240,64,64,.06)}}
.panel.pf{animation:pfx .25s ease-in-out 3}
@keyframes pfx{0%,100%{background:transparent}50%{background:rgba(168,85,247,.22)}}
.p-ripple{position:absolute;width:200px;height:200px;border-radius:50%;background:radial-gradient(circle,rgba(232,184,75,.22) 0%,transparent 70%);transform:scale(0);pointer-events:none;animation:ripple .55s cubic-bezier(.2,.8,.3,1) forwards}
@keyframes ripple{from{transform:scale(0);opacity:1}to{transform:scale(4);opacity:0}}
.p-label{font-size:9px;letter-spacing:6px;color:var(--dim);text-transform:uppercase;font-weight:700}
.clock{font-family:'Bebas Neue',sans-serif;font-size:clamp(58px,16vw,106px);letter-spacing:-3px;color:var(--gold);line-height:1;text-shadow:0 0 30px rgba(232,184,75,.45),0 0 70px rgba(232,184,75,.15);position:relative;z-index:1;transition:color .3s,text-shadow .3s}
.clock.dng{color:var(--red);text-shadow:0 0 20px rgba(240,64,64,.8),0 0 50px rgba(240,64,64,.4);animation:glitch 1.4s steps(1) infinite}
@keyframes glitch{0%,89%,100%{transform:translate(0,0)}90%{transform:translate(3px,0)}92%{transform:translate(-3px,0)}94%{transform:translate(0,0)}}
.sonar{width:9px;height:9px;border-radius:50%;background:var(--gold);box-shadow:0 0 0 0 rgba(232,184,75,.7);animation:sonar 1.4s ease-out infinite}
@keyframes sonar{0%{box-shadow:0 0 0 0 rgba(232,184,75,.8);transform:scale(1)}50%{box-shadow:0 0 0 10px rgba(232,184,75,0);transform:scale(1.15)}100%{box-shadow:0 0 0 0 rgba(232,184,75,0);transform:scale(1)}}
.dots{display:flex;gap:5px;flex-wrap:wrap;justify-content:center;max-width:190px}
.dot{width:6px;height:6px;border-radius:50%;border:1px solid rgba(122,92,30,.6);background:transparent;transition:transform .3s cubic-bezier(.34,1.56,.64,1),background .25s,box-shadow .25s}
.dot.f{background:var(--gold);border-color:var(--gold);box-shadow:0 0 6px rgba(232,184,75,.7);transform:scale(1.15);animation:dpop .35s cubic-bezier(.34,1.56,.64,1)}
@keyframes dpop{0%{transform:scale(0);opacity:0}60%{transform:scale(1.4)}100%{transform:scale(1.15);opacity:1}}
.dot.t{border-color:rgba(240,64,64,.5)}
.dot.f.t{background:var(--red);border-color:var(--red);animation:tstrobe .45s ease-in-out infinite alternate}
@keyframes tstrobe{from{box-shadow:0 0 4px rgba(240,64,64,.5);transform:scale(1.1)}to{box-shadow:0 0 14px rgba(240,64,64,1);transform:scale(1.3)}}
.pen-badge{background:rgba(168,85,247,.18);border:1px solid rgba(168,85,247,.45);border-radius:18px;padding:2px 9px;font-family:'Bebas Neue',sans-serif;font-size:10px;letter-spacing:2px;color:var(--pur)}
.mt-wrap{width:75%;max-width:160px;height:3px;background:var(--bd);border-radius:2px;overflow:hidden}
.mt-bar{height:100%;border-radius:2px;transition:width .1s linear;background:linear-gradient(90deg,var(--grn),var(--gold))}
.mt-bar.warn{background:linear-gradient(90deg,var(--gold),var(--red))}
.mt-bar.crit{background:var(--red);animation:crit .3s steps(1) infinite}
@keyframes crit{0%,100%{opacity:1}50%{opacity:.45}}
.pen-bar{position:absolute;bottom:0;left:0;right:0;height:3px;background:linear-gradient(90deg,transparent,var(--pur),transparent);animation:pb-fl .3s ease-in-out 3}
@keyframes pb-fl{0%,100%{opacity:0}50%{opacity:1}}
.div{height:3px;background:linear-gradient(90deg,transparent 0%,rgba(122,92,30,.4) 10%,var(--gold) 30%,var(--g2) 50%,var(--gold) 70%,rgba(122,92,30,.4) 90%,transparent 100%);background-size:200%;position:relative;z-index:10;flex-shrink:0;box-shadow:0 0 18px rgba(232,184,75,.4);animation:dflow 3s linear infinite}
@keyframes dflow{from{background-position:0%}to{background-position:200%}}
.div-c{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:3px;z-index:20;pointer-events:none}
.div-badge{background:var(--bg);border:1.5px solid var(--gold);border-radius:20px;padding:3px 13px;font-family:'Bebas Neue',sans-serif;font-size:12px;letter-spacing:3px;color:var(--gold);white-space:nowrap;box-shadow:0 0 12px rgba(232,184,75,.25)}
.div-sub{background:var(--bg);border:1px solid var(--bd);border-radius:10px;padding:1px 7px;font-size:8px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;white-space:nowrap}
.win-btn{position:absolute;top:50%;z-index:30;cursor:pointer;transform:translateY(-50%);transition:all .2s cubic-bezier(.34,1.56,.64,1)}
.win-btn.wl{left:8px}
.win-btn.wr{right:8px}
.win-btn-inner{background:var(--s2);border:1.5px solid var(--bd);border-radius:10px;padding:5px 8px;display:flex;flex-direction:column;align-items:center;gap:2px;transition:all .2s;box-shadow:0 2px 10px rgba(0,0,0,.3)}
.win-btn:active .win-btn-inner{transform:scale(.9)}
.win-btn.pnd .win-btn-inner{background:rgba(232,184,75,.12);border-color:var(--gold);animation:wbp .5s ease-in-out infinite alternate}
@keyframes wbp{from{box-shadow:0 0 8px rgba(232,184,75,.3)}to{box-shadow:0 0 22px rgba(232,184,75,.7)}}
.win-btn-ico{font-size:12px;line-height:1}
.win-btn-lbl{font-family:'Bebas Neue',sans-serif;font-size:8px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;white-space:nowrap}
.win-btn.pnd .win-btn-lbl{color:var(--gold)}
.win-btn.flip2{transform:translateY(-50%) rotate(180deg)}
.ctrls{position:fixed;bottom:0;left:0;right:0;display:flex;justify-content:center;gap:10px;padding:7px 14px clamp(10px,2.5vh,20px);z-index:100;background:linear-gradient(to top,rgba(6,10,14,.98) 55%,transparent)}
.cbtn{font-family:'Oxanium',sans-serif;font-size:10px;font-weight:700;letter-spacing:2px;color:var(--dim);background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:8px 13px;cursor:pointer;text-transform:uppercase;transition:all .2s cubic-bezier(.34,1.56,.64,1)}
.cbtn:active{color:var(--gold);border-color:var(--g3);transform:scale(.92)}
/* CS OVERLAY */
.cso{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(5px,1.3vh,9px);z-index:50;overflow:hidden;animation:cso-in .35s cubic-bezier(.16,1,.3,1) both}
@keyframes cso-in{from{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}
.cso-bg{position:absolute;inset:0;transition:background .4s}
.cso-bg.idle{background:radial-gradient(ellipse 70% 70% at center,rgba(20,55,130,.98) 0%,rgba(6,10,14,.99) 70%)}
.cso-bg.hold{animation:hbg .9s ease-in-out infinite alternate}
@keyframes hbg{from{background:radial-gradient(ellipse 65% 65% at center,rgba(10,110,44,.98) 0%,rgba(6,10,14,.99) 68%)}to{background:radial-gradient(ellipse 88% 88% at center,rgba(14,140,56,.98) 0%,rgba(6,10,14,.99) 68%)}}
.cso-bg.run{animation:rbg .4s ease-in-out infinite alternate}
@keyframes rbg{from{background:radial-gradient(ellipse 70% 70% at center,rgba(140,20,20,.98) 0%,rgba(6,10,14,.99) 68%)}to{background:radial-gradient(ellipse 90% 90% at center,rgba(180,28,28,.98) 0%,rgba(6,10,14,.99) 68%)}}
.cso-bg.stop{background:rgba(6,10,14,.98)}
.cso-bg.win{animation:wbg .6s ease-in-out infinite alternate}
@keyframes wbg{from{background:radial-gradient(ellipse 70% 70% at center,rgba(10,120,48,.98) 0%,rgba(6,10,14,.99) 68%)}to{background:radial-gradient(ellipse 95% 95% at center,rgba(16,160,64,.98) 0%,rgba(6,10,14,.99) 68%)}}
.cso-bg.lose{background:rgba(6,10,14,.98)}
.cso-lbl{font-family:'Bebas Neue',sans-serif;font-size:clamp(12px,3.2vw,15px);letter-spacing:6px;text-transform:uppercase;position:relative;z-index:1;font-weight:700}
.cso-lbl.blu{color:#7ec8ff;text-shadow:0 0 24px rgba(100,180,255,1),0 0 48px rgba(75,158,232,.6)}
.cso-lbl.grn{color:#7affc0;text-shadow:0 0 24px rgba(80,255,160,1),0 0 48px rgba(56,232,120,.6)}
.cso-lbl.red{color:#ff8080;text-shadow:0 0 24px rgba(255,100,100,1),0 0 48px rgba(240,64,64,.7)}
.cso-lbl.dim{color:rgba(200,216,232,.6)}
.cso-t{font-family:'Bebas Neue',sans-serif;font-size:clamp(38px,10.5vw,70px);line-height:1;letter-spacing:-1px;position:relative;z-index:1;transition:color .25s,text-shadow .25s}
.cso-t.idle{color:#90d4ff;text-shadow:0 0 40px rgba(100,190,255,1),0 0 80px rgba(75,158,232,.5)}
.cso-t.hold{color:#80ffbe;text-shadow:0 0 50px rgba(80,255,160,1),0 0 100px rgba(56,232,120,.5);animation:hbreathe .5s ease-in-out infinite alternate}
@keyframes hbreathe{from{transform:scale(.97);filter:brightness(.9)}to{transform:scale(1.05);filter:brightness(1.2)}}
.cso-t.run{color:#ff9090;text-shadow:0 0 40px rgba(255,100,100,1),0 0 80px rgba(240,64,64,.6);animation:runpulse .35s ease-in-out infinite alternate}
@keyframes runpulse{from{filter:brightness(1)}to{filter:brightness(1.3)}}
.cso-t.stop{color:rgba(200,216,232,.5)}
.cso-t.win{color:#80ffbe;text-shadow:0 0 60px rgba(80,255,160,1),0 0 120px rgba(56,232,120,.7);animation:wbreathe .5s ease-in-out infinite alternate}
.cso-t.lose{color:rgba(200,216,232,.3)}
@keyframes wbreathe{from{transform:scale(1);filter:brightness(1)}to{transform:scale(1.07);filter:brightness(1.25)}}
.cso-hint{font-size:clamp(9px,2.2vw,11px);letter-spacing:3px;color:rgba(200,216,232,.75);text-transform:uppercase;position:relative;z-index:1;text-align:center;padding:0 10px;font-weight:600}
.cso-win{font-family:'Bebas Neue',sans-serif;font-size:clamp(11px,3.2vw,15px);letter-spacing:6px;color:#80ffbe;text-shadow:0 0 30px rgba(80,255,160,1);position:relative;z-index:1;text-align:center}
.cso-ring{position:relative;z-index:1;display:flex;align-items:center;justify-content:center}
.cso-ring svg{position:absolute;top:0;left:0;overflow:visible}
.rb{fill:none;stroke:rgba(255,255,255,.08);stroke-linecap:round}
.rf{fill:none;stroke:#80ffbe;stroke-linecap:round;filter:drop-shadow(0 0 10px rgba(80,255,160,.9));stroke-dasharray:289;animation:rspin .7s linear infinite}
@keyframes rspin{from{stroke-dashoffset:289}to{stroke-dashoffset:0}}
.rf2{fill:none;stroke:rgba(80,255,160,.3);stroke-linecap:round;stroke-dasharray:60 229;animation:rspin2 1.4s linear infinite reverse}
@keyframes rspin2{from{stroke-dashoffset:0}to{stroke-dashoffset:289}}
/* DOUBLE MOVE */
.dbl{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(8px,2vh,14px);z-index:50;pointer-events:none;animation:dbl-in .4s cubic-bezier(.16,1,.3,1) both}
@keyframes dbl-in{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
.dbl-bw{position:absolute;inset:0;background:rgba(3,18,10,.97)}
.dbl-bl{position:absolute;inset:0;background:rgba(4,8,14,.97)}
.dbl-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:clamp(8px,2vh,13px);border-radius:20px;padding:clamp(16px,4vh,24px) clamp(20px,6vw,34px)}
.dbl-card.win-card{background:rgba(56,232,120,.12);border:1.5px solid rgba(56,232,120,.5);box-shadow:0 0 40px rgba(56,232,120,.15),inset 0 1px 0 rgba(56,232,120,.2)}
.dbl-card.lose-card{background:rgba(255,255,255,.06);border:1.5px solid rgba(255,255,255,.15);box-shadow:0 4px 24px rgba(0,0,0,.4)}
.dbl-icon{font-size:clamp(28px,8vw,40px);line-height:1;filter:drop-shadow(0 0 16px rgba(56,232,120,.8))}
.dbl-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(24px,6.5vw,34px);letter-spacing:6px;color:#ffffff;text-transform:uppercase;text-align:center;text-shadow:0 0 30px rgba(56,232,120,.8),0 2px 0 rgba(0,0,0,.6)}
.dbl-sub{font-size:11px;letter-spacing:2px;color:rgba(56,232,120,1);text-transform:uppercase;text-align:center;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,.7);background:rgba(56,232,120,.15);border:1px solid rgba(56,232,120,.4);border-radius:8px;padding:4px 10px}
.dbl-pips{display:flex;gap:16px}
.dbl-pip{width:20px;height:20px;border-radius:50%;background:var(--grn);box-shadow:0 0 16px rgba(56,232,120,.9),0 2px 6px rgba(0,0,0,.5);animation:psonar 1s ease-out infinite}
.dbl-pip:nth-child(2){animation-delay:.5s}
@keyframes psonar{0%{box-shadow:0 0 0 0 rgba(56,232,120,.9),0 2px 6px rgba(0,0,0,.5)}70%{box-shadow:0 0 0 14px rgba(56,232,120,0),0 2px 6px rgba(0,0,0,.5)}100%{box-shadow:0 0 0 0 rgba(56,232,120,0),0 2px 6px rgba(0,0,0,.5)}}
.dbl-pip.used{background:rgba(255,255,255,.2);box-shadow:none;animation:none;transform:scale(.65);opacity:.35;transition:all .35s cubic-bezier(.34,1.56,.64,1)}
.dbl-hint{font-size:12px;letter-spacing:3px;color:rgba(255,255,255,.85);text-transform:uppercase;font-weight:700;text-shadow:0 1px 6px rgba(0,0,0,.7)}
.dbl-wait-ico{font-size:clamp(22px,6.5vw,32px);line-height:1;opacity:.6}
.dbl-wait-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(18px,5vw,26px);letter-spacing:5px;color:#ffffff;text-transform:uppercase;text-align:center;text-shadow:0 2px 10px rgba(0,0,0,.8);opacity:.9}
.dbl-wait-sub{font-size:11px;letter-spacing:2px;color:rgba(200,216,232,.65);text-transform:uppercase;text-align:center;font-weight:600}
.dbl-challenge{font-size:11px;letter-spacing:2px;color:#ffffff;text-transform:uppercase;text-align:center;padding:8px 16px;background:rgba(168,85,247,.4);border:1.5px solid rgba(168,85,247,.8);border-radius:11px;font-weight:700;text-shadow:0 1px 4px rgba(0,0,0,.6);box-shadow:0 0 20px rgba(168,85,247,.5);animation:wfade 1s ease-in-out infinite alternate}
@keyframes wfade{from{opacity:.8}to{opacity:1}}
/* FORBIDDEN MOVE */
.forb{position:absolute;inset:0;z-index:60;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:rgba(240,64,64,.08);animation:fade-in .2s ease-out both}
.forb-ico{font-size:clamp(28px,8vw,46px);animation:fshake .4s ease-in-out}
@keyframes fshake{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}75%{transform:rotate(9deg)}}
.forb-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(14px,4vw,20px);letter-spacing:5px;color:var(--red);text-align:center}
.forb-sub{font-size:8px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;text-align:center;padding:0 14px}
/* TOURNAMENT */
.trn{position:fixed;inset:0;z-index:250;display:flex;flex-direction:column;background:var(--bg);animation:cfg-in .35s cubic-bezier(.16,1,.3,1) both}
.trn-hd{display:flex;align-items:center;justify-content:space-between;padding:clamp(14px,3.5vh,24px) 18px 10px;border-bottom:1px solid var(--bd);flex-shrink:0}
.trn-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(16px,4.5vw,22px);letter-spacing:5px;color:var(--gold)}
.trn-close{width:34px;height:34px;border-radius:50%;background:var(--s2);border:1px solid var(--bd);color:var(--dim);font-size:15px;display:flex;align-items:center;justify-content:center;cursor:pointer}
.trn-close:active{background:var(--s3)}
.trn-body{flex:1;overflow-y:auto;padding:12px 14px 36px;-webkit-overflow-scrolling:touch}
.trn-body::-webkit-scrollbar{display:none}
.trn-setup{display:flex;flex-direction:column;gap:14px}
.trn-players-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px}
.trn-player-slot{background:var(--s2);border:1px solid var(--bd);border-radius:11px;padding:9px 11px;display:flex;align-items:center;gap:7px}
.trn-p-num{font-family:'Bebas Neue',sans-serif;font-size:17px;color:var(--gold);width:20px;text-align:center}
.trn-p-name{font-size:11px;letter-spacing:1px;color:var(--txt);font-weight:600}
.trn-size-row{display:flex;gap:7px}
.trn-size-btn{flex:1;padding:11px;background:var(--s2);border:1.5px solid var(--bd);border-radius:11px;font-family:'Bebas Neue',sans-serif;font-size:15px;letter-spacing:3px;color:var(--dim);cursor:pointer;text-align:center;transition:all .2s}
.trn-size-btn.sel{background:rgba(232,184,75,.08);border-color:var(--gold);color:var(--gold)}
.trn-start-btn{padding:13px;border:none;border-radius:11px;font-family:'Bebas Neue',sans-serif;font-size:15px;letter-spacing:5px;color:var(--bg);background:linear-gradient(135deg,var(--g2),var(--gold));cursor:pointer;box-shadow:0 4px 20px rgba(232,184,75,.4)}
.trn-bracket{display:flex;flex-direction:column;gap:10px}
.trn-round-title{font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:4px;color:var(--gold);text-align:center;margin-bottom:3px}
.trn-match{background:var(--s1);border:1px solid var(--bd);border-radius:11px;overflow:hidden}
.trn-match.active{border-color:var(--gold);box-shadow:0 0 14px rgba(232,184,75,.2)}
.trn-match.done{opacity:.6}
.trn-m-row{display:flex;align-items:center;padding:9px 13px;border-bottom:1px solid rgba(255,255,255,.04)}
.trn-m-row:last-child{border-bottom:none}
.trn-m-row.won{background:rgba(232,184,75,.07)}
.trn-m-name{flex:1;font-size:12px;font-weight:600;color:var(--txt);letter-spacing:.5px}
.trn-m-row.won .trn-m-name{color:var(--g2)}
.trn-m-score{font-family:'Bebas Neue',sans-serif;font-size:15px;color:var(--dim);letter-spacing:2px;width:22px;text-align:center}
.trn-m-row.won .trn-m-score{color:var(--gold)}
.trn-m-play{padding:7px 0;background:rgba(232,184,75,.06);border-top:1px solid var(--bd);text-align:center;font-family:'Bebas Neue',sans-serif;font-size:11px;letter-spacing:3px;color:var(--gold);cursor:pointer}
.trn-m-play:active{background:rgba(232,184,75,.12)}
.trn-m-tbd{padding:9px 13px;font-size:10px;letter-spacing:2px;color:var(--dim);text-align:center}
.trn-winner-banner{background:linear-gradient(135deg,rgba(232,184,75,.12),rgba(232,184,75,.04));border:1px solid var(--gold);border-radius:13px;padding:18px;text-align:center;margin-bottom:14px}
.trn-w-ico{font-size:36px;animation:efloat 2s ease-in-out infinite alternate}
@keyframes efloat{from{transform:translateY(0)}to{transform:translateY(-8px)}}
.trn-w-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(20px,5.5vw,30px);letter-spacing:5px;background:linear-gradient(135deg,var(--g2),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent;margin-top:5px}
.trn-w-sub{font-size:9px;letter-spacing:3px;color:var(--dim);margin-top:3px}
.trn-new-btn{width:100%;padding:12px;border:none;border-radius:11px;font-family:'Bebas Neue',sans-serif;font-size:14px;letter-spacing:4px;color:var(--bg);background:linear-gradient(135deg,var(--g2),var(--gold));cursor:pointer;margin-top:7px}
.trn-p-input{flex:1;background:transparent;border:none;outline:none;font-family:'Oxanium',sans-serif;font-size:11px;font-weight:600;color:var(--gold);letter-spacing:.5px;width:0;min-width:0;caret-color:var(--gold)}
.trn-p-slot-edit{background:rgba(232,184,75,.07);border-color:var(--g3)}
.trn-challenge{position:absolute;inset:0;z-index:55;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;background:rgba(168,85,247,.1);animation:fade-in .2s ease-out both}
.trn-ch-ico{font-size:clamp(24px,7vw,38px);animation:fshake .4s ease-in-out}
.trn-ch-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(12px,3.5vw,18px);letter-spacing:5px;color:var(--pur);text-align:center}
.trn-ch-sub{font-size:8px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;text-align:center;padding:0 14px}
.dbl-challenge{font-family:'Bebas Neue',sans-serif;font-size:clamp(9px,2.5vw,12px);letter-spacing:3px;color:var(--pur);position:relative;z-index:1;text-transform:uppercase;text-align:center;padding:2px 10px;background:rgba(168,85,247,.12);border:1px solid rgba(168,85,247,.35);border-radius:8px;animation:wfade 1.2s ease-in-out infinite alternate}
/* HISTORY */
.hist{position:fixed;inset:0;z-index:250;display:flex;flex-direction:column;background:var(--bg);animation:cfg-in .35s cubic-bezier(.16,1,.3,1) both}
.hist-hd{display:flex;align-items:center;justify-content:space-between;padding:clamp(14px,3.5vh,24px) 18px 10px;border-bottom:1px solid var(--bd);flex-shrink:0}
.hist-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(16px,4.5vw,22px);letter-spacing:5px;color:var(--gold)}
.hist-close{width:34px;height:34px;border-radius:50%;background:var(--s2);border:1px solid var(--bd);color:var(--dim);font-size:15px;display:flex;align-items:center;justify-content:center;cursor:pointer}
.hist-body{flex:1;overflow-y:auto;padding:12px 14px 36px;-webkit-overflow-scrolling:touch}
.hist-body::-webkit-scrollbar{display:none}
.hist-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:10px;opacity:.4}
.hist-empty-ico{font-size:36px}
.hist-empty-lbl{font-family:'Bebas Neue',sans-serif;font-size:15px;letter-spacing:4px;color:var(--dim)}
.hist-entry{background:var(--s1);border:1px solid var(--bd);border-radius:13px;padding:11px 13px;margin-bottom:9px;animation:cfg-in .3s cubic-bezier(.16,1,.3,1) both}
.hist-entry.w0{border-left:3px solid var(--gold)}
.hist-entry.w1{border-left:3px solid var(--blu)}
.hist-row1{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}
.hist-date{font-size:9px;letter-spacing:2px;color:var(--dim);text-transform:uppercase}
.hist-badge{font-family:'Bebas Neue',sans-serif;font-size:10px;letter-spacing:3px;padding:2px 9px;border-radius:18px}
.hist-badge.b0{background:rgba(232,184,75,.15);color:var(--gold);border:1px solid var(--g3)}
.hist-badge.b1{background:rgba(75,158,232,.15);color:var(--blu);border:1px solid rgba(75,158,232,.4)}
.hist-players{display:flex;gap:7px;margin-bottom:7px}
.hist-player{flex:1;background:var(--s2);border-radius:9px;padding:8px 10px;display:flex;flex-direction:column;gap:4px}
.hist-p-top{display:flex;align-items:center;gap:5px}
.hist-p-name{font-family:'Bebas Neue',sans-serif;font-size:11px;letter-spacing:3px;color:var(--txt);flex:1}
.hist-p-time{font-family:'Bebas Neue',sans-serif;font-size:clamp(14px,4vw,20px);color:var(--gold);letter-spacing:2px;line-height:1}
.hist-player.winner .hist-p-time{color:var(--g2)}
.hist-p-meta{font-size:8px;letter-spacing:1px;color:var(--dim)}
.hist-footer{display:flex;align-items:center;justify-content:space-between;margin-top:3px}
.hist-meta{font-size:8px;letter-spacing:2px;color:var(--dim)}
.hist-clear{font-size:9px;letter-spacing:2px;color:var(--red);opacity:.6;background:none;border:none;cursor:pointer;font-family:'Oxanium',sans-serif;text-transform:uppercase}
/* END */
.end{position:fixed;inset:0;z-index:200;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(9px,2.2vh,16px);background:rgba(6,10,14,.97);overflow:hidden}
.end::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 80% 50% at 50% 50%,rgba(232,184,75,.07) 0%,transparent 70%);animation:eburst 2s ease-in-out infinite alternate}
@keyframes eburst{from{transform:scale(1);opacity:.7}to{transform:scale(1.15);opacity:1}}
.end > *{animation:estag .55s cubic-bezier(.16,1,.3,1) both;position:relative;z-index:1}
.end > *:nth-child(1){animation-delay:.0s}.end > *:nth-child(2){animation-delay:.1s}.end > *:nth-child(3){animation-delay:.18s}.end > *:nth-child(4){animation-delay:.26s}.end > *:nth-child(5){animation-delay:.34s}.end > *:nth-child(6){animation-delay:.4s}
@keyframes estag{from{opacity:0;transform:translateY(22px) scale(.92)}to{opacity:1;transform:translateY(0) scale(1)}}
.end-ico{font-size:clamp(38px,10vw,54px);filter:drop-shadow(0 0 20px rgba(232,184,75,.5));animation:efloat 2s ease-in-out infinite alternate}
.end-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(24px,7vw,40px);letter-spacing:7px;background:linear-gradient(135deg,var(--g2),var(--gold),#b8882b);background-size:200%;-webkit-background-clip:text;background-clip:text;color:transparent;text-align:center;animation:shimmer 3s ease-in-out infinite}
.end-reason{font-size:10px;letter-spacing:5px;color:var(--dim);text-transform:uppercase}
.end-cubes{display:flex;align-items:center;gap:18px}
.end-cube-card{display:flex;flex-direction:column;align-items:center;gap:4px;opacity:.45;transition:opacity .3s}
.end-cube-card.winner{opacity:1}
.end-cube-lbl{font-family:'Bebas Neue',sans-serif;font-size:9px;letter-spacing:3px;color:var(--dim);text-transform:uppercase}
.end-cube-card.winner .end-cube-lbl{color:var(--gold)}
.end-cube-sub{font-size:8px;letter-spacing:1px;color:var(--dim)}
.end-stats{display:flex;gap:clamp(14px,4.5vw,24px);flex-wrap:wrap;justify-content:center;background:var(--s1);border:1px solid var(--bd);border-radius:14px;padding:clamp(9px,2.2vw,14px) clamp(14px,3.5vw,22px);box-shadow:0 4px 30px rgba(0,0,0,.4)}
.stat{display:flex;flex-direction:column;align-items:center;gap:2px}
.stat-v{font-family:'Bebas Neue',sans-serif;font-size:clamp(18px,5vw,26px);color:var(--gold);letter-spacing:2px}
.stat-l{font-size:7px;letter-spacing:3px;color:var(--dim);text-transform:uppercase}
.end-btns{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}
.btn1{font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:5px;color:var(--bg);background:linear-gradient(110deg,var(--g3),var(--g2),var(--gold));background-size:200%;border:none;border-radius:11px;padding:13px 26px;cursor:pointer;box-shadow:0 0 26px rgba(232,184,75,.5);animation:shimmer 3s ease-in-out infinite;transition:transform .18s}
.btn1:active{transform:scale(.94)}
.btn2{font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:5px;color:var(--txt);background:var(--s2);border:1px solid var(--bd);border-radius:11px;padding:13px 26px;cursor:pointer;transition:transform .18s,border-color .2s}
.btn2:active{transform:scale(.94);border-color:var(--g3)}
/* RUBIK'S CUBE */
.rcube{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5px;padding:2px;background:var(--s3);border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}
.rcube.sm{width:22px;height:22px;gap:1px;padding:1.5px}
.rcube.md{width:30px;height:30px;gap:1.5px;padding:2px}
.rcube.lg{width:42px;height:42px;gap:2px;padding:3px}
.rc{border-radius:1.5px}
.p1c0{background:#e84b4b}.p1c1{background:#e8b84b}.p1c2{background:#38e878}
.p1c3{background:#4b9ee8}.p1c4{background:#e8b84b}.p1c5{background:#e84b4b}
.p1c6{background:#38e878}.p1c7{background:#4b9ee8}.p1c8{background:#e8b84b}
.p2c0{background:#4b9ee8}.p2c1{background:#38e878}.p2c2{background:#e8b84b}
.p2c3{background:#e84b4b}.p2c4{background:#4b9ee8}.p2c5{background:#38e878}
.p2c6{background:#e8b84b}.p2c7{background:#e84b4b}.p2c8{background:#4b9ee8}
/* USER MANAGER */
.um{position:fixed;inset:0;z-index:260;display:flex;flex-direction:column;background:var(--bg);animation:cfg-in .35s cubic-bezier(.16,1,.3,1) both}
.um-hd{display:flex;align-items:center;gap:8px;padding:clamp(14px,3.5vh,24px) 18px 10px;border-bottom:1px solid var(--bd);flex-shrink:0}
.um-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(16px,4.5vw,22px);letter-spacing:5px;color:var(--gold);flex:1}
.um-add-btn{font-family:'Bebas Neue',sans-serif;font-size:12px;letter-spacing:3px;color:var(--bg);background:linear-gradient(135deg,var(--g2),var(--gold));border:none;border-radius:9px;padding:7px 13px;cursor:pointer;white-space:nowrap}
.um-close{width:34px;height:34px;border-radius:50%;background:var(--s2);border:1px solid var(--bd);color:var(--dim);font-size:15px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0}
.um-body{flex:1;overflow-y:auto;padding:12px 14px 36px;-webkit-overflow-scrolling:touch}
.um-body::-webkit-scrollbar{display:none}
.um-card{background:var(--s1);border:1px solid var(--bd);border-radius:13px;padding:12px 13px;margin-bottom:9px;display:flex;flex-direction:column;gap:9px;transition:border-color .2s}
.um-card.editing{border-color:var(--gold);background:rgba(232,184,75,.04)}
.um-row{display:flex;align-items:center;gap:10px}
.um-av{font-size:24px;line-height:1;width:44px;height:44px;background:var(--s2);border:1.5px solid var(--bd);border-radius:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;transition:border-color .2s}
.um-av:active{transform:scale(.9)}
.um-av.open{border-color:var(--gold)}
.um-info{flex:1;display:flex;flex-direction:column;gap:3px;min-width:0}
.um-name{font-weight:700;font-size:clamp(13px,3.8vw,15px);color:var(--txt);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.um-meta{display:flex;gap:8px;align-items:center}
.um-elo{font-family:'Bebas Neue',sans-serif;font-size:13px;color:var(--grn);letter-spacing:1px}
.um-streak{font-size:10px;color:var(--red)}
.um-wins{font-size:10px;color:var(--dim)}
.um-actions{display:flex;gap:6px;flex-shrink:0}
.um-btn{width:32px;height:32px;border-radius:9px;background:var(--s2);border:1px solid var(--bd);font-size:14px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s}
.um-btn.edit:active{background:var(--s3);border-color:var(--gold)}
.um-btn.del:active{background:rgba(240,64,64,.1);border-color:var(--red)}
.um-name-input{background:var(--s2);border:1px solid var(--gold);border-radius:9px;padding:7px 11px;font-family:'Oxanium',sans-serif;font-size:clamp(13px,3.8vw,15px);font-weight:700;color:var(--gold);caret-color:var(--gold);outline:none;width:100%}
.um-av-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:5px;background:var(--s2);border-radius:10px;padding:8px}
.um-av-item{aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:17px;border-radius:7px;cursor:pointer;border:1.5px solid transparent;transition:all .15s}
.um-av-item.sel{border-color:var(--gold);background:rgba(232,184,75,.12)}
.um-av-item:active{transform:scale(.88)}
.um-save-row{display:flex;gap:7px}
.um-save-btn{flex:1;padding:8px;border:none;border-radius:9px;font-family:'Bebas Neue',sans-serif;font-size:12px;letter-spacing:3px;color:var(--bg);background:linear-gradient(135deg,var(--g2),var(--gold));cursor:pointer}
.um-cancel-btn{padding:8px 14px;border:1px solid var(--bd);background:transparent;border-radius:9px;font-size:10px;letter-spacing:2px;color:var(--dim);cursor:pointer;text-transform:uppercase}
/* PLAYER SELECT */
.ps{position:fixed;inset:0;z-index:170;display:flex;flex-direction:column;background:var(--bg);animation:cfg-in .35s cubic-bezier(.16,1,.3,1) both}
.ps-half{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:14px 14px 20px;gap:10px;overflow:hidden}
.ps-half.flip{transform:rotate(180deg)}
.ps-half.on0{background:radial-gradient(ellipse 80% 70% at center,rgba(232,184,75,.07) 0%,transparent 70%)}
.ps-half.on1{background:radial-gradient(ellipse 80% 70% at center,rgba(75,158,232,.07) 0%,transparent 70%)}
.ps-label{font-size:9px;letter-spacing:5px;color:var(--dim);text-transform:uppercase;font-weight:700}
.ps-sel-display{display:flex;align-items:center;gap:8px;height:32px}
.ps-sel-av{font-size:22px;line-height:1}
.ps-sel-name{font-family:'Bebas Neue',sans-serif;font-size:clamp(14px,4vw,20px);letter-spacing:3px;color:var(--gold)}
.ps-sel-name.blue{color:var(--blu)}
.ps-sel-empty{font-size:9px;letter-spacing:3px;color:var(--dim);text-transform:uppercase}
.ps-grid{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;max-width:360px}
.ps-chip{display:flex;align-items:center;gap:7px;padding:7px 11px;background:var(--s1);border:1.5px solid var(--bd);border-radius:11px;cursor:pointer;transition:all .18s cubic-bezier(.34,1.56,.64,1)}
.ps-chip:active{transform:scale(.91)}
.ps-chip.sel0{background:rgba(232,184,75,.1);border-color:var(--gold);box-shadow:0 0 12px rgba(232,184,75,.3)}
.ps-chip.sel1{background:rgba(75,158,232,.1);border-color:var(--blu);box-shadow:0 0 12px rgba(75,158,232,.3)}
.ps-chip.other-taken{opacity:.3;pointer-events:none}
.ps-chip-av{font-size:16px;line-height:1}
.ps-chip-name{font-size:11px;font-weight:700;color:var(--txt);white-space:nowrap}
.ps-chip-elo{font-family:'Bebas Neue',sans-serif;font-size:10px;color:var(--grn);letter-spacing:1px}
.ps-div{height:3px;flex-shrink:0;background:linear-gradient(90deg,transparent,var(--gold),var(--g2),var(--gold),transparent);background-size:200%;animation:dflow 3s linear infinite;position:relative}
.ps-vs{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--bg);border:1.5px solid var(--gold);border-radius:20px;padding:3px 13px;font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:4px;color:var(--gold)}
.ps-footer{position:absolute;bottom:0;left:0;right:0;padding:7px 14px clamp(10px,2.5vh,20px);background:linear-gradient(to top,rgba(6,10,14,.98) 55%,transparent);display:flex;gap:8px;z-index:20}
.ps-go{flex:1;padding:13px;border:none;border-radius:13px;font-family:'Bebas Neue',sans-serif;font-size:15px;letter-spacing:6px;cursor:pointer;transition:all .2s;text-transform:uppercase}
.ps-go.ready{color:var(--bg);background:linear-gradient(110deg,var(--g3),var(--g2),var(--gold));background-size:250%;box-shadow:0 4px 28px rgba(232,184,75,.5);animation:shimmer 3s ease-in-out infinite}
.ps-go.wait{color:var(--dim);background:var(--s2)}
.ps-back{padding:13px 16px;border:1px solid var(--bd);background:transparent;border-radius:13px;font-size:11px;letter-spacing:2px;color:var(--dim);cursor:pointer;text-transform:uppercase}
/* STREAK */
.streak-badge{display:inline-flex;align-items:center;gap:3px;background:rgba(240,64,64,.1);border:1px solid rgba(240,64,64,.3);border-radius:10px;padding:2px 7px;font-family:'Bebas Neue',sans-serif;font-size:10px;letter-spacing:2px;color:var(--red);animation:str-pulse 1.8s ease-in-out infinite alternate}
@keyframes str-pulse{from{box-shadow:0 0 3px rgba(240,64,64,.2)}to{box-shadow:0 0 10px rgba(240,64,64,.5)}}
.chaos-lbl{font-size:8px;letter-spacing:3px;color:var(--pur);text-transform:uppercase;animation:cpulse .8s ease-in-out infinite alternate}
@keyframes cpulse{from{opacity:.4}to{opacity:1}}
.p-av{font-size:clamp(16px,4.5vw,22px);line-height:1}
/* TOGGLE CHAOS */
.toggle.chaos.on{background:var(--pur);border-color:var(--pur)}
.toggle.chaos.on .toggle-knob{box-shadow:0 0 8px rgba(168,85,247,.8)}
/* TIME CHART */
.chart-wrap{width:100%;max-width:320px;background:var(--s1);border:1px solid var(--bd);border-radius:12px;padding:9px 12px}
.chart-ttl{font-size:8px;letter-spacing:3px;color:var(--dim);text-transform:uppercase;margin-bottom:6px;text-align:center}
.chart-leg{display:flex;gap:14px;justify-content:center;margin-top:5px}
.chart-li{display:flex;align-items:center;gap:4px;font-size:8px;letter-spacing:2px;color:var(--dim);text-transform:uppercase}
.chart-ld{width:8px;height:8px;border-radius:50%}
.end-elo{font-size:9px;letter-spacing:2px}
.end-elo.pos{color:var(--grn)}
.end-elo.neg{color:var(--red)}
/* PROFILE SCREEN */
.prf{position:fixed;inset:0;z-index:260;display:flex;flex-direction:column;background:var(--bg);animation:cfg-in .35s cubic-bezier(.16,1,.3,1) both}
.prf-hd{display:flex;align-items:center;justify-content:space-between;padding:clamp(14px,3.5vh,24px) 18px 10px;border-bottom:1px solid var(--bd);flex-shrink:0}
.prf-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(16px,4.5vw,22px);letter-spacing:5px;color:var(--gold)}
.prf-save-btn{font-family:'Bebas Neue',sans-serif;font-size:12px;letter-spacing:3px;color:var(--bg);background:linear-gradient(135deg,var(--g2),var(--gold));border:none;border-radius:9px;padding:7px 14px;cursor:pointer}
.prf-body{flex:1;overflow-y:auto;padding:14px 14px 36px;-webkit-overflow-scrolling:touch}
.prf-body::-webkit-scrollbar{display:none}
.prf-card{background:var(--s1);border:1px solid var(--bd);border-radius:14px;padding:14px;margin-bottom:12px}
.prf-card-title{font-size:9px;letter-spacing:4px;color:var(--dim);text-transform:uppercase;margin-bottom:10px}
.prf-top-row{display:flex;align-items:flex-start;gap:10px;margin-bottom:12px}
.prf-av-btn{font-size:26px;line-height:1;width:50px;height:50px;border-radius:12px;background:var(--s2);border:1.5px solid var(--bd);display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;transition:border-color .2s}
.prf-av-btn.open{border-color:var(--gold)}
.prf-name-col{flex:1;display:flex;flex-direction:column;gap:5px}
.prf-name-input{background:var(--s2);border:1px solid var(--bd);border-radius:9px;padding:8px 11px;font-family:'Oxanium',sans-serif;font-size:clamp(13px,3.8vw,16px);font-weight:700;color:var(--gold);caret-color:var(--gold);outline:none;width:100%;transition:border-color .2s}
.prf-name-input:focus{border-color:var(--gold)}
.prf-name-hint{font-size:8px;letter-spacing:2px;color:var(--dim);text-transform:uppercase}
.prf-stats-row{display:flex;gap:7px;margin-bottom:10px}
.prf-stat-box{flex:1;background:var(--s2);border:1px solid var(--bd);border-radius:10px;padding:7px 8px;display:flex;flex-direction:column;align-items:center;gap:2px}
.prf-stat-val{font-family:'Bebas Neue',sans-serif;font-size:clamp(16px,4.5vw,22px);color:var(--gold);letter-spacing:2px}
.prf-stat-val.green{color:var(--grn)}
.prf-stat-val.red{color:var(--red)}
.prf-stat-lbl{font-size:7px;letter-spacing:2px;color:var(--dim);text-transform:uppercase}
.prf-av-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:5px;background:var(--s2);border-radius:10px;padding:8px;margin-bottom:8px}
.prf-av-item{aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:17px;border-radius:7px;cursor:pointer;border:1.5px solid transparent;transition:all .15s}
.prf-av-item.sel{border-color:var(--gold);background:rgba(232,184,75,.12)}
.prf-av-item:active{transform:scale(.88)}
.prf-reset-btn{width:100%;padding:8px;border:1px solid rgba(240,64,64,.3);background:transparent;border-radius:9px;font-size:10px;letter-spacing:2px;color:rgba(240,64,64,.6);cursor:pointer;text-transform:uppercase;transition:all .2s}
.prf-reset-btn:active{background:rgba(240,64,64,.1);border-color:var(--red)}
/* CUBE PREP SCREEN */
.prep{position:fixed;inset:0;z-index:180;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(12px,3vh,22px);padding:20px;animation:cfg-in .4s cubic-bezier(.16,1,.3,1) both}
.prep::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 70% 50% at 50% 40%,rgba(75,158,232,.05) 0%,transparent 70%);pointer-events:none}
.prep-title{font-family:'Bebas Neue',sans-serif;font-size:clamp(20px,6vw,34px);letter-spacing:7px;background:linear-gradient(135deg,var(--g2),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent;text-align:center;background-size:200%;animation:shimmer 3s ease-in-out infinite}
.prep-sub{font-size:9px;letter-spacing:4px;color:var(--dim);text-transform:uppercase;text-align:center}
.prep-players{display:flex;gap:14px;align-items:flex-start;width:100%}
.prep-p-card{background:var(--s1);border:1px solid var(--bd);border-radius:14px;padding:10px 8px;display:flex;flex-direction:column;align-items:center;gap:7px;flex:1}
.prep-p-name{font-size:9px;letter-spacing:3px;color:var(--dim);text-transform:uppercase;text-align:center}
.prep-scramble{display:flex;flex-wrap:wrap;gap:3px;justify-content:center}
.prep-move{font-family:'Bebas Neue',sans-serif;font-size:clamp(10px,2.8vw,13px);color:var(--gold);background:var(--s2);border:1px solid var(--bd);border-radius:5px;padding:2px 5px;min-width:22px;text-align:center}
.prep-countdown{font-family:'Bebas Neue',sans-serif;font-size:clamp(48px,14vw,86px);color:var(--blu);text-shadow:0 0 40px rgba(75,158,232,.6);letter-spacing:-2px;animation:hbreathe .5s ease-in-out infinite alternate}
.prep-ready-btn{padding:13px 34px;border:none;border-radius:13px;font-family:'Bebas Neue',sans-serif;font-size:clamp(14px,4vw,18px);letter-spacing:6px;color:var(--bg);background:linear-gradient(135deg,var(--g2),var(--gold));cursor:pointer;box-shadow:0 4px 28px rgba(232,184,75,.5);transition:transform .18s}
.prep-ready-btn:active{transform:scale(.95)}
.prep-skip{font-size:9px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;cursor:pointer;text-decoration:underline;text-underline-offset:3px}
/* SOLO CS TIMER */
.scs{position:fixed;inset:0;z-index:200;display:flex;flex-direction:column;background:var(--bg)}
.scs-panel{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(8px,2vh,14px);position:relative;overflow:hidden;cursor:pointer;transition:background .3s}
.scs-panel.flip{transform:rotate(180deg)}
.scs-panel.idle-bg{background:radial-gradient(ellipse 70% 70% at center,rgba(20,55,130,.7) 0%,rgba(6,10,14,.98) 70%)}
.scs-panel.hold-bg{animation:scs-hbg .8s ease-in-out infinite alternate}
@keyframes scs-hbg{from{background:radial-gradient(ellipse 65% 65% at center,rgba(10,110,44,.85) 0%,rgba(6,10,14,.98) 68%)}to{background:radial-gradient(ellipse 88% 88% at center,rgba(14,150,58,.9) 0%,rgba(6,10,14,.98) 68%)}}
.scs-panel.run-bg{animation:scs-rbg .4s ease-in-out infinite alternate}
@keyframes scs-rbg{from{background:radial-gradient(ellipse 70% 70% at center,rgba(140,20,20,.9) 0%,rgba(6,10,14,.98) 68%)}to{background:radial-gradient(ellipse 90% 90% at center,rgba(180,28,28,.95) 0%,rgba(6,10,14,.98) 68%)}}
.scs-panel.stop-bg{background:rgba(6,10,14,.98)}
.scs-panel.win-bg{animation:scs-wbg .6s ease-in-out infinite alternate}
@keyframes scs-wbg{from{background:radial-gradient(ellipse 70% 70% at center,rgba(10,120,48,.9) 0%,rgba(6,10,14,.98) 68%)}to{background:radial-gradient(ellipse 95% 95% at center,rgba(16,160,64,.95) 0%,rgba(6,10,14,.98) 68%)}}
.scs-panel.lose-bg{background:rgba(6,10,14,.98)}
.scs-state{font-family:'Bebas Neue',sans-serif;font-size:clamp(11px,3vw,14px);letter-spacing:6px;text-transform:uppercase;position:relative;z-index:1}
.scs-state.blu{color:#7ec8ff;text-shadow:0 0 20px rgba(100,180,255,1)}
.scs-state.grn{color:#7affc0;text-shadow:0 0 20px rgba(80,255,160,1)}
.scs-state.red{color:#ff8080;text-shadow:0 0 20px rgba(255,100,100,1)}
.scs-state.gold{color:var(--g2);text-shadow:0 0 20px rgba(255,217,122,.9)}
.scs-state.dim{color:rgba(200,216,232,.5)}
.scs-time{font-family:'Bebas Neue',sans-serif;font-size:clamp(52px,14vw,90px);letter-spacing:-2px;line-height:1;position:relative;z-index:1;transition:color .2s,text-shadow .2s}
.scs-time.idle{color:#90d4ff;text-shadow:0 0 40px rgba(100,190,255,.9)}
.scs-time.hold{color:#80ffbe;text-shadow:0 0 50px rgba(80,255,160,1);animation:hbreathe .5s ease-in-out infinite alternate}
.scs-time.run{color:#ff9090;text-shadow:0 0 40px rgba(255,100,100,1);animation:runpulse .35s ease-in-out infinite alternate}
.scs-time.stop{color:rgba(200,216,232,.5)}
.scs-time.win{color:#80ffbe;text-shadow:0 0 60px rgba(80,255,160,1);animation:wbreathe .5s ease-in-out infinite alternate}
.scs-time.lose{color:rgba(200,216,232,.25)}
.scs-hint{font-size:clamp(9px,2.2vw,11px);letter-spacing:3px;color:rgba(200,216,232,.7);text-transform:uppercase;position:relative;z-index:1;font-weight:600}
.scs-ring{position:relative;z-index:1;display:flex;align-items:center;justify-content:center}
.scs-name{display:flex;align-items:center;gap:7px;position:relative;z-index:1}
.scs-av{font-size:clamp(16px,4.5vw,22px);line-height:1}
.scs-plbl{font-size:9px;letter-spacing:5px;color:var(--dim);text-transform:uppercase;font-weight:700}
.scs-win-lbl{font-family:'Bebas Neue',sans-serif;font-size:clamp(14px,4vw,22px);letter-spacing:5px;color:#80ffbe;text-shadow:0 0 24px rgba(80,255,160,.9);position:relative;z-index:1}
.scs-div{height:3px;flex-shrink:0;background:linear-gradient(90deg,transparent,var(--gold),var(--g2),var(--gold),transparent);background-size:200%;animation:dflow 3s linear infinite;position:relative}
.scs-vs{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--bg);border:1.5px solid var(--gold);border-radius:20px;padding:3px 13px;font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:4px;color:var(--gold)}
.scs-footer{position:absolute;bottom:0;left:0;right:0;padding:6px 14px clamp(8px,2vh,18px);background:linear-gradient(to top,rgba(6,10,14,.98) 55%,transparent);display:flex;justify-content:center;gap:10px;z-index:30}
.scs-reset{font-family:'Oxanium',sans-serif;font-size:10px;font-weight:700;letter-spacing:2px;color:var(--dim);background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:8px 13px;cursor:pointer;text-transform:uppercase;transition:all .2s}
.scs-reset:active{color:var(--gold);border-color:var(--g3);transform:scale(.92)}
.scs-close{font-family:'Oxanium',sans-serif;font-size:10px;font-weight:700;letter-spacing:2px;color:var(--dim);background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:8px 13px;cursor:pointer;text-transform:uppercase;transition:all .2s}
.scs-close:active{color:var(--red);border-color:var(--red);transform:scale(.92)}
/* SOLO ONE-PLAYER MODE */
.scs-solo{position:fixed;inset:0;z-index:200;display:flex;flex-direction:column;background:var(--bg)}
.scs-solo-panel{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(10px,2.5vh,18px);position:relative;overflow:hidden;cursor:pointer;transition:background .3s}
.scs-solo-panel.idle-bg{background:radial-gradient(ellipse 70% 70% at center,rgba(20,55,130,.7) 0%,rgba(6,10,14,.98) 70%)}
.scs-solo-panel.hold-bg,.scs-solo-panel.ready-bg{animation:scs-hbg .8s ease-in-out infinite alternate}
.scs-solo-panel.run-bg{animation:scs-rbg .4s ease-in-out infinite alternate}
.scs-solo-panel.stop-bg{background:rgba(6,10,14,.98)}
.scs-solo-time{font-family:'Bebas Neue',sans-serif;font-size:clamp(72px,20vw,130px);letter-spacing:-4px;line-height:1;position:relative;z-index:1;transition:color .2s,text-shadow .2s}
.scs-solo-time.idle{color:#90d4ff;text-shadow:0 0 40px rgba(100,190,255,.9)}
.scs-solo-time.hold{color:#80ffbe;text-shadow:0 0 50px rgba(80,255,160,1);animation:hbreathe .5s ease-in-out infinite alternate}
.scs-solo-time.ready{color:#ffffff;text-shadow:0 0 60px rgba(80,255,160,1);animation:wbreathe .4s ease-in-out infinite alternate}
.scs-solo-time.run{color:#ff9090;text-shadow:0 0 40px rgba(255,100,100,1)}
.scs-solo-time.stop{color:#80ffbe;text-shadow:0 0 50px rgba(80,255,160,.8)}
.scs-solo-state{font-family:'Bebas Neue',sans-serif;font-size:clamp(12px,3.5vw,16px);letter-spacing:6px;text-transform:uppercase;position:relative;z-index:1}
.scs-solo-state.blu{color:#7ec8ff;text-shadow:0 0 20px rgba(100,180,255,1)}
.scs-solo-state.grn{color:#7affc0;text-shadow:0 0 20px rgba(80,255,160,1)}
.scs-solo-state.wht{color:#ffffff;text-shadow:0 0 20px rgba(255,255,255,.8)}
.scs-solo-state.red{color:#ff8080;text-shadow:0 0 20px rgba(255,100,100,1)}
.scs-solo-state.gold{color:var(--g2);text-shadow:0 0 20px rgba(255,217,122,.9)}
.scs-solo-hint{font-size:clamp(10px,2.5vw,12px);letter-spacing:3px;color:rgba(200,216,232,.65);text-transform:uppercase;position:relative;z-index:1;font-weight:600}
.scs-solo-history{position:relative;z-index:1;display:flex;flex-direction:column;gap:5px;align-items:center;width:100%;max-width:280px}
.scs-solo-hist-row{display:flex;align-items:center;justify-content:space-between;width:100%;padding:3px 10px;background:rgba(255,255,255,.05);border-radius:8px;border:1px solid rgba(255,255,255,.07)}
.scs-solo-hist-n{font-size:9px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;width:30px}
.scs-solo-hist-t{font-family:'Bebas Neue',sans-serif;font-size:15px;letter-spacing:1px;color:var(--gold)}
.scs-solo-hist-t.best{color:#80ffbe}
.scs-solo-best{display:flex;gap:10px;align-items:center;position:relative;z-index:1}
.scs-solo-best-lbl{font-size:9px;letter-spacing:3px;color:var(--dim);text-transform:uppercase}
.scs-solo-best-val{font-family:'Bebas Neue',sans-serif;font-size:14px;letter-spacing:2px;color:#80ffbe}
.scs-mode-toggle{display:flex;background:var(--s2);border:1px solid var(--bd);border-radius:10px;overflow:hidden}
.scs-mode-btn{padding:7px 14px;font-family:'Bebas Neue',sans-serif;font-size:11px;letter-spacing:3px;color:var(--dim);cursor:pointer;transition:all .2s}
.scs-mode-btn.active{background:var(--s3);color:var(--gold)}

10
ethan/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);

6
ethan/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

View File

@@ -2,13 +2,22 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#140700" />
<meta name="application-name" content="ChessCubing Arena" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
<meta <meta
name="description" name="description"
content="Découvrez ChessCubing, l'association et le jeu qui réunit échecs et Rubik's Cube dans un format simple, vivant et spectaculaire." content="Découvrez ChessCubing, l'association et le jeu qui réunit échecs et Rubik's Cube dans un format simple, vivant et spectaculaire."
/> />
<title>ChessCubing Arena | Accueil</title> <title>ChessCubing Arena | Accueil</title>
<link rel="icon" type="image/png" href="logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="shortcut icon" href="/favicon.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body class="home-body"> <body class="home-body">
@@ -26,19 +35,14 @@
<h1>Les échecs rencontrent le Rubik's Cube</h1> <h1>Les échecs rencontrent le Rubik's Cube</h1>
<p class="lead"> <p class="lead">
ChessCubing propose un jeu hybride simple à comprendre, intense à ChessCubing propose un jeu hybride simple à comprendre, intense à
vivre et très plaisant à regarder. On joue un block d'échecs, on vivre et très plaisant à regarder. On joue une partie d'échecs, on
passe par une phase cube obligatoire, puis la partie repart avec un passe par une phase cube obligatoire, puis la partie repart avec un
nouveau rythme. nouveau rythme.
</p> </p>
<div class="hero-pills">
<span>Accessible en club</span>
<span>Spectaculaire en démonstration</span>
<span>Stratégie + vitesse</span>
<span>Application officielle</span>
</div>
<div class="hero-actions"> <div class="hero-actions">
<a class="button primary" href="application.html">Ouvrir l'application</a> <a class="button primary" href="application.html">Ouvrir l'application</a>
<a class="button secondary" href="reglement.html">Lire le règlement</a> <a class="button secondary" href="reglement.html">Lire le règlement</a>
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
</div> </div>
</div> </div>
@@ -46,7 +50,7 @@
<div class="preview-card"> <div class="preview-card">
<p class="micro-label">Le principe</p> <p class="micro-label">Le principe</p>
<ol class="phase-list"> <ol class="phase-list">
<li>Deux joueurs disputent un block d'échecs court et nerveux.</li> <li>Deux joueurs disputent une partie d'échecs courte et nerveuse.</li>
<li>Une phase cube coupe le rythme et relance la tension.</li> <li>Une phase cube coupe le rythme et relance la tension.</li>
<li>Le résultat du cube influence immédiatement la suite du match.</li> <li>Le résultat du cube influence immédiatement la suite du match.</li>
</ol> </ol>
@@ -116,7 +120,7 @@
<div class="home-mini-grid"> <div class="home-mini-grid">
<article class="mini-panel"> <article class="mini-panel">
<span class="micro-label">1</span> <span class="micro-label">1</span>
<strong>On joue un block d'échecs</strong> <strong>On joue une partie d'échecs</strong>
<p> <p>
La partie avance par séquences courtes, ce qui garde un très bon La partie avance par séquences courtes, ce qui garde un très bon
rythme et rend l'action facile à suivre. rythme et rend l'action facile à suivre.
@@ -187,8 +191,8 @@
<span class="mini-chip">ChessCubing Twice</span> <span class="mini-chip">ChessCubing Twice</span>
<h3>Le cube donne l'élan</h3> <h3>Le cube donne l'élan</h3>
<p> <p>
Le joueur le plus rapide sur la phase cube prend le départ du Le joueur le plus rapide sur la phase cube prend le départ de
block suivant et peut même obtenir un double coup dans la partie suivante et peut même obtenir un double coup dans
certaines situations. certaines situations.
</p> </p>
</div> </div>

202
install-chesscubing-proxmox.sh Executable file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_BRANCH="${CHESSCUBING_GIT_BRANCH:-main}"
RAW_BASE_URL="https://git.jeannerot.fr/christophe/chesscubing/raw/branch/${REPO_BRANCH}"
usage() {
cat <<'EOF'
Bootstrap d'installation ChessCubing pour Proxmox.
Usage local :
./install-chesscubing-proxmox.sh
Usage en une ligne :
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/install-chesscubing-proxmox.sh)"
Variables d'environnement reconnues :
CHESSCUBING_LOCAL
CHESSCUBING_PROXMOX_HOST
CHESSCUBING_PROXMOX_USER
CHESSCUBING_PROXMOX_PASSWORD
CHESSCUBING_SSH_PORT
CHESSCUBING_CTID
CHESSCUBING_LXC_HOSTNAME
CHESSCUBING_LXC_IP
CHESSCUBING_LXC_GATEWAY
CHESSCUBING_LXC_BRIDGE
CHESSCUBING_LXC_CORES
CHESSCUBING_LXC_MEMORY
CHESSCUBING_LXC_SWAP
CHESSCUBING_LXC_DISK_GB
CHESSCUBING_TEMPLATE_STORAGE
CHESSCUBING_ROOTFS_STORAGE
CHESSCUBING_LXC_PASSWORD
CHESSCUBING_GIT_BRANCH
CHESSCUBING_ETHAN_REPO_URL
CHESSCUBING_ETHAN_GIT_BRANCH
EOF
}
die() {
printf 'Erreur: %s\n' "$*" >&2
exit 1
}
have_cmd() {
command -v "$1" >/dev/null 2>&1
}
need_cmd() {
have_cmd "$1" || die "La commande '$1' est requise."
}
prompt_default() {
local var_name="$1"
local prompt_label="$2"
local default_value="${3:-}"
local current_value="${!var_name:-}"
local input=""
if [[ -n "$current_value" ]]; then
return 0
fi
if [[ -n "$default_value" ]]; then
read -r -p "$prompt_label [$default_value]: " input
printf -v "$var_name" '%s' "${input:-$default_value}"
else
read -r -p "$prompt_label: " input
printf -v "$var_name" '%s' "$input"
fi
}
prompt_secret() {
local var_name="$1"
local prompt_label="$2"
local current_value="${!var_name:-}"
if [[ -n "$current_value" ]]; then
return 0
fi
read -rsp "$prompt_label: " current_value
echo
printf -v "$var_name" '%s' "$current_value"
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
need_cmd curl
LOCAL_MODE="${CHESSCUBING_LOCAL:-}"
PROXMOX_HOST="${CHESSCUBING_PROXMOX_HOST:-}"
PROXMOX_USER="${CHESSCUBING_PROXMOX_USER:-}"
PROXMOX_PASSWORD="${CHESSCUBING_PROXMOX_PASSWORD:-}"
SSH_PORT="${CHESSCUBING_SSH_PORT:-22}"
CTID="${CHESSCUBING_CTID:-}"
LXC_HOSTNAME="${CHESSCUBING_LXC_HOSTNAME:-chesscubing-web}"
LXC_IP="${CHESSCUBING_LXC_IP:-dhcp}"
LXC_GATEWAY="${CHESSCUBING_LXC_GATEWAY:-}"
LXC_BRIDGE="${CHESSCUBING_LXC_BRIDGE:-vmbr0}"
LXC_CORES="${CHESSCUBING_LXC_CORES:-2}"
LXC_MEMORY="${CHESSCUBING_LXC_MEMORY:-1024}"
LXC_SWAP="${CHESSCUBING_LXC_SWAP:-512}"
LXC_DISK_GB="${CHESSCUBING_LXC_DISK_GB:-6}"
TEMPLATE_STORAGE="${CHESSCUBING_TEMPLATE_STORAGE:-}"
ROOTFS_STORAGE="${CHESSCUBING_ROOTFS_STORAGE:-}"
LXC_PASSWORD="${CHESSCUBING_LXC_PASSWORD:-}"
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
if have_cmd pct && have_cmd pveam; then
LOCAL_MODE="1"
else
LOCAL_MODE="0"
fi
fi
if [[ "$LOCAL_MODE" != "1" ]]; then
need_cmd ssh
need_cmd sshpass
prompt_default PROXMOX_HOST "IP ou nom du serveur Proxmox" ""
prompt_default PROXMOX_USER "Utilisateur SSH Proxmox" "root@pam"
prompt_secret PROXMOX_PASSWORD "Mot de passe SSH Proxmox"
prompt_default SSH_PORT "Port SSH Proxmox" "22"
fi
prompt_default CTID "CTID Proxmox (laisser vide pour auto)" ""
prompt_default LXC_HOSTNAME "Nom du LXC" "chesscubing-web"
prompt_default LXC_IP "IP du LXC ou dhcp" "dhcp"
if [[ "$LXC_IP" != "dhcp" ]]; then
prompt_default LXC_GATEWAY "Passerelle du LXC" ""
fi
prompt_default LXC_BRIDGE "Bridge réseau Proxmox" "vmbr0"
if [[ "$LOCAL_MODE" != "1" ]]; then
[[ -n "$PROXMOX_HOST" ]] || die "Le serveur Proxmox est obligatoire."
[[ -n "$PROXMOX_USER" ]] || die "L'utilisateur Proxmox est obligatoire."
[[ -n "$PROXMOX_PASSWORD" ]] || die "Le mot de passe Proxmox est obligatoire."
fi
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
printf 'Téléchargement du script dinstallation ChessCubing...\n'
curl -fsSL "${RAW_BASE_URL}/scripts/install-proxmox-lxc.sh" -o "$TMP_DIR/install-proxmox-lxc.sh"
chmod +x "$TMP_DIR/install-proxmox-lxc.sh"
cmd=(
"$TMP_DIR/install-proxmox-lxc.sh"
--hostname "$LXC_HOSTNAME"
--lxc-ip "$LXC_IP"
--bridge "$LXC_BRIDGE"
--cores "$LXC_CORES"
--memory "$LXC_MEMORY"
--swap "$LXC_SWAP"
--disk-gb "$LXC_DISK_GB"
--branch "$REPO_BRANCH"
--ethan-repo-url "$ETHAN_REPO_URL"
--ethan-branch "$ETHAN_REPO_BRANCH"
)
if [[ "$LOCAL_MODE" == "1" ]]; then
cmd+=(--local)
else
cmd+=(
--proxmox-host "$PROXMOX_HOST"
--proxmox-user "$PROXMOX_USER"
--proxmox-password "$PROXMOX_PASSWORD"
--ssh-port "$SSH_PORT"
)
fi
if [[ -n "$CTID" ]]; then
cmd+=(--ctid "$CTID")
fi
if [[ -n "$LXC_GATEWAY" ]]; then
cmd+=(--gateway "$LXC_GATEWAY")
fi
if [[ -n "$TEMPLATE_STORAGE" ]]; then
cmd+=(--template-storage "$TEMPLATE_STORAGE")
fi
if [[ -n "$ROOTFS_STORAGE" ]]; then
cmd+=(--rootfs-storage "$ROOTFS_STORAGE")
fi
if [[ -n "$LXC_PASSWORD" ]]; then
cmd+=(--lxc-password "$LXC_PASSWORD")
fi
printf 'Lancement de linstallation LXC ChessCubing...\n'
"${cmd[@]}"

View File

@@ -5,6 +5,14 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location = /ethan {
return 301 $scheme://$http_host/ethan/;
}
location /ethan/ {
try_files $uri $uri/ /ethan/index.html;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@@ -2,13 +2,22 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#140700" />
<meta name="application-name" content="ChessCubing Arena" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
<meta <meta
name="description" name="description"
content="Page règlement officielle du site ChessCubing Arena avec synthèse des formats Twice et Time." content="Page règlement officielle du site ChessCubing Arena avec synthèse des formats Twice et Time."
/> />
<title>ChessCubing Arena | Règlement officiel</title> <title>ChessCubing Arena | Règlement officiel</title>
<link rel="icon" type="image/png" href="logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="shortcut icon" href="/favicon.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body class="rules-body"> <body class="rules-body">
@@ -47,7 +56,7 @@
<p class="micro-label">Repères rapides</p> <p class="micro-label">Repères rapides</p>
<div class="rule-metrics"> <div class="rule-metrics">
<article class="metric-chip"> <article class="metric-chip">
<span>Block</span> <span>Partie</span>
<strong>180 s</strong> <strong>180 s</strong>
</article> </article>
<article class="metric-chip"> <article class="metric-chip">
@@ -86,7 +95,7 @@
</div> </div>
<p class="section-copy"> <p class="section-copy">
Les deux règlements partagent la même colonne vertébrale : un Les deux règlements partagent la même colonne vertébrale : un
match en blocks successifs, interrompus par une phase cube match en parties successives, interrompues par une phase cube
obligatoire. obligatoire.
</p> </p>
</div> </div>
@@ -101,10 +110,10 @@
</p> </p>
</article> </article>
<article class="stage-card"> <article class="stage-card">
<span class="micro-label">2. Block chess</span> <span class="micro-label">2. Partie d'échecs</span>
<strong>180 secondes de jeu</strong> <strong>180 secondes de jeu</strong>
<p> <p>
Chaque block comporte une phase d'échecs limitée par une durée Chaque partie comporte une phase d'échecs limitée par une durée
fixe et par un quota de coups selon le format FAST, FREEZE ou fixe et par un quota de coups selon le format FAST, FREEZE ou
MASTERS. MASTERS.
</p> </p>
@@ -123,7 +132,7 @@
<strong>Jamais au temps</strong> <strong>Jamais au temps</strong>
<p> <p>
La partie se termine uniquement par échec et mat ou abandon, La partie se termine uniquement par échec et mat ou abandon,
jamais par simple chute au temps ou dépassement d'un block. jamais par simple chute au temps ou dépassement d'une partie.
</p> </p>
</article> </article>
</div> </div>
@@ -159,7 +168,7 @@
<li>Vérifier la présence des huit cubes et des caches numérotés.</li> <li>Vérifier la présence des huit cubes et des caches numérotés.</li>
<li>Confirmer des mélanges identiques sous chaque numéro.</li> <li>Confirmer des mélanges identiques sous chaque numéro.</li>
<li>Préparer l'échiquier et la variante dans l'application.</li> <li>Préparer l'échiquier et la variante dans l'application.</li>
<li>Contrôler le tirage au sort avant le premier block.</li> <li>Contrôler le tirage au sort avant la première partie.</li>
<li>Déclencher chaque phase cube au bon moment.</li> <li>Déclencher chaque phase cube au bon moment.</li>
<li>Surveiller le respect du plafond de 120 s en mode Time.</li> <li>Surveiller le respect du plafond de 120 s en mode Time.</li>
</ul> </ul>
@@ -172,7 +181,7 @@
<h2>Twice et Time, côte à côte</h2> <h2>Twice et Time, côte à côte</h2>
</div> </div>
<p class="section-copy"> <p class="section-copy">
Les deux formats partagent les blocks et la phase cube, mais leur Les deux formats partagent les parties et la phase cube, mais leur
logique d'avantage diffère complètement. logique d'avantage diffère complètement.
</p> </p>
</div> </div>
@@ -183,24 +192,24 @@
<span class="mini-chip">Version V2</span> <span class="mini-chip">Version V2</span>
<h3>ChessCubing Twice</h3> <h3>ChessCubing Twice</h3>
<p> <p>
Le gagnant du cube obtient l'initiative sur le block suivant, Le gagnant du cube obtient l'initiative sur la partie suivante,
avec une règle de double coup encadrée. avec une règle de double coup encadrée.
</p> </p>
</div> </div>
<div class="format-badges"> <div class="format-badges">
<span>Block : 180 s</span> <span>Partie : 180 s</span>
<span>Temps par coup : 20 s max</span> <span>Temps par coup : 20 s max</span>
<span>FAST / FREEZE / MASTERS : 6 / 8 / 10</span> <span>FAST / FREEZE / MASTERS : 6 / 8 / 10</span>
</div> </div>
<div class="format-section"> <div class="format-section">
<h4>Début et fin des blocks</h4> <h4>Début et fin des parties</h4>
<ul class="rule-list compact"> <ul class="rule-list compact">
<li>Les Blancs commencent le block 1.</li> <li>Les Blancs commencent la partie 1.</li>
<li>Aucun double coup n'est possible au block 1.</li> <li>Aucun double coup n'est possible à la partie 1.</li>
<li>Un block s'arrête à 180 s ou quand les deux quotas sont atteints.</li> <li>Une partie s'arrête à 180 s ou quand les deux quotas sont atteints.</li>
<li>Il est interdit de finir un block avec un roi en échec.</li> <li>Il est interdit de finir une partie avec un roi en échec.</li>
<li> <li>
Si le dernier coup donne échec, les coups nécessaires pour Si le dernier coup donne échec, les coups nécessaires pour
parer sont joués hors quota. parer sont joués hors quota.
@@ -215,7 +224,7 @@
<li>Les deux joueurs reçoivent un mélange identique.</li> <li>Les deux joueurs reçoivent un mélange identique.</li>
<li>Le joueur le plus rapide gagne la phase cube.</li> <li>Le joueur le plus rapide gagne la phase cube.</li>
<li>En cas d'égalité parfaite, la phase cube est rejouée.</li> <li>En cas d'égalité parfaite, la phase cube est rejouée.</li>
<li>Le gagnant du cube commence le block suivant.</li> <li>Le gagnant du cube commence la partie suivante.</li>
</ul> </ul>
</div> </div>
@@ -223,10 +232,10 @@
<span class="micro-label">Double coup V2</span> <span class="micro-label">Double coup V2</span>
<strong>Condition stricte</strong> <strong>Condition stricte</strong>
<p> <p>
Le gagnant du cube ne doit pas avoir joué le dernier coup du Le gagnant du cube ne doit pas avoir joué le dernier coup de
block précédent. Le premier coup est gratuit, non compté, la partie précédente. Le premier coup est gratuit, non compté,
peut capturer mais ne peut pas donner échec. Le second compte peut capturer mais ne peut pas donner échec. Le second compte
comme premier coup du block, peut donner échec, mais ne peut comme premier coup de la partie, peut donner échec, mais ne peut
capturer qu'un pion ou une pièce mineure. capturer qu'un pion ou une pièce mineure.
</p> </p>
</div> </div>
@@ -262,9 +271,9 @@
<div class="format-section"> <div class="format-section">
<h4>Structure temporelle</h4> <h4>Structure temporelle</h4>
<ul class="rule-list compact"> <ul class="rule-list compact">
<li>La structure des blocks est identique à celle du Twice.</li> <li>La structure des Blocks est identique à celle du Twice.</li>
<li>Les quotas de coups restent les mêmes : 6, 8 ou 10.</li> <li>Les quotas de coups restent les mêmes : 6, 8 ou 10.</li>
<li>Chaque block est suivi d'une phase cube obligatoire.</li> <li>Chaque Block est suivi d'une phase cube obligatoire.</li>
<li>Le trait est conservé après la phase cube.</li> <li>Le trait est conservé après la phase cube.</li>
<li>Aucun système de priorité ou de double coup n'existe.</li> <li>Aucun système de priorité ou de double coup n'existe.</li>
</ul> </ul>

556
scripts/install-proxmox-lxc.sh Executable file
View File

@@ -0,0 +1,556 @@
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'printf "Erreur: echec de la commande [%s] a la ligne %s.\n" "$BASH_COMMAND" "$LINENO" >&2' ERR
usage() {
cat <<'EOF'
Usage:
./scripts/install-proxmox-lxc.sh \
--proxmox-host 192.168.1.10 \
--proxmox-user root@pam \
--proxmox-password 'motdepasse'
./scripts/install-proxmox-lxc.sh --local
Options principales:
--proxmox-host IP ou nom DNS du serveur Proxmox
--proxmox-user Utilisateur SSH Proxmox (defaut: root@pam)
--proxmox-password Mot de passe SSH Proxmox
--ssh-port Port SSH Proxmox (defaut: 22)
--local Execute directement sur l'hote Proxmox local
--ctid CTID Proxmox. Si vide, le prochain ID libre est utilise
--hostname Nom du LXC (defaut: chesscubing-web)
--lxc-ip IP du LXC ou 'dhcp' (defaut: dhcp)
--gateway Passerelle si IP statique
--bridge Bridge reseau Proxmox (defaut: vmbr0)
--cores Nombre de vCPU du LXC (defaut: 2)
--memory Memoire RAM en Mo (defaut: 1024)
--swap Swap en Mo (defaut: 512)
--disk-gb Taille disque du LXC en Go (defaut: 6)
--template-storage Stockage Proxmox pour les templates
--rootfs-storage Stockage Proxmox pour le disque LXC
--repo-url Depot Git a deployer
--branch Branche Git a deployer (defaut: main)
--ethan-repo-url Depot Git de l'application Ethan
--ethan-branch Branche Git de l'application Ethan (defaut: main)
--lxc-password Mot de passe root du LXC. Genere si absent
-h, --help Affiche cette aide
Exemple:
./scripts/install-proxmox-lxc.sh \
--proxmox-host 10.0.0.2 \
--proxmox-user root@pam \
--proxmox-password 'secret'
EOF
}
die() {
printf 'Erreur: %s\n' "$*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "La commande '$1' est requise."
}
PROXMOX_HOST=""
PROXMOX_USER="root@pam"
PROXMOX_PASSWORD="${PROXMOX_PASSWORD:-}"
PROXMOX_PORT="22"
LOCAL_MODE="0"
CTID=""
LXC_HOSTNAME="chesscubing-web"
LXC_IP="dhcp"
LXC_GATEWAY=""
LXC_BRIDGE="vmbr0"
LXC_CORES="2"
LXC_MEMORY="1024"
LXC_SWAP="512"
LXC_DISK_GB="6"
TEMPLATE_STORAGE=""
ROOTFS_STORAGE=""
REPO_URL="https://git.jeannerot.fr/christophe/chesscubing.git"
REPO_BRANCH="main"
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
ETHAN_REPO_BRANCH="main"
LXC_PASSWORD=""
while [[ $# -gt 0 ]]; do
case "$1" in
--proxmox-host)
PROXMOX_HOST="${2:-}"
shift 2
;;
--proxmox-user)
PROXMOX_USER="${2:-}"
shift 2
;;
--proxmox-password)
PROXMOX_PASSWORD="${2:-}"
shift 2
;;
--ssh-port)
PROXMOX_PORT="${2:-}"
shift 2
;;
--local)
LOCAL_MODE="1"
shift
;;
--ctid)
CTID="${2:-}"
shift 2
;;
--hostname)
LXC_HOSTNAME="${2:-}"
shift 2
;;
--lxc-ip)
LXC_IP="${2:-}"
shift 2
;;
--gateway)
LXC_GATEWAY="${2:-}"
shift 2
;;
--bridge)
LXC_BRIDGE="${2:-}"
shift 2
;;
--cores)
LXC_CORES="${2:-}"
shift 2
;;
--memory)
LXC_MEMORY="${2:-}"
shift 2
;;
--swap)
LXC_SWAP="${2:-}"
shift 2
;;
--disk-gb)
LXC_DISK_GB="${2:-}"
shift 2
;;
--template-storage)
TEMPLATE_STORAGE="${2:-}"
shift 2
;;
--rootfs-storage)
ROOTFS_STORAGE="${2:-}"
shift 2
;;
--repo-url)
REPO_URL="${2:-}"
shift 2
;;
--branch)
REPO_BRANCH="${2:-}"
shift 2
;;
--ethan-repo-url)
ETHAN_REPO_URL="${2:-}"
shift 2
;;
--ethan-branch)
ETHAN_REPO_BRANCH="${2:-}"
shift 2
;;
--lxc-password)
LXC_PASSWORD="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
die "Option inconnue: $1"
;;
esac
done
if [[ "$LOCAL_MODE" != "1" && -z "$PROXMOX_HOST" ]]; then
if command -v pct >/dev/null 2>&1 && command -v pveam >/dev/null 2>&1; then
LOCAL_MODE="1"
fi
fi
payload_script="$(mktemp)"
cleanup() {
rm -f "$payload_script"
}
trap cleanup EXIT
cat >"$payload_script" <<'REMOTE'
set -Eeuo pipefail
trap 'printf "Erreur: echec de la commande [%s] a la ligne %s.\n" "$BASH_COMMAND" "$LINENO" >&2' ERR
ctid="$1"
lxc_hostname="$2"
lxc_ip="$3"
lxc_gateway="$4"
lxc_bridge="$5"
lxc_cores="$6"
lxc_memory="$7"
lxc_swap="$8"
lxc_disk_gb="$9"
shift 9
template_storage="$1"
rootfs_storage="$2"
repo_url="$3"
repo_branch="$4"
ethan_repo_url="$5"
ethan_repo_branch="$6"
lxc_password="$7"
die() {
printf 'Erreur: %s\n' "$*" >&2
exit 1
}
need_remote_cmd() {
command -v "$1" >/dev/null 2>&1 || die "La commande '$1' est absente sur Proxmox."
}
pick_storage() {
local candidate=""
for candidate in "$@"; do
if pvesm status 2>/dev/null | awk 'NR > 1 { print $1 }' | grep -Fxq "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
pick_first_dir_storage() {
pvesm status 2>/dev/null | awk 'NR > 1 && $2 == "dir" { print $1; exit }'
}
find_ctid_by_hostname() {
local wanted="$1"
local candidate=""
local candidate_hostname=""
while read -r candidate; do
[[ -n "$candidate" ]] || continue
candidate_hostname="$(pct config "$candidate" 2>/dev/null | awk -F ': ' '/^hostname:/ { print $2; exit }')"
if [[ "$candidate_hostname" == "$wanted" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done < <(pct list | awk 'NR > 1 { print $1 }')
return 1
}
ct_exec() {
pct exec "$ctid" -- bash -lc "$1"
}
need_remote_cmd pct
need_remote_cmd pveam
need_remote_cmd pvesm
if [[ -z "$ctid" ]]; then
command -v pvesh >/dev/null 2>&1 || die "Impossible de calculer le prochain CTID sans pvesh."
ctid="$(pvesh get /cluster/nextid)"
fi
if pct status "$ctid" >/dev/null 2>&1; then
die "Le CTID $ctid existe deja."
fi
if existing_ctid="$(find_ctid_by_hostname "$lxc_hostname")"; then
die "Un conteneur nomme $lxc_hostname existe deja sous le CTID $existing_ctid."
fi
if [[ -z "$template_storage" ]]; then
template_storage="$(pick_storage local)"
if [[ -z "$template_storage" ]]; then
template_storage="$(pick_first_dir_storage)"
fi
fi
[[ -n "$template_storage" ]] || die "Aucun stockage de template detecte. Passe --template-storage."
if [[ -z "$rootfs_storage" ]]; then
rootfs_storage="$(pick_storage local-lvm local-zfs local)"
fi
[[ -n "$rootfs_storage" ]] || die "Aucun stockage rootfs detecte. Passe --rootfs-storage."
printf 'Mise a jour du catalogue de templates Proxmox...\n'
pveam update >/dev/null
template_name="$(pveam available --section system | awk '/debian-12-standard/ { print $2 }' | sort -V | tail -n 1)"
if [[ -z "$template_name" ]]; then
template_name="debian-12-standard_12.7-1_amd64.tar.zst"
fi
if ! pveam list "$template_storage" 2>/dev/null | grep -Fq "$template_name"; then
printf 'Telechargement du template %s sur %s...\n' "$template_name" "$template_storage"
pveam download "$template_storage" "$template_name" >/dev/null
fi
template_ref="${template_storage}:vztmpl/${template_name}"
if [[ -z "$lxc_password" ]]; then
lxc_password="$(od -An -N12 -tx1 /dev/urandom | tr -d ' \n')"
fi
net0="name=eth0,bridge=${lxc_bridge},ip=dhcp"
if [[ "$lxc_ip" != "dhcp" ]]; then
net0="name=eth0,bridge=${lxc_bridge},ip=${lxc_ip}"
if [[ -n "$lxc_gateway" ]]; then
net0="${net0},gw=${lxc_gateway}"
fi
fi
printf 'Creation du LXC %s (%s)...\n' "$ctid" "$lxc_hostname"
pct create "$ctid" "$template_ref" \
--hostname "$lxc_hostname" \
--cores "$lxc_cores" \
--memory "$lxc_memory" \
--swap "$lxc_swap" \
--rootfs "${rootfs_storage}:${lxc_disk_gb}" \
--net0 "$net0" \
--onboot 1 \
--ostype debian \
--password "$lxc_password" \
--unprivileged 1
pct start "$ctid"
printf 'Attente du demarrage du LXC...\n'
for _ in $(seq 1 20); do
if pct exec "$ctid" -- true >/dev/null 2>&1; then
break
fi
sleep 2
done
pct exec "$ctid" -- true >/dev/null 2>&1 || die "Le LXC n'est pas joignable apres le demarrage."
printf 'Installation de nginx, git et rsync dans le conteneur...\n'
ct_exec "apt-get update && apt-get install -y ca-certificates git nginx rsync"
ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /var/www/chesscubing/current"
printf 'Clonage du depot %s...\n' "$repo_url"
ct_exec "if [ ! -d /opt/chesscubing/repo/.git ]; then \
rm -rf /opt/chesscubing/repo/* /opt/chesscubing/repo/.[!.]* /opt/chesscubing/repo/..?* 2>/dev/null || true; \
git clone --branch '$repo_branch' --single-branch '$repo_url' /opt/chesscubing/repo; \
else \
cd /opt/chesscubing/repo && \
git fetch origin '$repo_branch' && \
if git show-ref --verify --quiet 'refs/heads/$repo_branch'; then git checkout '$repo_branch'; else git checkout -b '$repo_branch' --track 'origin/$repo_branch'; fi && \
git pull --ff-only origin '$repo_branch'; \
fi"
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_COMMAND\" \"\$LINENO\" >&2' ERR
main_repo_dir='/opt/chesscubing/repo'
ethan_repo_dir='/opt/chesscubing/ethan-repo'
web_root='/var/www/chesscubing/current'
main_branch=\"\${1:-${repo_branch}}\"
ethan_repo_url='${ethan_repo_url}'
ethan_branch='${ethan_repo_branch}'
sync_git_repo() {
local repo_dir=\"\$1\"
local repo_url=\"\$2\"
local branch=\"\$3\"
local label=\"\$4\"
if [[ -d \"\$repo_dir/.git\" ]]; then
cd \"\$repo_dir\"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo \"Le depot \${label} contient des modifications locales. Mise a jour annulee.\" >&2
exit 1
fi
git fetch origin \"\$branch\"
if git show-ref --verify --quiet \"refs/heads/\$branch\"; then
git checkout \"\$branch\"
else
git checkout -b \"\$branch\" --track \"origin/\$branch\"
fi
git pull --ff-only origin \"\$branch\"
return 0
fi
[[ -n \"\$repo_url\" ]] || {
echo \"Le depot \${label} est absent et aucune URL n'a ete fournie.\" >&2
exit 1
}
rm -rf \"\$repo_dir\"
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
}
publish_static_tree() {
local source_dir=\"\$1\"
local destination_dir=\"\$2\"
install -d -m 0755 \"\$destination_dir\"
rsync -a --delete \
--include='*/' \
--include='*.html' \
--include='*.css' \
--include='*.js' \
--include='*.mjs' \
--include='*.png' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.svg' \
--include='*.webp' \
--include='*.ico' \
--include='*.pdf' \
--include='*.webmanifest' \
--exclude='*' \
\"\$source_dir/\" \"\$destination_dir/\"
}
sync_git_repo \"\$main_repo_dir\" '${repo_url}' \"\$main_branch\" 'principal'
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\"
install -d -m 0755 \"\$web_root\"
publish_static_tree \"\$main_repo_dir\" \"\$web_root\"
publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\"
while IFS= read -r -d '' html_file; do
LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\"
done < <(find \"\$web_root\" -type f -name '*.html' -print0)
chown -R www-data:www-data \"\$web_root\"
nginx -t
systemctl reload nginx
SCRIPT
chmod +x /usr/local/bin/update-chesscubing"
ct_exec "cat > /etc/nginx/sites-available/chesscubing.conf <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name _;
root /var/www/chesscubing/current;
index index.html;
location = /ethan {
return 301 \$scheme://\$http_host/ethan/;
}
location /ethan/ {
try_files \$uri \$uri/ /ethan/index.html;
}
location / {
try_files \$uri \$uri/ /index.html;
}
location ~* \.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest)$ {
expires -1;
add_header Cache-Control 'no-cache, no-store, must-revalidate';
}
}
NGINX
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/chesscubing.conf /etc/nginx/sites-enabled/chesscubing.conf"
printf 'Publication du site dans le LXC...\n'
ct_exec "/usr/local/bin/update-chesscubing '$repo_branch'"
ct_exec "systemctl enable nginx >/dev/null && systemctl restart nginx"
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
cat <<EOF
Installation terminee.
- CTID: $ctid
- Nom du LXC: $lxc_hostname
- Mot de passe root du LXC: $lxc_password
- URL probable: http://${container_ip:-<ip_du_lxc>}
Pour mettre a jour l'application plus tard:
./scripts/update-proxmox-lxc.sh --proxmox-host <ip-proxmox> --proxmox-user <user> --proxmox-password '<motdepasse>' --ctid $ctid
EOF
REMOTE
if [[ "$LOCAL_MODE" == "1" ]]; then
printf 'Creation du LXC "%s" en local sur cet hote Proxmox...\n' "$LXC_HOSTNAME"
bash "$payload_script" \
"$CTID" \
"$LXC_HOSTNAME" \
"$LXC_IP" \
"$LXC_GATEWAY" \
"$LXC_BRIDGE" \
"$LXC_CORES" \
"$LXC_MEMORY" \
"$LXC_SWAP" \
"$LXC_DISK_GB" \
"$TEMPLATE_STORAGE" \
"$ROOTFS_STORAGE" \
"$REPO_URL" \
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH" \
"$LXC_PASSWORD"
exit 0
fi
[[ -n "$PROXMOX_HOST" ]] || die "Merci de fournir --proxmox-host."
[[ -n "$PROXMOX_USER" ]] || die "Merci de fournir --proxmox-user."
if [[ -z "$PROXMOX_PASSWORD" ]]; then
read -rsp "Mot de passe SSH pour ${PROXMOX_USER}@${PROXMOX_HOST}: " PROXMOX_PASSWORD
echo
fi
need_cmd ssh
need_cmd sshpass
printf 'Creation du LXC "%s" sur %s...\n' "$LXC_HOSTNAME" "$PROXMOX_HOST"
sshpass -p "$PROXMOX_PASSWORD" \
ssh \
-p "$PROXMOX_PORT" \
-o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
"$PROXMOX_USER@$PROXMOX_HOST" \
bash -s -- \
"$CTID" \
"$LXC_HOSTNAME" \
"$LXC_IP" \
"$LXC_GATEWAY" \
"$LXC_BRIDGE" \
"$LXC_CORES" \
"$LXC_MEMORY" \
"$LXC_SWAP" \
"$LXC_DISK_GB" \
"$TEMPLATE_STORAGE" \
"$ROOTFS_STORAGE" \
"$REPO_URL" \
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH" \
"$LXC_PASSWORD" < "$payload_script"

346
scripts/update-proxmox-lxc.sh Executable file
View File

@@ -0,0 +1,346 @@
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'printf "Erreur: echec de la commande [%s] a la ligne %s.\n" "$BASH_COMMAND" "$LINENO" >&2' ERR
usage() {
cat <<'EOF'
Usage:
./scripts/update-proxmox-lxc.sh \
--proxmox-host 192.168.1.10 \
--proxmox-user root@pam \
--proxmox-password 'motdepasse' \
--ctid 120
./scripts/update-proxmox-lxc.sh --local --ctid 120
Options principales:
--proxmox-host IP ou nom DNS du serveur Proxmox
--proxmox-user Utilisateur SSH Proxmox (defaut: root@pam)
--proxmox-password Mot de passe SSH Proxmox
--ssh-port Port SSH Proxmox (defaut: 22)
--local Execute directement sur l'hote Proxmox local
--ctid CTID du LXC a mettre a jour
--hostname Nom du LXC si le CTID n'est pas fourni (defaut: chesscubing-web)
--branch Branche Git a deployer (defaut: main)
--ethan-repo-url Depot Git de l'application Ethan
--ethan-branch Branche Git de l'application Ethan (defaut: main)
-h, --help Affiche cette aide
EOF
}
die() {
printf 'Erreur: %s\n' "$*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "La commande '$1' est requise."
}
PROXMOX_HOST=""
PROXMOX_USER="root@pam"
PROXMOX_PASSWORD="${PROXMOX_PASSWORD:-}"
PROXMOX_PORT="22"
LOCAL_MODE="0"
CTID=""
LXC_HOSTNAME="chesscubing-web"
REPO_BRANCH="main"
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
ETHAN_REPO_BRANCH="main"
while [[ $# -gt 0 ]]; do
case "$1" in
--proxmox-host)
PROXMOX_HOST="${2:-}"
shift 2
;;
--proxmox-user)
PROXMOX_USER="${2:-}"
shift 2
;;
--proxmox-password)
PROXMOX_PASSWORD="${2:-}"
shift 2
;;
--ssh-port)
PROXMOX_PORT="${2:-}"
shift 2
;;
--local)
LOCAL_MODE="1"
shift
;;
--ctid)
CTID="${2:-}"
shift 2
;;
--hostname)
LXC_HOSTNAME="${2:-}"
shift 2
;;
--branch)
REPO_BRANCH="${2:-}"
shift 2
;;
--ethan-repo-url)
ETHAN_REPO_URL="${2:-}"
shift 2
;;
--ethan-branch)
ETHAN_REPO_BRANCH="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
die "Option inconnue: $1"
;;
esac
done
if [[ "$LOCAL_MODE" != "1" && -z "$PROXMOX_HOST" ]]; then
if command -v pct >/dev/null 2>&1 && command -v pveam >/dev/null 2>&1; then
LOCAL_MODE="1"
fi
fi
payload_script="$(mktemp)"
cleanup() {
rm -f "$payload_script"
}
trap cleanup EXIT
cat >"$payload_script" <<'REMOTE'
set -Eeuo pipefail
trap 'printf "Erreur: echec de la commande [%s] a la ligne %s.\n" "$BASH_COMMAND" "$LINENO" >&2' ERR
ctid="$1"
lxc_hostname="$2"
repo_branch="$3"
ethan_repo_url="$4"
ethan_repo_branch="$5"
die() {
printf 'Erreur: %s\n' "$*" >&2
exit 1
}
find_ctid_by_hostname() {
local wanted="$1"
local candidate=""
local candidate_hostname=""
while read -r candidate; do
[[ -n "$candidate" ]] || continue
candidate_hostname="$(pct config "$candidate" 2>/dev/null | awk -F ': ' '/^hostname:/ { print $2; exit }')"
if [[ "$candidate_hostname" == "$wanted" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done < <(pct list | awk 'NR > 1 { print $1 }')
return 1
}
command -v pct >/dev/null 2>&1 || die "La commande 'pct' est absente sur Proxmox."
if [[ -z "$ctid" ]]; then
ctid="$(find_ctid_by_hostname "$lxc_hostname" || true)"
fi
[[ -n "$ctid" ]] || die "Impossible de retrouver le LXC. Passe --ctid ou --hostname."
if ! pct status "$ctid" >/dev/null 2>&1; then
die "Le conteneur $ctid est introuvable."
fi
detected_hostname="$(pct config "$ctid" 2>/dev/null | awk -F ': ' '/^hostname:/ { print $2; exit }')"
if [[ -n "$detected_hostname" ]]; then
lxc_hostname="$detected_hostname"
fi
if ! pct status "$ctid" | grep -q "running"; then
pct start "$ctid"
sleep 5
fi
ct_exec() {
pct exec "$ctid" -- bash -lc "$1"
}
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_COMMAND\" \"\$LINENO\" >&2' ERR
main_repo_dir='/opt/chesscubing/repo'
ethan_repo_dir='/opt/chesscubing/ethan-repo'
web_root='/var/www/chesscubing/current'
main_branch=\"\${1:-${repo_branch}}\"
ethan_repo_url='${ethan_repo_url}'
ethan_branch='${ethan_repo_branch}'
sync_git_repo() {
local repo_dir=\"\$1\"
local repo_url=\"\$2\"
local branch=\"\$3\"
local label=\"\$4\"
if [[ -d \"\$repo_dir/.git\" ]]; then
cd \"\$repo_dir\"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo \"Le depot \${label} contient des modifications locales. Mise a jour annulee.\" >&2
exit 1
fi
git fetch origin \"\$branch\"
if git show-ref --verify --quiet \"refs/heads/\$branch\"; then
git checkout \"\$branch\"
else
git checkout -b \"\$branch\" --track \"origin/\$branch\"
fi
git pull --ff-only origin \"\$branch\"
return 0
fi
[[ -n \"\$repo_url\" ]] || {
echo \"Le depot \${label} est absent et aucune URL n'a ete fournie.\" >&2
exit 1
}
rm -rf \"\$repo_dir\"
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
}
publish_static_tree() {
local source_dir=\"\$1\"
local destination_dir=\"\$2\"
install -d -m 0755 \"\$destination_dir\"
rsync -a --delete \
--include='*/' \
--include='*.html' \
--include='*.css' \
--include='*.js' \
--include='*.mjs' \
--include='*.png' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.svg' \
--include='*.webp' \
--include='*.ico' \
--include='*.pdf' \
--include='*.webmanifest' \
--exclude='*' \
\"\$source_dir/\" \"\$destination_dir/\"
}
sync_git_repo \"\$main_repo_dir\" '' \"\$main_branch\" 'principal'
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\"
install -d -m 0755 \"\$web_root\"
publish_static_tree \"\$main_repo_dir\" \"\$web_root\"
publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\"
while IFS= read -r -d '' html_file; do
LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\"
done < <(find \"\$web_root\" -type f -name '*.html' -print0)
chown -R www-data:www-data \"\$web_root\"
nginx -t
systemctl reload nginx
SCRIPT
chmod +x /usr/local/bin/update-chesscubing"
ct_exec "cat > /etc/nginx/sites-available/chesscubing.conf <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name _;
root /var/www/chesscubing/current;
index index.html;
location = /ethan {
return 301 \$scheme://\$http_host/ethan/;
}
location /ethan/ {
try_files \$uri \$uri/ /ethan/index.html;
}
location / {
try_files \$uri \$uri/ /index.html;
}
location ~* \.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest)$ {
expires -1;
add_header Cache-Control 'no-cache, no-store, must-revalidate';
}
}
NGINX
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/chesscubing.conf /etc/nginx/sites-enabled/chesscubing.conf"
pct exec "$ctid" -- /usr/local/bin/update-chesscubing "$repo_branch"
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
cat <<EOF
Mise a jour terminee.
- CTID: $ctid
- Nom du LXC: $lxc_hostname
- URL probable: http://${container_ip:-<ip_du_lxc>}
EOF
REMOTE
if [[ "$LOCAL_MODE" == "1" ]]; then
printf 'Mise a jour du LXC ChessCubing en local sur cet hote Proxmox...\n'
bash "$payload_script" \
"$CTID" \
"$LXC_HOSTNAME" \
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH"
exit 0
fi
[[ -n "$PROXMOX_HOST" ]] || die "Merci de fournir --proxmox-host."
[[ -n "$PROXMOX_USER" ]] || die "Merci de fournir --proxmox-user."
if [[ -z "$PROXMOX_PASSWORD" ]]; then
read -rsp "Mot de passe SSH pour ${PROXMOX_USER}@${PROXMOX_HOST}: " PROXMOX_PASSWORD
echo
fi
need_cmd ssh
need_cmd sshpass
printf 'Mise a jour du LXC ChessCubing sur %s...\n' "$PROXMOX_HOST"
sshpass -p "$PROXMOX_PASSWORD" \
ssh \
-p "$PROXMOX_PORT" \
-o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
"$PROXMOX_USER@$PROXMOX_HOST" \
bash -s -- \
"$CTID" \
"$LXC_HOSTNAME" \
"$REPO_BRANCH" \
"$ETHAN_REPO_URL" \
"$ETHAN_REPO_BRANCH" < "$payload_script"

17
site.webmanifest Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "ChessCubing Arena",
"short_name": "ChessCubing",
"description": "Application officielle de match ChessCubing pour chrono, cube et arbitrage.",
"start_url": "/application.html",
"scope": "/",
"display": "standalone",
"background_color": "#140700",
"theme_color": "#140700",
"icons": [
{
"src": "/logo.png",
"sizes": "2048x2048",
"type": "image/png"
}
]
}

1377
styles.css

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 73 KiB

153
update-chesscubing-proxmox.sh Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_BRANCH="${CHESSCUBING_GIT_BRANCH:-main}"
RAW_BASE_URL="https://git.jeannerot.fr/christophe/chesscubing/raw/branch/${REPO_BRANCH}"
usage() {
cat <<'EOF'
Bootstrap de mise à jour ChessCubing pour Proxmox.
Usage local :
./update-chesscubing-proxmox.sh
Usage en une ligne :
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/update-chesscubing-proxmox.sh)"
Variables d'environnement reconnues :
CHESSCUBING_LOCAL
CHESSCUBING_PROXMOX_HOST
CHESSCUBING_PROXMOX_USER
CHESSCUBING_PROXMOX_PASSWORD
CHESSCUBING_SSH_PORT
CHESSCUBING_CTID
CHESSCUBING_LXC_HOSTNAME
CHESSCUBING_GIT_BRANCH
CHESSCUBING_ETHAN_REPO_URL
CHESSCUBING_ETHAN_GIT_BRANCH
EOF
}
die() {
printf 'Erreur: %s\n' "$*" >&2
exit 1
}
have_cmd() {
command -v "$1" >/dev/null 2>&1
}
need_cmd() {
have_cmd "$1" || die "La commande '$1' est requise."
}
prompt_default() {
local var_name="$1"
local prompt_label="$2"
local default_value="${3:-}"
local current_value="${!var_name:-}"
local input=""
if [[ -n "$current_value" ]]; then
return 0
fi
if [[ -n "$default_value" ]]; then
read -r -p "$prompt_label [$default_value]: " input
printf -v "$var_name" '%s' "${input:-$default_value}"
else
read -r -p "$prompt_label: " input
printf -v "$var_name" '%s' "$input"
fi
}
prompt_secret() {
local var_name="$1"
local prompt_label="$2"
local current_value="${!var_name:-}"
if [[ -n "$current_value" ]]; then
return 0
fi
read -rsp "$prompt_label: " current_value
echo
printf -v "$var_name" '%s' "$current_value"
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
need_cmd curl
LOCAL_MODE="${CHESSCUBING_LOCAL:-}"
PROXMOX_HOST="${CHESSCUBING_PROXMOX_HOST:-}"
PROXMOX_USER="${CHESSCUBING_PROXMOX_USER:-}"
PROXMOX_PASSWORD="${CHESSCUBING_PROXMOX_PASSWORD:-}"
SSH_PORT="${CHESSCUBING_SSH_PORT:-22}"
CTID="${CHESSCUBING_CTID:-}"
LXC_HOSTNAME="${CHESSCUBING_LXC_HOSTNAME:-chesscubing-web}"
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
if have_cmd pct && have_cmd pveam; then
LOCAL_MODE="1"
else
LOCAL_MODE="0"
fi
fi
if [[ "$LOCAL_MODE" != "1" ]]; then
need_cmd ssh
need_cmd sshpass
prompt_default PROXMOX_HOST "IP ou nom du serveur Proxmox" ""
prompt_default PROXMOX_USER "Utilisateur SSH Proxmox" "root@pam"
prompt_secret PROXMOX_PASSWORD "Mot de passe SSH Proxmox"
prompt_default SSH_PORT "Port SSH Proxmox" "22"
fi
if [[ "$LOCAL_MODE" != "1" ]]; then
[[ -n "$PROXMOX_HOST" ]] || die "Le serveur Proxmox est obligatoire."
[[ -n "$PROXMOX_USER" ]] || die "L'utilisateur Proxmox est obligatoire."
[[ -n "$PROXMOX_PASSWORD" ]] || die "Le mot de passe Proxmox est obligatoire."
fi
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
printf 'Téléchargement du script de mise à jour ChessCubing...\n'
curl -fsSL "${RAW_BASE_URL}/scripts/update-proxmox-lxc.sh" -o "$TMP_DIR/update-proxmox-lxc.sh"
chmod +x "$TMP_DIR/update-proxmox-lxc.sh"
cmd=(
"$TMP_DIR/update-proxmox-lxc.sh"
--branch "$REPO_BRANCH"
--ethan-repo-url "$ETHAN_REPO_URL"
--ethan-branch "$ETHAN_REPO_BRANCH"
)
if [[ "$LOCAL_MODE" == "1" ]]; then
cmd+=(--local)
else
cmd+=(
--proxmox-host "$PROXMOX_HOST"
--proxmox-user "$PROXMOX_USER"
--proxmox-password "$PROXMOX_PASSWORD"
--ssh-port "$SSH_PORT"
)
fi
if [[ -n "$CTID" ]]; then
cmd+=(--ctid "$CTID")
else
cmd+=(--hostname "$LXC_HOSTNAME")
fi
printf 'Lancement de la mise à jour ChessCubing...\n'
"${cmd[@]}"