feat: ShootTracker SQLite+JWT+YOLOv8
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
+52
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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'))`),
|
||||||
|
})
|
||||||
@@ -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'}`)
|
||||||
|
})
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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' }))
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 été 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 1–10' },
|
||||||
|
{ 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 où
|
||||||
|
</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" />9–10</span>
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded-full bg-lime-400 mr-1" />7–8</span>
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded-full bg-yellow-400 mr-1" />5–6</span>
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded-full bg-orange-400 mr-1" />3–4</span>
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded-full bg-red-500 mr-1" />1–2</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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]);
|
||||||
Reference in New Issue
Block a user