From 2578cb6ec277383aa26d6d29c558476f9b270d54 Mon Sep 17 00:00:00 2001 From: ShootTracker Deploy Date: Thu, 30 Apr 2026 22:44:27 +0200 Subject: [PATCH] feat: ShootTracker SQLite+JWT+YOLOv8 --- .env.example | 16 + .gitignore | 7 + Dockerfile | 52 +++ README.md | 222 +++++++++ ai-service/Dockerfile | 41 ++ ai-service/analyzer.py | 424 +++++++++++++++++ ai-service/main.py | 169 +++++++ ai-service/requirements.txt | 12 + backend/.env.example | 12 + backend/package.json | 39 ++ backend/src/db/index.ts | 138 ++++++ backend/src/db/schema.ts | 112 +++++ backend/src/index.ts | 58 +++ backend/src/middleware/auth.ts | 29 ++ backend/src/routes/analyze.ts | 49 ++ backend/src/routes/auth.ts | 128 ++++++ backend/src/routes/export.ts | 171 +++++++ backend/src/routes/misc.ts | 88 ++++ backend/src/routes/series.ts | 133 ++++++ backend/src/routes/sessions.ts | 132 ++++++ backend/src/routes/weapons.ts | 133 ++++++ backend/tsconfig.json | 18 + deploy_shoottracker.py | 313 +++++++++++++ docker-compose.yml | 66 +++ frontend/.env.example | 3 + frontend/index.html | 23 + frontend/package.json | 32 ++ frontend/postcss.config.js | 3 + frontend/src/App.tsx | 81 ++++ frontend/src/components/BottomNav.tsx | 37 ++ frontend/src/components/Layout.tsx | 26 ++ frontend/src/components/LoadingScreen.tsx | 11 + frontend/src/components/Sidebar.tsx | 82 ++++ frontend/src/index.css | 60 +++ frontend/src/lib/api.ts | 146 ++++++ frontend/src/lib/store.ts | 59 +++ frontend/src/lib/supabase.ts | 61 +++ frontend/src/main.tsx | 13 + frontend/src/pages/Arsenal.tsx | 138 ++++++ frontend/src/pages/Dashboard.tsx | 128 ++++++ frontend/src/pages/InvitationAccept.tsx | 68 +++ frontend/src/pages/Profile.tsx | 222 +++++++++ frontend/src/pages/Progress.tsx | 226 +++++++++ frontend/src/pages/WeaponForm.tsx | 198 ++++++++ frontend/src/pages/auth/Login.tsx | 96 ++++ frontend/src/pages/auth/Register.tsx | 122 +++++ frontend/src/pages/session/NewSession.tsx | 167 +++++++ frontend/src/pages/session/SessionCapture.tsx | 429 ++++++++++++++++++ frontend/src/pages/session/SessionDetail.tsx | 222 +++++++++ frontend/src/types/index.ts | 178 ++++++++ frontend/tailwind.config.js | 34 ++ frontend/tsconfig.json | 23 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 53 +++ supabase/migrations/001_initial_schema.sql | 246 ++++++++++ 55 files changed, 5759 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ai-service/Dockerfile create mode 100644 ai-service/analyzer.py create mode 100644 ai-service/main.py create mode 100644 ai-service/requirements.txt create mode 100644 backend/.env.example create mode 100644 backend/package.json create mode 100644 backend/src/db/index.ts create mode 100644 backend/src/db/schema.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/routes/analyze.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/export.ts create mode 100644 backend/src/routes/misc.ts create mode 100644 backend/src/routes/series.ts create mode 100644 backend/src/routes/sessions.ts create mode 100644 backend/src/routes/weapons.ts create mode 100644 backend/tsconfig.json create mode 100644 deploy_shoottracker.py create mode 100644 docker-compose.yml create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/BottomNav.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/LoadingScreen.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/store.ts create mode 100644 frontend/src/lib/supabase.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Arsenal.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/InvitationAccept.tsx create mode 100644 frontend/src/pages/Profile.tsx create mode 100644 frontend/src/pages/Progress.tsx create mode 100644 frontend/src/pages/WeaponForm.tsx create mode 100644 frontend/src/pages/auth/Login.tsx create mode 100644 frontend/src/pages/auth/Register.tsx create mode 100644 frontend/src/pages/session/NewSession.tsx create mode 100644 frontend/src/pages/session/SessionCapture.tsx create mode 100644 frontend/src/pages/session/SessionDetail.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 supabase/migrations/001_initial_schema.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c35c42 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65ebe5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +data/ +*.db +__pycache__/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e336e8d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..595adbe --- /dev/null +++ b/README.md @@ -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 diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..20a5c8b --- /dev/null +++ b/ai-service/Dockerfile @@ -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"] diff --git a/ai-service/analyzer.py b/ai-service/analyzer.py new file mode 100644 index 0000000..5194c0f --- /dev/null +++ b/ai-service/analyzer.py @@ -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 diff --git a/ai-service/main.py b/ai-service/main.py new file mode 100644 index 0000000..5cd5d68 --- /dev/null +++ b/ai-service/main.py @@ -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) diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt new file mode 100644 index 0000000..808cd39 --- /dev/null +++ b/ai-service/requirements.txt @@ -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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ec5e373 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b69295a --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..cba9c30 --- /dev/null +++ b/backend/src/db/index.ts @@ -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 } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts new file mode 100644 index 0000000..7e88ba9 --- /dev/null +++ b/backend/src/db/schema.ts @@ -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'))`), +}) diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..b3d132a --- /dev/null +++ b/backend/src/index.ts @@ -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'}`) +}) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..ca39da4 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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' }) +} diff --git a/backend/src/routes/analyze.ts b/backend/src/routes/analyze.ts new file mode 100644 index 0000000..915d507 --- /dev/null +++ b/backend/src/routes/analyze.ts @@ -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' }) + } +}) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..d8cb3af --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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 } +} diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts new file mode 100644 index 0000000..55e8344 --- /dev/null +++ b/backend/src/routes/export.ts @@ -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 = {} + 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' })) +}) diff --git a/backend/src/routes/misc.ts b/backend/src/routes/misc.ts new file mode 100644 index 0000000..dc76dfc --- /dev/null +++ b/backend/src/routes/misc.ts @@ -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 }) +}) diff --git a/backend/src/routes/series.ts b/backend/src/routes/series.ts new file mode 100644 index 0000000..61b5d06 --- /dev/null +++ b/backend/src/routes/series.ts @@ -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 + + 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 } +} diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts new file mode 100644 index 0000000..ae7b7ad --- /dev/null +++ b/backend/src/routes/sessions.ts @@ -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 = {} + 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 } +} diff --git a/backend/src/routes/weapons.ts b/backend/src/routes/weapons.ts new file mode 100644 index 0000000..0312859 --- /dev/null +++ b/backend/src/routes/weapons.ts @@ -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' }) +}) diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..e41dbca --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/deploy_shoottracker.py b/deploy_shoottracker.py new file mode 100644 index 0000000..ca091f6 --- /dev/null +++ b/deploy_shoottracker.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f587b06 --- /dev/null +++ b/docker-compose.yml @@ -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= +# 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 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..3685c43 --- /dev/null +++ b/frontend/.env.example @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8b73cb6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + ShootTracker + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..753048e --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..be56e0e --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e13d031 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 + + return ( + + {/* Pages publiques */} + : } /> + : } /> + } /> + + {/* Pages protégées */} + : }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + ) +} diff --git a/frontend/src/components/BottomNav.tsx b/frontend/src/components/BottomNav.tsx new file mode 100644 index 0000000..91ec63d --- /dev/null +++ b/frontend/src/components/BottomNav.tsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..92a3a62 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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 ( +
+ {/* Sidebar — desktop */} + + + {/* Contenu principal */} +
+
+ +
+
+ + {/* Bottom nav — mobile */} + +
+ ) +} diff --git a/frontend/src/components/LoadingScreen.tsx b/frontend/src/components/LoadingScreen.tsx new file mode 100644 index 0000000..9251e85 --- /dev/null +++ b/frontend/src/components/LoadingScreen.tsx @@ -0,0 +1,11 @@ +export default function LoadingScreen() { + return ( +
+
+
+
+
+
ShootTracker
+
+ ) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..17aafa8 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -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 ( + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..657c05a --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..7afe9cc --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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( + method: string, + path: string, + body?: Record | FormData, +): Promise { + const headers: Record = {} + 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) => req('POST', '/api/sessions', data), + update: (id: string, data: Record) => 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 => { + 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 => { + const qs = new URLSearchParams(params as Record).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 { + 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) +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..2d7ff75 --- /dev/null +++ b/frontend/src/lib/store.ts @@ -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 + fetchWeapons: () => Promise + fetchSessions: () => Promise + reset: () => void +} + +export const useStore = create((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 }), +})) diff --git a/frontend/src/lib/supabase.ts b/frontend/src/lib/supabase.ts new file mode 100644 index 0000000..f2ba2ac --- /dev/null +++ b/frontend/src/lib/supabase.ts @@ -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 { + 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 { + // 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 { + 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 + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..38d3698 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + +) diff --git a/frontend/src/pages/Arsenal.tsx b/frontend/src/pages/Arsenal.tsx new file mode 100644 index 0000000..4d4d009 --- /dev/null +++ b/frontend/src/pages/Arsenal.tsx @@ -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 = { + pistolet: 'Pistolet', carabine: 'Carabine', fusil: 'Fusil', + arc: 'Arc', arbalète: 'Arbalète', autre: 'Autre', +} +const TYPE_COLORS: Record = { + 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('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 ( +
+
+

Arsenal

+ + Ajouter + +
+ + {/* Filtres */} +
+
+ + setSearch(e.target.value)} /> +
+
+ {(['all', 'pistolet', 'carabine', 'fusil', 'arc', 'arbalète', 'autre'] as const).map(t => ( + + ))} +
+ +
+ + {/* Liste */} + {filtered.length === 0 ? ( +
+ +

Aucune arme

+

Ajoutez votre première arme au coffre

+ + Ajouter une arme + +
+ ) : ( +
+ {filtered.map(w => toggleArchive(w)} />)} +
+ )} +
+ ) +} + +function WeaponCard({ weapon: w, onToggleArchive }: { weapon: Weapon; onToggleArchive: () => void }) { + return ( +
+ {w.is_archived && ( +
+ Archivée +
+ )} +
+
+ {w.photo_url ? ( + {w.name} + ) : ( + + )} +
+
+
+
+
{w.name}
+ {w.nickname &&
"{w.nickname}"
} +
+
+
+ {TYPE_LABELS[w.type]} +
+ {(w.brand || w.model) && ( +
+ {[w.brand, w.model].filter(Boolean).join(' ')} +
+ )} + {w.caliber &&
{w.caliber}
} +
+
+
+ + Modifier + + +
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..eeb46e8 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -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([]) + 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 ( +
+ {/* Header */} +
+
+

{greeting}

+

{displayName} 👋

+ {profile?.club &&

{profile.club}

} +
+ + Séance + +
+ + {/* Stats rapides */} +
+ } label="Séances" value={stats.total} /> + } label="Tirs totaux" value={stats.shots.toLocaleString()} /> + } label="Score moyen" value={stats.avg > 0 ? stats.avg : '—'} /> + } label="Meilleur score" value={stats.best > 0 ? stats.best : '—'} /> +
+ + {/* Accès rapides */} +
+ } label="Nouvelle séance" color="accent" /> + } label={`Arsenal (${activeWeapons.length})`} color="orange" /> + } label="Progression" color="green-500" /> +
+ + {/* Séances récentes */} +
+
+

Séances récentes

+ Voir tout +
+ + {recentSessions.length === 0 ? ( +
+ +

Aucune séance enregistrée

+

Commencez par créer votre première séance

+ + Créer une séance + +
+ ) : ( +
+ {recentSessions.map(session => ( + +
+ +
+
+
+ {(session.weapon as any)?.name || 'Arme inconnue'} — {session.location || 'Lieu non précisé'} +
+
+ + + {format(new Date(session.session_date), 'd MMM yyyy', { locale: fr })} + + {session.total_shots_declared} tirs + {session.total_score > 0 && ( + {session.total_score} pts + )} +
+
+ + + ))} +
+ )} +
+
+ ) +} + +function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) { + return ( +
+
{icon}
+
+
{value}
+
{label}
+
+
+ ) +} + +function QuickAction({ to, icon, label, color }: { to: string; icon: React.ReactNode; label: string; color: string }) { + return ( + +
{icon}
+ {label} + + ) +} diff --git a/frontend/src/pages/InvitationAccept.tsx b/frontend/src/pages/InvitationAccept.tsx new file mode 100644 index 0000000..2ad1e16 --- /dev/null +++ b/frontend/src/pages/InvitationAccept.tsx @@ -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 ( +
+
+
+ ) + + return ( +
+
+
+ +
+ + {status === 'valid' ? ( + <> +
+ +
+

Invitation valide !

+

+ Vous avez été invité à rejoindre ShootTracker. Créez votre compte pour commencer à suivre vos performances. +

+ + + ) : ( + <> +
+ +
+

+ {status === 'expired' ? 'Invitation expirée' : 'Invitation invalide'} +

+

+ {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é."} +

+ + + )} +
+
+ ) +} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..f530a28 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -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(null) + const [form, setForm] = useState({ + first_name: '', last_name: '', club: '', disciplines: [] as string[], + }) + const [avatarFile, setAvatarFile] = useState(null) + const [avatarPreview, setAvatarPreview] = useState('') + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [invitations, setInvitations] = useState([]) + const [copiedId, setCopiedId] = useState('') + + 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) { + 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 ( +
+

Profil

+ + {/* Avatar */} +
+
fileRef.current?.click()}> + {avatarPreview ? ( + + ) : ( +
+ +
+ )} +
+ +
+
+
+
{form.first_name} {form.last_name}
+
+ {profile?.email || ''} +
+ {form.club &&
{form.club}
} +
+ +
+ + {/* Infos */} +
+

Informations

+
+
+ + setForm(f => ({ ...f, first_name: e.target.value }))} /> +
+
+ + setForm(f => ({ ...f, last_name: e.target.value }))} /> +
+
+
+ + setForm(f => ({ ...f, club: e.target.value }))} /> +
+
+ +
+ {DISCIPLINES.map(d => ( + + ))} +
+
+ +
+ + {/* Invitations */} +
+
+

+ Invitations ({activeInvites.length}/5) +

+ +
+

Invitez jusqu'à 5 amis. Chacun crée son propre compte indépendant.

+ {invitations.length === 0 ? ( +

Aucune invitation créée

+ ) : ( +
+ {invitations.map(inv => ( +
+
+
{inv.token.slice(0, 16)}...
+ {inv.is_used + ?
Utilisée
+ :
Expire {new Date(inv.expires_at).toLocaleDateString('fr')}
+ } +
+ {!inv.is_used && ( + + )} + +
+ ))} +
+ )} +
+ + {/* Déconnexion */} + +
+ ) +} diff --git a/frontend/src/pages/Progress.tsx b/frontend/src/pages/Progress.tsx new file mode 100644 index 0000000..fb6c44f --- /dev/null +++ b/frontend/src/pages/Progress.tsx @@ -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([]) + const [filtered, setFiltered] = useState([]) + const [period, setPeriod] = useState('90j') + const [weaponFilter, setWeaponFilter] = useState('all') + const [chartData, setChartData] = useState([]) + 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 ( +
+
+

Progression

+ +
+ + {/* Filtres */} +
+
+ {PERIODS.map(p => ( + + ))} +
+
+ + +
+
+ + {/* Stats */} +
+
+
{stats.total}
+
Séances
+
+
+
{stats.shots.toLocaleString()}
+
Tirs
+
+
+
{stats.avgScore || '—'}
+
Score moyen
+
+
+
0 ? 'text-green-400' : stats.progress < 0 ? 'text-red-400' : 'text-text'}`}> + {stats.progress > 0 ? '+' : ''}{stats.progress}% +
+
Progression
+
+
+ + {filtered.length === 0 ? ( +
+ +

Aucune donnée pour cette période

+
+ ) : ( + <> +
+

Score moyen par séance

+ + + + + + + + + + + + + + + + +
+ +
+

Tirs par séance

+ + + + + + + + + +
+ + )} + + {/* Historique */} +
+

Historique des séances

+ {filtered.length === 0 ? ( +

Aucune séance

+ ) : ( +
+ {[...filtered].reverse().map(s => ( + +
+ +
+
+
+ {(s.weapon as any)?.name || 'Arme inconnue'}{s.location ? ` — ${s.location}` : ''} +
+
+ {format(new Date(s.session_date), 'dd MMM yyyy', { locale: fr })} + {s.total_shots_declared} tirs + {s.total_score > 0 && {s.total_score} pts} + 80 ? 'text-green-400' : 'text-yellow-400'}>IA {s.ai_detection_rate}% +
+
+ + + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/WeaponForm.tsx b/frontend/src/pages/WeaponForm.tsx new file mode 100644 index 0000000..1606740 --- /dev/null +++ b/frontend/src/pages/WeaponForm.tsx @@ -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(null) + + const [form, setForm] = useState({ + name: '', nickname: '', type: 'pistolet' as WeaponType, + caliber: '', brand: '', model: '', serial_number: '', notes: '', + }) + const [photoFile, setPhotoFile] = useState(null) + const [photoPreview, setPhotoPreview] = useState('') + const [currentPhotoUrl, setCurrentPhotoUrl] = useState('') + 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) { + 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 ( +
+
+ +

{isEdit ? "Modifier l'arme" : 'Ajouter une arme'}

+
+ +
+ {/* Photo */} +
+ +
+
fileRef.current?.click()} + > + {photoPreview || currentPhotoUrl ? ( + + ) : ( + + )} +
+
+ +

JPG, PNG, WebP — max 20 Mo

+
+ +
+
+ + {/* Infos */} +
+

Informations

+
+ + update('name', e.target.value)} /> +
+
+ + update('nickname', e.target.value)} /> +
+
+ + +
+
+
+ + update('brand', e.target.value)} /> +
+
+ + update('model', e.target.value)} /> +
+
+
+
+ + update('caliber', e.target.value)} /> +
+
+ + update('serial_number', e.target.value)} /> +
+
+
+ + {/* Notes */} +
+ +