Refonte UI : DA Strava + Kurzgesagt
Build and Publish Docker Image / build-and-push-image (push) Successful in 59s
- Nouveau logo planète flat (PlanetMark) + icônes PWA régénérées + splash navy cosmique - Tokens de thème unifiés : accent teal de marque, rayons plus doux, dark mode cosmique (surfaces élevées) - Fond CosmicBackground raffiné (halos aurora teal/indigo) - Navigation par barre d'onglets en bas (BottomNav) ; menu ☰ réduit au secondaire - Composants partagés PageHeader + SessionCard - Accueil = dashboard : tuiles de stats + démarrage rapide + feed d'activité - Stats : classement en barres horizontales (teinte unique, méthode dataviz) - Restyle de tous les écrans liste + boucle de jeu immersive conservée - Doc du format de création de jeu (docs/creation-jeu.md) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -0,0 +1,372 @@
|
|||||||
|
# Créer un jeu personnalisé pour Skori
|
||||||
|
|
||||||
|
Ce document décrit **tous les paramètres** d'un fichier de définition de jeu (`.json`)
|
||||||
|
importable dans Skori via **Paramètres → Jeux → Gérer les jeux importés → Importer**.
|
||||||
|
|
||||||
|
Un jeu importé ajoute une nouvelle tuile à l'écran d'accueil, avec ses propres
|
||||||
|
règles de score, options, icône et couleurs — sans recompiler l'application.
|
||||||
|
|
||||||
|
> Les règles de calcul sont **déclaratives** (décrites par des données, pas du
|
||||||
|
> code). C'est volontaire : un fichier importé ne peut jamais exécuter de code
|
||||||
|
> arbitraire sur votre appareil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Structure minimale
|
||||||
|
|
||||||
|
Le plus petit fichier valide :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "mon-jeu",
|
||||||
|
"name": "Mon Jeu",
|
||||||
|
"minPlayers": 2,
|
||||||
|
"maxPlayers": 6,
|
||||||
|
"scoreType": "positive",
|
||||||
|
"scoreFormula": { "strategy": "sum_rounds" },
|
||||||
|
"colors": { "primary": "#3b82f6" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tout le reste est optionnel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Champs racine
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `schemaVersion` | `1` | ✅ | Toujours `1` (version du format). |
|
||||||
|
| `id` | texte | ✅ | Identifiant unique. Lettres, chiffres, `_` et `-` uniquement. **Ne peut pas** être un id natif (voir §9). Réimporter avec le même `id` **met à jour** le jeu. |
|
||||||
|
| `name` | texte | ✅ | Nom affiché (max 60 caractères). |
|
||||||
|
| `minPlayers` | entier | ✅ | Nombre minimum de joueurs (1 à 20). |
|
||||||
|
| `maxPlayers` | entier | ✅ | Nombre maximum de joueurs (1 à 20). |
|
||||||
|
| `scoreType` | énum | ✅ | Nature du score — voir §3. |
|
||||||
|
| `scoreFormula` | objet | ✅ | Comment additionner les manches — voir §4. |
|
||||||
|
| `colors` | objet | ✅ | Couleur(s) du thème — voir §7. |
|
||||||
|
| `description` | texte | ❌ | Phrase affichée sur l'écran de la partie (max 400 caractères). |
|
||||||
|
| `targetScore` | objet | ❌ | Score de fin de partie — voir §5. |
|
||||||
|
| `targetScoreCondition` | `"reaches"` \| `"exceeds"` | ❌ | `reaches` = fin si score **≥** cible (défaut logique) ; `exceeds` = fin si score **>** cible. |
|
||||||
|
| `fixedRounds` | entier | ❌ | La partie se termine après ce nombre de manches — voir §6. |
|
||||||
|
| `scoringCategories` | tableau | ❌ | Catégories de score saisies une par une — voir §8. |
|
||||||
|
| `options` | tableau | ❌ | Options configurables avant la partie — voir §6/§8. |
|
||||||
|
| `iconImage` | texte | ❌ | Icône du jeu (image en base64 « data URI ») — voir §7. |
|
||||||
|
| `bannerImage` | texte | ❌ | Bannière de l'écran de fin (base64 data URI) — voir §7. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `scoreType` — nature du score
|
||||||
|
|
||||||
|
Détermine **qui gagne** et la façon dont le score est interprété.
|
||||||
|
|
||||||
|
| Valeur | Le gagnant est… | Exemple |
|
||||||
|
|---|---|---|
|
||||||
|
| `positive` | le **plus haut** score | Azul, Harmonies |
|
||||||
|
| `negative` | le **plus bas** score | Skyjo, Cabo, Odin |
|
||||||
|
| `target` | le plus haut (course vers un objectif) | jeux à objectif de points |
|
||||||
|
| `fixed_rounds` | le plus haut, sur un nombre fixe de manches | — |
|
||||||
|
| `sudden_death` | le plus haut (mort subite) | — |
|
||||||
|
|
||||||
|
> En pratique, le classement de fin de partie utilise `negative` → plus petit
|
||||||
|
> gagne, **tous les autres** → plus grand gagne. Choisissez `negative` pour un
|
||||||
|
> jeu « le moins de points possible », sinon `positive`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `scoreFormula` — calcul du total
|
||||||
|
|
||||||
|
Comment le total d'un joueur est calculé à partir de ses scores de manche.
|
||||||
|
|
||||||
|
### `sum_rounds` (par défaut)
|
||||||
|
|
||||||
|
Simple addition de toutes les manches.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scoreFormula": { "strategy": "sum_rounds" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sum_rounds_with_reset`
|
||||||
|
|
||||||
|
Addition, avec **remise à un palier** quand le total atteint *exactement* un seuil
|
||||||
|
(règle « Cabo » : tomber pile sur 100 redescend à 50).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scoreFormula": {
|
||||||
|
"strategy": "sum_rounds_with_reset",
|
||||||
|
"resetThreshold": 100,
|
||||||
|
"resetTo": 50,
|
||||||
|
"oncePerPlayer": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Paramètre | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `resetThreshold` | nombre | Si le total **égale exactement** cette valeur… |
|
||||||
|
| `resetTo` | nombre | …il est ramené à cette valeur. |
|
||||||
|
| `oncePerPlayer` | booléen | `true` = la remise ne peut avoir lieu qu'**une seule fois** par joueur. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `targetScore` — fin de partie sur un score
|
||||||
|
|
||||||
|
Déclenche la fin de la partie dès qu'un joueur atteint (ou dépasse, selon
|
||||||
|
`targetScoreCondition`) une valeur cible. Combinable avec `fixedRounds`
|
||||||
|
(la première condition atteinte l'emporte).
|
||||||
|
|
||||||
|
### Cible fixe
|
||||||
|
|
||||||
|
```json
|
||||||
|
"targetScore": { "type": "fixed", "value": 100 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cible dépendant d'une option
|
||||||
|
|
||||||
|
Permet à l'utilisateur de choisir la cible avant la partie (comme Odin :
|
||||||
|
« 15 pts / 20 pts / personnalisé »).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"targetScore": {
|
||||||
|
"type": "fromOption",
|
||||||
|
"optionId": "target_score",
|
||||||
|
"customSentinel": "custom",
|
||||||
|
"customOptionId": "custom_target_score"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Paramètre | Description |
|
||||||
|
|---|---|
|
||||||
|
| `optionId` | id de l'option (`select` ou `number`) qui contient la cible. |
|
||||||
|
| `customSentinel` | *(optionnel)* valeur de l'option signifiant « personnalisé ». |
|
||||||
|
| `customOptionId` | *(optionnel)* id de l'option `number` lue si la valeur = `customSentinel`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `fixedRounds` — nombre de manches fixe
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fixedRounds": 5
|
||||||
|
```
|
||||||
|
|
||||||
|
- La partie se termine automatiquement après ce nombre de manches.
|
||||||
|
- `fixedRounds: 1` active un mode spécial **« Décompte final »** (une seule
|
||||||
|
saisie de scores, idéal pour un jeu où l'on ne compte qu'à la fin).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Apparence : `colors`, `iconImage`, `bannerImage`
|
||||||
|
|
||||||
|
### Couleurs
|
||||||
|
|
||||||
|
```json
|
||||||
|
"colors": { "primary": "#6e1d5a", "secondary": "#c2410c" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `primary` (obligatoire) : couleur principale du thème du jeu (format hex).
|
||||||
|
- `secondary` (optionnel) : couleur d'appoint.
|
||||||
|
|
||||||
|
### Images (base64)
|
||||||
|
|
||||||
|
`iconImage` et `bannerImage` sont des **data URI** : l'image encodée directement
|
||||||
|
dans le fichier (aucun chemin externe). Formats : PNG, SVG, WebP, JPEG.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"iconImage": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour convertir une image en data URI :
|
||||||
|
- en ligne : chercher « image to base64 data uri » ;
|
||||||
|
- ou dans un terminal :
|
||||||
|
- macOS/Linux : `echo "data:image/png;base64,$(base64 -w0 icone.png)"`
|
||||||
|
- Windows PowerShell : `"data:image/png;base64," + [Convert]::ToBase64String([IO.File]::ReadAllBytes("icone.png"))`
|
||||||
|
|
||||||
|
> Gardez les images légères (idéalement < 200 Ko) : elles sont stockées telles
|
||||||
|
> quelles dans l'appareil. Si `iconImage` est absent, une icône générique est
|
||||||
|
> utilisée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Options et catégories
|
||||||
|
|
||||||
|
### `options` — réglages avant la partie
|
||||||
|
|
||||||
|
Chaque option apparaît sur l'écran de préparation de la partie.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "kamikaze",
|
||||||
|
"label": "Règle Kamikaze",
|
||||||
|
"type": "boolean",
|
||||||
|
"defaultValue": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "target_score",
|
||||||
|
"label": "Score de fin",
|
||||||
|
"type": "select",
|
||||||
|
"defaultValue": "15",
|
||||||
|
"options": [
|
||||||
|
{ "label": "Courte (15)", "value": "15" },
|
||||||
|
{ "label": "Classique (20)", "value": "20" },
|
||||||
|
{ "label": "Personnalisé", "value": "custom" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom_target_score",
|
||||||
|
"label": "Score personnalisé",
|
||||||
|
"type": "number",
|
||||||
|
"defaultValue": 25
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|---|---|
|
||||||
|
| `id` | Identifiant de l'option (référencé par `targetScore.fromOption`). |
|
||||||
|
| `label` | Libellé affiché. |
|
||||||
|
| `type` | `boolean` (case à cocher), `number` (champ numérique), `select` (liste). |
|
||||||
|
| `defaultValue` | Valeur par défaut. |
|
||||||
|
| `options` | *(select uniquement)* liste de `{ label, value }`. |
|
||||||
|
|
||||||
|
### `scoringCategories` — saisie par catégories
|
||||||
|
|
||||||
|
Active une saisie **étape par étape** (une catégorie à la fois, comme Harmonies).
|
||||||
|
Le score de la manche = **somme des catégories**.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scoringCategories": [
|
||||||
|
{ "id": "arbres", "label": "Arbres" },
|
||||||
|
{ "id": "montagnes", "label": "Montagnes" },
|
||||||
|
{ "id": "eau", "label": "Champs d'eau" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
> Généralement associé à `fixedRounds: 1` (un seul décompte final réparti en
|
||||||
|
> catégories).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Contraintes & validation
|
||||||
|
|
||||||
|
À l'import, le fichier est validé. En cas d'erreur, un message précis indique le
|
||||||
|
champ fautif.
|
||||||
|
|
||||||
|
- `schemaVersion` doit valoir `1`.
|
||||||
|
- `id` : lettres/chiffres/`_`/`-`, et **pas** un id réservé :
|
||||||
|
`azul`, `cabo`, `skyjo`, `harmonie`, `odin_cards`.
|
||||||
|
- `name` ≤ 60 caractères, `description` ≤ 400 caractères.
|
||||||
|
- `minPlayers` / `maxPlayers` entre 1 et 20.
|
||||||
|
- `scoreType` parmi les valeurs du §3.
|
||||||
|
- `scoreFormula.strategy` parmi `sum_rounds`, `sum_rounds_with_reset`.
|
||||||
|
- Les jeux importés sont **propres à chaque profil** (pas encore synchronisés
|
||||||
|
entre appareils).
|
||||||
|
|
||||||
|
> **Non pris en charge en v1** : les formules 100 % personnalisées par expression
|
||||||
|
> (`custom_expression`). Les deux stratégies ci-dessus couvrent tous les jeux
|
||||||
|
> fournis en standard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Exemples complets
|
||||||
|
|
||||||
|
### A. Skyjo (le moins de points, fin à 100)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "mon-skyjo",
|
||||||
|
"name": "Skyjo maison",
|
||||||
|
"minPlayers": 2,
|
||||||
|
"maxPlayers": 8,
|
||||||
|
"scoreType": "negative",
|
||||||
|
"scoreFormula": { "strategy": "sum_rounds" },
|
||||||
|
"targetScore": { "type": "fixed", "value": 100 },
|
||||||
|
"targetScoreCondition": "reaches",
|
||||||
|
"description": "Le moins de points possible. Fin dès qu'un joueur atteint 100.",
|
||||||
|
"colors": { "primary": "#0f766e" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Cabo (remise à 50 sur 100 pile)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "mon-cabo",
|
||||||
|
"name": "Cabo maison",
|
||||||
|
"minPlayers": 2,
|
||||||
|
"maxPlayers": 5,
|
||||||
|
"scoreType": "negative",
|
||||||
|
"scoreFormula": {
|
||||||
|
"strategy": "sum_rounds_with_reset",
|
||||||
|
"resetThreshold": 100,
|
||||||
|
"resetTo": 50,
|
||||||
|
"oncePerPlayer": true
|
||||||
|
},
|
||||||
|
"targetScore": { "type": "fixed", "value": 100 },
|
||||||
|
"targetScoreCondition": "reaches",
|
||||||
|
"colors": { "primary": "#6e1d5a" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Odin (cible choisie par option)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "mon-odin",
|
||||||
|
"name": "Odin maison",
|
||||||
|
"minPlayers": 2,
|
||||||
|
"maxPlayers": 6,
|
||||||
|
"scoreType": "negative",
|
||||||
|
"scoreFormula": { "strategy": "sum_rounds" },
|
||||||
|
"targetScore": {
|
||||||
|
"type": "fromOption",
|
||||||
|
"optionId": "target_score",
|
||||||
|
"customSentinel": "custom",
|
||||||
|
"customOptionId": "custom_target_score"
|
||||||
|
},
|
||||||
|
"targetScoreCondition": "reaches",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": "target_score",
|
||||||
|
"label": "Score de fin de partie",
|
||||||
|
"type": "select",
|
||||||
|
"defaultValue": "15",
|
||||||
|
"options": [
|
||||||
|
{ "label": "Partie courte (15 pts)", "value": "15" },
|
||||||
|
{ "label": "Partie classique (20 pts)", "value": "20" },
|
||||||
|
{ "label": "Personnalisé", "value": "custom" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom_target_score",
|
||||||
|
"label": "Score personnalisé",
|
||||||
|
"type": "number",
|
||||||
|
"defaultValue": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"colors": { "primary": "#c2410c" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. Jeu par catégories (le plus de points, décompte final)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "mon-jeu-categories",
|
||||||
|
"name": "Jeu à catégories",
|
||||||
|
"minPlayers": 1,
|
||||||
|
"maxPlayers": 4,
|
||||||
|
"scoreType": "positive",
|
||||||
|
"scoreFormula": { "strategy": "sum_rounds" },
|
||||||
|
"fixedRounds": 1,
|
||||||
|
"scoringCategories": [
|
||||||
|
{ "id": "cat1", "label": "Objectifs" },
|
||||||
|
{ "id": "cat2", "label": "Bonus" },
|
||||||
|
{ "id": "cat3", "label": "Pénalités" }
|
||||||
|
],
|
||||||
|
"colors": { "primary": "#16a34a" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Home, History, BarChart2, Users2 } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Avatar } from "./ui/avatar";
|
||||||
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ path: "/", label: "Accueil", icon: Home },
|
||||||
|
{ path: "/history", label: "Parties", icon: History },
|
||||||
|
{ path: "/stats", label: "Stats", icon: BarChart2 },
|
||||||
|
{ path: "/friends", label: "Amis", icon: Users2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const activeProfile = useProfileStore((s) => s.activeProfile);
|
||||||
|
|
||||||
|
const isActive = (path: string) =>
|
||||||
|
path === "/" ? pathname === "/" : pathname.startsWith(path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 inset-x-0 z-50 pointer-events-none">
|
||||||
|
<nav className="pointer-events-auto mx-auto max-w-md px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2">
|
||||||
|
<div className="flex items-center justify-around bg-card/80 backdrop-blur-2xl border border-black/5 dark:border-white/10 shadow-lg shadow-black/10 rounded-[1.5rem] px-2 py-1.5">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const active = isActive(tab.path);
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.path}
|
||||||
|
onClick={() => navigate(tab.path)}
|
||||||
|
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-2xl"
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="tab-pill"
|
||||||
|
className="absolute inset-0 bg-primary/10 dark:bg-primary/20 rounded-2xl"
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 32 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
className={`w-[22px] h-[22px] relative z-10 transition-colors ${
|
||||||
|
active ? "text-primary" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
strokeWidth={active ? 2.6 : 2}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold relative z-10 transition-colors ${
|
||||||
|
active ? "text-primary" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Profile tab */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/profiles")}
|
||||||
|
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-2xl"
|
||||||
|
>
|
||||||
|
{isActive("/profiles") && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="tab-pill"
|
||||||
|
className="absolute inset-0 bg-primary/10 dark:bg-primary/20 rounded-2xl"
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 32 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10">
|
||||||
|
<Avatar
|
||||||
|
src={activeProfile?.avatar}
|
||||||
|
name={activeProfile?.name || "?"}
|
||||||
|
size="sm"
|
||||||
|
className={`w-[22px] h-[22px] ${
|
||||||
|
isActive("/profiles") ? "ring-2 ring-primary" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold relative z-10 transition-colors ${
|
||||||
|
isActive("/profiles") ? "text-primary" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Profil
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,40 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
// Refined cosmic backdrop: soft aurora glows tuned to the brand, calm float,
|
||||||
|
// coherent in light and dark. Replaces the previous loud sun/cloud blobs.
|
||||||
export const CosmicBackground = () => (
|
export const CosmicBackground = () => (
|
||||||
<div className="fixed inset-0 z-[-1] pointer-events-none overflow-hidden bg-sky-200 dark:bg-[#0F1423] transition-colors duration-700">
|
<div className="fixed inset-0 z-[-1] pointer-events-none overflow-hidden bg-background transition-colors duration-700">
|
||||||
{/* Light Mode Elements (Sun & Clouds) */}
|
{/* Base wash */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-teal-50/80 via-background to-background dark:from-transparent dark:to-transparent" />
|
||||||
|
|
||||||
|
{/* Aurora glows */}
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ y: [0, -10, 0] }}
|
animate={{ y: [0, -18, 0], opacity: [0.5, 0.7, 0.5] }}
|
||||||
transition={{ repeat: Infinity, duration: 6, ease: "easeInOut" }}
|
transition={{ repeat: Infinity, duration: 12, ease: "easeInOut" }}
|
||||||
className="absolute -top-20 -right-20 w-80 h-80 bg-yellow-300 rounded-full opacity-80 dark:opacity-0 transition-opacity duration-700 blur-2xl"
|
className="absolute -top-32 -right-24 w-[28rem] h-[28rem] rounded-full blur-[90px] bg-teal-300/40 dark:bg-teal-500/20"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-32 left-10 w-40 h-12 bg-white/60 rounded-full blur-xl dark:opacity-0 transition-opacity duration-700" />
|
<motion.div
|
||||||
<div className="absolute top-60 -right-10 w-56 h-16 bg-white/40 rounded-full blur-xl dark:opacity-0 transition-opacity duration-700" />
|
animate={{ y: [0, 16, 0], opacity: [0.4, 0.6, 0.4] }}
|
||||||
<div className="absolute bottom-20 left-20 w-64 h-20 bg-white/50 rounded-full blur-2xl dark:opacity-0 transition-opacity duration-700" />
|
transition={{ repeat: Infinity, duration: 14, ease: "easeInOut", delay: 1 }}
|
||||||
|
className="absolute -bottom-32 -left-24 w-[26rem] h-[26rem] rounded-full blur-[90px] bg-indigo-300/40 dark:bg-indigo-600/25"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 w-[22rem] h-[22rem] rounded-full blur-[110px] bg-amber-200/30 dark:bg-fuchsia-600/10" />
|
||||||
|
|
||||||
{/* Dark Mode Elements (Nebulas & Stars) */}
|
{/* Twinkling stars (dark only) */}
|
||||||
<div className="absolute -top-20 -left-20 w-[30rem] h-[30rem] bg-purple-600/30 rounded-full blur-[100px] opacity-0 dark:opacity-100 transition-opacity duration-700" />
|
{[
|
||||||
<div className="absolute bottom-[-10%] -right-10 w-96 h-96 bg-cyan-600/20 rounded-full blur-[80px] opacity-0 dark:opacity-100 transition-opacity duration-700" />
|
{ top: "14%", left: "22%", d: 3, delay: 0 },
|
||||||
|
{ top: "26%", left: "72%", d: 4, delay: 1 },
|
||||||
{/* Twinkling Stars */}
|
{ top: "44%", left: "12%", d: 5, delay: 2 },
|
||||||
<motion.div animate={{ opacity: [0.2, 0.8, 0.2] }} transition={{ repeat: Infinity, duration: 3 }} className="absolute top-24 left-1/4 w-2 h-2 bg-white rounded-full opacity-0 dark:opacity-100" />
|
{ top: "68%", left: "80%", d: 3.5, delay: 0.5 },
|
||||||
<motion.div animate={{ opacity: [0.3, 1, 0.3] }} transition={{ repeat: Infinity, duration: 4, delay: 1 }} className="absolute top-40 right-1/4 w-1.5 h-1.5 bg-white rounded-full opacity-0 dark:opacity-100" />
|
{ top: "78%", left: "34%", d: 4.5, delay: 1.5 },
|
||||||
<motion.div animate={{ opacity: [0.1, 0.6, 0.1] }} transition={{ repeat: Infinity, duration: 5, delay: 2 }} className="absolute top-1/3 left-10 w-2 h-2 bg-white rounded-full opacity-0 dark:opacity-100" />
|
].map((s, i) => (
|
||||||
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 3.5, delay: 0.5 }} className="absolute bottom-1/3 right-20 w-2 h-2 bg-white rounded-full opacity-0 dark:opacity-100" />
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
animate={{ opacity: [0.15, 0.9, 0.15] }}
|
||||||
|
transition={{ repeat: Infinity, duration: s.d, delay: s.delay }}
|
||||||
|
className="absolute w-1.5 h-1.5 bg-white rounded-full opacity-0 dark:opacity-100"
|
||||||
|
style={{ top: s.top, left: s.left }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Skori brand mark — a flat, Kurzgesagt-style ringed planet in brand teal.
|
||||||
|
// Geometric, two-tone flat shading, an orbit ring and a sparkle. Pure SVG so it
|
||||||
|
// stays crisp at any size and inherits no theming surprises.
|
||||||
|
export function PlanetMark({
|
||||||
|
size = 96,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
fill="none"
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-label="Skori"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="skori-planet" x1="20" y1="18" x2="78" y2="82">
|
||||||
|
<stop offset="0%" stopColor="#5eead4" />
|
||||||
|
<stop offset="55%" stopColor="#14b8a6" />
|
||||||
|
<stop offset="100%" stopColor="#0d9488" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="skori-clip">
|
||||||
|
<circle cx="50" cy="50" r="28" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Orbit ring — back half (behind the planet) */}
|
||||||
|
<path
|
||||||
|
d="M 12 66 A 42 14 -18 0 1 88 40"
|
||||||
|
stroke="#a5f3fc"
|
||||||
|
strokeWidth="4.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="dark:opacity-90"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Planet body */}
|
||||||
|
<circle cx="50" cy="50" r="28" fill="url(#skori-planet)" />
|
||||||
|
|
||||||
|
{/* Flat two-tone shading + craters, clipped to the planet */}
|
||||||
|
<g clipPath="url(#skori-clip)">
|
||||||
|
<circle cx="72" cy="70" r="30" fill="#0f766e" opacity="0.5" />
|
||||||
|
<circle cx="40" cy="40" r="6" fill="#99f6e4" opacity="0.55" />
|
||||||
|
<circle cx="58" cy="56" r="4" fill="#0f766e" opacity="0.45" />
|
||||||
|
<circle cx="46" cy="62" r="3" fill="#0f766e" opacity="0.4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Orbit ring — front half (in front of the planet) */}
|
||||||
|
<path
|
||||||
|
d="M 12 66 A 42 14 -18 0 0 88 40"
|
||||||
|
stroke="#22d3ee"
|
||||||
|
strokeWidth="4.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sparkle */}
|
||||||
|
<path
|
||||||
|
d="M 80 20 q 2 6 8 8 q -6 2 -8 8 q -2 -6 -8 -8 q 6 -2 8 -8 z"
|
||||||
|
fill="#fde68a"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
Home,
|
|
||||||
History,
|
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
BarChart2,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
UserCircle,
|
Puzzle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Users2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@@ -17,6 +13,14 @@ import { Button } from "./ui/button";
|
|||||||
import { Avatar } from "./ui/avatar";
|
import { Avatar } from "./ui/avatar";
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
|
|
||||||
|
// Secondary navigation (the main sections live in the bottom tab bar).
|
||||||
|
const SECONDARY = [
|
||||||
|
{ path: "/players", label: "Joueurs", icon: Users },
|
||||||
|
{ path: "/locations", label: "Emplacements", icon: MapPin },
|
||||||
|
{ path: "/games", label: "Jeux importés", icon: Puzzle },
|
||||||
|
{ path: "/settings", label: "Paramètres", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
export function NavigationMenu() {
|
export function NavigationMenu() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -78,54 +82,18 @@ export function NavigationMenu() {
|
|||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
{SECONDARY.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => nav("/")}
|
key={item.path}
|
||||||
|
onClick={() => nav(item.path)}
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
||||||
>
|
>
|
||||||
<Home className="w-5 h-5 mr-3 text-primary" /> Accueil
|
<Icon className="w-5 h-5 mr-3 text-primary" /> {item.label}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => nav("/history")}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<History className="w-5 h-5 mr-3 text-primary" /> Historique
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => nav("/players")}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<Users className="w-5 h-5 mr-3 text-primary" /> Joueurs
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => nav("/locations")}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<MapPin className="w-5 h-5 mr-3 text-primary" /> Emplacements
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => nav("/friends")}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<Users2 className="w-5 h-5 mr-3 text-primary" /> Amis
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => nav("/stats")}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<BarChart2 className="w-5 h-5 mr-3 text-primary" /> Statistiques
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => nav("/profiles")}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<UserCircle className="w-5 h-5 mr-3 text-primary" /> Profils
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => nav("/settings")}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<Settings className="w-5 h-5 mr-3 text-primary" /> Paramètres
|
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { NavigationMenu } from "./NavigationMenu";
|
||||||
|
|
||||||
|
// Consistent sticky page header used across the main screens.
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
back = false,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
back?: boolean;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<header className="sticky top-4 z-40 flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center bg-card/60 backdrop-blur-xl rounded-full shadow-sm p-0.5 pr-5 border border-black/5 dark:border-white/10">
|
||||||
|
{back ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="rounded-full hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<NavigationMenu />
|
||||||
|
)}
|
||||||
|
<h1 className="text-xl font-black tracking-tighter ml-1.5 drop-shadow-sm">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import * as Icons from "lucide-react";
|
||||||
|
import { Trophy, MapPin } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { GameConfig, GameSession, Location } from "../types";
|
||||||
|
import { calculatePlayerTotalScore } from "../utils/scoring";
|
||||||
|
import { Avatar } from "./ui/avatar";
|
||||||
|
|
||||||
|
function formatDate(ms: number) {
|
||||||
|
return new Date(ms).toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A finished-game "activity" card for the Home feed and History list.
|
||||||
|
export function SessionCard({
|
||||||
|
session,
|
||||||
|
game,
|
||||||
|
location,
|
||||||
|
}: {
|
||||||
|
session: GameSession;
|
||||||
|
game: GameConfig;
|
||||||
|
location?: Location;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const GameIcon = (Icons as any)[game.icon || "Box"] || Icons.Box;
|
||||||
|
const playing = session.status === "playing";
|
||||||
|
|
||||||
|
const ranked = session.players
|
||||||
|
.map((p) => ({
|
||||||
|
p,
|
||||||
|
score: calculatePlayerTotalScore(p.id, session.rounds, game, true),
|
||||||
|
win: session.winnerIds?.includes(p.id),
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
game.scoreType === "negative" ? a.score - b.score : b.score - a.score,
|
||||||
|
);
|
||||||
|
const winner = ranked.find((r) => r.win) || ranked[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(playing ? `/play/${session.id}` : `/gameover/${session.id}`)
|
||||||
|
}
|
||||||
|
className="w-full text-left rounded-[1.75rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center overflow-hidden shrink-0 ring-1 ring-black/5 dark:ring-white/10"
|
||||||
|
style={{
|
||||||
|
backgroundColor: game.imagePath ? "transparent" : game.colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{game.imagePath ? (
|
||||||
|
<img src={game.imagePath} alt={game.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<GameIcon className="w-5 h-5 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-black tracking-tight leading-tight truncate">
|
||||||
|
{game.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
|
<span>{formatDate(session.dateStart)}</span>
|
||||||
|
{location && (
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
<MapPin className="w-3 h-3 mr-0.5" />
|
||||||
|
{location.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{playing && (
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-wide bg-pink-500 text-white px-2 py-1 rounded-full animate-pulse shrink-0">
|
||||||
|
En cours
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!playing && ranked.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between bg-primary/10 dark:bg-primary/20 rounded-2xl px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Trophy className="w-4 h-4 text-amber-500 shrink-0" fill="currentColor" />
|
||||||
|
<Avatar src={winner.p.avatar} name={winner.p.name} size="sm" />
|
||||||
|
<span className="font-bold truncate">{winner.p.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-black text-primary shrink-0">{winner.score}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ranked.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ranked
|
||||||
|
.filter((r) => r !== winner)
|
||||||
|
.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.p.id}
|
||||||
|
className="text-xs font-bold bg-black/5 dark:bg-white/10 text-foreground/80 rounded-lg px-2 py-1"
|
||||||
|
>
|
||||||
|
{r.p.name} · {r.score}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playing && (
|
||||||
|
<p className="text-sm text-muted-foreground font-medium truncate">
|
||||||
|
{session.players.map((p) => p.name).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { CosmicBackground } from "../components/CosmicBackground";
|
import { CosmicBackground } from "../components/CosmicBackground";
|
||||||
|
import { BottomNav } from "../components/BottomNav";
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-foreground flex flex-col font-sans selection:bg-white/30">
|
<div className="min-h-screen text-foreground flex flex-col font-sans selection:bg-white/30">
|
||||||
<CosmicBackground />
|
<CosmicBackground />
|
||||||
<main className="flex-1 w-full max-w-md mx-auto relative safe-area-inset-top safe-area-inset-bottom z-10">
|
<main className="flex-1 w-full max-w-md mx-auto relative safe-area-inset-top z-10 pb-[104px]">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { LogIn, UserPlus, Loader2, ChevronLeft } from "lucide-react";
|
import { LogIn, UserPlus, Loader2 } from "lucide-react";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
import { ApiError } from "../lib/apiClient";
|
import { ApiError } from "../lib/apiClient";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email("Email invalide"),
|
email: z.string().email("Email invalide"),
|
||||||
@@ -69,20 +70,11 @@ export default function Auth() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader
|
||||||
<Button
|
title={mode === "login" ? "Connexion" : "Créer un compte"}
|
||||||
variant="ghost"
|
back
|
||||||
size="icon"
|
/>
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="rounded-full hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-6 h-6" />
|
|
||||||
</Button>
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
{mode === "login" ? "Connexion" : "Créer un compte"}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
<p className="text-sm text-muted-foreground px-2">
|
||||||
{mode === "login"
|
{mode === "login"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Card, CardContent } from "../components/ui/card";
|
|||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Avatar } from "../components/ui/avatar";
|
import { Avatar } from "../components/ui/avatar";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export default function Friends() {
|
export default function Friends() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -42,13 +42,8 @@ export default function Friends() {
|
|||||||
|
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Amis" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Amis
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
||||||
<CardContent className="p-6 text-center space-y-4">
|
<CardContent className="p-6 text-center space-y-4">
|
||||||
<Users2 className="w-14 h-14 mx-auto opacity-20" />
|
<Users2 className="w-14 h-14 mx-auto opacity-20" />
|
||||||
@@ -122,13 +117,8 @@ export default function Friends() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Amis" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Amis
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive font-medium bg-destructive/10 rounded-xl p-3">
|
<p className="text-sm text-destructive font-medium bg-destructive/10 rounded-xl p-3">
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import { History as HistoryIcon } from "lucide-react";
|
||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { useAllGames } from "../games";
|
import { useAllGames } from "../games";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { SessionCard } from "../components/SessionCard";
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
import { MapPin } from "lucide-react";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
function formatDate(ms: number) {
|
|
||||||
const d = new Date(ms);
|
|
||||||
return d.toLocaleDateString("fr-FR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
const games = useAllGames();
|
const games = useAllGames();
|
||||||
const sessions = useLiveQuery(
|
const sessions = useLiveQuery(
|
||||||
@@ -37,99 +24,30 @@ export default function History() {
|
|||||||
const locations = useLiveQuery(() => db.locations.toArray()) || [];
|
const locations = useLiveQuery(() => db.locations.toArray()) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Parties" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Historique
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{!sessions || sessions.length === 0 ? (
|
{!sessions || sessions.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-10 bg-background/60 backdrop-blur-sm rounded-[2rem]">
|
<div className="text-center text-muted-foreground py-14 bg-card/50 backdrop-blur-sm rounded-[1.75rem] border border-black/5 dark:border-white/10">
|
||||||
Aucune partie enregistrée.
|
<HistoryIcon className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="font-bold">Aucune partie enregistrée.</p>
|
||||||
|
<p className="text-sm mt-1">Lancez une partie depuis l'accueil.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{sessions.map((session, index) => {
|
{sessions.map((session) => {
|
||||||
const game = games.find((g) => g.id === session.gameId);
|
const game = games.find((g) => g.id === session.gameId);
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
const location = session.locationId
|
const location = session.locationId
|
||||||
? locations.find((l) => l.id === session.locationId)
|
? locations.find((l) => l.id === session.locationId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<SessionCard
|
||||||
key={session.id}
|
key={session.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
session={session}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
game={game}
|
||||||
transition={{
|
location={location}
|
||||||
delay: index * 0.05,
|
/>
|
||||||
type: "spring",
|
|
||||||
stiffness: 100,
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r p-1 rounded-[2rem] shadow-lg cursor-pointer"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `linear-gradient(to right, ${game.colors.primary}, ${game.colors.primary}80)`,
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
session.status === "playing"
|
|
||||||
? `/play/${session.id}`
|
|
||||||
: `/gameover/${session.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card className="border-0 bg-background/90 backdrop-blur-md m-0 h-full w-full">
|
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<h3
|
|
||||||
className="font-black text-xl leading-tight"
|
|
||||||
style={{ color: game.colors.primary }}
|
|
||||||
>
|
|
||||||
{game.name}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-bold px-2 py-1 rounded-full ${session.status === "playing" ? "bg-pink-500 text-white animate-pulse" : "bg-secondary text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{session.status === "playing"
|
|
||||||
? "En cours"
|
|
||||||
: "Terminée"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-foreground/70 font-medium mb-2 flex items-center flex-wrap gap-x-2">
|
|
||||||
<span>{formatDate(session.dateStart)}</span>
|
|
||||||
{session.locationId && (
|
|
||||||
<span className="inline-flex items-center text-xs font-bold text-muted-foreground bg-black/5 dark:bg-white/10 px-2 py-0.5 rounded-full">
|
|
||||||
<MapPin className="w-3 h-3 mr-1" />
|
|
||||||
{location?.name ?? "—"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
{session.players.map((p) => {
|
|
||||||
const isWinner = session.winnerIds?.includes(p.id);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={p.id}
|
|
||||||
className={`text-xs px-2 py-1 rounded-md font-bold ${isWinner ? "bg-yellow-400 text-yellow-900 shadow-sm" : "bg-black/5 dark:bg-white/10 text-foreground/80"}`}
|
|
||||||
>
|
|
||||||
{isWinner ? "🏆 " : ""}
|
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,112 +1,153 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import * as Icons from "lucide-react";
|
import * as Icons from "lucide-react";
|
||||||
|
import { Play, Trophy, Gamepad2, CalendarDays, Clock } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { useAllGames } from "../games";
|
import { useAllGames } from "../games";
|
||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { NavigationMenu } from "../components/NavigationMenu";
|
||||||
|
import { PlanetMark } from "../components/Logo";
|
||||||
|
import { SessionCard } from "../components/SessionCard";
|
||||||
|
import { Avatar } from "../components/ui/avatar";
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
|
import { GameSession } from "../types";
|
||||||
|
|
||||||
// Kurzgesagt-style Planet Logo
|
function StatTile({
|
||||||
const PlanetLogo = () => (
|
icon: Icon,
|
||||||
<motion.div
|
value,
|
||||||
animate={{ y: [0, -12, 0], rotate: [0, 5, 0] }}
|
label,
|
||||||
transition={{ repeat: Infinity, duration: 5, ease: "easeInOut" }}
|
tint,
|
||||||
className="relative w-28 h-28 flex items-center justify-center mb-4"
|
}: {
|
||||||
|
icon: any;
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
tint: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 rounded-[1.5rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-3.5 flex flex-col items-center text-center">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-full grid place-items-center mb-2"
|
||||||
|
style={{ backgroundColor: `${tint}22`, color: tint }}
|
||||||
>
|
>
|
||||||
{/* The Planet Body */}
|
<Icon className="w-[18px] h-[18px]" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-orange-600 to-yellow-400 rounded-full shadow-[inset_-8px_-8px_20px_rgba(0,0,0,0.2),0_10px_30px_rgba(245,158,11,0.5)]">
|
</div>
|
||||||
{/* Craters */}
|
<span className="text-2xl font-black tracking-tight leading-none">
|
||||||
<div className="absolute top-4 right-6 w-5 h-5 bg-orange-700/30 rounded-full shadow-[inset_2px_2px_4px_rgba(0,0,0,0.2)]" />
|
{value}
|
||||||
<div className="absolute bottom-6 left-5 w-8 h-8 bg-orange-700/30 rounded-full shadow-[inset_2px_2px_4px_rgba(0,0,0,0.2)]" />
|
</span>
|
||||||
<div className="absolute top-12 left-4 w-3 h-3 bg-orange-700/30 rounded-full shadow-[inset_1px_1px_2px_rgba(0,0,0,0.2)]" />
|
<span className="text-[11px] font-bold text-muted-foreground mt-1 uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The Planet Ring */}
|
|
||||||
<div className="absolute w-36 h-10 border-[6px] border-yellow-200/90 rounded-[100%] transform -rotate-[20deg] shadow-[0_4px_10px_rgba(0,0,0,0.1)]" />
|
|
||||||
|
|
||||||
{/* Center Icon */}
|
|
||||||
<Icons.Dices className="w-12 h-12 text-white relative z-10 drop-shadow-md transform rotate-12" />
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
|
const activeProfile = useProfileStore((s) => s.activeProfile);
|
||||||
const games = useAllGames();
|
const games = useAllGames();
|
||||||
|
|
||||||
// Load unfinished games for the active profile
|
const sessions = useLiveQuery(
|
||||||
const activeSessions = useLiveQuery(
|
|
||||||
async () => {
|
async () => {
|
||||||
if (!activeProfileId) return [];
|
if (!activeProfileId) return [];
|
||||||
return db.sessions
|
const arr = await db.sessions
|
||||||
.where("profileId")
|
.where("profileId")
|
||||||
.equals(activeProfileId)
|
.equals(activeProfileId)
|
||||||
.and((s) => s.status === "playing" && !s.deletedAt)
|
.and((s) => !s.deletedAt)
|
||||||
.toArray();
|
.sortBy("dateStart");
|
||||||
|
return arr.reverse();
|
||||||
},
|
},
|
||||||
[activeProfileId],
|
[activeProfileId],
|
||||||
);
|
);
|
||||||
|
const locations = useLiveQuery(() => db.locations.toArray()) || [];
|
||||||
|
|
||||||
|
const all = sessions || [];
|
||||||
|
const active = all.filter((s) => s.status === "playing");
|
||||||
|
const finished = all.filter((s) => s.status === "finished");
|
||||||
|
|
||||||
|
const startOfMonth = new Date(
|
||||||
|
new Date().getFullYear(),
|
||||||
|
new Date().getMonth(),
|
||||||
|
1,
|
||||||
|
).getTime();
|
||||||
|
const thisMonth = finished.filter((s) => s.dateStart >= startOfMonth).length;
|
||||||
|
const totalMinutes = finished.reduce(
|
||||||
|
(sum, s) =>
|
||||||
|
sum + (s.dateEnd ? Math.round((s.dateEnd - s.dateStart) / 60000) : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const playTime =
|
||||||
|
totalMinutes >= 60
|
||||||
|
? `${Math.round(totalMinutes / 60)}h`
|
||||||
|
: `${totalMinutes}m`;
|
||||||
|
|
||||||
|
const gameOf = (s: GameSession) => games.find((g) => g.id === s.gameId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col font-sans">
|
<div className="relative flex flex-col font-sans">
|
||||||
<div className="relative z-10 p-4 space-y-8 pb-24">
|
<div className="relative z-10 px-5 pt-5 pb-28 space-y-6">
|
||||||
{/* Header Section */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between sticky top-4 z-50">
|
<header className="flex items-center justify-between">
|
||||||
<div className="flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm p-0.5 border border-black/5 dark:border-white/5">
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="bg-card/60 backdrop-blur-xl rounded-full p-0.5 border border-black/5 dark:border-white/10 shadow-sm">
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PlanetMark size={30} />
|
||||||
|
<span className="text-xl font-black tracking-tighter">Skori</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<header className="flex flex-col items-center justify-center pt-2 pb-6">
|
{activeProfile && (
|
||||||
<PlanetLogo />
|
<button
|
||||||
<h1 className="text-4xl font-black tracking-tighter text-foreground drop-shadow-sm">
|
onClick={() => navigate("/profiles")}
|
||||||
Skori
|
className="active:scale-95 transition-transform"
|
||||||
</h1>
|
>
|
||||||
|
<Avatar
|
||||||
|
src={activeProfile.avatar}
|
||||||
|
name={activeProfile.name}
|
||||||
|
size="md"
|
||||||
|
className="ring-2 ring-primary/20"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Active Game Section */}
|
{/* Stat tiles */}
|
||||||
{activeSessions && activeSessions.length > 0 && (
|
<div className="flex gap-3">
|
||||||
<section className="space-y-4">
|
<StatTile icon={Gamepad2} value={finished.length} label="Parties" tint="#14b8a6" />
|
||||||
<div className="flex items-center justify-between px-2">
|
<StatTile icon={CalendarDays} value={thisMonth} label="Ce mois" tint="#f59e0b" />
|
||||||
<h2 className="text-xl font-bold tracking-tight text-foreground/90">
|
<StatTile icon={Clock} value={playTime} label="Temps de jeu" tint="#8b5cf6" />
|
||||||
Partie en cours
|
|
||||||
</h2>
|
|
||||||
<span className="flex h-3 w-3 relative">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-pink-500"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Resume in-progress */}
|
||||||
{activeSessions.map((session) => {
|
{active.length > 0 && (
|
||||||
const game = games.find((g) => g.id === session.gameId);
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<span className="flex h-2.5 w-2.5 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-pink-500" />
|
||||||
|
</span>
|
||||||
|
<h2 className="text-lg font-black tracking-tight">Reprendre</h2>
|
||||||
|
</div>
|
||||||
|
{active.map((session) => {
|
||||||
|
const game = gameOf(session);
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
const GameIcon = game.icon
|
const GameIcon = (Icons as any)[game.icon || "Box"] || Icons.Box;
|
||||||
? (Icons as any)[game.icon]
|
|
||||||
: Icons.Box;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-pink-500 to-orange-400 rounded-[2rem] p-1 shadow-xl cursor-pointer"
|
|
||||||
onClick={() => navigate(`/play/${session.id}`)}
|
onClick={() => navigate(`/play/${session.id}`)}
|
||||||
|
className="w-full text-left rounded-[1.75rem] p-4 flex items-center justify-between shadow-lg relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(120deg, ${game.colors.primary}, ${game.colors.primary}cc)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-background/20 backdrop-blur-sm rounded-[1.8rem] p-4 flex items-center justify-between">
|
<div className="absolute -right-6 -top-8 w-28 h-28 rounded-full bg-white/10" />
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center gap-4 relative z-10">
|
||||||
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center shadow-inner">
|
<div className="w-14 h-14 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center shadow-inner overflow-hidden">
|
||||||
{game.imagePath ? (
|
{game.imagePath ? (
|
||||||
<img
|
<img src={game.imagePath} alt={game.name} className="w-9 h-9 object-contain" />
|
||||||
src={game.imagePath}
|
|
||||||
alt={game.name}
|
|
||||||
className="w-8 h-8 object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<GameIcon className="w-8 h-8 text-white" />
|
<GameIcon className="w-8 h-8 text-white" />
|
||||||
)}
|
)}
|
||||||
@@ -115,85 +156,81 @@ export default function Home() {
|
|||||||
<p className="font-black text-white text-lg leading-tight drop-shadow-sm">
|
<p className="font-black text-white text-lg leading-tight drop-shadow-sm">
|
||||||
{game.name}
|
{game.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-white/90 font-medium">
|
<p className="text-sm text-white/90 font-medium truncate max-w-[190px]">
|
||||||
{session.players.map((p) => p.name).join(", ")}
|
{session.players.map((p) => p.name).join(", ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-md">
|
<div className="w-11 h-11 bg-white rounded-full flex items-center justify-center shadow-md relative z-10 shrink-0">
|
||||||
<Icons.Play className="w-5 h-5 text-pink-500 ml-1" />
|
<Play className="w-5 h-5 ml-0.5" style={{ color: game.colors.primary }} fill="currentColor" />
|
||||||
</div>
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick start */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-black tracking-tight px-1">Nouvelle partie</h2>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 -mx-5 px-5 hide-scrollbar">
|
||||||
|
{games.map((game) => {
|
||||||
|
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={game.id}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => navigate(`/new/${game.id}`)}
|
||||||
|
className="shrink-0 w-[84px] flex flex-col items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[84px] h-[84px] rounded-[1.4rem] shadow-md flex items-center justify-center overflow-hidden ring-1 ring-black/5 dark:ring-white/10 transition-shadow group-hover:shadow-xl"
|
||||||
|
style={{ backgroundColor: game.imagePath ? "transparent" : game.colors.primary }}
|
||||||
|
>
|
||||||
|
{game.imagePath ? (
|
||||||
|
<img src={game.imagePath} alt={game.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Icon className="w-10 h-10 text-white drop-shadow-md" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className="text-[11px] font-bold leading-tight truncate max-w-full text-foreground/90">
|
||||||
</motion.div>
|
{game.name}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New Game Section */}
|
{/* Recent activity feed */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-3">
|
||||||
<h2 className="text-xl font-bold tracking-tight px-2 text-foreground/90">
|
<h2 className="text-lg font-black tracking-tight px-1">Activité récente</h2>
|
||||||
Nouveau Jeu
|
{finished.length === 0 ? (
|
||||||
</h2>
|
<div className="text-center text-muted-foreground py-10 bg-card/50 backdrop-blur-sm rounded-[1.75rem] border border-black/5 dark:border-white/10">
|
||||||
|
<Trophy className="w-10 h-10 mx-auto mb-2 opacity-20" />
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3 px-1">
|
<p className="text-sm font-medium">
|
||||||
{games.map((game, index) => {
|
Vos parties terminées apparaîtront ici.
|
||||||
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
|
</p>
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={game.id}
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{
|
|
||||||
delay: index * 0.05,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 150,
|
|
||||||
damping: 20,
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
onClick={() => navigate(`/new/${game.id}`)}
|
|
||||||
className="flex flex-col items-center gap-2 cursor-pointer group"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative w-full aspect-square rounded-3xl shadow-lg flex items-center justify-center border-4 border-black/5 dark:border-white/5 overflow-hidden transition-all group-hover:shadow-2xl"
|
|
||||||
style={{
|
|
||||||
backgroundColor: game.imagePath
|
|
||||||
? "transparent"
|
|
||||||
: game.colors.primary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.imagePath ? (
|
|
||||||
<div className="absolute inset-0 bg-white dark:bg-black/20">
|
|
||||||
<img
|
|
||||||
src={game.imagePath}
|
|
||||||
alt={`Logo ${game.name}`}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-3">
|
||||||
<div className="absolute -top-4 -right-4 w-16 h-16 rounded-full bg-white/10" />
|
{finished.slice(0, 8).map((session) => {
|
||||||
<div className="absolute -bottom-2 -left-2 w-12 h-12 rounded-full bg-black/10" />
|
const game = gameOf(session);
|
||||||
<Icon
|
if (!game) return null;
|
||||||
className="w-12 h-12 text-white drop-shadow-md relative z-10"
|
const location = session.locationId
|
||||||
strokeWidth={1.5}
|
? locations.find((l) => l.id === session.locationId)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
game={game}
|
||||||
|
location={location}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center w-full">
|
|
||||||
<h3 className="font-bold text-xs leading-tight truncate px-1 drop-shadow-sm text-foreground/90">
|
|
||||||
{game.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { parseImportedGame } from "../utils/gameImportAdapter";
|
|||||||
import { ImportedGameDefinition } from "../types/gameImport";
|
import { ImportedGameDefinition } from "../types/gameImport";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
const TEMPLATE: ImportedGameDefinition = {
|
const TEMPLATE: ImportedGameDefinition = {
|
||||||
@@ -98,13 +98,8 @@ export default function ImportedGames() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Jeux importés" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Jeux importés
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
<p className="text-sm text-muted-foreground px-2">
|
||||||
Ajoutez de nouveaux jeux à l'app via un fichier de définition JSON
|
Ajoutez de nouveaux jeux à l'app via un fichier de définition JSON
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MapPin, Trash2, Edit2, Check, X, Plus, Home } from "lucide-react";
|
|||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { generateId } from "../utils/id";
|
import { generateId } from "../utils/id";
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@@ -75,13 +75,8 @@ export default function Locations() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Emplacements" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Emplacements
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Avatar } from "../components/ui/avatar";
|
import { Avatar } from "../components/ui/avatar";
|
||||||
import { resizeImage } from "../utils/image";
|
import { resizeImage } from "../utils/image";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@@ -163,13 +163,8 @@ export default function Players() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Joueurs" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Joueurs
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!players || players.length === 0 ? (
|
{!players || players.length === 0 ? (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Avatar } from "../components/ui/avatar";
|
import { Avatar } from "../components/ui/avatar";
|
||||||
import { resizeImage } from "../utils/image";
|
import { resizeImage } from "../utils/image";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@@ -103,13 +103,8 @@ export default function Profiles() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Profils" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Profils
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
<p className="text-sm text-muted-foreground px-2">
|
||||||
Chaque profil possède ses propres parties, joueurs et emplacements.
|
Chaque profil possède ses propres parties, joueurs et emplacements.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { getGameConfig } from "../games";
|
|||||||
import { calculatePlayerTotalScore } from "../utils/scoring";
|
import { calculatePlayerTotalScore } from "../utils/scoring";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { theme, setTheme } = useAppStore();
|
const { theme, setTheme } = useAppStore();
|
||||||
@@ -237,13 +237,8 @@ export default function Settings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-6 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Paramètres" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Paramètres
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-bold px-2 tracking-tight">Compte</h2>
|
<h2 className="text-xl font-bold px-2 tracking-tight">Compte</h2>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useProfileStore } from "../../stores/profileStore";
|
|||||||
import { useAllGames } from "../../games";
|
import { useAllGames } from "../../games";
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { NavigationMenu } from "../../components/NavigationMenu";
|
import { PageHeader } from "../../components/PageHeader";
|
||||||
import { Trophy, Clock, Gamepad2, Medal, Filter } from "lucide-react";
|
import { Trophy, Clock, Gamepad2, Medal, Filter } from "lucide-react";
|
||||||
import { Avatar } from "../../components/ui/avatar";
|
import { Avatar } from "../../components/ui/avatar";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@@ -201,13 +201,8 @@ export default function Statistics() {
|
|||||||
: leaderboard;
|
: leaderboard;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
<PageHeader title="Stats" />
|
||||||
<NavigationMenu />
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
|
||||||
Statistiques
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<section className="space-y-3 bg-secondary/30 p-3 rounded-2xl border border-border/50">
|
<section className="space-y-3 bg-secondary/30 p-3 rounded-2xl border border-border/50">
|
||||||
@@ -261,27 +256,31 @@ export default function Statistics() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Hero Stats */}
|
{/* Hero Stats */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Card className="bg-primary text-primary-foreground border-none">
|
<div className="rounded-[1.5rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 flex flex-col items-center text-center">
|
||||||
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-2">
|
<div className="w-10 h-10 rounded-full grid place-items-center mb-2 bg-primary/15 text-primary">
|
||||||
<Gamepad2 className="w-8 h-8 opacity-80" />
|
<Gamepad2 className="w-5 h-5" />
|
||||||
<div className="text-3xl font-bold">{totalGames}</div>
|
</div>
|
||||||
<div className="text-sm opacity-90">Parties jouées</div>
|
<div className="text-3xl font-black tracking-tight leading-none">
|
||||||
</CardContent>
|
{totalGames}
|
||||||
</Card>
|
</div>
|
||||||
|
<div className="text-[11px] font-bold text-muted-foreground mt-1.5 uppercase tracking-wide">
|
||||||
|
Parties jouées
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="bg-secondary border-none">
|
<div className="rounded-[1.5rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 flex flex-col items-center text-center">
|
||||||
<CardContent className="p-4 flex flex-col items-center justify-center text-center space-y-2">
|
<div className="w-10 h-10 rounded-full grid place-items-center mb-2 bg-amber-500/15 text-amber-500">
|
||||||
<Clock className="w-8 h-8 opacity-60 text-secondary-foreground" />
|
<Clock className="w-5 h-5" />
|
||||||
<div className="text-3xl font-bold text-secondary-foreground">
|
</div>
|
||||||
|
<div className="text-3xl font-black tracking-tight leading-none">
|
||||||
{averageDuration}
|
{averageDuration}
|
||||||
<span className="text-lg">m</span>
|
<span className="text-lg">m</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-foreground/80">
|
<div className="text-[11px] font-bold text-muted-foreground mt-1.5 uppercase tracking-wide">
|
||||||
Durée moyenne
|
Durée moyenne
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{favoriteGame && selectedGameId === "all" && (
|
{favoriteGame && selectedGameId === "all" && (
|
||||||
@@ -364,62 +363,69 @@ export default function Statistics() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leaderboard */}
|
{/* Leaderboard — winrate as horizontal bars (single hue) */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-lg font-semibold px-1 flex items-center">
|
<h2 className="text-lg font-black tracking-tight px-1 flex items-center">
|
||||||
<Medal className="w-5 h-5 mr-2 text-primary" />
|
<Medal className="w-5 h-5 mr-2 text-primary" />
|
||||||
Classement des joueurs (Taux de victoire)
|
Classement (taux de victoire)
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{displayLeaderboard.length === 0 ? (
|
{displayLeaderboard.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-8 bg-secondary/30 rounded-2xl">
|
<div className="text-center text-muted-foreground py-10 bg-card/50 backdrop-blur-sm rounded-[1.75rem] border border-black/5 dark:border-white/10">
|
||||||
|
<Medal className="w-10 h-10 mx-auto mb-2 opacity-20" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
{allSessions.length === 0
|
{allSessions.length === 0
|
||||||
? "Terminez des parties pour voir le classement."
|
? "Terminez des parties pour voir le classement."
|
||||||
: "Aucune partie ne correspond à ces filtres."}
|
: "Aucune partie ne correspond à ces filtres."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displayLeaderboard.map((player, index) => (
|
<div className="rounded-[1.75rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 space-y-4">
|
||||||
|
{displayLeaderboard.map((player, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={player.name}
|
key={player.id}
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -12 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.06 }}
|
||||||
|
className="space-y-1.5"
|
||||||
>
|
>
|
||||||
<Card className="overflow-hidden">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex items-stretch">
|
<span
|
||||||
<div
|
className={`w-6 h-6 rounded-full grid place-items-center text-[11px] font-black shrink-0 ${
|
||||||
className={`w-12 flex items-center justify-center font-bold ${index === 0 ? "bg-amber-400 text-amber-950" : index === 1 ? "bg-slate-300 text-slate-800" : index === 2 ? "bg-amber-700 text-amber-100" : "bg-secondary text-muted-foreground"}`}
|
index === 0
|
||||||
|
? "bg-amber-400 text-amber-950"
|
||||||
|
: index === 1
|
||||||
|
? "bg-slate-300 text-slate-800"
|
||||||
|
: index === 2
|
||||||
|
? "bg-amber-700 text-amber-50"
|
||||||
|
: "bg-secondary text-muted-foreground"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
#{index + 1}
|
{index + 1}
|
||||||
</div>
|
</span>
|
||||||
<CardContent className="p-4 flex-1 flex items-center justify-between">
|
<Avatar src={player.avatar} name={player.name} size="sm" />
|
||||||
<div className="flex items-center space-x-3">
|
<span className="font-bold truncate flex-1">{player.name}</span>
|
||||||
<Avatar
|
<span className="font-black text-primary tabular-nums shrink-0">
|
||||||
src={player.avatar}
|
|
||||||
name={player.name}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold">{player.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{player.won} victoire{player.won > 1 ? "s" : ""} sur{" "}
|
|
||||||
{player.played} partie{player.played > 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-bold text-primary">
|
|
||||||
{player.winrate}%
|
{player.winrate}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Bar: thin track + rounded teal fill anchored to the baseline */}
|
||||||
|
<div className="h-2.5 rounded-full bg-secondary/70 dark:bg-white/10 overflow-hidden ml-[34px]">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${Math.max(player.winrate, 2)}%` }}
|
||||||
|
transition={{ delay: index * 0.06 + 0.1, type: "spring", stiffness: 90, damping: 18 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<p className="text-[11px] text-muted-foreground ml-[34px]">
|
||||||
</div>
|
{player.won} victoire{player.won > 1 ? "s" : ""} · {player.played} partie
|
||||||
</Card>
|
{player.played > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,65 +4,70 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
/* Slightly cool off-white so translucent cards read cleanly */
|
||||||
--foreground: 222.2 84% 4.9%;
|
--background: 200 30% 98%;
|
||||||
|
--foreground: 210 30% 12%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 210 30% 12%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 210 30% 12%;
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
/* Brand accent (teal) — used everywhere except during a game, where the
|
||||||
--primary-foreground: 210 40% 98%;
|
per-game colour overrides --primary. */
|
||||||
|
--primary: 174 72% 30%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 200 24% 94%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 210 30% 20%;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 200 24% 94%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 210 12% 42%;
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 174 60% 94%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 174 72% 24%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 78% 58%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 210 20% 90%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 210 20% 90%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 174 72% 30%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
/* Deep cosmic navy */
|
||||||
--foreground: 210 40% 98%;
|
--background: 222 40% 8%;
|
||||||
|
--foreground: 210 30% 96%;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
/* Elevated surface, distinctly lighter than the background */
|
||||||
--card-foreground: 210 40% 98%;
|
--card: 222 32% 13%;
|
||||||
|
--card-foreground: 210 30% 96%;
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222 32% 13%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 30% 96%;
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
--primary: 172 62% 44%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 200 40% 8%;
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 220 24% 20%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 30% 96%;
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 220 24% 18%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 214 15% 65%;
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 172 40% 22%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 172 62% 70%;
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62% 45%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 220 22% 22%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 220 22% 24%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 172 62% 44%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
|||||||
display: "standalone",
|
display: "standalone",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
theme_color: "#0f766e",
|
theme_color: "#0f766e",
|
||||||
background_color: "#ffffff",
|
background_color: "#0b1120",
|
||||||
categories: ["games", "utilities"],
|
categories: ["games", "utilities"],
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
|
|||||||