feat: ShootTracker SQLite+JWT+YOLOv8

This commit is contained in:
ShootTracker Deploy
2026-04-30 22:44:27 +02:00
commit 2578cb6ec2
55 changed files with 5759 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# ─── Supabase ────────────────────────────────────────────────────────────────
# Trouvez ces valeurs dans : Supabase Dashboard → Settings → API
# URL du projet Supabase
SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co
VITE_SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co
# Clé publique (anon) — utilisée par le frontend
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Clé service role — utilisée par le backend UNIQUEMENT (ne jamais exposer)
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# ─── CORS ────────────────────────────────────────────────────────────────────
CORS_ORIGIN=https://shoottracker.domench.fr
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.env
data/
*.db
__pycache__/
.DS_Store
+52
View File
@@ -0,0 +1,52 @@
# ─── ShootTracker — Application principale ────────────────────────────────────
# Multi-stage : Build frontend React → Build backend Node.js → Runtime
# ─── Stage 1 : Build frontend React ──────────────────────────────────────────
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install --include=dev
COPY frontend/ ./
RUN npm run build
# ─── Stage 2 : Build backend Node.js ─────────────────────────────────────────
FROM node:20-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install
COPY backend/ ./
RUN npm run build
# ─── Stage 3 : Runtime ────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
# Copier node_modules et build backend
COPY --from=backend-builder /app/backend/node_modules ./node_modules
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=backend-builder /app/backend/package.json ./
# Copier le frontend buildé (servi par Express)
COPY --from=frontend-builder /app/frontend/dist ./public
# Créer les dossiers de données (volume monté en prod)
RUN mkdir -p /app/data/uploads
ENV NODE_ENV=production
ENV PORT=3001
# JWT_SECRET doit être injecté via variable d'environnement au runtime
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
EXPOSE 3001
# Volume pour SQLite DB + uploads (persistant entre les redémarrages)
VOLUME ["/app/data"]
CMD ["node", "dist/index.js"]
+222
View File
@@ -0,0 +1,222 @@
# ShootTracker
**PWA de suivi de performance en tir sportif** — Analyse d'impacts par IA (YOLOv8), scores, progression, export PDF/Excel.
---
## Architecture
| Composant | Technologie | Rôle |
|---|---|---|
| Frontend | React 18 + TypeScript + Tailwind + Vite + PWA | Interface utilisateur |
| Backend | Node.js + Express + TypeScript | API proxy IA, exports PDF/Excel |
| Base de données | Supabase (PostgreSQL) | Données, authentification, stockage photos |
| Microservice IA | Python + FastAPI + YOLOv8 + OpenCV | Analyse automatique des impacts |
| Déploiement | Docker + Coolify + Gitea | Infrastructure domench.fr |
---
## Démarrage rapide (développement local)
### Prérequis
- Node.js 20+
- Python 3.11+
- Un projet Supabase créé (voir ci-dessous)
### 1. Configurer Supabase
1. Créez un projet sur [supabase.com](https://supabase.com)
2. Dans **SQL Editor**, exécutez le fichier `supabase/migrations/001_initial_schema.sql`
3. Dans **Storage**, créez 3 buckets privés : `avatars`, `weapon-photos`, `target-photos`
4. Dans **Authentication → URL Configuration**, ajoutez `http://localhost:5173` en Site URL
### 2. Variables d'environnement
```bash
# Frontend
cp frontend/.env.example frontend/.env
# Remplir VITE_SUPABASE_URL et VITE_SUPABASE_ANON_KEY
# Backend
cp backend/.env.example backend/.env
# Remplir SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY
```
### 3. Lancer le microservice IA
```bash
cd ai-service
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# → http://localhost:8000
```
### 4. Lancer le backend
```bash
cd backend
npm install
npm run dev
# → http://localhost:3001
```
### 5. Lancer le frontend
```bash
cd frontend
npm install
npm run dev
# → http://localhost:5173
```
---
## Déploiement sur domench.fr
```bash
# Depuis le dossier Applications VPS
python deploy_shoottracker.py
```
Le script :
1. Demande les clés Supabase
2. Crée les repos Gitea (`shoottracker` + `shoottracker-ai`)
3. Push le code
4. Crée et configure les applications dans Coolify
5. Déclenche le déploiement
6. Attends que l'app soit live
**URLs de production :**
- Application : `https://shoottracker.domench.fr`
- Microservice IA : `https://shoottracker-ai.domench.fr`
---
## Déploiement avec Docker Compose (local)
```bash
# Créer .env depuis le modèle
cp .env.example .env
# Remplir les valeurs Supabase
# Build et démarrage
docker-compose up --build
# L'app est accessible sur http://localhost:3001
```
---
## Structure du projet
```
shoottracker/
├── frontend/ # React PWA
│ ├── src/
│ │ ├── pages/
│ │ │ ├── auth/ # Login, Register
│ │ │ ├── session/ # NewSession, SessionCapture, SessionDetail
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Arsenal.tsx # Coffre virtuel
│ │ │ ├── WeaponForm.tsx
│ │ │ ├── Progress.tsx # Progression + graphiques
│ │ │ └── Profile.tsx # Profil + invitations
│ │ ├── components/ # Layout, BottomNav, Sidebar...
│ │ ├── lib/ # supabase.ts, api.ts, store.ts
│ │ └── types/ # TypeScript interfaces
│ └── ...
├── backend/ # Node.js/Express
│ └── src/
│ ├── routes/
│ │ ├── analyze.ts # Proxy → microservice IA
│ │ └── export.ts # PDF et Excel
│ └── middleware/auth.ts # Vérification JWT Supabase
├── ai-service/ # Python FastAPI
│ ├── main.py # API FastAPI
│ ├── analyzer.py # YOLOv8 + OpenCV
│ ├── requirements.txt
│ └── Dockerfile
└── supabase/
└── migrations/
└── 001_initial_schema.sql # Tables + RLS + Storage
```
---
## Modules
### Module 1 — Authentification
- Inscription / connexion email via Supabase Auth
- Profil éditable (nom, club, disciplines, photo)
- Invitations : jusqu'à 5 amis (lien unique, 7 jours)
### Module 2 — Arsenal (coffre virtuel)
- CRUD armes avec photo, type, calibre, marque, modèle, numéro de série
- Filtre par type, recherche, archivage
- Photos stockées dans Supabase Storage
### Module 3 — Séance d'entraînement
- Ouverture : date, lieu (mémorisé), arme, type cible, distance
- Cycle de tirs : photo → IA → ajustement manuel
- Analyse IA : YOLOv8 + zones ISSF (1-10) + groupement
- Soustraction d'image pour les séries sur même cible
- Clôture avec résumé complet
### Module 4 — Dashboard & Progression
- Tableau de bord global avec graphiques Recharts
- Filtres : période (30j/90j/1an/tout) + arme
- Courbe de progression, dispersion, histogramme
- Historique cliquable des séances
### Module 5 — Export
- PDF par séance (jsPDF + jspdf-autotable) avec photo annotée
- Excel global (SheetJS) avec toutes les séances
---
## Variables d'environnement
### Frontend (Vite)
| Variable | Description |
|---|---|
| `VITE_SUPABASE_URL` | URL du projet Supabase |
| `VITE_SUPABASE_ANON_KEY` | Clé publique Supabase |
### Backend (Node.js)
| Variable | Description |
|---|---|
| `SUPABASE_URL` | URL du projet Supabase |
| `SUPABASE_SERVICE_ROLE_KEY` | Clé service role (privée) |
| `AI_SERVICE_URL` | URL du microservice IA |
| `PORT` | Port du serveur (défaut: 3001) |
| `CORS_ORIGIN` | Domaine autorisé en CORS |
### Microservice IA (Python)
| Variable | Description |
|---|---|
| `PORT` | Port du serveur (défaut: 8000) |
| `YOLO_MODEL_PATH` | Chemin du modèle YOLOv8 (défaut: yolov8n.pt) |
---
## Fine-tuning YOLOv8
Pour améliorer la détection des impacts au-delà du modèle généraliste :
1. Collectez des photos de cibles annotées (labelisez les trous)
2. Utilisez [Roboflow](https://roboflow.com) pour l'annotation
3. Entraînez avec Ultralytics :
```bash
yolo train model=yolov8n.pt data=dataset.yaml epochs=100 imgsz=640
```
4. Remplacez `yolov8n.pt` par votre modèle fine-tuné dans `YOLO_MODEL_PATH`
---
## Sécurité
- Row Level Security (RLS) activé sur toutes les tables Supabase
- Chaque utilisateur n'accède qu'à ses propres données
- Photos stockées avec accès privé (buckets Supabase)
- Clé `service_role` uniquement côté backend, jamais exposée au frontend
- Tokens JWT Supabase vérifiés sur chaque appel API backend
+41
View File
@@ -0,0 +1,41 @@
# ─── ShootTracker AI Service ──────────────────────────────────────────────────
# Python + FastAPI + YOLOv8 + OpenCV
FROM python:3.11-slim
# Dépendances système pour OpenCV
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgl1-mesa-glx \
libgomp1 \
wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copier et installer les dépendances Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Pré-télécharger le modèle YOLOv8n (pour éviter le téléchargement au premier démarrage)
RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')" || echo "Download YOLOv8n on first run"
# Copier le code
COPY . .
# Variables d'environnement
ENV PORT=8000
ENV YOLO_MODEL_PATH=yolov8n.pt
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Healthcheck
HEALTHCHECK --interval=30s --timeout=15s --start-period=60s --retries=3 \
CMD wget -qO- http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
+424
View File
@@ -0,0 +1,424 @@
"""
ShootTracker — Analyseur d'impacts de tir
YOLOv8 + OpenCV
Responsabilités :
1. Détecter les impacts sur la photo de cible (YOLOv8)
2. Analyser les zones selon le type de cible (OpenCV)
3. Gérer la soustraction d'image pour les impacts cumulés
4. Calculer score, groupement et dispersion
"""
import base64
import io
import math
import logging
from typing import Optional
from dataclasses import dataclass, field
import cv2
import numpy as np
from PIL import Image
logger = logging.getLogger(__name__)
# ─── Structures ───────────────────────────────────────────────────────────────
@dataclass
class ImpactPoint:
x: float # coordonnée normalisée [0,1] depuis le centre
y: float # coordonnée normalisée [0,1] depuis le centre
zone: Optional[int] = None
points: int = 0
confidence: float = 1.0
@dataclass
class AnalysisResult:
impacts: list = field(default_factory=list)
total_detected: int = 0
score_zone: int = 0
score_groupement: int = 0
score_total: int = 0
dispersion_radius: float = 0.0
center_x: float = 0.5
center_y: float = 0.5
annotated_image_base64: Optional[str] = None
error: Optional[str] = None
warning: Optional[str] = None
# ─── Constantes de cibles ─────────────────────────────────────────────────────
# Cible ISSF : 10 zones concentriques
# zones[i] = rayon relatif de la zone (10 à l'intérieur, 1 à l'extérieur)
# Normalisé sur 0.5 (demi-image = bord de la cible)
ISSF_ZONES = {
10: 0.05, 9: 0.10, 8: 0.16, 7: 0.22, 6: 0.28,
5: 0.33, 4: 0.38, 3: 0.43, 2: 0.47, 1: 0.50
}
# Couleurs OpenCV BGR pour l'annotation
ZONE_COLORS_BGR = {
10: (0, 200, 0), # Vert
9: (80, 220, 0),
8: (140, 220, 0),
7: (0, 200, 100),
6: (0, 200, 200),
5: (0, 150, 255),
4: (0, 80, 255),
3: (0, 0, 255),
2: (0, 0, 180),
1: (0, 0, 100),
}
DEFAULT_COLOR_BGR = (128, 128, 128)
# ─── Classe principale ────────────────────────────────────────────────────────
class TargetAnalyzer:
def __init__(self, model_path: str = 'yolov8n.pt'):
self.model = None
self.model_path = model_path
self._load_model()
def _load_model(self):
"""Charge le modèle YOLOv8."""
try:
from ultralytics import YOLO
self.model = YOLO(self.model_path)
logger.info(f"Modèle YOLOv8 chargé : {self.model_path}")
except Exception as e:
logger.error(f"Impossible de charger YOLOv8 : {e}")
self.model = None
def analyze(
self,
image_b64: str,
target_type: str,
previous_image_b64: Optional[str] = None
) -> AnalysisResult:
"""
Point d'entrée principal.
Args:
image_b64: Image base64 de la cible
target_type: 'issf' | 'silhouette' | 'libre'
previous_image_b64: Image précédente de la même cible (pour soustraction)
"""
result = AnalysisResult()
try:
# 1. Décoder les images
img_cv = self._decode_b64_to_cv2(image_b64)
if img_cv is None:
result.error = "Image illisible ou corrompue"
return result
prev_cv = None
if previous_image_b64:
prev_cv = self._decode_b64_to_cv2(previous_image_b64)
# 2. Soustraction d'image si image précédente fournie
working_img = img_cv.copy()
if prev_cv is not None:
working_img = self._subtract_images(img_cv, prev_cv)
# 3. Détection des impacts
if self.model:
impacts_raw = self._detect_with_yolo(working_img)
else:
# Fallback OpenCV si YOLOv8 non disponible
impacts_raw = self._detect_with_opencv(working_img)
# 4. Analyse des zones selon le type de cible
h, w = img_cv.shape[:2]
scored_impacts = self._score_impacts(impacts_raw, w, h, target_type)
result.impacts = [vars(imp) for imp in scored_impacts]
result.total_detected = len(scored_impacts)
# 5. Calcul scores
result.score_zone = sum(imp.points for imp in scored_impacts)
# 6. Calcul groupement (dispersion)
if len(scored_impacts) >= 2:
center_x, center_y, radius = self._compute_dispersion(scored_impacts)
result.center_x = center_x
result.center_y = center_y
result.dispersion_radius = radius
result.score_groupement = self._groupement_score(radius, result.score_zone)
elif len(scored_impacts) == 1:
result.center_x = scored_impacts[0].x
result.center_y = scored_impacts[0].y
result.dispersion_radius = 0.0
result.score_groupement = round(result.score_zone * 0.10)
result.score_total = result.score_zone + result.score_groupement
# 7. Annotation de l'image
annotated = self._annotate_image(img_cv, scored_impacts, target_type)
result.annotated_image_base64 = self._encode_cv2_to_b64(annotated)
if result.total_detected == 0:
result.warning = "Aucun impact détecté. Vérifiez la qualité de l'image."
except Exception as e:
logger.exception(f"Erreur analyze: {e}")
result.error = f"Erreur d'analyse : {str(e)}"
return result
# ─── Décodage / Encodage ──────────────────────────────────────────────────
def _decode_b64_to_cv2(self, b64_str: str) -> Optional[np.ndarray]:
try:
# Supprimer le préfixe data URL si présent
if ',' in b64_str:
b64_str = b64_str.split(',', 1)[1]
data = base64.b64decode(b64_str)
pil_img = Image.open(io.BytesIO(data)).convert('RGB')
# Redimensionner si trop grande (max 2000px)
max_dim = 2000
w, h = pil_img.size
if max(w, h) > max_dim:
ratio = max_dim / max(w, h)
pil_img = pil_img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS)
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
except Exception as e:
logger.error(f"Décodage image échoué: {e}")
return None
def _encode_cv2_to_b64(self, img: np.ndarray) -> str:
_, buffer = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])
return 'data:image/jpeg;base64,' + base64.b64encode(buffer.tobytes()).decode('utf-8')
# ─── Détection YOLOv8 ─────────────────────────────────────────────────────
def _detect_with_yolo(self, img: np.ndarray) -> list[tuple[float, float, float]]:
"""
Détecte les impacts avec YOLOv8.
Utilise yolov8n.pt (modèle généraliste) en cherchant des objets ronds/petits.
Pour un fine-tuning ultérieur, entraîner sur des photos de cibles annotées.
Retourne une liste de (cx_norm, cy_norm, confidence) en coordonnées normalisées.
"""
h, w = img.shape[:2]
try:
results = self.model(img, conf=0.15, verbose=False)
detections = []
for r in results:
for box in r.boxes:
# Toutes les détections (le modèle de base peut détecter des "trous")
x1, y1, x2, y2 = box.xyxy[0].tolist()
cx = (x1 + x2) / 2 / w
cy = (y1 + y2) / 2 / h
conf = float(box.conf[0])
# Filtrer les boîtes trop grandes (pas des impacts)
box_w = (x2 - x1) / w
box_h = (y2 - y1) / h
if box_w < 0.15 and box_h < 0.15: # Max 15% de l'image
detections.append((cx, cy, conf))
logger.info(f"YOLOv8 détecté {len(detections)} impacts potentiels")
return detections
except Exception as e:
logger.warning(f"YOLOv8 échoué, fallback OpenCV: {e}")
return self._detect_with_opencv(img)
# ─── Détection OpenCV (fallback) ──────────────────────────────────────────
def _detect_with_opencv(self, img: np.ndarray) -> list[tuple[float, float, float]]:
"""
Détection des impacts par analyse d'image OpenCV.
Recherche des zones sombres circulaires (trous dans la cible).
"""
h, w = img.shape[:2]
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Amélioration du contraste
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray = clahe.apply(gray)
# Détecter les bords
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 30, 100)
# Détection de cercles (Hough)
min_r = max(3, int(min(w, h) * 0.005)) # Rayon min ~ 0.5%
max_r = max(15, int(min(w, h) * 0.03)) # Rayon max ~ 3%
circles = cv2.HoughCircles(
gray, cv2.HOUGH_GRADIENT, dp=1.2,
minDist=int(min(w, h) * 0.02),
param1=100, param2=20,
minRadius=min_r, maxRadius=max_r
)
detections = []
if circles is not None:
circles = np.round(circles[0, :]).astype(int)
for (cx, cy, r) in circles[:30]: # Max 30 impacts
detections.append((cx / w, cy / h, 0.8))
# Si peu de cercles détectés, essayer par zones sombres
if len(detections) < 2:
detections.extend(self._detect_dark_spots(gray, w, h))
logger.info(f"OpenCV détecté {len(detections)} impacts")
return detections
def _detect_dark_spots(self, gray: np.ndarray, w: int, h: int) -> list[tuple[float, float, float]]:
"""Détecte les zones sombres (trous) dans l'image."""
_, thresh = cv2.threshold(gray, 80, 255, cv2.THRESH_BINARY_INV)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
opened = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
contours, _ = cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
detections = []
min_area = (w * h) * 0.0001 # 0.01% de l'image
max_area = (w * h) * 0.005 # 0.5% de l'image
for cnt in contours:
area = cv2.contourArea(cnt)
if min_area <= area <= max_area:
M = cv2.moments(cnt)
if M['m00'] > 0:
cx = M['m10'] / M['m00'] / w
cy = M['m01'] / M['m00'] / h
detections.append((cx, cy, 0.6))
return detections[:25] # Max 25
# ─── Soustraction d'image ────────────────────────────────────────────────
def _subtract_images(self, current: np.ndarray, previous: np.ndarray) -> np.ndarray:
"""
Soustrait l'image précédente pour isoler les NOUVEAUX impacts.
"""
# Redimensionner si nécessaire
if current.shape[:2] != previous.shape[:2]:
previous = cv2.resize(previous, (current.shape[1], current.shape[0]))
# Conversion en niveaux de gris
curr_gray = cv2.cvtColor(current, cv2.COLOR_BGR2GRAY)
prev_gray = cv2.cvtColor(previous, cv2.COLOR_BGR2GRAY)
# Soustraction absolue
diff = cv2.absdiff(curr_gray, prev_gray)
_, mask = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
# Appliquer le masque sur l'image courante
result = current.copy()
background = np.full_like(current, 200) # Fond gris clair
background[mask == 255] = current[mask == 255]
return background
# ─── Scoring par zones ───────────────────────────────────────────────────
def _score_impacts(
self, raw_detections: list[tuple[float, float, float]],
w: int, h: int, target_type: str
) -> list[ImpactPoint]:
"""Assigne une zone et des points à chaque impact détecté."""
impacts = []
for (cx, cy, conf) in raw_detections:
imp = ImpactPoint(x=cx, y=cy, confidence=conf)
if target_type == 'issf':
imp.zone, imp.points = self._get_issf_zone(cx, cy)
else:
# Silhouette / libre : pas de scoring par zones
imp.zone = None
imp.points = 1 # 1 point par tir
impacts.append(imp)
return impacts
def _get_issf_zone(self, cx: float, cy: float) -> tuple[Optional[int], int]:
"""
Calcule la zone ISSF (1-10) pour un impact à (cx, cy) normalisé.
Assume que le centre de la cible est à (0.5, 0.5).
"""
dx = cx - 0.5
dy = cy - 0.5
dist = math.sqrt(dx*dx + dy*dy) # Distance au centre [0, ~0.7]
for zone in range(10, 0, -1): # De 10 (centre) à 1 (bord)
if dist <= ISSF_ZONES[zone]:
return zone, zone
return None, 0 # Hors cible
# ─── Calcul groupement ───────────────────────────────────────────────────
def _compute_dispersion(self, impacts: list[ImpactPoint]) -> tuple[float, float, float]:
"""Calcule le centre et le rayon de dispersion (en pixels normalisés * 1000)."""
xs = [imp.x for imp in impacts]
ys = [imp.y for imp in impacts]
cx = sum(xs) / len(xs)
cy = sum(ys) / len(ys)
# Rayon moyen des distances au centre
dists = [math.sqrt((x - cx)**2 + (y - cy)**2) for x, y in zip(xs, ys)]
radius = max(dists) if dists else 0.0
# Convertir en "pixels" (on multiplie par 1000 pour un nombre lisible)
return cx, cy, round(radius * 1000, 2)
def _groupement_score(self, radius: float, zone_score: int) -> int:
"""
Bonus de groupement : jusqu'à 10% du score de zones.
Radius : normalisé * 1000. Moins c'est grand, meilleur c'est.
"""
if radius <= 0: return 0
# Excellent groupement (radius < 10) → 10%, mauvais (>100) → 0%
if radius <= 10: ratio = 0.10
elif radius <= 30: ratio = 0.07
elif radius <= 60: ratio = 0.04
elif radius <= 100: ratio = 0.02
else: ratio = 0.0
return round(zone_score * ratio)
# ─── Annotation de l'image ───────────────────────────────────────────────
def _annotate_image(
self, img: np.ndarray,
impacts: list[ImpactPoint],
target_type: str
) -> np.ndarray:
"""Dessine les impacts annotés sur l'image."""
annotated = img.copy()
h, w = annotated.shape[:2]
dot_radius = max(8, int(min(w, h) * 0.012))
for imp in impacts:
px = int(imp.x * w)
py = int(imp.y * h)
color = ZONE_COLORS_BGR.get(imp.zone or 0, DEFAULT_COLOR_BGR)
# Cercle extérieur (blanc)
cv2.circle(annotated, (px, py), dot_radius + 2, (255, 255, 255), 2)
# Cercle de couleur par zone
cv2.circle(annotated, (px, py), dot_radius, color, -1)
# Texte de la zone
if imp.zone:
text = str(imp.zone)
font_scale = max(0.4, dot_radius / 20)
thickness = max(1, dot_radius // 10)
(tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
cv2.putText(
annotated, text,
(px - tw // 2, py + th // 2),
cv2.FONT_HERSHEY_SIMPLEX, font_scale,
(255, 255, 255), thickness, cv2.LINE_AA
)
# Dessiner le centre de gravité si plusieurs impacts
if len(impacts) >= 2:
cx_px = int(sum(i.x for i in impacts) / len(impacts) * w)
cy_px = int(sum(i.y for i in impacts) / len(impacts) * h)
cv2.drawMarker(
annotated, (cx_px, cy_px), (0, 255, 255),
cv2.MARKER_CROSS, 20, 2
)
# Watermark
cv2.putText(
annotated, 'ShootTracker AI',
(10, h - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5,
(100, 100, 100), 1, cv2.LINE_AA
)
return annotated
+169
View File
@@ -0,0 +1,169 @@
"""
ShootTracker — Microservice IA
FastAPI + YOLOv8 + OpenCV
Endpoints :
POST /analyze — Analyse d'une photo de cible
GET /health — Santé du service
GET / — Infos
"""
import os
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, field_validator
from analyzer import TargetAnalyzer, AnalysisResult
# ─── Logging ──────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%H:%M:%S'
)
logger = logging.getLogger(__name__)
# ─── Modèle global ────────────────────────────────────────────────────────────
analyzer: Optional[TargetAnalyzer] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Charge le modèle au démarrage."""
global analyzer
model_path = os.getenv('YOLO_MODEL_PATH', 'yolov8n.pt')
logger.info(f"Chargement du modèle YOLOv8 : {model_path}")
analyzer = TargetAnalyzer(model_path=model_path)
logger.info("✓ Service IA prêt")
yield
logger.info("Service IA arrêté")
# ─── Application FastAPI ──────────────────────────────────────────────────────
app = FastAPI(
title="ShootTracker AI Service",
description="Analyse d'impacts de tir avec YOLOv8 + OpenCV",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Schémas Pydantic ─────────────────────────────────────────────────────────
class AnalyzeRequest(BaseModel):
image_base64: str
target_type: str
previous_image_base64: Optional[str] = None
@field_validator('target_type')
@classmethod
def validate_target_type(cls, v: str) -> str:
allowed = ['issf', 'silhouette', 'libre']
if v not in allowed:
raise ValueError(f"target_type doit être parmi : {allowed}")
return v
@field_validator('image_base64')
@classmethod
def validate_image(cls, v: str) -> str:
if not v or len(v) < 100:
raise ValueError("image_base64 invalide ou trop courte")
return v
class ImpactOut(BaseModel):
x: float
y: float
zone: Optional[int]
points: int
confidence: float
class AnalyzeResponse(BaseModel):
impacts: list[ImpactOut]
total_detected: int
score_zone: int
score_groupement: int
score_total: int
dispersion_radius: float
center_x: float
center_y: float
annotated_image_base64: Optional[str]
error: Optional[str]
warning: Optional[str]
# ─── Endpoints ────────────────────────────────────────────────────────────────
@app.get("/", tags=["Info"])
async def root():
return {
"service": "ShootTracker AI",
"version": "1.0.0",
"status": "running",
"model": os.getenv('YOLO_MODEL_PATH', 'yolov8n.pt'),
}
@app.get("/health", tags=["Info"])
async def health():
return {
"status": "ok",
"model_loaded": analyzer is not None and analyzer.model is not None,
"service": "shoottracker-ai",
}
@app.post("/analyze", response_model=AnalyzeResponse, tags=["Analyse"])
async def analyze_target(req: AnalyzeRequest):
"""
Analyse une photo de cible de tir.
- Détecte les impacts avec YOLOv8
- Analyse les zones selon le type de cible
- Si image_précédente fournie : ne compte que les NOUVEAUX impacts
- Calcule le score et le groupement
- Retourne la photo annotée en base64
"""
if analyzer is None:
raise HTTPException(status_code=503, detail="Service IA non initialisé")
logger.info(f"Analyse demandée : target_type={req.target_type}, prev={'oui' if req.previous_image_base64 else 'non'}")
try:
result: AnalysisResult = analyzer.analyze(
image_b64=req.image_base64,
target_type=req.target_type,
previous_image_b64=req.previous_image_base64,
)
except Exception as e:
logger.exception(f"Erreur inattendue: {e}")
raise HTTPException(status_code=500, detail=f"Erreur interne : {str(e)}")
if result.error and not result.annotated_image_base64:
# Erreur critique (image illisible)
raise HTTPException(status_code=422, detail=result.error)
return AnalyzeResponse(
impacts=[ImpactOut(**imp) for imp in result.impacts],
total_detected=result.total_detected,
score_zone=result.score_zone,
score_groupement=result.score_groupement,
score_total=result.score_total,
dispersion_radius=result.dispersion_radius,
center_x=result.center_x,
center_y=result.center_y,
annotated_image_base64=result.annotated_image_base64,
error=result.error,
warning=result.warning,
)
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 8000))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False, workers=1)
+12
View File
@@ -0,0 +1,12 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
python-multipart==0.0.9
pillow==10.3.0
numpy==1.26.4
opencv-python-headless==4.9.0.80
ultralytics==8.2.37
torch==2.3.0
torchvision==0.18.0
httpx==0.27.0
pydantic==2.7.3
python-dotenv==1.0.1
+12
View File
@@ -0,0 +1,12 @@
PORT=3001
NODE_ENV=production
# Supabase
SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Microservice IA
AI_SERVICE_URL=http://shoottracker-ai:8000
# CORS
CORS_ORIGIN=https://shoottracker.domench.fr
+39
View File
@@ -0,0 +1,39 @@
{
"name": "shoottracker-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.6.0",
"cors": "^2.8.5",
"date-fns": "^3.6.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.31.2",
"express": "^4.19.2",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"uuid": "^10.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.2",
"@types/uuid": "^10.0.0",
"drizzle-kit": "^0.22.7",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}
+138
View File
@@ -0,0 +1,138 @@
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema'
import path from 'path'
import fs from 'fs'
const DB_PATH = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'shoottracker.db')
// Assurer que le répertoire existe
const dbDir = path.dirname(DB_PATH)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
const sqlite = new Database(DB_PATH)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
export const db = drizzle(sqlite, { schema })
// ─── Migration auto au démarrage ──────────────────────────────────────────────
export function runMigrations() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
first_name TEXT DEFAULT '',
last_name TEXT DEFAULT '',
club TEXT DEFAULT '',
disciplines TEXT DEFAULT '[]',
avatar_url TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS weapons (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
nickname TEXT,
type TEXT NOT NULL,
caliber TEXT,
brand TEXT,
model TEXT,
serial_number TEXT,
photo_url TEXT,
notes TEXT,
is_archived INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS training_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
weapon_id TEXT REFERENCES weapons(id) ON DELETE SET NULL,
session_date TEXT NOT NULL,
location TEXT,
target_type TEXT NOT NULL,
distance_m INTEGER,
notes_start TEXT,
notes_end TEXT,
total_shots_declared INTEGER DEFAULT 0,
total_shots_detected INTEGER DEFAULT 0,
total_score INTEGER DEFAULT 0,
avg_score REAL DEFAULT 0,
best_series_score INTEGER DEFAULT 0,
ai_detection_rate INTEGER DEFAULT 0,
avg_dispersion REAL,
is_completed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS series (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES training_sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
series_number INTEGER NOT NULL,
shots_declared INTEGER DEFAULT 0,
shots_detected INTEGER DEFAULT 0,
shots_manual INTEGER DEFAULT 0,
photo_url TEXT,
annotated_photo_url TEXT,
score_zone INTEGER DEFAULT 0,
score_groupement INTEGER DEFAULT 0,
score_total INTEGER DEFAULT 0,
dispersion_radius REAL,
center_x REAL,
center_y REAL,
ai_data TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS impacts (
id TEXT PRIMARY KEY,
series_id TEXT NOT NULL REFERENCES series(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
x REAL NOT NULL,
y REAL NOT NULL,
zone INTEGER,
points INTEGER DEFAULT 0,
is_manual INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS invitations (
id TEXT PRIMARY KEY,
inviter_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT,
token TEXT NOT NULL UNIQUE,
is_used INTEGER DEFAULT 0,
used_by TEXT,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_locations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
used_count INTEGER DEFAULT 1,
last_used_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_weapons_user ON weapons(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON training_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_date ON training_sessions(session_date DESC);
CREATE INDEX IF NOT EXISTS idx_series_session ON series(session_id);
CREATE INDEX IF NOT EXISTS idx_impacts_series ON impacts(series_id);
CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations(token);
`)
console.log('✓ Migrations SQLite exécutées')
}
export { sqlite }
+112
View File
@@ -0,0 +1,112 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
// ─── Users ────────────────────────────────────────────────────────────────────
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
password_hash: text('password_hash').notNull(),
first_name: text('first_name').default(''),
last_name: text('last_name').default(''),
club: text('club').default(''),
disciplines: text('disciplines').default('[]'), // JSON array string
avatar_url: text('avatar_url'),
created_at: text('created_at').default(sql`(datetime('now'))`),
updated_at: text('updated_at').default(sql`(datetime('now'))`),
})
// ─── Weapons ──────────────────────────────────────────────────────────────────
export const weapons = sqliteTable('weapons', {
id: text('id').primaryKey(),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
nickname: text('nickname'),
type: text('type').notNull(), // pistolet|carabine|fusil|arc|arbalète|autre
caliber: text('caliber'),
brand: text('brand'),
model: text('model'),
serial_number: text('serial_number'),
photo_url: text('photo_url'),
notes: text('notes'),
is_archived: integer('is_archived', { mode: 'boolean' }).default(false),
created_at: text('created_at').default(sql`(datetime('now'))`),
updated_at: text('updated_at').default(sql`(datetime('now'))`),
})
// ─── Training sessions ────────────────────────────────────────────────────────
export const training_sessions = sqliteTable('training_sessions', {
id: text('id').primaryKey(),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
weapon_id: text('weapon_id').references(() => weapons.id, { onDelete: 'set null' }),
session_date: text('session_date').notNull(),
location: text('location'),
target_type: text('target_type').notNull(), // issf|silhouette|libre
distance_m: integer('distance_m'),
notes_start: text('notes_start'),
notes_end: text('notes_end'),
total_shots_declared: integer('total_shots_declared').default(0),
total_shots_detected: integer('total_shots_detected').default(0),
total_score: integer('total_score').default(0),
avg_score: real('avg_score').default(0),
best_series_score: integer('best_series_score').default(0),
ai_detection_rate: integer('ai_detection_rate').default(0),
avg_dispersion: real('avg_dispersion'),
is_completed: integer('is_completed', { mode: 'boolean' }).default(false),
created_at: text('created_at').default(sql`(datetime('now'))`),
updated_at: text('updated_at').default(sql`(datetime('now'))`),
})
// ─── Series ───────────────────────────────────────────────────────────────────
export const series = sqliteTable('series', {
id: text('id').primaryKey(),
session_id: text('session_id').notNull().references(() => training_sessions.id, { onDelete: 'cascade' }),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
series_number: integer('series_number').notNull(),
shots_declared: integer('shots_declared').notNull().default(0),
shots_detected: integer('shots_detected').default(0),
shots_manual: integer('shots_manual').default(0),
photo_url: text('photo_url'),
annotated_photo_url: text('annotated_photo_url'),
score_zone: integer('score_zone').default(0),
score_groupement: integer('score_groupement').default(0),
score_total: integer('score_total').default(0),
dispersion_radius: real('dispersion_radius'),
center_x: real('center_x'),
center_y: real('center_y'),
ai_data: text('ai_data'), // JSON string
created_at: text('created_at').default(sql`(datetime('now'))`),
})
// ─── Impacts ──────────────────────────────────────────────────────────────────
export const impacts = sqliteTable('impacts', {
id: text('id').primaryKey(),
series_id: text('series_id').notNull().references(() => series.id, { onDelete: 'cascade' }),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
x: real('x').notNull(),
y: real('y').notNull(),
zone: integer('zone'),
points: integer('points').default(0),
is_manual: integer('is_manual', { mode: 'boolean' }).default(false),
created_at: text('created_at').default(sql`(datetime('now'))`),
})
// ─── Invitations ──────────────────────────────────────────────────────────────
export const invitations = sqliteTable('invitations', {
id: text('id').primaryKey(),
inviter_id: text('inviter_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
email: text('email'),
token: text('token').notNull().unique(),
is_used: integer('is_used', { mode: 'boolean' }).default(false),
used_by: text('used_by'),
created_at: text('created_at').default(sql`(datetime('now'))`),
expires_at: text('expires_at').notNull(),
})
// ─── User locations ───────────────────────────────────────────────────────────
export const user_locations = sqliteTable('user_locations', {
id: text('id').primaryKey(),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
used_count: integer('used_count').default(1),
last_used_at: text('last_used_at').default(sql`(datetime('now'))`),
})
+58
View File
@@ -0,0 +1,58 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import path from 'path'
import fs from 'fs'
import { runMigrations } from './db'
import { authRouter } from './routes/auth'
import { weaponsRouter } from './routes/weapons'
import { sessionsRouter } from './routes/sessions'
import { seriesRouter } from './routes/series'
import { miscRouter } from './routes/misc'
import { analyzeRouter } from './routes/analyze'
import { exportRouter } from './routes/export'
const app = express()
const PORT = parseInt(process.env.PORT || '3001')
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(process.cwd(), 'data', 'uploads')
// ─── Migrations ──────────────────────────────────────────────────────────────
runMigrations()
// ─── Dossiers ─────────────────────────────────────────────────────────────────
fs.mkdirSync(UPLOADS_DIR, { recursive: true })
// ─── Middlewares ──────────────────────────────────────────────────────────────
app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }))
app.use(express.json({ limit: '50mb' }))
app.use(express.urlencoded({ extended: true, limit: '50mb' }))
// ─── Fichiers statiques (uploads) ─────────────────────────────────────────────
app.use('/uploads', express.static(UPLOADS_DIR))
// ─── Routes API ───────────────────────────────────────────────────────────────
app.use('/api/auth', authRouter)
app.use('/api/weapons', weaponsRouter)
app.use('/api', sessionsRouter)
app.use('/api', seriesRouter)
app.use('/api', miscRouter)
app.use('/api', analyzeRouter)
app.use('/api/export', exportRouter)
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', service: 'shoottracker', ts: new Date().toISOString() })
})
// ─── Serve Frontend (production) ─────────────────────────────────────────────
const publicDir = path.join(__dirname, '..', 'public')
if (fs.existsSync(publicDir)) {
app.use(express.static(publicDir))
app.get('*', (_req, res) => res.sendFile(path.join(publicDir, 'index.html')))
}
// ─── Start ────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`✓ ShootTracker backend → http://localhost:${PORT}`)
console.log(` Uploads : ${UPLOADS_DIR}`)
console.log(` AI : ${process.env.AI_SERVICE_URL || 'http://localhost:8000'}`)
})
+29
View File
@@ -0,0 +1,29 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
export interface AuthRequest extends Request {
userId?: string
userEmail?: string
}
const JWT_SECRET = process.env.JWT_SECRET || 'shoottracker-dev-secret-change-in-prod'
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token manquant' })
}
const token = authHeader.slice(7)
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string; email: string }
req.userId = payload.userId
req.userEmail = payload.email
next()
} catch {
return res.status(401).json({ error: 'Token invalide ou expiré' })
}
}
export function signToken(userId: string, email: string): string {
return jwt.sign({ userId, email }, JWT_SECRET, { expiresIn: '30d' })
}
+49
View File
@@ -0,0 +1,49 @@
import { Router } from 'express'
import axios from 'axios'
export const analyzeRouter = Router()
const AI_SERVICE_URL = process.env.AI_SERVICE_URL || 'http://localhost:8000'
/**
* POST /api/analyze
* Proxy vers le microservice Python IA
* Body: { imageBase64: string, targetType: string, previousImageBase64?: string }
*/
analyzeRouter.post('/analyze', async (req, res) => {
try {
const { imageBase64, targetType, previousImageBase64 } = req.body
if (!imageBase64) {
return res.status(400).json({ error: 'Image manquante' })
}
if (!targetType || !['issf', 'silhouette', 'libre'].includes(targetType)) {
return res.status(400).json({ error: 'Type de cible invalide' })
}
const payload = {
image_base64: imageBase64,
target_type: targetType,
previous_image_base64: previousImageBase64 || null,
}
const response = await axios.post(`${AI_SERVICE_URL}/analyze`, payload, {
timeout: 60000, // 60s pour le traitement IA
maxBodyLength: Infinity,
maxContentLength: Infinity,
})
return res.json(response.data)
} catch (error: any) {
console.error('AI service error:', error.message)
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED') {
return res.status(503).json({ error: 'Service IA indisponible. Veuillez réessayer.' })
}
if (error.response) {
return res.status(error.response.status).json(error.response.data)
}
}
return res.status(500).json({ error: 'Erreur lors de l\'analyse IA' })
}
})
+128
View File
@@ -0,0 +1,128 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import { v4 as uuid } from 'uuid'
import { eq } from 'drizzle-orm'
import { db } from '../db'
import { users, invitations } from '../db/schema'
import { requireAuth, signToken, AuthRequest } from '../middleware/auth'
export const authRouter = Router()
// ─── Inscription ──────────────────────────────────────────────────────────────
authRouter.post('/register', async (req, res) => {
const { email, password, first_name, last_name, club, invite_token } = req.body
if (!email || !password) return res.status(400).json({ error: 'Email et mot de passe requis' })
if (password.length < 8) return res.status(400).json({ error: 'Mot de passe minimum 8 caractères' })
try {
// Vérifier l'email unique
const existing = db.select().from(users).where(eq(users.email, email.toLowerCase())).get()
if (existing) return res.status(409).json({ error: 'Email déjà utilisé' })
// Vérifier l'invitation si fournie
if (invite_token) {
const inv = db.select().from(invitations)
.where(eq(invitations.token, invite_token)).get()
if (!inv || inv.is_used || new Date(inv.expires_at) < new Date()) {
return res.status(400).json({ error: 'Invitation invalide ou expirée' })
}
}
const id = uuid()
const password_hash = await bcrypt.hash(password, 12)
db.insert(users).values({
id, email: email.toLowerCase(),
password_hash,
first_name: first_name || '',
last_name: last_name || '',
club: club || '',
disciplines: '[]',
}).run()
// Marquer l'invitation comme utilisée
if (invite_token) {
db.update(invitations).set({ is_used: true, used_by: id })
.where(eq(invitations.token, invite_token)).run()
}
const token = signToken(id, email.toLowerCase())
const user = db.select().from(users).where(eq(users.id, id)).get()
return res.status(201).json({ token, user: sanitizeUser(user!) })
} catch (e: any) {
console.error('Register error:', e)
return res.status(500).json({ error: 'Erreur serveur' })
}
})
// ─── Connexion ────────────────────────────────────────────────────────────────
authRouter.post('/login', async (req, res) => {
const { email, password } = req.body
if (!email || !password) return res.status(400).json({ error: 'Email et mot de passe requis' })
try {
const user = db.select().from(users).where(eq(users.email, email.toLowerCase())).get()
if (!user) return res.status(401).json({ error: 'Email ou mot de passe incorrect' })
const ok = await bcrypt.compare(password, user.password_hash)
if (!ok) return res.status(401).json({ error: 'Email ou mot de passe incorrect' })
const token = signToken(user.id, user.email)
return res.json({ token, user: sanitizeUser(user) })
} catch (e) {
return res.status(500).json({ error: 'Erreur serveur' })
}
})
// ─── Profil courant ───────────────────────────────────────────────────────────
authRouter.get('/me', requireAuth, (req: AuthRequest, res) => {
const user = db.select().from(users).where(eq(users.id, req.userId!)).get()
if (!user) return res.status(404).json({ error: 'Utilisateur introuvable' })
return res.json(sanitizeUser(user))
})
// ─── Mise à jour profil ───────────────────────────────────────────────────────
authRouter.put('/profile', requireAuth, async (req: AuthRequest, res) => {
const { first_name, last_name, club, disciplines } = req.body
try {
db.update(users).set({
first_name: first_name ?? '',
last_name: last_name ?? '',
club: club ?? '',
disciplines: Array.isArray(disciplines) ? JSON.stringify(disciplines) : (disciplines || '[]'),
updated_at: new Date().toISOString(),
}).where(eq(users.id, req.userId!)).run()
const updated = db.select().from(users).where(eq(users.id, req.userId!)).get()
return res.json(sanitizeUser(updated!))
} catch (e) {
return res.status(500).json({ error: 'Erreur mise à jour profil' })
}
})
// ─── Changer le mot de passe ──────────────────────────────────────────────────
authRouter.put('/password', requireAuth, async (req: AuthRequest, res) => {
const { current_password, new_password } = req.body
if (!current_password || !new_password) return res.status(400).json({ error: 'Champs requis' })
if (new_password.length < 8) return res.status(400).json({ error: 'Minimum 8 caractères' })
const user = db.select().from(users).where(eq(users.id, req.userId!)).get()
if (!user) return res.status(404).json({ error: 'Utilisateur introuvable' })
const ok = await bcrypt.compare(current_password, user.password_hash)
if (!ok) return res.status(401).json({ error: 'Mot de passe actuel incorrect' })
const hash = await bcrypt.hash(new_password, 12)
db.update(users).set({ password_hash: hash }).where(eq(users.id, req.userId!)).run()
return res.json({ message: 'Mot de passe mis à jour' })
})
// ─── Helpers ──────────────────────────────────────────────────────────────────
function sanitizeUser(u: typeof users.$inferSelect) {
const { password_hash, ...safe } = u
return {
...safe,
disciplines: safeJson(safe.disciplines, []),
}
}
function safeJson(str: string | null, fallback: any) {
try { return JSON.parse(str || '[]') } catch { return fallback }
}
+171
View File
@@ -0,0 +1,171 @@
import { Router } from 'express'
import { eq, and, gte } from 'drizzle-orm'
import { db } from '../db'
import { training_sessions, weapons, series } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import jsPDF from 'jspdf'
import 'jspdf-autotable'
import * as XLSX from 'xlsx'
export const exportRouter = Router()
exportRouter.use(requireAuth)
// ─── Export PDF d'une séance ──────────────────────────────────────────────────
exportRouter.get('/pdf/:sessionId', async (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.sessionId), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
const weapon = sess.weapon_id ? db.select().from(weapons).where(eq(weapons.id, sess.weapon_id)).get() : null
const seriesList = db.select().from(series).where(eq(series.session_id, sess.id)).all()
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// En-tête
doc.setFillColor(13, 13, 13)
doc.rect(0, 0, 210, 35, 'F')
doc.setTextColor(220, 38, 38)
doc.setFontSize(22); doc.setFont('helvetica', 'bold')
doc.text('SHOOTTRACKER', 14, 15)
doc.setTextColor(180, 180, 180); doc.setFontSize(10); doc.setFont('helvetica', 'normal')
doc.text('Rapport de séance d\'entraînement', 14, 22)
doc.text(`Généré le ${format(new Date(), 'dd/MM/yyyy à HH:mm', { locale: fr })}`, 14, 28)
// Infos séance
doc.setTextColor(30, 30, 30); doc.setFontSize(14); doc.setFont('helvetica', 'bold')
doc.text('Informations de séance', 14, 48)
;(doc as any).autoTable({
startY: 52,
body: [
['Date', format(new Date(sess.session_date), "dd MMMM yyyy 'à' HH:mm", { locale: fr })],
['Lieu', sess.location || 'Non précisé'],
['Arme', weapon ? `${weapon.name}${weapon.brand ? ' — ' + weapon.brand : ''}${weapon.model ? ' ' + weapon.model : ''}` : 'Non précisé'],
['Calibre', weapon?.caliber || 'Non précisé'],
['Type de cible', sess.target_type.toUpperCase()],
['Distance', sess.distance_m ? `${sess.distance_m} mètres` : 'Non précisé'],
],
theme: 'striped', styles: { fontSize: 10, cellPadding: 3 },
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 40 } },
})
// Résumé
let y = (doc as any).lastAutoTable.finalY + 10
doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Résumé', 14, y)
;(doc as any).autoTable({
startY: y + 4,
body: [
['Séries', String(seriesList.length)],
['Tirs déclarés', String(sess.total_shots_declared)],
['Tirs détectés IA', `${sess.total_shots_detected} (${sess.ai_detection_rate}%)`],
['Score total', String(sess.total_score)],
['Score moyen/série', String(sess.avg_score)],
['Meilleure série', String(sess.best_series_score)],
],
theme: 'grid', styles: { fontSize: 10, cellPadding: 3 },
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { textColor: [220, 38, 38], fontStyle: 'bold' } },
})
// Tableau des séries
y = (doc as any).lastAutoTable.finalY + 10
if (y > 240) { doc.addPage(); y = 20 }
doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Détail des séries', 14, y)
if (seriesList.length > 0) {
;(doc as any).autoTable({
startY: y + 4,
head: [['Série', 'Déclarés', 'Détectés', 'Manuels', 'Zones', 'Groupement', 'Total', 'Dispersion']],
body: seriesList.map((s: any) => [
s.series_number, s.shots_declared, s.shots_detected, s.shots_manual || '—',
s.score_zone, s.score_groupement, s.score_total,
s.dispersion_radius ? Number(s.dispersion_radius).toFixed(1) : '—',
]),
theme: 'striped', styles: { fontSize: 9, cellPadding: 2.5 },
headStyles: { fillColor: [220, 38, 38], textColor: [255, 255, 255], fontStyle: 'bold' },
})
}
// Notes
if (sess.notes_start || sess.notes_end) {
y = (doc as any).lastAutoTable.finalY + 10
if (y > 240) { doc.addPage(); y = 20 }
doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Notes', 14, y); y += 6
doc.setFontSize(10); doc.setFont('helvetica', 'normal')
if (sess.notes_start) {
doc.setFont('helvetica', 'bold'); doc.text('Début :', 14, y); y += 5
doc.setFont('helvetica', 'normal')
const lines = doc.splitTextToSize(sess.notes_start, 180)
doc.text(lines, 14, y); y += lines.length * 5 + 3
}
if (sess.notes_end) {
doc.setFont('helvetica', 'bold'); doc.text('Fin :', 14, y); y += 5
doc.setFont('helvetica', 'normal')
const lines = doc.splitTextToSize(sess.notes_end, 180)
doc.text(lines, 14, y)
}
}
// Footer
const pc = doc.getNumberOfPages()
for (let i = 1; i <= pc; i++) {
doc.setPage(i); doc.setFontSize(8); doc.setTextColor(150)
doc.text(`ShootTracker — Page ${i}/${pc}`, 105, 290, { align: 'center' })
}
const filename = `shoottracker_${format(new Date(sess.session_date), 'yyyy-MM-dd')}.pdf`
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.send(Buffer.from(doc.output('arraybuffer')))
})
// ─── Export Excel progression ─────────────────────────────────────────────────
exportRouter.get('/excel', (req: AuthRequest, res) => {
const { period, weaponId } = req.query as { period?: string; weaponId?: string }
let query = db.select().from(training_sessions)
.$dynamic()
const filters = [eq(training_sessions.user_id, req.userId!), eq(training_sessions.is_completed, true)]
const now = new Date()
if (period === '30j') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 30*86400000).toISOString()))
if (period === '90j') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 90*86400000).toISOString()))
if (period === '1an') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 365*86400000).toISOString()))
const sessions = db.select().from(training_sessions)
.where(and(...filters)).all()
const weaponMap: Record<string, any> = {}
sessions.forEach(s => {
if (s.weapon_id && !weaponMap[s.weapon_id]) {
const w = db.select().from(weapons).where(eq(weapons.id, s.weapon_id)).get()
if (w) weaponMap[w.id] = w
}
})
const rows = sessions
.filter(s => !weaponId || weaponId === 'all' || s.weapon_id === weaponId)
.map(s => ({
'Date': format(new Date(s.session_date), 'dd/MM/yyyy HH:mm'),
'Lieu': s.location || '',
'Arme': weaponMap[s.weapon_id || '']?.name || '',
'Distance (m)': s.distance_m || '',
'Type cible': s.target_type,
'Tirs déclarés': s.total_shots_declared,
'Tirs détectés IA': s.total_shots_detected,
'Taux détection (%)': s.ai_detection_rate,
'Score total': s.total_score,
'Score moyen/série': s.avg_score,
'Meilleure série': s.best_series_score,
'Dispersion moy.': s.avg_dispersion || '',
}))
const ws = XLSX.utils.json_to_sheet(rows)
ws['!cols'] = Array(12).fill({ wch: 18 })
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Séances')
const filename = `shoottracker_${format(new Date(), 'yyyy-MM-dd')}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.send(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }))
})
+88
View File
@@ -0,0 +1,88 @@
import { Router } from 'express'
import { eq, and, desc } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import crypto from 'crypto'
import path from 'path'
import fs from 'fs'
import multer from 'multer'
import { db } from '../db'
import { invitations, user_locations, users } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
export const miscRouter = Router()
// ─── Lieux mémorisés ──────────────────────────────────────────────────────────
miscRouter.get('/locations', requireAuth, (req: AuthRequest, res) => {
const rows = db.select().from(user_locations)
.where(eq(user_locations.user_id, req.userId!))
.orderBy(desc(user_locations.last_used_at))
.all()
return res.json(rows)
})
// ─── Invitations ──────────────────────────────────────────────────────────────
miscRouter.get('/invitations', requireAuth, (req: AuthRequest, res) => {
const rows = db.select().from(invitations)
.where(eq(invitations.inviter_id, req.userId!))
.orderBy(desc(invitations.created_at))
.all()
return res.json(rows)
})
miscRouter.post('/invitations', requireAuth, (req: AuthRequest, res) => {
const active = db.select().from(invitations)
.where(and(eq(invitations.inviter_id, req.userId!), eq(invitations.is_used, false)))
.all()
if (active.length >= 5) {
return res.status(400).json({ error: 'Maximum 5 invitations actives' })
}
const token = crypto.randomBytes(32).toString('hex')
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
const id = uuid()
db.insert(invitations).values({
id, inviter_id: req.userId!, token,
email: req.body.email || null,
expires_at: expires,
}).run()
const inv = db.select().from(invitations).where(eq(invitations.id, id)).get()
return res.status(201).json(inv)
})
miscRouter.delete('/invitations/:id', requireAuth, (req: AuthRequest, res) => {
db.delete(invitations)
.where(and(eq(invitations.id, req.params.id), eq(invitations.inviter_id, req.userId!)))
.run()
return res.json({ message: 'Invitation supprimée' })
})
// Vérification publique d'un token d'invitation
miscRouter.get('/invitations/validate/:token', (req, res) => {
const inv = db.select().from(invitations)
.where(eq(invitations.token, req.params.token)).get()
if (!inv || inv.is_used || new Date(inv.expires_at) < new Date()) {
return res.json({ valid: false, reason: inv?.is_used ? 'used' : 'invalid' })
}
return res.json({ valid: true, expires_at: inv.expires_at })
})
// ─── Upload avatar ────────────────────────────────────────────────────────────
const avatarStorage = multer.diskStorage({
destination: (req: any, _file, cb) => {
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'avatar')
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname)
cb(null, `avatar_${Date.now()}${ext}`)
},
})
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 } })
miscRouter.post('/auth/avatar', requireAuth, avatarUpload.single('avatar'), (req: AuthRequest, res) => {
if (!req.file) return res.status(400).json({ error: 'Fichier manquant' })
const relPath = `/uploads/${req.userId}/avatar/${req.file.filename}`
db.update(users).set({ avatar_url: relPath, updated_at: new Date().toISOString() })
.where(eq(users.id, req.userId!)).run()
return res.json({ avatar_url: relPath })
})
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import { eq, and } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import path from 'path'
import fs from 'fs'
import multer from 'multer'
import { db } from '../db'
import { series, impacts, training_sessions } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
export const seriesRouter = Router()
seriesRouter.use(requireAuth)
// ─── Config multer (photos cibles) ───────────────────────────────────────────
const storage = multer.diskStorage({
destination: (req: any, _file, cb) => {
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'targets')
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg'
cb(null, `target_${Date.now()}${ext}`)
},
})
const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } })
// ─── GET /api/sessions/:sessionId/series ─────────────────────────────────────
seriesRouter.get('/sessions/:sessionId/series', (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.sessionId), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
const rows = db.select().from(series)
.where(eq(series.session_id, req.params.sessionId))
.all()
return res.json(rows.map(s => ({ ...s, ai_data: safeJson(s.ai_data) })))
})
// ─── POST /api/sessions/:sessionId/series ────────────────────────────────────
seriesRouter.post('/sessions/:sessionId/series', upload.fields([
{ name: 'photo', maxCount: 1 },
{ name: 'annotated', maxCount: 1 },
]), async (req: AuthRequest, res) => {
const {
series_number, shots_declared, shots_detected, shots_manual,
score_zone, score_groupement, score_total,
dispersion_radius, center_x, center_y,
ai_data, impacts: impactsJson,
} = req.body
const id = uuid()
const files = req.files as Record<string, Express.Multer.File[]>
let photo_url: string | null = null
let annotated_url: string | null = null
if (files?.photo?.[0]) {
photo_url = `/uploads/${req.userId}/targets/${files.photo[0].filename}`
}
if (files?.annotated?.[0]) {
annotated_url = `/uploads/${req.userId}/targets/${files.annotated[0].filename}`
}
db.insert(series).values({
id,
session_id: req.params.sessionId,
user_id: req.userId!,
series_number: parseInt(series_number) || 1,
shots_declared: parseInt(shots_declared) || 0,
shots_detected: parseInt(shots_detected) || 0,
shots_manual: parseInt(shots_manual) || 0,
photo_url,
annotated_photo_url: annotated_url,
score_zone: parseInt(score_zone) || 0,
score_groupement: parseInt(score_groupement) || 0,
score_total: parseInt(score_total) || 0,
dispersion_radius: dispersion_radius ? parseFloat(dispersion_radius) : null,
center_x: center_x ? parseFloat(center_x) : null,
center_y: center_y ? parseFloat(center_y) : null,
ai_data: ai_data || null,
}).run()
// Insérer les impacts
if (impactsJson) {
try {
const impList = JSON.parse(impactsJson)
for (const imp of impList) {
db.insert(impacts).values({
id: uuid(),
series_id: id,
user_id: req.userId!,
x: imp.x,
y: imp.y,
zone: imp.zone ?? null,
points: imp.points ?? 0,
is_manual: imp.is_manual ?? false,
}).run()
}
} catch { /* ignore */ }
}
const created = db.select().from(series).where(eq(series.id, id)).get()
return res.status(201).json({ ...created, ai_data: safeJson(created?.ai_data) })
})
// ─── POST /api/series/annotated (upload base64 annotée) ──────────────────────
seriesRouter.post('/series/:id/annotated', (req: AuthRequest, res) => {
const { image_base64 } = req.body
if (!image_base64) return res.status(400).json({ error: 'Image manquante' })
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'targets')
fs.mkdirSync(dir, { recursive: true })
const filename = `annotated_${Date.now()}.jpg`
const b64Data = image_base64.replace(/^data:image\/\w+;base64,/, '')
const buffer = Buffer.from(b64Data, 'base64')
const filepath = path.join(dir, filename)
fs.writeFileSync(filepath, buffer)
const annotated_url = `/uploads/${req.userId}/targets/${filename}`
db.update(series).set({ annotated_photo_url: annotated_url })
.where(and(eq(series.id, req.params.id), eq(series.user_id, req.userId!)))
.run()
return res.json({ annotated_photo_url: annotated_url })
})
function safeJson(str: string | null, fallback: any = null) {
if (!str) return fallback
try { return JSON.parse(str) } catch { return fallback }
}
+132
View File
@@ -0,0 +1,132 @@
import { Router } from 'express'
import { eq, and, desc } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import { db } from '../db'
import { training_sessions, weapons, series, impacts, user_locations } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
export const sessionsRouter = Router()
sessionsRouter.use(requireAuth)
// ─── GET /api/sessions ────────────────────────────────────────────────────────
sessionsRouter.get('/', (req: AuthRequest, res) => {
const rows = db.select().from(training_sessions)
.where(eq(training_sessions.user_id, req.userId!))
.orderBy(desc(training_sessions.session_date))
.all()
// Join weapon
const weaponIds = [...new Set(rows.map(r => r.weapon_id).filter(Boolean))]
const weaponMap: Record<string, any> = {}
if (weaponIds.length > 0) {
weaponIds.forEach(wid => {
const w = db.select().from(weapons).where(eq(weapons.id, wid!)).get()
if (w) weaponMap[w.id] = w
})
}
return res.json(rows.map(r => ({
...r,
weapon: r.weapon_id ? weaponMap[r.weapon_id] || null : null,
})))
})
// ─── POST /api/sessions ───────────────────────────────────────────────────────
sessionsRouter.post('/', (req: AuthRequest, res) => {
const { weapon_id, session_date, location, target_type, distance_m, notes_start } = req.body
if (!target_type) return res.status(400).json({ error: 'Type de cible requis' })
const id = uuid()
db.insert(training_sessions).values({
id, user_id: req.userId!,
weapon_id: weapon_id || null,
session_date: session_date || new Date().toISOString(),
location: location || null,
target_type,
distance_m: distance_m ? parseInt(distance_m) : null,
notes_start: notes_start || null,
is_completed: false,
}).run()
// Mémoriser le lieu
if (location?.trim()) {
const existing = db.select().from(user_locations)
.where(and(eq(user_locations.user_id, req.userId!), eq(user_locations.name, location.trim()))).get()
if (existing) {
db.update(user_locations).set({
used_count: (existing.used_count || 0) + 1,
last_used_at: new Date().toISOString(),
}).where(eq(user_locations.id, existing.id)).run()
} else {
db.insert(user_locations).values({
id: uuid(), user_id: req.userId!, name: location.trim(),
}).run()
}
}
const sess = db.select().from(training_sessions).where(eq(training_sessions.id, id)).get()
return res.status(201).json(sess)
})
// ─── GET /api/sessions/:id ────────────────────────────────────────────────────
sessionsRouter.get('/:id', (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.id), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
const weapon = sess.weapon_id
? db.select().from(weapons).where(eq(weapons.id, sess.weapon_id)).get()
: null
const seriesList = db.select().from(series)
.where(eq(series.session_id, sess.id)).all()
const seriesIds = seriesList.map(s => s.id)
let allImpacts: any[] = []
if (seriesIds.length > 0) {
seriesIds.forEach(sid => {
const imp = db.select().from(impacts).where(eq(impacts.series_id, sid)).all()
allImpacts = allImpacts.concat(imp)
})
}
return res.json({
...sess,
weapon,
series: seriesList.map(s => ({
...s,
ai_data: safeJson(s.ai_data),
impacts: allImpacts.filter(i => i.series_id === s.id),
})),
})
})
// ─── PUT /api/sessions/:id ────────────────────────────────────────────────────
sessionsRouter.put('/:id', (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.id), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
db.update(training_sessions).set({
...req.body,
updated_at: new Date().toISOString(),
}).where(eq(training_sessions.id, req.params.id)).run()
const updated = db.select().from(training_sessions).where(eq(training_sessions.id, req.params.id)).get()
return res.json(updated)
})
// ─── DELETE /api/sessions/:id ─────────────────────────────────────────────────
sessionsRouter.delete('/:id', (req: AuthRequest, res) => {
db.delete(training_sessions)
.where(and(eq(training_sessions.id, req.params.id), eq(training_sessions.user_id, req.userId!)))
.run()
return res.json({ message: 'Séance supprimée' })
})
function safeJson(str: string | null, fallback: any = null) {
if (!str) return fallback
try { return JSON.parse(str) } catch { return fallback }
}
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import { eq, and } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import { db } from '../db'
import { weapons } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
export const weaponsRouter = Router()
weaponsRouter.use(requireAuth)
// ─── Config multer (photos armes) ────────────────────────────────────────────
const storage = multer.diskStorage({
destination: (req: any, _file, cb) => {
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'weapons')
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg'
cb(null, `weapon_${Date.now()}${ext}`)
},
})
const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } })
// ─── GET /api/weapons ─────────────────────────────────────────────────────────
weaponsRouter.get('/', (req: AuthRequest, res) => {
const rows = db.select().from(weapons)
.where(eq(weapons.user_id, req.userId!))
.all()
return res.json(rows)
})
// ─── GET /api/weapons/:id ─────────────────────────────────────────────────────
weaponsRouter.get('/:id', (req: AuthRequest, res) => {
const w = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!)))
.get()
if (!w) return res.status(404).json({ error: 'Arme introuvable' })
return res.json(w)
})
// ─── POST /api/weapons (multipart form) ───────────────────────────────────────
weaponsRouter.post('/', upload.single('photo'), (req: AuthRequest, res) => {
const { name, nickname, type, caliber, brand, model, serial_number, notes } = req.body
if (!name || !type) return res.status(400).json({ error: 'Nom et type requis' })
const id = uuid()
let photo_url: string | null = null
if (req.file) {
photo_url = `/uploads/${req.userId}/weapons/${req.file.filename}`
}
db.insert(weapons).values({
id, user_id: req.userId!, name, nickname: nickname || null, type,
caliber: caliber || null, brand: brand || null, model: model || null,
serial_number: serial_number || null, notes: notes || null,
photo_url, is_archived: false,
}).run()
const w = db.select().from(weapons).where(eq(weapons.id, id)).get()
return res.status(201).json(w)
})
// ─── PUT /api/weapons/:id (multipart form) ────────────────────────────────────
weaponsRouter.put('/:id', upload.single('photo'), (req: AuthRequest, res) => {
const existing = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!))).get()
if (!existing) return res.status(404).json({ error: 'Arme introuvable' })
const { name, nickname, type, caliber, brand, model, serial_number, notes } = req.body
let photo_url = existing.photo_url
if (req.file) {
// Supprimer l'ancienne photo
if (existing.photo_url) {
const old = path.join(process.env.UPLOADS_DIR || './data/uploads',
existing.photo_url.replace(`/uploads/${req.userId}/`, `${req.userId}/`))
if (fs.existsSync(old)) fs.unlinkSync(old)
}
photo_url = `/uploads/${req.userId}/weapons/${req.file.filename}`
}
db.update(weapons).set({
name: name || existing.name,
nickname: nickname ?? existing.nickname,
type: type || existing.type,
caliber: caliber ?? existing.caliber,
brand: brand ?? existing.brand,
model: model ?? existing.model,
serial_number: serial_number ?? existing.serial_number,
notes: notes ?? existing.notes,
photo_url,
updated_at: new Date().toISOString(),
}).where(eq(weapons.id, req.params.id)).run()
const updated = db.select().from(weapons).where(eq(weapons.id, req.params.id)).get()
return res.json(updated)
})
// ─── PATCH /api/weapons/:id/archive ──────────────────────────────────────────
weaponsRouter.patch('/:id/archive', (req: AuthRequest, res) => {
const existing = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!))).get()
if (!existing) return res.status(404).json({ error: 'Arme introuvable' })
db.update(weapons).set({
is_archived: req.body.is_archived ?? !existing.is_archived,
updated_at: new Date().toISOString(),
}).where(eq(weapons.id, req.params.id)).run()
const updated = db.select().from(weapons).where(eq(weapons.id, req.params.id)).get()
return res.json(updated)
})
// ─── DELETE /api/weapons/:id ──────────────────────────────────────────────────
weaponsRouter.delete('/:id', (req: AuthRequest, res) => {
const existing = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!))).get()
if (existing?.photo_url) {
const old = path.join(process.env.UPLOADS_DIR || './data/uploads',
existing.photo_url.replace(`/uploads/${req.userId}/`, `${req.userId}/`))
if (fs.existsSync(old)) fs.unlinkSync(old)
}
db.delete(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!)))
.run()
return res.json({ message: 'Arme supprimée' })
})
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
deploy_shoottracker.py
──────────────────────
Déploie ShootTracker sur domench.fr via Gitea + Coolify.
Architecture : Docker Compose — un seul dépôt contient
• l'app principale (frontend React + backend Node/Express + SQLite)
• le microservice IA (FastAPI + YOLOv8)
Usage :
python deploy_shoottracker.py
Prérequis (variables d'environnement ou saisie interactive) :
GITEA_URL ex. https://git.domench.fr
GITEA_USER ex. gael
GITEA_TOKEN token API Gitea avec droits repo
COOLIFY_URL ex. https://coolify.domench.fr
COOLIFY_TOKEN token API Coolify (Settings → API)
"""
import os
import sys
import secrets
import subprocess
import json
import time
import textwrap
import urllib.request
import urllib.parse
import urllib.error
# ─── Couleurs terminal ────────────────────────────────────────────────────────
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
CYAN = "\033[96m"
BOLD = "\033[1m"
RESET = "\033[0m"
def ok(msg): print(f"{GREEN}{msg}{RESET}")
def warn(msg): print(f"{YELLOW}{msg}{RESET}")
def err(msg): print(f"{RED}{msg}{RESET}")
def info(msg): print(f"{CYAN}{msg}{RESET}")
def step(n, msg): print(f"\n{BOLD}── Étape {n} : {msg} ──{RESET}")
# ─── HTTP helpers ─────────────────────────────────────────────────────────────
def api_call(url, method="GET", data=None, headers=None, token=None, base_url=None):
"""Appel HTTP JSON simple (sans dépendance externe)."""
full_url = url if url.startswith("http") else f"{base_url}{url}"
body = json.dumps(data).encode() if data is not None else None
h = {"Content-Type": "application/json", "Accept": "application/json"}
if token:
h["Authorization"] = f"Bearer {token}"
if headers:
h.update(headers)
req = urllib.request.Request(full_url, data=body, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
body_text = e.read().decode(errors="replace")
raise RuntimeError(f"HTTP {e.code} {e.reason}{body_text}") from e
# ─── Configuration ────────────────────────────────────────────────────────────
def get_config():
def ask(env, prompt, secret=False):
val = os.environ.get(env, "").strip()
if val:
return val
if secret:
import getpass
return getpass.getpass(f" {prompt} : ").strip()
return input(f" {prompt} : ").strip()
print(f"\n{BOLD}=== Déploiement ShootTracker ==={RESET}")
print("Renseignez les paramètres (ou définissez les variables d'environnement) :\n")
cfg = {
"gitea_url": ask("GITEA_URL", "Gitea URL (ex: https://git.domench.fr)").rstrip("/"),
"gitea_user": ask("GITEA_USER", "Gitea username"),
"gitea_token": ask("GITEA_TOKEN", "Gitea API token", secret=True),
"coolify_url": ask("COOLIFY_URL", "Coolify URL (ex: https://coolify.domench.fr)").rstrip("/"),
"coolify_token": ask("COOLIFY_TOKEN","Coolify API token", secret=True),
}
# Paramètres optionnels avec valeurs par défaut
cfg["repo_name"] = os.environ.get("REPO_NAME", "shoottracker")
cfg["app_domain"] = os.environ.get("APP_DOMAIN", "shoottracker.domench.fr")
# JWT_SECRET généré automatiquement (64 octets hex = 128 chars)
cfg["jwt_secret"] = secrets.token_hex(64)
return cfg
# ─── Étape 1 : Créer le dépôt Gitea ─────────────────────────────────────────
def create_gitea_repo(cfg):
step(1, "Création du dépôt Gitea")
base = cfg["gitea_url"]
tok = cfg["gitea_token"]
user = cfg["gitea_user"]
name = cfg["repo_name"]
# Vérifier si le dépôt existe déjà
try:
api_call(f"{base}/api/v1/repos/{user}/{name}", token=tok)
warn(f"Le dépôt {user}/{name} existe déjà → on continue")
return f"{base}/{user}/{name}.git"
except RuntimeError:
pass # N'existe pas encore
resp = api_call(
f"{base}/api/v1/user/repos",
method="POST",
data={
"name": name,
"description": "ShootTracker — application de suivi des performances de tir sportif",
"private": True,
"auto_init": False,
},
token=tok,
)
ok(f"Dépôt créé : {resp['html_url']}")
return resp["clone_url"]
# ─── Étape 2 : Push du code vers Gitea ───────────────────────────────────────
def push_code(cfg, clone_url):
step(2, "Push du code vers Gitea")
script_dir = os.path.dirname(os.path.abspath(__file__))
os.chdir(script_dir)
# Construire l'URL authentifiée
from urllib.parse import urlparse, urlunparse
parsed = urlparse(clone_url)
auth_url = urlunparse(parsed._replace(
netloc=f"{cfg['gitea_user']}:{cfg['gitea_token']}@{parsed.netloc}"
))
def run(cmd, **kw):
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)
if result.returncode != 0:
raise RuntimeError(f"Commande échouée : {cmd}\n{result.stderr}")
return result.stdout.strip()
# Init git si nécessaire
if not os.path.isdir(".git"):
run("git init")
run("git checkout -b main")
ok("Dépôt git initialisé")
# .gitignore
gitignore = textwrap.dedent("""\
node_modules/
dist/
.env
data/
*.db
__pycache__/
.pytest_cache/
*.pyc
.DS_Store
""")
with open(".gitignore", "w") as f:
f.write(gitignore)
# Remote
try:
run("git remote remove origin")
except RuntimeError:
pass
run(f"git remote add origin {auth_url}")
run("git add -A")
try:
run('git commit -m "feat: ShootTracker — SQLite + JWT + YOLOv8 AI"')
except RuntimeError:
warn("Rien à committer (arbre propre)")
run("git push -u origin main --force")
ok("Code pushé sur Gitea")
# ─── Étape 3 : Créer l'application Coolify ───────────────────────────────────
def create_coolify_app(cfg, clone_url):
step(3, "Création de l'application Coolify")
base = cfg["coolify_url"]
tok = cfg["coolify_token"]
# Récupérer le premier serveur disponible
servers = api_call(f"{base}/api/v1/servers", token=tok)
if not servers:
raise RuntimeError("Aucun serveur trouvé dans Coolify")
server_uuid = servers[0]["uuid"]
info(f"Serveur Coolify : {servers[0].get('name', server_uuid)}")
# Variables d'environnement de l'application
env_vars = "\n".join([
f"NODE_ENV=production",
f"PORT=3001",
f"JWT_SECRET={cfg['jwt_secret']}",
f"AI_SERVICE_URL=http://shoottracker-ai:8000",
f"CORS_ORIGIN=https://{cfg['app_domain']}",
f"UPLOADS_DIR=/app/data/uploads",
])
payload = {
"name": "shoottracker",
"description": "ShootTracker — tir sportif IA",
"server_uuid": server_uuid,
"git_repository": clone_url,
"git_branch": "main",
"build_pack": "dockercompose",
"docker_compose_location": "/docker-compose.yml",
"fqdn": f"https://{cfg['app_domain']}",
"ports_exposes": "3001",
"environment_variables": env_vars,
"instant_deploy": False,
}
# Vérifier si l'app existe déjà
try:
apps = api_call(f"{base}/api/v1/applications", token=tok)
existing = next((a for a in apps if a.get("name") == "shoottracker"), None)
if existing:
app_uuid = existing["uuid"]
warn(f"Application 'shoottracker' existe déjà (uuid={app_uuid}) → mise à jour des variables")
# Mettre à jour les env vars
api_call(
f"{base}/api/v1/applications/{app_uuid}",
method="PATCH",
data={"environment_variables": env_vars},
token=tok,
)
ok("Variables d'environnement mises à jour")
return app_uuid
except Exception:
pass
resp = api_call(
f"{base}/api/v1/applications",
method="POST",
data=payload,
token=tok,
)
app_uuid = resp.get("uuid") or resp.get("id")
ok(f"Application créée (uuid={app_uuid})")
return app_uuid
# ─── Étape 4 : Déployer ───────────────────────────────────────────────────────
def trigger_deploy(cfg, app_uuid):
step(4, "Déclenchement du déploiement")
base = cfg["coolify_url"]
tok = cfg["coolify_token"]
try:
resp = api_call(
f"{base}/api/v1/applications/{app_uuid}/deploy",
method="POST",
token=tok,
)
deploy_id = resp.get("deployment_uuid") or resp.get("id", "")
ok(f"Déploiement déclenché (id={deploy_id})")
info(f"Suivez l'avancement sur : {base}/project/shoottracker/deployments")
except RuntimeError as e:
warn(f"Impossible de déclencher via API : {e}")
info("Déclenchez manuellement depuis l'interface Coolify")
# ─── Résumé final ─────────────────────────────────────────────────────────────
def print_summary(cfg):
box = "" * 60
print(f"\n{GREEN}{BOLD}{box}")
print(" ✓ ShootTracker déployé avec succès !")
print(box + RESET)
print(f"""
🌐 App : https://{cfg['app_domain']}
📦 Gitea : {cfg['gitea_url']}/{cfg['gitea_user']}/{cfg['repo_name']}
🚀 Coolify : {cfg['coolify_url']}
{YELLOW}⚠ Conservez ce JWT_SECRET en lieu sûr (non récupérable) :{RESET}
{BOLD}{cfg['jwt_secret']}{RESET}
Données persistées dans le volume Docker : shoottracker_data
(SQLite DB + photos cibles + avatars)
""")
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
try:
cfg = get_config()
clone_url = create_gitea_repo(cfg)
push_code(cfg, clone_url)
app_uuid = create_coolify_app(cfg, clone_url)
trigger_deploy(cfg, app_uuid)
print_summary(cfg)
except KeyboardInterrupt:
print(f"\n{YELLOW}Annulé.{RESET}")
sys.exit(1)
except RuntimeError as e:
err(str(e))
sys.exit(1)
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
version: '3.8'
# ─── Développement local ──────────────────────────────────────────────────────
# Usage : docker-compose up --build
#
# Nécessite un fichier .env à la racine avec :
# JWT_SECRET=<clé secrète longue et aléatoire>
# CORS_ORIGIN=http://localhost:5173 (optionnel)
services:
# ─── App principale (frontend + backend) ────────────────────────────────────
shoottracker-app:
build:
context: .
dockerfile: Dockerfile
container_name: shoottracker-app
restart: unless-stopped
ports:
- "3001:3001"
environment:
NODE_ENV: production
PORT: 3001
JWT_SECRET: ${JWT_SECRET:-changeme_use_a_real_secret_in_production}
AI_SERVICE_URL: http://shoottracker-ai:8000
CORS_ORIGIN: ${CORS_ORIGIN:-*}
UPLOADS_DIR: /app/data/uploads
volumes:
# SQLite DB + uploads — persistant entre les redémarrages
- shoottracker_data:/app/data
depends_on:
shoottracker-ai:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
# ─── Microservice IA ─────────────────────────────────────────────────────────
shoottracker-ai:
build:
context: ./ai-service
dockerfile: Dockerfile
container_name: shoottracker-ai
restart: unless-stopped
ports:
- "8000:8000"
environment:
PORT: 8000
YOLO_MODEL_PATH: yolov8n.pt
volumes:
# Cache du modèle YOLOv8 pour éviter re-téléchargement
- yolo_models:/root/.config/Ultralytics
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s
timeout: 15s
retries: 3
start_period: 60s
volumes:
shoottracker_data:
driver: local
yolo_models:
driver: local
+3
View File
@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
VITE_AI_SERVICE_URL=https://shoottracker-ai.domench.fr
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d0d0d" />
<meta name="description" content="Suivi de performance en tir sportif" />
<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="ShootTracker" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>ShootTracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "shoottracker-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx"
},
"dependencies": {
"date-fns": "^3.6.0",
"lucide-react": "^0.395.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1",
"vite-plugin-pwa": "^0.20.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { api, getToken, clearToken } from './lib/api'
import { useStore } from './lib/store'
import Layout from './components/Layout'
import Login from './pages/auth/Login'
import Register from './pages/auth/Register'
import Dashboard from './pages/Dashboard'
import Arsenal from './pages/Arsenal'
import WeaponForm from './pages/WeaponForm'
import NewSession from './pages/session/NewSession'
import SessionCapture from './pages/session/SessionCapture'
import SessionDetail from './pages/session/SessionDetail'
import Progress from './pages/Progress'
import Profile from './pages/Profile'
import InvitationAccept from './pages/InvitationAccept'
import LoadingScreen from './components/LoadingScreen'
export default function App() {
const [authReady, setAuthReady] = useState(false)
const [isAuth, setIsAuth] = useState(false)
const { fetchProfile, fetchWeapons, fetchSessions, reset } = useStore()
useEffect(() => {
const token = getToken()
if (!token) { setAuthReady(true); return }
api.auth.me()
.then(() => {
setIsAuth(true)
fetchProfile()
fetchWeapons()
fetchSessions()
})
.catch(() => {
clearToken()
setIsAuth(false)
})
.finally(() => setAuthReady(true))
}, [])
function handleLogin() {
setIsAuth(true)
fetchProfile()
fetchWeapons()
fetchSessions()
}
function handleLogout() {
clearToken()
reset()
setIsAuth(false)
}
if (!authReady) return <LoadingScreen />
return (
<Routes>
{/* Pages publiques */}
<Route path="/login" element={!isAuth ? <Login onLogin={handleLogin} /> : <Navigate to="/" />} />
<Route path="/register" element={!isAuth ? <Register /> : <Navigate to="/" />} />
<Route path="/invitation/:token" element={<InvitationAccept />} />
{/* Pages protégées */}
<Route element={isAuth ? <Layout onLogout={handleLogout} /> : <Navigate to="/login" />}>
<Route path="/" element={<Dashboard />} />
<Route path="/arsenal" element={<Arsenal />} />
<Route path="/arsenal/new" element={<WeaponForm />} />
<Route path="/arsenal/:id/edit" element={<WeaponForm />} />
<Route path="/session/new" element={<NewSession />} />
<Route path="/session/:id/capture" element={<SessionCapture />} />
<Route path="/session/:id" element={<SessionDetail />} />
<Route path="/progress" element={<Progress />} />
<Route path="/profile" element={<Profile onLogout={handleLogout} />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { NavLink } from 'react-router-dom'
import { Home, Crosshair, Shield, TrendingUp, User } from 'lucide-react'
const navItems = [
{ to: '/', icon: Home, label: 'Accueil' },
{ to: '/session/new', icon: Crosshair, label: 'Séance' },
{ to: '/arsenal', icon: Shield, label: 'Arsenal' },
{ to: '/progress', icon: TrendingUp, label: 'Progression'},
{ to: '/profile', icon: User, label: 'Profil' },
]
export default function BottomNav() {
return (
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-surface border-t border-border safe-bottom z-50">
<div className="flex items-stretch h-16">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors
${isActive ? 'text-accent' : 'text-muted hover:text-text-muted'}`
}
>
{({ isActive }) => (
<>
<Icon size={20} className={isActive ? 'text-accent' : ''} />
<span>{label}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import BottomNav from './BottomNav'
interface LayoutProps {
onLogout: () => void
}
export default function Layout({ onLogout }: LayoutProps) {
return (
<div className="flex h-screen bg-bg overflow-hidden">
{/* Sidebar — desktop */}
<Sidebar onLogout={onLogout} className="hidden lg:flex w-64 shrink-0" />
{/* Contenu principal */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 py-6 pb-24 lg:pb-6">
<Outlet />
</div>
</main>
{/* Bottom nav — mobile */}
<BottomNav className="lg:hidden" />
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export default function LoadingScreen() {
return (
<div className="fixed inset-0 bg-bg flex flex-col items-center justify-center gap-4">
<div className="relative w-16 h-16">
<div className="absolute inset-0 rounded-full border-4 border-surface2" />
<div className="absolute inset-0 rounded-full border-4 border-t-accent animate-spin" />
</div>
<div className="text-text-muted text-sm font-medium tracking-widest uppercase">ShootTracker</div>
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { NavLink } from 'react-router-dom'
import { Home, Crosshair, Shield, TrendingUp, User, LogOut, Target } from 'lucide-react'
import { useStore } from '../lib/store'
const navItems = [
{ to: '/', icon: Home, label: 'Tableau de bord' },
{ to: '/session/new', icon: Crosshair, label: 'Nouvelle séance' },
{ to: '/arsenal', icon: Shield, label: 'Arsenal' },
{ to: '/progress', icon: TrendingUp, label: 'Progression' },
{ to: '/profile', icon: User, label: 'Profil' },
]
interface SidebarProps {
onLogout: () => void
className?: string
}
export default function Sidebar({ onLogout, className = '' }: SidebarProps) {
const profile = useStore(s => s.profile)
return (
<aside className={`bg-surface border-r border-border flex-col z-40 ${className}`}>
{/* Logo */}
<div className="p-6 flex items-center gap-3 border-b border-border">
<div className="w-9 h-9 rounded-lg bg-accent flex items-center justify-center">
<Target size={20} className="text-white" />
</div>
<div>
<div className="font-bold text-text text-lg leading-tight">ShootTracker</div>
<div className="text-xs text-muted">Tir sportif</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all
${isActive
? 'bg-accent/10 text-accent border border-accent/20'
: 'text-text-muted hover:bg-surface2 hover:text-text'}`
}
>
{({ isActive }) => (
<>
<Icon size={18} className={isActive ? 'text-accent' : ''} />
{label}
</>
)}
</NavLink>
))}
</nav>
{/* Profil + déconnexion */}
<div className="p-4 border-t border-border">
<div className="flex items-center gap-3 mb-3 px-2">
<div className="w-8 h-8 rounded-full bg-surface2 border border-border flex items-center justify-center text-xs font-bold text-accent overflow-hidden">
{profile?.avatar_url
? <img src={profile.avatar_url} alt="" className="w-full h-full object-cover" />
: profile?.first_name?.[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text truncate">
{profile?.first_name} {profile?.last_name}
</div>
<div className="text-xs text-muted truncate">{profile?.club || 'Pas de club'}</div>
</div>
</div>
<button
onClick={onLogout}
className="btn-ghost w-full flex items-center gap-2 text-sm justify-start"
>
<LogOut size={16} /> Déconnexion
</button>
</div>
</aside>
)
}
+60
View File
@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
background-color: #0d0d0d;
color: #f4f4f5;
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar personnalisée */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: #1a1a1a; }
::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 2px; }
/* Safe area iOS */
.safe-bottom { padding-bottom: env(safe-area-inset-bottom); }
.safe-top { padding-top: env(safe-area-inset-top); }
}
@layer components {
.btn-primary {
@apply bg-accent hover:bg-accent-hover text-white font-semibold px-4 py-2.5 rounded-lg
transition-all duration-150 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-surface2 hover:bg-border text-text font-medium px-4 py-2.5 rounded-lg
border border-border transition-all duration-150 active:scale-95;
}
.btn-ghost {
@apply hover:bg-surface2 text-text-muted hover:text-text font-medium px-3 py-2 rounded-lg
transition-all duration-150;
}
.card {
@apply bg-surface border border-border rounded-xl p-4;
}
.input {
@apply bg-surface2 border border-border rounded-lg px-3 py-2.5 text-text placeholder-muted
focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent
transition-colors w-full;
}
.label {
@apply block text-sm font-medium text-text-muted mb-1;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.divider {
@apply border-t border-border my-4;
}
.page-header {
@apply text-xl font-bold text-text mb-6;
}
}
+146
View File
@@ -0,0 +1,146 @@
// ─── HTTP client JWT ──────────────────────────────────────────────────────────
const BASE = import.meta.env.VITE_API_URL || ''
export function getToken(): string | null {
return localStorage.getItem('st-token')
}
export function setToken(token: string) {
localStorage.setItem('st-token', token)
}
export function clearToken() {
localStorage.removeItem('st-token')
}
async function req<T = any>(
method: string,
path: string,
body?: Record<string, any> | FormData,
): Promise<T> {
const headers: Record<string, string> = {}
const token = getToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const isFormData = body instanceof FormData
if (body && !isFormData) headers['Content-Type'] = 'application/json'
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? (isFormData ? body : JSON.stringify(body)) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err?.error || `HTTP ${res.status}`)
}
return res.json()
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
export const api = {
auth: {
login: (email: string, password: string) =>
req('POST', '/api/auth/login', { email, password }),
register: (data: {
email: string; password: string
first_name: string; last_name: string
club?: string; invite_token?: string
}) => req('POST', '/api/auth/register', data),
me: () => req('GET', '/api/auth/me'),
updateProfile: (data: {
first_name?: string; last_name?: string
club?: string; disciplines?: string[]
}) => req('PUT', '/api/auth/profile', data),
updatePassword: (current_password: string, new_password: string) =>
req('PUT', '/api/auth/password', { current_password, new_password }),
uploadAvatar: (file: File) => {
const fd = new FormData(); fd.append('avatar', file)
return req('POST', '/api/auth/avatar', fd)
},
},
weapons: {
list: () => req('GET', '/api/weapons'),
get: (id: string) => req('GET', `/api/weapons/${id}`),
create: (fd: FormData) => req('POST', '/api/weapons', fd),
update: (id: string, fd: FormData) => req('PUT', `/api/weapons/${id}`, fd),
delete: (id: string) => req('DELETE', `/api/weapons/${id}`),
archive: (id: string, is_archived: boolean) =>
req('PATCH', `/api/weapons/${id}/archive`, { is_archived }),
},
sessions: {
list: () => req('GET', '/api/sessions'),
get: (id: string) => req('GET', `/api/sessions/${id}`),
create: (data: Record<string, any>) => req('POST', '/api/sessions', data),
update: (id: string, data: Record<string, any>) => req('PUT', `/api/sessions/${id}`, data),
delete: (id: string) => req('DELETE', `/api/sessions/${id}`),
},
series: {
list: (sessionId: string) => req('GET', `/api/sessions/${sessionId}/series`),
create: (sessionId: string, fd: FormData) =>
req('POST', `/api/sessions/${sessionId}/series`, fd),
saveAnnotated: (seriesId: string, image_base64: string) =>
req('POST', `/api/series/${seriesId}/annotated`, { image_base64 }),
},
analyze: {
target: (data: {
image_base64: string
target_type: string
previous_image_base64?: string
}) => req('POST', '/api/analyze', data),
},
export: {
pdf: async (sessionId: string): Promise<Blob> => {
const res = await fetch(`${BASE}/api/export/pdf/${sessionId}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Export PDF échoué')
return res.blob()
},
excel: async (params: { period?: string; weaponId?: string }): Promise<Blob> => {
const qs = new URLSearchParams(params as Record<string, string>).toString()
const res = await fetch(`${BASE}/api/export/excel?${qs}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Export Excel échoué')
return res.blob()
},
},
misc: {
locations: () => req('GET', '/api/locations'),
invitations: {
list: () => req('GET', '/api/invitations'),
create: (email?: string) => req('POST', '/api/invitations', email ? { email } : {}),
delete: (id: string) => req('DELETE', `/api/invitations/${id}`),
validate: (token: string) => req('GET', `/api/invitations/validate/${token}`),
},
},
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename; a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+59
View File
@@ -0,0 +1,59 @@
import { create } from 'zustand'
import type { Profile, Weapon, TrainingSession } from '../types'
import { api } from './api'
interface AppStore {
profile: Profile | null
weapons: Weapon[]
sessions: TrainingSession[]
activeSession: TrainingSession | null
loading: boolean
setProfile: (p: Profile | null) => void
setWeapons: (w: Weapon[]) => void
setSessions: (s: TrainingSession[]) => void
setActiveSession: (s: TrainingSession | null) => void
setLoading: (v: boolean) => void
fetchProfile: () => Promise<void>
fetchWeapons: () => Promise<void>
fetchSessions: () => Promise<void>
reset: () => void
}
export const useStore = create<AppStore>((set) => ({
profile: null,
weapons: [],
sessions: [],
activeSession: null,
loading: false,
setProfile: (p) => set({ profile: p }),
setWeapons: (w) => set({ weapons: w }),
setSessions: (s) => set({ sessions: s }),
setActiveSession: (s) => set({ activeSession: s }),
setLoading: (v) => set({ loading: v }),
fetchProfile: async () => {
try {
const data = await api.auth.me()
if (data) set({ profile: data as Profile })
} catch { /* token expiré */ }
},
fetchWeapons: async () => {
try {
const data = await api.weapons.list()
if (data) set({ weapons: data as Weapon[] })
} catch { /* ignore */ }
},
fetchSessions: async () => {
try {
const data = await api.sessions.list()
if (data) set({ sessions: (data as TrainingSession[]).filter(s => s.is_completed) })
} catch { /* ignore */ }
},
reset: () => set({ profile: null, weapons: [], sessions: [], activeSession: null }),
}))
+61
View File
@@ -0,0 +1,61 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Variables Supabase manquantes : VITE_SUPABASE_URL et VITE_SUPABASE_ANON_KEY requis')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
})
// ─── Upload helpers ───────────────────────────────────────────────────────────
export async function uploadFile(
bucket: string,
userId: string,
file: File,
folder = ''
): Promise<string | null> {
const ext = file.name.split('.').pop()
const path = `${userId}/${folder ? folder + '/' : ''}${Date.now()}.${ext}`
const { error } = await supabase.storage.from(bucket).upload(path, file, { upsert: true })
if (error) { console.error('Upload error:', error); return null }
const { data } = supabase.storage.from(bucket).getPublicUrl(path)
return data.publicUrl
}
export async function getSignedUrl(bucket: string, path: string): Promise<string | null> {
// path = 'userId/...'
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(path, 3600)
if (error || !data) return null
return data.signedUrl
}
export async function uploadBase64Image(
bucket: string,
userId: string,
base64: string,
folder = 'annotated'
): Promise<string | null> {
try {
const base64Data = base64.replace(/^data:image\/\w+;base64,/, '')
const byteCharacters = atob(base64Data)
const byteArray = new Uint8Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteArray[i] = byteCharacters.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: 'image/jpeg' })
const file = new File([blob], `annotated_${Date.now()}.jpg`, { type: 'image/jpeg' })
return uploadFile(bucket, userId, file, folder)
} catch (e) {
console.error('uploadBase64Image error:', e)
return null
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)
+138
View File
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Search, Shield, Archive, Edit2, ArchiveRestore } from 'lucide-react'
import { api } from '../lib/api'
import { useStore } from '../lib/store'
import type { Weapon, WeaponType } from '../types'
const TYPE_LABELS: Record<WeaponType, string> = {
pistolet: 'Pistolet', carabine: 'Carabine', fusil: 'Fusil',
arc: 'Arc', arbalète: 'Arbalète', autre: 'Autre',
}
const TYPE_COLORS: Record<WeaponType, string> = {
pistolet: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
carabine: 'text-green-400 bg-green-400/10 border-green-400/20',
fusil: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
arc: 'text-purple-400 bg-purple-400/10 border-purple-400/20',
arbalète: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20',
autre: 'text-muted bg-surface2 border-border',
}
export default function Arsenal() {
const { weapons, fetchWeapons } = useStore()
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<WeaponType | 'all'>('all')
const [showArchived, setShowArchived] = useState(false)
const filtered = weapons.filter(w => {
if (!showArchived && w.is_archived) return false
if (typeFilter !== 'all' && w.type !== typeFilter) return false
const q = search.toLowerCase()
return !q || w.name.toLowerCase().includes(q) || w.brand?.toLowerCase().includes(q) || w.model?.toLowerCase().includes(q)
})
async function toggleArchive(w: Weapon) {
try {
await api.weapons.archive(w.id, !w.is_archived)
fetchWeapons()
} catch (e) { console.error(e) }
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<h1 className="page-header mb-0">Arsenal</h1>
<Link to="/arsenal/new" className="btn-primary flex items-center gap-2 text-sm">
<Plus size={16} /> Ajouter
</Link>
</div>
{/* Filtres */}
<div className="space-y-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input type="text" className="input pl-9" placeholder="Rechercher une arme..."
value={search} onChange={e => setSearch(e.target.value)} />
</div>
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{(['all', 'pistolet', 'carabine', 'fusil', 'arc', 'arbalète', 'autre'] as const).map(t => (
<button key={t} onClick={() => setTypeFilter(t)}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium border transition-all
${typeFilter === t ? 'bg-accent text-white border-accent' : 'border-border text-text-muted hover:border-accent/30'}`}>
{t === 'all' ? 'Tout' : TYPE_LABELS[t]}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)}
className="rounded bg-surface2 border-border accent-accent" />
Afficher les armes archivées
</label>
</div>
{/* Liste */}
{filtered.length === 0 ? (
<div className="card text-center py-12">
<Shield size={36} className="text-muted mx-auto mb-3" />
<p className="text-text-muted font-medium">Aucune arme</p>
<p className="text-sm text-muted mt-1 mb-4">Ajoutez votre première arme au coffre</p>
<Link to="/arsenal/new" className="btn-primary inline-flex items-center gap-2">
<Plus size={16} /> Ajouter une arme
</Link>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map(w => <WeaponCard key={w.id} weapon={w} onToggleArchive={() => toggleArchive(w)} />)}
</div>
)}
</div>
)
}
function WeaponCard({ weapon: w, onToggleArchive }: { weapon: Weapon; onToggleArchive: () => void }) {
return (
<div className={`card relative overflow-hidden transition-all ${w.is_archived ? 'opacity-60' : 'hover:border-accent/20'}`}>
{w.is_archived && (
<div className="absolute top-2 right-2 badge bg-surface2 border border-border text-muted">
<Archive size={10} className="mr-1" /> Archivée
</div>
)}
<div className="flex gap-3">
<div className="w-20 h-20 rounded-lg bg-surface2 border border-border overflow-hidden shrink-0 flex items-center justify-center">
{w.photo_url ? (
<img src={w.photo_url} alt={w.name} className="w-full h-full object-cover" />
) : (
<Shield size={28} className="text-muted" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold text-text truncate">{w.name}</div>
{w.nickname && <div className="text-xs text-muted italic">"{w.nickname}"</div>}
</div>
</div>
<div className={`badge border text-xs mt-1.5 ${TYPE_COLORS[w.type]}`}>
{TYPE_LABELS[w.type]}
</div>
{(w.brand || w.model) && (
<div className="text-xs text-text-muted mt-1 truncate">
{[w.brand, w.model].filter(Boolean).join(' ')}
</div>
)}
{w.caliber && <div className="text-xs text-muted">{w.caliber}</div>}
</div>
</div>
<div className="flex gap-2 mt-3 pt-3 border-t border-border">
<Link to={`/arsenal/${w.id}/edit`}
className="flex-1 btn-secondary text-xs flex items-center justify-center gap-1.5 py-2">
<Edit2 size={13} /> Modifier
</Link>
<button onClick={onToggleArchive}
className="flex-1 btn-secondary text-xs flex items-center justify-center gap-1.5 py-2">
{w.is_archived ? <><ArchiveRestore size={13} /> Restaurer</> : <><Archive size={13} /> Archiver</>}
</button>
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Target, Shield, TrendingUp, Plus, ChevronRight, Activity, Crosshair, Clock } from 'lucide-react'
import { useStore } from '../lib/store'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import type { TrainingSession } from '../types'
export default function Dashboard() {
const { profile, sessions, weapons } = useStore()
const [recentSessions, setRecentSessions] = useState<TrainingSession[]>([])
const [stats, setStats] = useState({ total: 0, shots: 0, avg: 0, best: 0 })
useEffect(() => {
setRecentSessions(sessions.slice(0, 5))
if (sessions.length > 0) {
const total = sessions.length
const shots = sessions.reduce((a, s) => a + s.total_shots_declared, 0)
const avg = sessions.reduce((a, s) => a + s.avg_score, 0) / total
const best = Math.max(...sessions.map(s => s.best_series_score))
setStats({ total, shots, avg: Math.round(avg * 10) / 10, best })
}
}, [sessions])
const activeWeapons = weapons.filter(w => !w.is_archived)
const displayName = profile?.first_name || 'Tireur'
const hour = new Date().getHours()
const greeting = hour < 12 ? 'Bonjour' : hour < 18 ? 'Bon après-midi' : 'Bonsoir'
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<p className="text-text-muted text-sm">{greeting}</p>
<h1 className="text-2xl font-bold text-text">{displayName} 👋</h1>
{profile?.club && <p className="text-text-muted text-sm mt-0.5">{profile.club}</p>}
</div>
<Link to="/session/new"
className="flex items-center gap-2 bg-accent hover:bg-accent-hover text-white text-sm font-semibold px-4 py-2.5 rounded-lg transition-all active:scale-95 shadow-lg shadow-accent/20">
<Plus size={16} /> Séance
</Link>
</div>
{/* Stats rapides */}
<div className="grid grid-cols-2 gap-3">
<StatCard icon={<Activity size={18} className="text-accent" />} label="Séances" value={stats.total} />
<StatCard icon={<Crosshair size={18} className="text-orange" />} label="Tirs totaux" value={stats.shots.toLocaleString()} />
<StatCard icon={<TrendingUp size={18} className="text-green-400" />} label="Score moyen" value={stats.avg > 0 ? stats.avg : '—'} />
<StatCard icon={<Target size={18} className="text-yellow-400" />} label="Meilleur score" value={stats.best > 0 ? stats.best : '—'} />
</div>
{/* Accès rapides */}
<div className="grid grid-cols-3 gap-3">
<QuickAction to="/session/new" icon={<Crosshair size={20} />} label="Nouvelle séance" color="accent" />
<QuickAction to="/arsenal" icon={<Shield size={20} />} label={`Arsenal (${activeWeapons.length})`} color="orange" />
<QuickAction to="/progress" icon={<TrendingUp size={20} />} label="Progression" color="green-500" />
</div>
{/* Séances récentes */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-text">Séances récentes</h2>
<Link to="/progress" className="text-sm text-accent hover:underline">Voir tout</Link>
</div>
{recentSessions.length === 0 ? (
<div className="card text-center py-10">
<Crosshair size={32} className="text-muted mx-auto mb-3" />
<p className="text-text-muted font-medium">Aucune séance enregistrée</p>
<p className="text-sm text-muted mt-1 mb-4">Commencez par créer votre première séance</p>
<Link to="/session/new" className="btn-primary inline-flex items-center gap-2">
<Plus size={16} /> Créer une séance
</Link>
</div>
) : (
<div className="space-y-2">
{recentSessions.map(session => (
<Link key={session.id} to={`/session/${session.id}`}
className="card flex items-center gap-3 hover:border-accent/30 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Target size={18} className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-text text-sm truncate">
{(session.weapon as any)?.name || 'Arme inconnue'} {session.location || 'Lieu non précisé'}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-muted flex items-center gap-1">
<Clock size={11} />
{format(new Date(session.session_date), 'd MMM yyyy', { locale: fr })}
</span>
<span className="text-xs text-text-muted">{session.total_shots_declared} tirs</span>
{session.total_score > 0 && (
<span className="text-xs font-semibold text-accent">{session.total_score} pts</span>
)}
</div>
</div>
<ChevronRight size={16} className="text-muted group-hover:text-text-muted transition-colors shrink-0" />
</Link>
))}
</div>
)}
</div>
</div>
)
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) {
return (
<div className="card flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-surface2 flex items-center justify-center shrink-0">{icon}</div>
<div>
<div className="text-lg font-bold text-text leading-tight">{value}</div>
<div className="text-xs text-muted">{label}</div>
</div>
</div>
)
}
function QuickAction({ to, icon, label, color }: { to: string; icon: React.ReactNode; label: string; color: string }) {
return (
<Link to={to} className="card flex flex-col items-center gap-2 py-4 hover:border-border/80 transition-colors active:scale-95 text-center">
<div className={`w-10 h-10 rounded-xl bg-${color}/10 flex items-center justify-center text-${color}`}>{icon}</div>
<span className="text-xs font-medium text-text-muted leading-tight">{label}</span>
</Link>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { CheckCircle2, XCircle, Target } from 'lucide-react'
import { api } from '../lib/api'
export default function InvitationAccept() {
const { token } = useParams()
const navigate = useNavigate()
const [status, setStatus] = useState<'checking' | 'valid' | 'invalid' | 'expired'>('checking')
useEffect(() => {
if (!token) { setStatus('invalid'); return }
api.misc.invitations.validate(token)
.then(data => {
if (!data.valid) setStatus(data.reason === 'used' ? 'invalid' : 'invalid')
else setStatus('valid')
})
.catch(() => setStatus('invalid'))
}, [token])
if (status === 'checking') return (
<div className="min-h-screen bg-bg flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full" />
</div>
)
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<div className="w-full max-w-sm text-center animate-slide-up">
<div className="w-16 h-16 rounded-2xl bg-accent flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
<Target size={32} className="text-white" />
</div>
{status === 'valid' ? (
<>
<div className="w-12 h-12 rounded-full bg-green-500/10 border border-green-500/30 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 size={24} className="text-green-400" />
</div>
<h2 className="text-xl font-bold text-text mb-2">Invitation valide !</h2>
<p className="text-text-muted text-sm mb-6">
Vous avez é invité à rejoindre ShootTracker. Créez votre compte pour commencer à suivre vos performances.
</p>
<button onClick={() => navigate(`/register?token=${token}`)} className="btn-primary w-full">
Créer mon compte gratuit
</button>
</>
) : (
<>
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center mx-auto mb-4">
<XCircle size={24} className="text-red-400" />
</div>
<h2 className="text-xl font-bold text-text mb-2">
{status === 'expired' ? 'Invitation expirée' : 'Invitation invalide'}
</h2>
<p className="text-text-muted text-sm mb-6">
{status === 'expired'
? "Ce lien d'invitation a expiré. Demandez un nouveau lien à votre ami."
: "Ce lien d'invitation n'est pas valide ou a déjà été utilisé."}
</p>
<button onClick={() => navigate('/login')} className="btn-secondary w-full">
Aller à la connexion
</button>
</>
)}
</div>
</div>
)
}
+222
View File
@@ -0,0 +1,222 @@
import { useState, useEffect, useRef } from 'react'
import { User, LogOut, Save, Camera, Mail, Copy, CheckCircle2, Plus, Trash2 } from 'lucide-react'
import { api } from '../lib/api'
import { useStore } from '../lib/store'
import type { Invitation } from '../types'
const DISCIPLINES = [
'Pistolet 25m', 'Pistolet 50m', 'Carabine 10m', 'Carabine 50m',
'Tir de défense', 'Bench rest', 'Longue distance', 'Arc traditionnel',
'Arc à poulies', 'Arbalète', 'Fusil', 'Trap', 'Skeet', 'Autre'
]
interface ProfileProps {
onLogout: () => void
}
export default function Profile({ onLogout }: ProfileProps) {
const { profile, fetchProfile } = useStore()
const fileRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
first_name: '', last_name: '', club: '', disciplines: [] as string[],
})
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [avatarPreview, setAvatarPreview] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [copiedId, setCopiedId] = useState<string>('')
useEffect(() => { loadInvitations() }, [])
useEffect(() => {
if (profile) {
setForm({
first_name: profile.first_name || '',
last_name: profile.last_name || '',
club: profile.club || '',
disciplines: profile.disciplines || [],
})
if (profile.avatar_url) setAvatarPreview(profile.avatar_url)
}
}, [profile])
async function loadInvitations() {
try {
const data = await api.misc.invitations.list()
setInvitations(data as Invitation[])
} catch { /* ignore */ }
}
function toggleDiscipline(d: string) {
setForm(f => ({
...f, disciplines: f.disciplines.includes(d)
? f.disciplines.filter(x => x !== d)
: [...f.disciplines, d]
}))
}
function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (!f) return
setAvatarFile(f)
setAvatarPreview(URL.createObjectURL(f))
}
async function handleSave() {
setSaving(true)
try {
if (avatarFile) {
const res = await api.auth.uploadAvatar(avatarFile)
if (res?.avatar_url) setAvatarPreview(res.avatar_url)
}
await api.auth.updateProfile(form)
await fetchProfile()
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) { console.error(e) }
setSaving(false)
}
async function createInvitation() {
const active = invitations.filter(i => !i.is_used)
if (active.length >= 5) { alert('Maximum 5 invitations actives'); return }
try {
await api.misc.invitations.create()
loadInvitations()
} catch { /* ignore */ }
}
async function deleteInvitation(id: string) {
try {
await api.misc.invitations.delete(id)
loadInvitations()
} catch { /* ignore */ }
}
function copyInviteLink(token: string, id: string) {
const url = `${window.location.origin}/register?token=${token}`
navigator.clipboard.writeText(url)
setCopiedId(id)
setTimeout(() => setCopiedId(''), 2000)
}
const activeInvites = invitations.filter(i => !i.is_used)
return (
<div className="space-y-5 animate-fade-in">
<h1 className="page-header">Profil</h1>
{/* Avatar */}
<div className="card flex items-center gap-4">
<div className="relative cursor-pointer" onClick={() => fileRef.current?.click()}>
{avatarPreview ? (
<img src={avatarPreview} alt="" className="w-20 h-20 rounded-full object-cover border-2 border-accent/30" />
) : (
<div className="w-20 h-20 rounded-full bg-surface2 border-2 border-border flex items-center justify-center">
<User size={32} className="text-muted" />
</div>
)}
<div className="absolute bottom-0 right-0 w-7 h-7 bg-accent rounded-full flex items-center justify-center border-2 border-surface">
<Camera size={13} className="text-white" />
</div>
</div>
<div>
<div className="font-semibold text-text">{form.first_name} {form.last_name}</div>
<div className="text-sm text-text-muted flex items-center gap-1.5 mt-0.5">
<Mail size={12} /> {profile?.email || ''}
</div>
{form.club && <div className="text-xs text-muted mt-0.5">{form.club}</div>}
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onAvatarChange} />
</div>
{/* Infos */}
<div className="card space-y-4">
<h3 className="font-semibold text-text text-sm uppercase tracking-wide text-muted">Informations</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Prénom</label>
<input type="text" className="input" value={form.first_name}
onChange={e => setForm(f => ({ ...f, first_name: e.target.value }))} />
</div>
<div>
<label className="label">Nom</label>
<input type="text" className="input" value={form.last_name}
onChange={e => setForm(f => ({ ...f, last_name: e.target.value }))} />
</div>
</div>
<div>
<label className="label">Club</label>
<input type="text" className="input" placeholder="Club de tir..."
value={form.club} onChange={e => setForm(f => ({ ...f, club: e.target.value }))} />
</div>
<div>
<label className="label">Disciplines pratiquées</label>
<div className="flex flex-wrap gap-2 mt-1">
{DISCIPLINES.map(d => (
<button key={d} type="button" onClick={() => toggleDiscipline(d)}
className={`text-xs px-2.5 py-1 rounded-full border transition-all
${form.disciplines.includes(d) ? 'bg-accent text-white border-accent' : 'border-border text-muted hover:border-accent/30'}`}>
{d}
</button>
))}
</div>
</div>
<button onClick={handleSave} disabled={saving}
className="btn-primary w-full flex items-center justify-center gap-2">
{saved ? <><CheckCircle2 size={16} /> Sauvegardé !</> : <><Save size={16} /> {saving ? 'Sauvegarde...' : 'Sauvegarder'}</>}
</button>
</div>
{/* Invitations */}
<div className="card space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-text text-sm uppercase tracking-wide text-muted">
Invitations ({activeInvites.length}/5)
</h3>
<button onClick={createInvitation} disabled={activeInvites.length >= 5}
className="btn-secondary text-xs flex items-center gap-1 py-1.5 px-2.5">
<Plus size={13} /> Créer
</button>
</div>
<p className="text-xs text-muted">Invitez jusqu'à 5 amis. Chacun crée son propre compte indépendant.</p>
{invitations.length === 0 ? (
<p className="text-sm text-text-muted text-center py-3">Aucune invitation créée</p>
) : (
<div className="space-y-2">
{invitations.map(inv => (
<div key={inv.id} className={`flex items-center gap-2 p-2.5 rounded-lg border text-sm
${inv.is_used ? 'border-border opacity-50' : 'border-accent/20 bg-accent/5'}`}>
<div className="flex-1 min-w-0">
<div className="text-xs text-muted font-mono truncate">{inv.token.slice(0, 16)}...</div>
{inv.is_used
? <div className="text-xs text-green-400 flex items-center gap-1"><CheckCircle2 size={11} /> Utilisée</div>
: <div className="text-xs text-text-muted">Expire {new Date(inv.expires_at).toLocaleDateString('fr')}</div>
}
</div>
{!inv.is_used && (
<button onClick={() => copyInviteLink(inv.token, inv.id)}
className="btn-secondary text-xs py-1 px-2 flex items-center gap-1 shrink-0">
{copiedId === inv.id ? <CheckCircle2 size={12} className="text-green-400" /> : <Copy size={12} />}
{copiedId === inv.id ? 'Copié' : 'Copier'}
</button>
)}
<button onClick={() => deleteInvitation(inv.id)}
className="w-7 h-7 flex items-center justify-center rounded text-muted hover:text-red-400 transition-colors shrink-0">
<Trash2 size={13} />
</button>
</div>
))}
</div>
)}
</div>
{/* Déconnexion */}
<button onClick={onLogout}
className="w-full flex items-center justify-center gap-2 p-3 rounded-lg border border-red-400/20 text-red-400 hover:bg-red-400/10 transition-colors text-sm font-medium">
<LogOut size={16} /> Se déconnecter
</button>
</div>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { TrendingUp, Download, Filter, ChevronRight, Clock, Target } from 'lucide-react'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Area, AreaChart
} from 'recharts'
import { api, downloadBlob } from '../lib/api'
import { useStore } from '../lib/store'
import { format, subDays, subYears } from 'date-fns'
import { fr } from 'date-fns/locale'
import type { TrainingSession, ChartPoint } from '../types'
type Period = '30j' | '90j' | '1an' | 'tout'
export default function Progress() {
const { weapons } = useStore()
const [sessions, setSessions] = useState<TrainingSession[]>([])
const [filtered, setFiltered] = useState<TrainingSession[]>([])
const [period, setPeriod] = useState<Period>('90j')
const [weaponFilter, setWeaponFilter] = useState<string>('all')
const [chartData, setChartData] = useState<ChartPoint[]>([])
const [stats, setStats] = useState({ total: 0, shots: 0, avgScore: 0, bestScore: 0, progress: 0 })
const [exporting, setExporting] = useState(false)
useEffect(() => { loadSessions() }, [])
useEffect(() => { applyFilters() }, [sessions, period, weaponFilter])
useEffect(() => { buildChartData() }, [filtered])
async function loadSessions() {
try {
const data = await api.sessions.list()
const completed = (data as TrainingSession[])
.filter(s => s.is_completed)
.sort((a, b) => new Date(a.session_date).getTime() - new Date(b.session_date).getTime())
setSessions(completed)
} catch { /* ignore */ }
}
function applyFilters() {
let f = [...sessions]
const now = new Date()
const cutoff = period === '30j' ? subDays(now, 30)
: period === '90j' ? subDays(now, 90)
: period === '1an' ? subYears(now, 1)
: new Date('2000-01-01')
f = f.filter(s => new Date(s.session_date) >= cutoff)
if (weaponFilter !== 'all') f = f.filter(s => s.weapon_id === weaponFilter)
setFiltered(f)
}
function buildChartData() {
const pts: ChartPoint[] = filtered.map(s => ({
date: format(new Date(s.session_date), 'dd/MM', { locale: fr }),
score: s.avg_score,
best: s.best_series_score,
shots: s.total_shots_declared,
sessions: 1,
dispersion: s.avg_dispersion,
}))
const total = filtered.length
const shots = filtered.reduce((a, s) => a + s.total_shots_declared, 0)
const avgScore = total > 0 ? filtered.reduce((a, s) => a + s.avg_score, 0) / total : 0
const bestScore = total > 0 ? Math.max(...filtered.map(s => s.best_series_score)) : 0
let progress = 0
if (filtered.length >= 2) {
const first = filtered[0].avg_score
const last = filtered[filtered.length - 1].avg_score
progress = first > 0 ? Math.round((last - first) / first * 100) : 0
}
setChartData(pts)
setStats({ total, shots, avgScore: Math.round(avgScore * 10) / 10, bestScore, progress })
}
async function handleExcelExport() {
setExporting(true)
try {
const blob = await api.export.excel({
period: period !== 'tout' ? period : undefined,
weaponId: weaponFilter !== 'all' ? weaponFilter : undefined,
})
downloadBlob(blob, `shoottracker_progression_${format(new Date(), 'yyyy-MM-dd')}.xlsx`)
} catch (e) { console.error(e) }
setExporting(false)
}
const PERIODS: { value: Period; label: string }[] = [
{ value: '30j', label: '30 jours' }, { value: '90j', label: '90 jours' },
{ value: '1an', label: '1 an' }, { value: 'tout', label: 'Tout' },
]
const tooltipStyle = {
backgroundColor: '#1a1a1a', border: '1px solid #2e2e2e',
borderRadius: '8px', color: '#f4f4f5', fontSize: '12px'
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<h1 className="page-header mb-0">Progression</h1>
<button onClick={handleExcelExport} disabled={exporting}
className="btn-secondary flex items-center gap-1.5 text-sm py-2">
<Download size={14} /> {exporting ? '...' : 'Excel'}
</button>
</div>
{/* Filtres */}
<div className="space-y-3">
<div className="flex gap-2 overflow-x-auto pb-1">
{PERIODS.map(p => (
<button key={p.value} onClick={() => setPeriod(p.value)}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium border transition-all
${period === p.value ? 'bg-accent text-white border-accent' : 'border-border text-text-muted hover:border-accent/30'}`}>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<Filter size={14} className="text-muted shrink-0" />
<select className="input text-sm py-2" value={weaponFilter}
onChange={e => setWeaponFilter(e.target.value)}>
<option value="all">Toutes les armes</option>
{weapons.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3">
<div className="card text-center">
<div className="text-2xl font-bold text-text">{stats.total}</div>
<div className="text-xs text-muted">Séances</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{stats.shots.toLocaleString()}</div>
<div className="text-xs text-muted">Tirs</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{stats.avgScore || '—'}</div>
<div className="text-xs text-muted">Score moyen</div>
</div>
<div className="card text-center">
<div className={`text-2xl font-bold ${stats.progress > 0 ? 'text-green-400' : stats.progress < 0 ? 'text-red-400' : 'text-text'}`}>
{stats.progress > 0 ? '+' : ''}{stats.progress}%
</div>
<div className="text-xs text-muted">Progression</div>
</div>
</div>
{filtered.length === 0 ? (
<div className="card text-center py-12">
<TrendingUp size={32} className="text-muted mx-auto mb-3" />
<p className="text-text-muted">Aucune donnée pour cette période</p>
</div>
) : (
<>
<div className="card">
<h3 className="font-semibold text-text text-sm mb-3">Score moyen par séance</h3>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 0 }}>
<defs>
<linearGradient id="scoreGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#dc2626" stopOpacity={0.3} />
<stop offset="95%" stopColor="#dc2626" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2e2e2e" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} width={30} />
<Tooltip contentStyle={tooltipStyle} />
<Area type="monotone" dataKey="score" stroke="#dc2626" fill="url(#scoreGrad)"
name="Score moyen" strokeWidth={2} dot={{ fill: '#dc2626', r: 3 }} />
<Line type="monotone" dataKey="best" stroke="#ea580c"
name="Meilleur" strokeWidth={2} dot={false} strokeDasharray="4 4" />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="card">
<h3 className="font-semibold text-text text-sm mb-3">Tirs par séance</h3>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2e2e2e" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} width={30} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="shots" fill="#ea580c" name="Tirs" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
{/* Historique */}
<div>
<h3 className="font-semibold text-text mb-3">Historique des séances</h3>
{filtered.length === 0 ? (
<p className="text-text-muted text-sm text-center py-4">Aucune séance</p>
) : (
<div className="space-y-2">
{[...filtered].reverse().map(s => (
<Link key={s.id} to={`/session/${s.id}`}
className="card flex items-center gap-3 hover:border-accent/30 transition-colors group">
<div className="w-9 h-9 rounded-lg bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Target size={16} className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-text text-sm truncate">
{(s.weapon as any)?.name || 'Arme inconnue'}{s.location ? `${s.location}` : ''}
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-text-muted">
<span className="flex items-center gap-0.5"><Clock size={10} /> {format(new Date(s.session_date), 'dd MMM yyyy', { locale: fr })}</span>
<span>{s.total_shots_declared} tirs</span>
{s.total_score > 0 && <span className="text-accent font-semibold">{s.total_score} pts</span>}
<span className={s.ai_detection_rate > 80 ? 'text-green-400' : 'text-yellow-400'}>IA {s.ai_detection_rate}%</span>
</div>
</div>
<ChevronRight size={16} className="text-muted group-hover:text-text-muted shrink-0" />
</Link>
))}
</div>
)}
</div>
</div>
)
}
+198
View File
@@ -0,0 +1,198 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ChevronLeft, Camera, Trash2, Save } from 'lucide-react'
import { api } from '../lib/api'
import { useStore } from '../lib/store'
import type { WeaponType } from '../types'
const TYPES: { value: WeaponType; label: string }[] = [
{ value: 'pistolet', label: 'Pistolet' },
{ value: 'carabine', label: 'Carabine' },
{ value: 'fusil', label: 'Fusil' },
{ value: 'arc', label: 'Arc' },
{ value: 'arbalète', label: 'Arbalète' },
{ value: 'autre', label: 'Autre' },
]
export default function WeaponForm() {
const navigate = useNavigate()
const { id } = useParams()
const isEdit = !!id
const { fetchWeapons } = useStore()
const fileRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
name: '', nickname: '', type: 'pistolet' as WeaponType,
caliber: '', brand: '', model: '', serial_number: '', notes: '',
})
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [photoPreview, setPhotoPreview] = useState<string>('')
const [currentPhotoUrl, setCurrentPhotoUrl] = useState<string>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (isEdit) {
api.weapons.get(id!).then(data => {
if (data) {
setForm({
name: data.name || '', nickname: data.nickname || '',
type: data.type, caliber: data.caliber || '',
brand: data.brand || '', model: data.model || '',
serial_number: data.serial_number || '', notes: data.notes || '',
})
setCurrentPhotoUrl(data.photo_url || '')
}
}).catch(console.error)
}
}, [id])
function update(field: string, val: string) { setForm(f => ({ ...f, [field]: val })) }
function onPhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setPhotoFile(file)
setPhotoPreview(URL.createObjectURL(file))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!form.name) { setError('Le nom est requis'); return }
setError(''); setLoading(true)
try {
const fd = new FormData()
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v) })
if (photoFile) fd.append('photo', photoFile)
if (isEdit) {
await api.weapons.update(id!, fd)
} else {
await api.weapons.create(fd)
}
await fetchWeapons()
navigate('/arsenal')
} catch (err: any) {
setError(err.message || 'Erreur')
} finally {
setLoading(false)
}
}
async function handleDelete() {
if (!window.confirm('Supprimer définitivement cette arme ?')) return
try {
await api.weapons.delete(id!)
await fetchWeapons()
navigate('/arsenal')
} catch (err: any) {
setError(err.message)
}
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="btn-ghost p-2"><ChevronLeft size={20} /></button>
<h1 className="page-header mb-0">{isEdit ? "Modifier l'arme" : 'Ajouter une arme'}</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Photo */}
<div className="card">
<label className="label">Photo de l'arme</label>
<div className="flex items-center gap-4">
<div
className="w-24 h-24 rounded-lg bg-surface2 border-2 border-dashed border-border flex items-center justify-center overflow-hidden cursor-pointer hover:border-accent/50 transition-colors"
onClick={() => fileRef.current?.click()}
>
{photoPreview || currentPhotoUrl ? (
<img src={photoPreview || currentPhotoUrl} alt="" className="w-full h-full object-cover" />
) : (
<Camera size={24} className="text-muted" />
)}
</div>
<div>
<button type="button" onClick={() => fileRef.current?.click()}
className="btn-secondary text-sm flex items-center gap-2">
<Camera size={15} /> {photoPreview || currentPhotoUrl ? 'Changer' : 'Ajouter une photo'}
</button>
<p className="text-xs text-muted mt-1.5">JPG, PNG, WebP — max 20 Mo</p>
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onPhotoChange} />
</div>
</div>
{/* Infos */}
<div className="card space-y-4">
<h3 className="font-semibold text-text text-sm uppercase tracking-wide text-muted">Informations</h3>
<div>
<label className="label">Nom de l'arme *</label>
<input type="text" required className="input" placeholder="Ex: Mon Glock 17"
value={form.name} onChange={e => update('name', e.target.value)} />
</div>
<div>
<label className="label">Surnom / Alias</label>
<input type="text" className="input" placeholder='Ex: "Le fidèle"'
value={form.nickname} onChange={e => update('nickname', e.target.value)} />
</div>
<div>
<label className="label">Type *</label>
<select className="input" value={form.type} onChange={e => update('type', e.target.value)}>
{TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Marque</label>
<input type="text" className="input" placeholder="Glock, S&W..."
value={form.brand} onChange={e => update('brand', e.target.value)} />
</div>
<div>
<label className="label">Modèle</label>
<input type="text" className="input" placeholder="17, M&P..."
value={form.model} onChange={e => update('model', e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Calibre</label>
<input type="text" className="input" placeholder="9mm, .308..."
value={form.caliber} onChange={e => update('caliber', e.target.value)} />
</div>
<div>
<label className="label">N° de série</label>
<input type="text" className="input" placeholder="Optionnel"
value={form.serial_number} onChange={e => update('serial_number', e.target.value)} />
</div>
</div>
</div>
{/* Notes */}
<div className="card">
<label className="label">Notes (réglages, entretien, historique)</label>
<textarea className="input resize-none" rows={4}
placeholder="Réglages de visée, historique des réparations..."
value={form.notes} onChange={e => update('notes', e.target.value)} />
</div>
{error && (
<div className="bg-red-400/10 border border-red-400/20 text-red-400 text-sm rounded-lg p-3">{error}</div>
)}
<div className="flex gap-3">
<button type="submit" disabled={loading} className="btn-primary flex-1 flex items-center justify-center gap-2">
<Save size={16} /> {loading ? 'Enregistrement...' : isEdit ? 'Sauvegarder' : "Ajouter l'arme"}
</button>
{isEdit && (
<button type="button" onClick={handleDelete}
className="btn-secondary flex items-center gap-2 text-red-400 hover:bg-red-400/10 border-red-400/20">
<Trash2 size={16} />
</button>
)}
</div>
</form>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Target, Eye, EyeOff, AlertCircle } from 'lucide-react'
import { api, setToken } from '../../lib/api'
interface LoginProps {
onLogin: () => void
}
export default function Login({ onLogin }: LoginProps) {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPwd, setShowPwd] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await api.auth.login(email, password)
setToken(data.token)
onLogin()
navigate('/')
} catch (err: any) {
setError(err.message || 'Identifiants incorrects')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-slide-up">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-accent flex items-center justify-center mb-4 shadow-lg shadow-accent/20">
<Target size={32} className="text-white" />
</div>
<h1 className="text-2xl font-bold text-text">ShootTracker</h1>
<p className="text-text-muted text-sm mt-1">Performance en tir sportif</p>
</div>
<div className="card">
<h2 className="text-lg font-semibold text-text mb-6">Connexion</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Adresse email</label>
<input
type="email" required className="input"
placeholder="vous@exemple.com"
value={email} onChange={e => setEmail(e.target.value)}
/>
</div>
<div>
<label className="label">Mot de passe</label>
<div className="relative">
<input
type={showPwd ? 'text' : 'password'} required
className="input pr-10" placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)}
/>
<button type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-text"
onClick={() => setShowPwd(v => !v)}>
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-400/10 rounded-lg p-3">
<AlertCircle size={16} className="shrink-0" />{error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
<p className="text-center text-sm text-text-muted mt-4">
Pas encore de compte ?{' '}
<Link to="/register" className="text-accent hover:underline font-medium">
Créer un compte
</Link>
</p>
</div>
</div>
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Target, Eye, EyeOff, AlertCircle, CheckCircle2 } from 'lucide-react'
import { api, setToken } from '../../lib/api'
export default function Register() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const inviteToken = searchParams.get('token') || undefined
const [form, setForm] = useState({
firstName: '', lastName: '', email: '', password: '', confirmPwd: '', club: ''
})
const [showPwd, setShowPwd] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
function update(field: string, val: string) {
setForm(f => ({ ...f, [field]: val }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (form.password !== form.confirmPwd) { setError('Les mots de passe ne correspondent pas'); return }
if (form.password.length < 8) { setError('Mot de passe minimum 8 caractères'); return }
setLoading(true)
try {
const data = await api.auth.register({
email: form.email,
password: form.password,
first_name: form.firstName,
last_name: form.lastName,
club: form.club || undefined,
invite_token: inviteToken,
})
setToken(data.token)
navigate('/')
} catch (err: any) {
setError(err.message || 'Erreur lors de la création du compte')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-accent flex items-center justify-center mb-4 shadow-lg shadow-accent/20">
<Target size={32} className="text-white" />
</div>
<h1 className="text-2xl font-bold text-text">ShootTracker</h1>
{inviteToken && (
<div className="mt-2 text-xs text-green-400 bg-green-400/10 px-3 py-1 rounded-full">
Inscription par invitation
</div>
)}
</div>
<div className="card">
<h2 className="text-lg font-semibold text-text mb-6">Créer un compte</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Prénom</label>
<input type="text" required className="input" placeholder="Jean"
value={form.firstName} onChange={e => update('firstName', e.target.value)} />
</div>
<div>
<label className="label">Nom</label>
<input type="text" required className="input" placeholder="Dupont"
value={form.lastName} onChange={e => update('lastName', e.target.value)} />
</div>
</div>
<div>
<label className="label">Club (optionnel)</label>
<input type="text" className="input" placeholder="Club de tir de Paris"
value={form.club} onChange={e => update('club', e.target.value)} />
</div>
<div>
<label className="label">Email</label>
<input type="email" required className="input" placeholder="vous@exemple.com"
value={form.email} onChange={e => update('email', e.target.value)} />
</div>
<div>
<label className="label">Mot de passe</label>
<div className="relative">
<input type={showPwd ? 'text' : 'password'} required className="input pr-10"
placeholder="8 caractères minimum"
value={form.password} onChange={e => update('password', e.target.value)} />
<button type="button" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-text"
onClick={() => setShowPwd(v => !v)}>
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="label">Confirmer le mot de passe</label>
<input type={showPwd ? 'text' : 'password'} required className="input"
placeholder="••••••••"
value={form.confirmPwd} onChange={e => update('confirmPwd', e.target.value)} />
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-400/10 rounded-lg p-3">
<AlertCircle size={16} className="shrink-0" />{error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Création...' : 'Créer mon compte'}
</button>
</form>
<p className="text-center text-sm text-text-muted mt-4">
Déjà un compte ?{' '}
<Link to="/login" className="text-accent hover:underline font-medium">Se connecter</Link>
</p>
</div>
</div>
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, MapPin, Calendar, Crosshair, Ruler, FileText } from 'lucide-react'
import { api } from '../../lib/api'
import { useStore } from '../../lib/store'
import type { TargetType } from '../../types'
import { format } from 'date-fns'
const TARGET_TYPES: { value: TargetType; label: string; desc: string }[] = [
{ value: 'issf', label: 'ISSF', desc: 'Cible réglementaire cercles 110' },
{ value: 'silhouette', label: 'Silhouette', desc: 'Cible silhouette — groupement' },
{ value: 'libre', label: 'Libre', desc: 'Cible libre — groupement seul' },
]
export default function NewSession() {
const navigate = useNavigate()
const { weapons, fetchSessions } = useStore()
const [locations, setLocations] = useState<string[]>([])
const [form, setForm] = useState({
session_date: format(new Date(), "yyyy-MM-dd'T'HH:mm"),
location: '',
weapon_id: '',
target_type: 'issf' as TargetType,
distance_m: '',
notes_start: '',
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const activeWeapons = weapons.filter(w => !w.is_archived)
useEffect(() => {
api.misc.locations()
.then(data => setLocations((data as any[]).map(l => l.name)))
.catch(() => {})
}, [])
function update(field: string, val: string) { setForm(f => ({ ...f, [field]: val })) }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!form.weapon_id) { setError('Sélectionnez une arme'); return }
setLoading(true)
try {
const session = await api.sessions.create({
weapon_id: form.weapon_id,
session_date: new Date(form.session_date).toISOString(),
location: form.location || null,
target_type: form.target_type,
distance_m: form.distance_m ? parseInt(form.distance_m) : null,
notes_start: form.notes_start || null,
})
navigate(`/session/${session.id}/capture`)
} catch (err: any) {
setError(err.message || 'Erreur lors de la création de la séance')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="btn-ghost p-2"><ChevronLeft size={20} /></button>
<h1 className="page-header mb-0">Nouvelle séance</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Date & lieu */}
<div className="card space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-text-muted uppercase tracking-wide">
<Calendar size={14} /> Quand et
</div>
<div>
<label className="label">Date et heure</label>
<input type="datetime-local" className="input"
value={form.session_date} onChange={e => update('session_date', e.target.value)} />
</div>
<div>
<label className="label">Lieu / Stand</label>
<div className="relative">
<MapPin size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input type="text" className="input pl-9" placeholder="Stand de tir de..."
list="locations-list"
value={form.location} onChange={e => update('location', e.target.value)} />
<datalist id="locations-list">
{locations.map(l => <option key={l} value={l} />)}
</datalist>
</div>
</div>
</div>
{/* Arme & cible */}
<div className="card space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-text-muted uppercase tracking-wide">
<Crosshair size={14} /> Configuration
</div>
<div>
<label className="label">Arme *</label>
{activeWeapons.length === 0 ? (
<div className="bg-orange/10 border border-orange/20 text-orange text-sm rounded-lg p-3">
Aucune arme active. <a href="/arsenal/new" className="underline">Ajouter une arme</a>
</div>
) : (
<select className="input" required value={form.weapon_id}
onChange={e => update('weapon_id', e.target.value)}>
<option value="">Sélectionner une arme</option>
{activeWeapons.map(w => (
<option key={w.id} value={w.id}>
{w.name}{w.caliber ? `${w.caliber}` : ''}
</option>
))}
</select>
)}
</div>
<div>
<label className="label">Type de cible *</label>
<div className="space-y-2">
{TARGET_TYPES.map(t => (
<label key={t.value} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all
${form.target_type === t.value ? 'border-accent bg-accent/5' : 'border-border hover:border-accent/30'}`}>
<input type="radio" name="target_type" value={t.value}
checked={form.target_type === t.value}
onChange={e => update('target_type', e.target.value)}
className="accent-accent" />
<div>
<div className="font-medium text-text text-sm">{t.label}</div>
<div className="text-xs text-muted">{t.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<label className="label flex items-center gap-1.5"><Ruler size={13} /> Distance de tir (mètres)</label>
<input type="number" className="input" placeholder="Ex: 25"
min="1" max="1000"
value={form.distance_m} onChange={e => update('distance_m', e.target.value)} />
</div>
</div>
{/* Notes */}
<div className="card">
<div className="flex items-center gap-2 text-sm font-medium text-text-muted uppercase tracking-wide mb-3">
<FileText size={14} /> Notes de début de séance
</div>
<textarea className="input resize-none" rows={3}
placeholder="Conditions météo, état de forme, objectifs..."
value={form.notes_start} onChange={e => update('notes_start', e.target.value)} />
</div>
{error && (
<div className="bg-red-400/10 border border-red-400/20 text-red-400 text-sm rounded-lg p-3">{error}</div>
)}
<button type="submit" disabled={loading || activeWeapons.length === 0}
className="btn-primary w-full py-3 text-base">
{loading ? 'Création...' : 'Commencer la séance →'}
</button>
</form>
</div>
)
}
@@ -0,0 +1,429 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Camera, Upload, Crosshair, CheckCircle2, AlertTriangle, Plus, X, ChevronRight, Flag } from 'lucide-react'
import { api, fileToBase64 } from '../../lib/api'
import { useStore } from '../../lib/store'
import type { TrainingSession, Series, AIAnalysisResult } from '../../types'
type Step = 'saisie' | 'analyse' | 'resultat' | 'cloture'
export default function SessionCapture() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { fetchSessions } = useStore()
const fileRef = useRef<HTMLInputElement>(null)
const [session, setSession] = useState<TrainingSession | null>(null)
const [step, setStep] = useState<Step>('saisie')
const [seriesList, setSeriesList] = useState<Series[]>([])
// Saisie
const [shotsDeclared, setShotsDeclared] = useState(10)
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [photoPreview, setPhotoPreview] = useState('')
const [prevPhotoB64, setPrevPhotoB64] = useState<string | undefined>()
// Analyse
const [aiResult, setAiResult] = useState<AIAnalysisResult | null>(null)
const [analysisError, setAnalysisError] = useState('')
const [manualAdjustments, setManualAdjustments] = useState<number[]>([])
// Clôture
const [notesEnd, setNotesEnd] = useState('')
const [closing, setClosing] = useState(false)
useEffect(() => { loadSession() }, [id])
async function loadSession() {
if (!id) return
try {
const data = await api.sessions.get(id)
setSession(data as TrainingSession)
const seriesData = await api.series.list(id)
setSeriesList((seriesData as Series[]).sort((a, b) => a.series_number - b.series_number))
} catch { /* ignore */ }
}
function onPhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (!f) return
setPhotoFile(f)
setPhotoPreview(URL.createObjectURL(f))
}
async function handleAnalyze() {
if (!photoFile || !session) return
setStep('analyse')
setAnalysisError('')
try {
const imageB64 = await fileToBase64(photoFile)
const result = await api.analyze.target({
image_base64: imageB64,
target_type: session.target_type,
previous_image_base64: prevPhotoB64,
})
setAiResult(result as AIAnalysisResult)
const undetected = shotsDeclared - (result.total_detected || 0)
setManualAdjustments(new Array(Math.max(0, undetected)).fill(0))
setStep('resultat')
} catch (err: any) {
setAnalysisError(err.message || "Erreur lors de l'analyse IA")
setStep('resultat')
}
}
const manualTotal = manualAdjustments.reduce((a, v) => a + v, 0)
const finalScore = (aiResult?.score_total || 0) + manualTotal
const shotsDetected = aiResult?.total_detected || 0
async function saveSeries(startNew: boolean, newTarget: boolean) {
if (!session) return
const fd = new FormData()
fd.append('series_number', String(seriesList.length + 1))
fd.append('shots_declared', String(shotsDeclared))
fd.append('shots_detected', String(shotsDetected))
fd.append('shots_manual', String(manualAdjustments.length))
fd.append('score_zone', String(aiResult?.score_zone || 0))
fd.append('score_groupement', String(aiResult?.score_groupement || 0))
fd.append('score_total', String(finalScore))
if (aiResult?.dispersion_radius) fd.append('dispersion_radius', String(aiResult.dispersion_radius))
if (aiResult?.center_x != null) fd.append('center_x', String(aiResult.center_x))
if (aiResult?.center_y != null) fd.append('center_y', String(aiResult.center_y))
if (aiResult) fd.append('ai_data', JSON.stringify(aiResult))
// Impacts: AI + manual
const impacts: any[] = []
if (aiResult?.impacts) {
for (const imp of aiResult.impacts) {
impacts.push({ x: imp.x, y: imp.y, zone: imp.zone, points: imp.points, is_manual: false })
}
}
for (const pts of manualAdjustments) {
impacts.push({ x: -1, y: -1, zone: pts, points: pts, is_manual: true })
}
fd.append('impacts', JSON.stringify(impacts))
if (photoFile) fd.append('photo', photoFile)
try {
const newSeries = await api.series.create(session.id, fd)
// Upload annotated image separately if present
if (aiResult?.annotated_image_base64) {
await api.series.saveAnnotated(newSeries.id, aiResult.annotated_image_base64)
}
const updatedList = [...seriesList, newSeries as Series]
setSeriesList(updatedList)
// Keep previous photo for subtraction if same target
if (!newTarget && photoFile) {
const b64 = await fileToBase64(photoFile)
setPrevPhotoB64(b64)
} else {
setPrevPhotoB64(undefined)
}
// Reset
setPhotoFile(null)
setPhotoPreview('')
setAiResult(null)
setManualAdjustments([])
setShotsDeclared(10)
if (startNew) setStep('saisie')
else setStep('cloture')
} catch (err) {
console.error(err)
}
}
async function handleClose() {
if (!session) return
setClosing(true)
const totalDeclared = seriesList.reduce((a, s) => a + s.shots_declared, 0)
const totalDetected = seriesList.reduce((a, s) => a + s.shots_detected, 0)
const totalScore = seriesList.reduce((a, s) => a + s.score_total, 0)
const avgScore = seriesList.length > 0 ? totalScore / seriesList.length : 0
const bestScore = Math.max(...seriesList.map(s => s.score_total), 0)
const dispSeries = seriesList.filter(s => s.dispersion_radius)
const avgDisp = dispSeries.length > 0 ? dispSeries.reduce((a, s) => a + (s.dispersion_radius || 0), 0) / dispSeries.length : null
try {
await api.sessions.update(session.id, {
is_completed: true,
notes_end: notesEnd || null,
total_shots_declared: totalDeclared,
total_shots_detected: totalDetected,
total_score: totalScore,
avg_score: Math.round(avgScore * 10) / 10,
best_series_score: bestScore,
ai_detection_rate: totalDeclared > 0 ? Math.round((totalDetected / totalDeclared) * 100) : 0,
avg_dispersion: avgDisp ? Math.round(avgDisp * 10) / 10 : null,
})
await fetchSessions()
navigate(`/session/${session.id}`)
} catch (err) {
console.error(err)
} finally {
setClosing(false)
}
}
if (!session) return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full" />
</div>
)
return (
<div className="space-y-5 animate-fade-in">
{/* En-tête */}
<div className="card">
<div className="flex items-start justify-between">
<div>
<div className="text-sm text-muted mb-1">Séance en cours</div>
<div className="font-semibold text-text">{(session.weapon as any)?.name || 'Arme'}</div>
<div className="text-xs text-text-muted">
{session.location && `${session.location} · `}
{session.distance_m && `${session.distance_m}m · `}
{session.target_type.toUpperCase()}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-accent">{seriesList.length}</div>
<div className="text-xs text-muted">série{seriesList.length !== 1 ? 's' : ''}</div>
</div>
</div>
{seriesList.length > 0 && (
<div className="mt-3 pt-3 border-t border-border flex gap-4 text-sm">
<span className="text-text-muted">Total : <b className="text-text">{seriesList.reduce((a,s)=>a+s.score_total,0)} pts</b></span>
<span className="text-text-muted">Tirs : <b className="text-text">{seriesList.reduce((a,s)=>a+s.shots_declared,0)}</b></span>
</div>
)}
</div>
{/* ─── SAISIE ─── */}
{step === 'saisie' && (
<div className="space-y-4">
<h2 className="font-semibold text-text flex items-center gap-2">
<span className="w-6 h-6 bg-accent rounded-full flex items-center justify-center text-xs font-bold text-white">1</span>
Série {seriesList.length + 1} Saisie
</h2>
<div className="card space-y-4">
<div>
<label className="label">Nombre de tirs</label>
<div className="flex items-center gap-3">
<button type="button" onClick={() => setShotsDeclared(v => Math.max(1, v - 1))}
className="w-10 h-10 rounded-lg bg-surface2 border border-border flex items-center justify-center text-xl font-bold hover:border-accent/50"></button>
<input type="number" className="input text-center text-xl font-bold w-20" min="1" max="100"
value={shotsDeclared} onChange={e => setShotsDeclared(Math.max(1, parseInt(e.target.value) || 1))} />
<button type="button" onClick={() => setShotsDeclared(v => Math.min(100, v + 1))}
className="w-10 h-10 rounded-lg bg-surface2 border border-border flex items-center justify-center text-xl font-bold hover:border-accent/50">+</button>
</div>
</div>
<div>
<label className="label">Photo de la cible</label>
{photoPreview ? (
<div className="relative">
<img src={photoPreview} alt="Cible" className="w-full rounded-lg border border-border max-h-64 object-contain bg-black" />
<button onClick={() => { setPhotoFile(null); setPhotoPreview('') }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center">
<X size={14} className="text-white" />
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={() => { fileRef.current!.setAttribute('capture', 'environment'); fileRef.current?.click() }}
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-dashed border-border hover:border-accent/50 transition-colors">
<Camera size={24} className="text-muted" />
<span className="text-xs text-muted">Prendre une photo</span>
</button>
<button type="button" onClick={() => { fileRef.current!.removeAttribute('capture'); fileRef.current?.click() }}
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-dashed border-border hover:border-accent/50 transition-colors">
<Upload size={24} className="text-muted" />
<span className="text-xs text-muted">Importer</span>
</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onPhotoChange} />
</div>
</div>
<button onClick={handleAnalyze} disabled={!photoFile}
className="btn-primary w-full py-3 flex items-center justify-center gap-2">
<Crosshair size={18} /> Analyser les impacts
</button>
{seriesList.length > 0 && (
<button onClick={() => setStep('cloture')}
className="btn-secondary w-full flex items-center justify-center gap-2">
<Flag size={16} /> Terminer la séance
</button>
)}
</div>
)}
{/* ─── ANALYSE ─── */}
{step === 'analyse' && (
<div className="card text-center py-12 space-y-4">
<div className="relative w-20 h-20 mx-auto">
<div className="absolute inset-0 rounded-full border-4 border-surface2" />
<div className="absolute inset-0 rounded-full border-4 border-t-accent animate-spin" />
<Crosshair size={24} className="absolute inset-0 m-auto text-accent" />
</div>
<div>
<p className="font-semibold text-text">Analyse des impacts...</p>
<p className="text-sm text-text-muted mt-1">YOLOv8 détecte les impacts sur la cible</p>
</div>
</div>
)}
{/* ─── RÉSULTAT ─── */}
{step === 'resultat' && (
<div className="space-y-4">
<h2 className="font-semibold text-text flex items-center gap-2">
<span className="w-6 h-6 bg-accent rounded-full flex items-center justify-center text-xs font-bold text-white">3</span>
Résultat de l'analyse
</h2>
{analysisError ? (
<div className="card border-red-400/20 bg-red-400/5 text-center py-6">
<AlertTriangle size={28} className="text-red-400 mx-auto mb-2" />
<p className="font-medium text-red-400">Analyse échouée</p>
<p className="text-sm text-text-muted mt-1">{analysisError}</p>
</div>
) : aiResult && (
<>
{aiResult.annotated_image_base64 && (
<div className="card p-2">
<img src={aiResult.annotated_image_base64} alt="Cible annotée"
className="w-full rounded-lg max-h-72 object-contain bg-black" />
</div>
)}
<div className="card">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-2xl font-bold text-text">{shotsDeclared}</div>
<div className="text-xs text-muted">Déclarés</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{shotsDetected}</div>
<div className="text-xs text-muted">Détectés</div>
</div>
<div>
<div className={`text-2xl font-bold ${manualAdjustments.length > 0 ? 'text-orange' : 'text-text-muted'}`}>
{manualAdjustments.length}
</div>
<div className="text-xs text-muted">Non détectés</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-border grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-lg font-bold text-text">{aiResult.score_zone}</div>
<div className="text-xs text-muted">Zones</div>
</div>
<div>
<div className="text-lg font-bold text-text">{aiResult.score_groupement}</div>
<div className="text-xs text-muted">Groupement</div>
</div>
<div>
<div className="text-lg font-bold text-accent">{finalScore}</div>
<div className="text-xs text-muted">Score</div>
</div>
</div>
</div>
{manualAdjustments.length > 0 && (
<div className="card space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle size={15} className="text-orange" />
<span className="text-sm font-medium text-text">
{manualAdjustments.length} tir{manualAdjustments.length > 1 ? 's' : ''} non détecté{manualAdjustments.length > 1 ? 's' : ''}
</span>
</div>
<p className="text-xs text-muted">Saisissez la valeur de zone :</p>
<div className="flex flex-wrap gap-2">
{manualAdjustments.map((pts, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-xs text-muted">Tir {i + 1} :</span>
<select className="input w-16 text-sm py-1 px-2" value={pts}
onChange={e => {
const v = [...manualAdjustments]
v[i] = parseInt(e.target.value)
setManualAdjustments(v)
}}>
{session?.target_type === 'issf'
? [0,1,2,3,4,5,6,7,8,9,10].map(n => <option key={n} value={n}>{n}</option>)
: [0,1,2,3,4,5].map(n => <option key={n} value={n}>{n}</option>)
}
</select>
</div>
))}
</div>
<div className="text-sm text-text-muted">
Score final : <b className="text-accent">{finalScore} pts</b>
</div>
</div>
)}
</>
)}
<div className="space-y-2">
<button onClick={() => saveSeries(true, false)}
className="btn-primary w-full flex items-center justify-center gap-2">
<Plus size={16} /> Nouvelle série — même cible
</button>
<button onClick={() => saveSeries(true, true)}
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronRight size={16} /> Nouvelle série — nouvelle cible
</button>
<button onClick={() => saveSeries(false, false)}
className="btn-secondary w-full flex items-center justify-center gap-2 text-orange">
<Flag size={16} /> Terminer la séance
</button>
</div>
</div>
)}
{/* ─── CLÔTURE ─── */}
{step === 'cloture' && (
<div className="space-y-4">
<h2 className="font-semibold text-text flex items-center gap-2">
<Flag size={18} className="text-orange" /> Clôture de séance
</h2>
<div className="card space-y-3">
<h3 className="font-medium text-text text-sm">Résumé</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Séries</div>
<div className="text-xl font-bold text-text">{seriesList.length}</div>
</div>
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Tirs déclarés</div>
<div className="text-xl font-bold text-text">{seriesList.reduce((a,s)=>a+s.shots_declared,0)}</div>
</div>
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Score total</div>
<div className="text-xl font-bold text-accent">{seriesList.reduce((a,s)=>a+s.score_total,0)} pts</div>
</div>
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Meilleure série</div>
<div className="text-xl font-bold text-text">{Math.max(...seriesList.map(s=>s.score_total), 0)} pts</div>
</div>
</div>
</div>
<div className="card">
<label className="label">Notes de fin de séance</label>
<textarea className="input resize-none" rows={3}
placeholder="Sensations, axes d'amélioration..."
value={notesEnd} onChange={e => setNotesEnd(e.target.value)} />
</div>
<button onClick={handleClose} disabled={closing}
className="btn-primary w-full py-3 flex items-center justify-center gap-2">
<CheckCircle2 size={18} /> {closing ? 'Enregistrement...' : 'Valider et enregistrer la séance'}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ChevronLeft, Target, Clock, MapPin, Crosshair, Download, TrendingUp, Zap } from 'lucide-react'
import { api, downloadBlob } from '../../lib/api'
import type { TrainingSession, Series, Impact } from '../../types'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Cell, ResponsiveContainer } from 'recharts'
export default function SessionDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [session, setSession] = useState<TrainingSession | null>(null)
const [series, setSeries] = useState<Series[]>([])
const [allImpacts, setAllImpacts] = useState<Impact[]>([])
const [exporting, setExporting] = useState(false)
useEffect(() => { if (id) loadDetail() }, [id])
async function loadDetail() {
try {
const data = await api.sessions.get(id!)
setSession(data as TrainingSession)
setSeries((data.series || []) as Series[])
const impacts: Impact[] = []
for (const s of (data.series || [])) {
if (s.impacts) impacts.push(...s.impacts)
}
setAllImpacts(impacts)
} catch { /* ignore */ }
}
async function handleExportPDF() {
if (!session) return
setExporting(true)
try {
const blob = await api.export.pdf(id!)
downloadBlob(blob, `shoottracker_${format(new Date(session.session_date), 'yyyy-MM-dd')}.pdf`)
} catch {
alert('Export PDF non disponible pour le moment')
}
setExporting(false)
}
function getZoneColor(zone: number | null): string {
if (!zone) return '#6b7280'
if (zone >= 9) return '#22c55e'
if (zone >= 7) return '#84cc16'
if (zone >= 5) return '#eab308'
if (zone >= 3) return '#f97316'
return '#ef4444'
}
if (!session) return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full" />
</div>
)
const weapon = session.weapon as any
const detectionRate = session.ai_detection_rate || 0
return (
<div className="space-y-5 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="btn-ghost p-2"><ChevronLeft size={20} /></button>
<h1 className="page-header mb-0 flex-1">Détail de séance</h1>
<button onClick={handleExportPDF} disabled={exporting}
className="btn-secondary flex items-center gap-1.5 text-sm py-2">
<Download size={14} /> {exporting ? '...' : 'PDF'}
</button>
</div>
{/* Infos séance */}
<div className="card space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-bold text-text">{weapon?.name || 'Arme inconnue'}</div>
<div className="text-sm text-text-muted">
{[weapon?.brand, weapon?.model].filter(Boolean).join(' ')}
{weapon?.caliber ? ` · ${weapon.caliber}` : ''}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-accent">{session.total_score}</div>
<div className="text-xs text-muted">pts total</div>
</div>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-text-muted">
<span className="flex items-center gap-1">
<Clock size={13} /> {format(new Date(session.session_date), 'dd MMMM yyyy · HH:mm', { locale: fr })}
</span>
{session.location && <span className="flex items-center gap-1"><MapPin size={13} /> {session.location}</span>}
{session.distance_m && <span className="flex items-center gap-1"><Crosshair size={13} /> {session.distance_m}m</span>}
<span className="flex items-center gap-1"><Target size={13} /> {session.target_type.toUpperCase()}</span>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3">
<div className="card text-center">
<div className="text-2xl font-bold text-text">{series.length}</div>
<div className="text-xs text-muted">Séries</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.total_shots_declared}</div>
<div className="text-xs text-muted">Tirs</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.avg_score > 0 ? session.avg_score : '—'}</div>
<div className="text-xs text-muted">Score moy./série</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.best_series_score > 0 ? session.best_series_score : '—'}</div>
<div className="text-xs text-muted">Meilleure série</div>
</div>
<div className="card text-center">
<div className={`text-2xl font-bold ${detectionRate > 80 ? 'text-green-400' : detectionRate > 50 ? 'text-yellow-400' : 'text-red-400'}`}>
{detectionRate}%
</div>
<div className="text-xs text-muted">Taux détection IA</div>
</div>
{session.avg_dispersion ? (
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.avg_dispersion}</div>
<div className="text-xs text-muted">Dispersion moy.</div>
</div>
) : <div />}
</div>
{/* Impacts scatter */}
{allImpacts.filter(i => i.x >= 0).length > 0 && (
<div className="card">
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
<Zap size={16} className="text-accent" /> Dispersion des impacts
</h3>
<ResponsiveContainer width="100%" height={240}>
<ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2e2e2e" />
<XAxis type="number" dataKey="x" domain={[0, 100]} hide />
<YAxis type="number" dataKey="y" domain={[0, 100]} hide />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ payload }) => {
if (!payload?.length) return null
const d = payload[0].payload as Impact
return (
<div className="bg-surface border border-border rounded-lg p-2 text-xs">
Zone {d.zone ?? '?'} · {d.points} pts
{d.is_manual ? ' (manuel)' : ''}
</div>
)
}}
/>
<Scatter data={allImpacts.filter(i => i.x >= 0)}>
{allImpacts.filter(i => i.x >= 0).map((imp, i) => (
<Cell key={i} fill={getZoneColor(imp.zone)} opacity={imp.is_manual ? 0.5 : 1} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
<div className="flex items-center gap-3 mt-2 justify-center flex-wrap text-xs text-muted">
<span><span className="inline-block w-3 h-3 rounded-full bg-green-500 mr-1" />910</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-lime-400 mr-1" />78</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-yellow-400 mr-1" />56</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-orange-400 mr-1" />34</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-red-500 mr-1" />12</span>
</div>
</div>
)}
{/* Séries */}
<div>
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
<TrendingUp size={16} className="text-accent" /> Séries ({series.length})
</h3>
<div className="space-y-3">
{series.map(s => (
<div key={s.id} className="card">
<div className="flex items-start gap-3">
{(s.annotated_photo_url || s.photo_url) && (
<img src={s.annotated_photo_url || s.photo_url || ''} alt=""
className="w-20 h-20 rounded-lg object-contain bg-black border border-border shrink-0" />
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-semibold text-text">Série {s.series_number}</span>
<span className="text-xl font-bold text-accent">{s.score_total} pts</span>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-xs text-text-muted">
<span>{s.shots_declared} déclarés</span>
<span className="text-green-400">{s.shots_detected} détectés</span>
{s.shots_manual > 0 && <span className="text-orange">{s.shots_manual} manuels</span>}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-xs text-muted">
<span>Zones : {s.score_zone} pts</span>
<span>Groupement : {s.score_groupement} pts</span>
{s.dispersion_radius != null && <span>Dispersion : {Number(s.dispersion_radius).toFixed(1)}</span>}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Notes */}
{(session.notes_start || session.notes_end) && (
<div className="card space-y-2">
<h3 className="font-semibold text-text text-sm">Notes</h3>
{session.notes_start && (
<div><span className="text-xs text-muted uppercase">Début : </span><span className="text-sm text-text-muted">{session.notes_start}</span></div>
)}
{session.notes_end && (
<div><span className="text-xs text-muted uppercase">Fin : </span><span className="text-sm text-text-muted">{session.notes_end}</span></div>
)}
</div>
)}
</div>
)
}
+178
View File
@@ -0,0 +1,178 @@
// ─── Types de base ───────────────────────────────────────────────────────────
export interface Profile {
id: string
first_name: string | null
last_name: string | null
club: string | null
disciplines: string[]
avatar_url: string | null
created_at: string
updated_at: string
}
export type WeaponType = 'pistolet' | 'carabine' | 'fusil' | 'arc' | 'arbalète' | 'autre'
export interface Weapon {
id: string
user_id: string
name: string
nickname: string | null
type: WeaponType
caliber: string | null
brand: string | null
model: string | null
serial_number: string | null
photo_url: string | null
notes: string | null
is_archived: boolean
created_at: string
updated_at: string
}
export type TargetType = 'issf' | 'silhouette' | 'libre'
export interface TrainingSession {
id: string
user_id: string
weapon_id: string | null
session_date: string
location: string | null
target_type: TargetType
distance_m: number | null
notes_start: string | null
notes_end: string | null
total_shots_declared: number
total_shots_detected: number
total_score: number
avg_score: number
best_series_score: number
ai_detection_rate: number
avg_dispersion: number | null
is_completed: boolean
created_at: string
updated_at: string
// Joined
weapon?: Weapon
series?: Series[]
}
export interface Series {
id: string
session_id: string
user_id: string
series_number: number
shots_declared: number
shots_detected: number
shots_manual: number
photo_url: string | null
annotated_photo_url: string | null
score_zone: number
score_groupement: number
score_total: number
dispersion_radius: number | null
center_x: number | null
center_y: number | null
ai_data: AIAnalysisResult | null
created_at: string
// Joined
impacts?: Impact[]
}
export interface Impact {
id: string
series_id: string
user_id: string
x: number
y: number
zone: number | null
points: number
is_manual: boolean
created_at: string
}
export interface Invitation {
id: string
inviter_id: string
email: string | null
token: string
is_used: boolean
used_by: string | null
created_at: string
expires_at: string
}
export interface UserLocation {
id: string
user_id: string
name: string
used_count: number
last_used_at: string
}
// ─── Résultats IA ────────────────────────────────────────────────────────────
export interface ImpactPoint {
x: number
y: number
zone: number | null
points: number
confidence: number
}
export interface AIAnalysisResult {
impacts: ImpactPoint[]
total_detected: number
score_zone: number
score_groupement: number
score_total: number
dispersion_radius: number
center_x: number
center_y: number
annotated_image_base64: string | null
error: string | null
warning: string | null
}
// ─── Forms ───────────────────────────────────────────────────────────────────
export interface SessionFormData {
session_date: string
location: string
weapon_id: string
target_type: TargetType
distance_m: number | string
notes_start: string
}
export interface WeaponFormData {
name: string
nickname: string
type: WeaponType
caliber: string
brand: string
model: string
serial_number: string
notes: string
photoFile?: File
}
// ─── Stats ───────────────────────────────────────────────────────────────────
export interface ProgressStats {
totalSessions: number
totalShots: number
avgScore: number
bestScore: number
progressPercent: number
avgDispersion: number | null
}
export interface ChartPoint {
date: string
score: number
best: number
shots: number
sessions: number
dispersion: number | null
}
+34
View File
@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
bg: '#0d0d0d',
surface: '#1a1a1a',
surface2: '#242424',
border: '#2e2e2e',
accent: '#dc2626',
'accent-hover': '#b91c1c',
orange: '#ea580c',
muted: '#6b7280',
text: '#f4f4f5',
'text-muted': '#9ca3af',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.2s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: { '0%': { opacity: 0 }, '100%': { opacity: 1 } },
slideUp: { '0%': { transform: 'translateY(20px)', opacity: 0 }, '100%': { transform: 'translateY(0)', opacity: 1 } },
},
},
},
plugins: [],
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icons/*.png', 'icons/*.svg'],
manifest: {
name: 'ShootTracker',
short_name: 'ShootTracker',
description: 'Suivi de performance en tir sportif',
theme_color: '#0d0d0d',
background_color: '#0d0d0d',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
networkTimeoutSeconds: 10,
}
}
]
}
})
],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3001', changeOrigin: true },
'/uploads': { target: 'http://localhost:3001', changeOrigin: true }
}
},
build: {
outDir: 'dist',
sourcemap: false
}
})
+246
View File
@@ -0,0 +1,246 @@
-- ============================================================
-- ShootTracker — Schéma Supabase complet
-- Migration 001 — Schéma initial
-- ============================================================
-- Extension UUID
create extension if not exists "uuid-ossp";
-- ─────────────────────────────────────────────────────────────
-- TABLES
-- ─────────────────────────────────────────────────────────────
-- Profils utilisateurs (étend auth.users)
create table if not exists public.profiles (
id uuid references auth.users on delete cascade primary key,
first_name text,
last_name text,
club text,
disciplines text[] default '{}',
avatar_url text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Armes (coffre virtuel)
create table if not exists public.weapons (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users on delete cascade not null,
name text not null,
nickname text,
type text not null check (type in ('pistolet','carabine','fusil','arc','arbalète','autre')),
caliber text,
brand text,
model text,
serial_number text,
photo_url text,
notes text,
is_archived boolean default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Séances d'entraînement
create table if not exists public.training_sessions (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users on delete cascade not null,
weapon_id uuid references public.weapons on delete set null,
session_date timestamptz not null default now(),
location text,
target_type text not null check (target_type in ('issf','silhouette','libre')),
distance_m integer,
notes_start text,
notes_end text,
total_shots_declared integer default 0,
total_shots_detected integer default 0,
total_score integer default 0,
avg_score real default 0,
best_series_score integer default 0,
ai_detection_rate real default 0,
avg_dispersion real,
is_completed boolean default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Séries de tirs (dans une séance)
create table if not exists public.series (
id uuid default gen_random_uuid() primary key,
session_id uuid references public.training_sessions on delete cascade not null,
user_id uuid references auth.users on delete cascade not null,
series_number integer not null,
shots_declared integer not null default 0,
shots_detected integer default 0,
shots_manual integer default 0,
photo_url text,
annotated_photo_url text,
score_zone integer default 0,
score_groupement integer default 0,
score_total integer default 0,
dispersion_radius real,
center_x real,
center_y real,
ai_data jsonb,
created_at timestamptz default now()
);
-- Impacts individuels
create table if not exists public.impacts (
id uuid default gen_random_uuid() primary key,
series_id uuid references public.series on delete cascade not null,
user_id uuid references auth.users on delete cascade not null,
x real not null,
y real not null,
zone integer,
points integer default 0,
is_manual boolean default false,
created_at timestamptz default now()
);
-- Invitations
create table if not exists public.invitations (
id uuid default gen_random_uuid() primary key,
inviter_id uuid references auth.users on delete cascade not null,
email text,
token text unique not null default encode(gen_random_bytes(32), 'hex'),
is_used boolean default false,
used_by uuid references auth.users on delete set null,
created_at timestamptz default now(),
expires_at timestamptz default (now() + interval '7 days')
);
-- Lieux mémorisés
create table if not exists public.user_locations (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users on delete cascade not null,
name text not null,
used_count integer default 1,
last_used_at timestamptz default now(),
unique (user_id, name)
);
-- ─────────────────────────────────────────────────────────────
-- INDEXES
-- ─────────────────────────────────────────────────────────────
create index if not exists weapons_user_id_idx on public.weapons(user_id);
create index if not exists weapons_type_idx on public.weapons(type);
create index if not exists weapons_archived_idx on public.weapons(is_archived);
create index if not exists sessions_user_id_idx on public.training_sessions(user_id);
create index if not exists sessions_weapon_id_idx on public.training_sessions(weapon_id);
create index if not exists sessions_date_idx on public.training_sessions(session_date desc);
create index if not exists sessions_completed_idx on public.training_sessions(is_completed);
create index if not exists series_session_id_idx on public.series(session_id);
create index if not exists series_user_id_idx on public.series(user_id);
create index if not exists impacts_series_id_idx on public.impacts(series_id);
create index if not exists impacts_user_id_idx on public.impacts(user_id);
create index if not exists invitations_token_idx on public.invitations(token);
create index if not exists invitations_inviter_idx on public.invitations(inviter_id);
create index if not exists locations_user_id_idx on public.user_locations(user_id);
-- ─────────────────────────────────────────────────────────────
-- ROW LEVEL SECURITY
-- ─────────────────────────────────────────────────────────────
alter table public.profiles enable row level security;
alter table public.weapons enable row level security;
alter table public.training_sessions enable row level security;
alter table public.series enable row level security;
alter table public.impacts enable row level security;
alter table public.invitations enable row level security;
alter table public.user_locations enable row level security;
-- Profiles
create policy "profiles_own" on public.profiles
for all using (auth.uid() = id);
-- Weapons
create policy "weapons_own" on public.weapons
for all using (auth.uid() = user_id);
-- Training sessions
create policy "sessions_own" on public.training_sessions
for all using (auth.uid() = user_id);
-- Series
create policy "series_own" on public.series
for all using (auth.uid() = user_id);
-- Impacts
create policy "impacts_own" on public.impacts
for all using (auth.uid() = user_id);
-- Invitations — le créateur voit ses invitations ; les non-connectés peuvent vérifier un token
create policy "invitations_own_select" on public.invitations
for select using (auth.uid() = inviter_id or not is_used);
create policy "invitations_own_insert" on public.invitations
for insert with check (auth.uid() = inviter_id);
create policy "invitations_own_update" on public.invitations
for update using (auth.uid() = inviter_id or (auth.uid() is not null and not is_used));
-- User locations
create policy "locations_own" on public.user_locations
for all using (auth.uid() = user_id);
-- ─────────────────────────────────────────────────────────────
-- FUNCTIONS & TRIGGERS
-- ─────────────────────────────────────────────────────────────
-- Crée automatiquement un profil à l'inscription
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
begin
insert into public.profiles (id, first_name, last_name)
values (
new.id,
coalesce(new.raw_user_meta_data->>'first_name', ''),
coalesce(new.raw_user_meta_data->>'last_name', '')
);
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- Met à jour updated_at automatiquement
create or replace function public.set_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger weapons_updated_at before update on public.weapons for each row execute procedure public.set_updated_at();
create trigger sessions_updated_at before update on public.training_sessions for each row execute procedure public.set_updated_at();
create trigger profiles_updated_at before update on public.profiles for each row execute procedure public.set_updated_at();
-- ─────────────────────────────────────────────────────────────
-- STORAGE BUCKETS (à exécuter dans Supabase Dashboard → Storage)
-- ─────────────────────────────────────────────────────────────
-- NB : ces commandes SQL ne fonctionnent pas directement depuis le SQL editor
-- Créer manuellement dans Dashboard → Storage :
-- bucket "avatars" → privé, taille max 5 MB
-- bucket "weapon-photos" → privé, taille max 10 MB
-- bucket "target-photos" → privé, taille max 20 MB (photos haute res)
--
-- Puis appliquer ces policies de storage :
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values
('avatars', 'avatars', false, 5242880, array['image/jpeg','image/png','image/webp']),
('weapon-photos', 'weapon-photos', false, 10485760, array['image/jpeg','image/png','image/webp']),
('target-photos', 'target-photos', false, 20971520, array['image/jpeg','image/png','image/webp'])
on conflict (id) do nothing;
-- Policies storage : chaque user n'accède qu'à son propre dossier
create policy "avatars_own" on storage.objects
for all using (bucket_id = 'avatars' and auth.uid()::text = (storage.foldername(name))[1]);
create policy "weapon_photos_own" on storage.objects
for all using (bucket_id = 'weapon-photos' and auth.uid()::text = (storage.foldername(name))[1]);
create policy "target_photos_own" on storage.objects
for all using (bucket_id = 'target-photos' and auth.uid()::text = (storage.foldername(name))[1]);