feat: ShootTracker SQLite+JWT+YOLOv8

This commit is contained in:
ShootTracker Deploy
2026-04-30 22:44:27 +02:00
commit 2578cb6ec2
55 changed files with 5759 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
VITE_AI_SERVICE_URL=https://shoottracker-ai.domench.fr
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d0d0d" />
<meta name="description" content="Suivi de performance en tir sportif" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ShootTracker" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>ShootTracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "shoottracker-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx"
},
"dependencies": {
"date-fns": "^3.6.0",
"lucide-react": "^0.395.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1",
"vite-plugin-pwa": "^0.20.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { api, getToken, clearToken } from './lib/api'
import { useStore } from './lib/store'
import Layout from './components/Layout'
import Login from './pages/auth/Login'
import Register from './pages/auth/Register'
import Dashboard from './pages/Dashboard'
import Arsenal from './pages/Arsenal'
import WeaponForm from './pages/WeaponForm'
import NewSession from './pages/session/NewSession'
import SessionCapture from './pages/session/SessionCapture'
import SessionDetail from './pages/session/SessionDetail'
import Progress from './pages/Progress'
import Profile from './pages/Profile'
import InvitationAccept from './pages/InvitationAccept'
import LoadingScreen from './components/LoadingScreen'
export default function App() {
const [authReady, setAuthReady] = useState(false)
const [isAuth, setIsAuth] = useState(false)
const { fetchProfile, fetchWeapons, fetchSessions, reset } = useStore()
useEffect(() => {
const token = getToken()
if (!token) { setAuthReady(true); return }
api.auth.me()
.then(() => {
setIsAuth(true)
fetchProfile()
fetchWeapons()
fetchSessions()
})
.catch(() => {
clearToken()
setIsAuth(false)
})
.finally(() => setAuthReady(true))
}, [])
function handleLogin() {
setIsAuth(true)
fetchProfile()
fetchWeapons()
fetchSessions()
}
function handleLogout() {
clearToken()
reset()
setIsAuth(false)
}
if (!authReady) return <LoadingScreen />
return (
<Routes>
{/* Pages publiques */}
<Route path="/login" element={!isAuth ? <Login onLogin={handleLogin} /> : <Navigate to="/" />} />
<Route path="/register" element={!isAuth ? <Register /> : <Navigate to="/" />} />
<Route path="/invitation/:token" element={<InvitationAccept />} />
{/* Pages protégées */}
<Route element={isAuth ? <Layout onLogout={handleLogout} /> : <Navigate to="/login" />}>
<Route path="/" element={<Dashboard />} />
<Route path="/arsenal" element={<Arsenal />} />
<Route path="/arsenal/new" element={<WeaponForm />} />
<Route path="/arsenal/:id/edit" element={<WeaponForm />} />
<Route path="/session/new" element={<NewSession />} />
<Route path="/session/:id/capture" element={<SessionCapture />} />
<Route path="/session/:id" element={<SessionDetail />} />
<Route path="/progress" element={<Progress />} />
<Route path="/profile" element={<Profile onLogout={handleLogout} />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { NavLink } from 'react-router-dom'
import { Home, Crosshair, Shield, TrendingUp, User } from 'lucide-react'
const navItems = [
{ to: '/', icon: Home, label: 'Accueil' },
{ to: '/session/new', icon: Crosshair, label: 'Séance' },
{ to: '/arsenal', icon: Shield, label: 'Arsenal' },
{ to: '/progress', icon: TrendingUp, label: 'Progression'},
{ to: '/profile', icon: User, label: 'Profil' },
]
export default function BottomNav() {
return (
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-surface border-t border-border safe-bottom z-50">
<div className="flex items-stretch h-16">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors
${isActive ? 'text-accent' : 'text-muted hover:text-text-muted'}`
}
>
{({ isActive }) => (
<>
<Icon size={20} className={isActive ? 'text-accent' : ''} />
<span>{label}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import BottomNav from './BottomNav'
interface LayoutProps {
onLogout: () => void
}
export default function Layout({ onLogout }: LayoutProps) {
return (
<div className="flex h-screen bg-bg overflow-hidden">
{/* Sidebar — desktop */}
<Sidebar onLogout={onLogout} className="hidden lg:flex w-64 shrink-0" />
{/* Contenu principal */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 py-6 pb-24 lg:pb-6">
<Outlet />
</div>
</main>
{/* Bottom nav — mobile */}
<BottomNav className="lg:hidden" />
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export default function LoadingScreen() {
return (
<div className="fixed inset-0 bg-bg flex flex-col items-center justify-center gap-4">
<div className="relative w-16 h-16">
<div className="absolute inset-0 rounded-full border-4 border-surface2" />
<div className="absolute inset-0 rounded-full border-4 border-t-accent animate-spin" />
</div>
<div className="text-text-muted text-sm font-medium tracking-widest uppercase">ShootTracker</div>
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { NavLink } from 'react-router-dom'
import { Home, Crosshair, Shield, TrendingUp, User, LogOut, Target } from 'lucide-react'
import { useStore } from '../lib/store'
const navItems = [
{ to: '/', icon: Home, label: 'Tableau de bord' },
{ to: '/session/new', icon: Crosshair, label: 'Nouvelle séance' },
{ to: '/arsenal', icon: Shield, label: 'Arsenal' },
{ to: '/progress', icon: TrendingUp, label: 'Progression' },
{ to: '/profile', icon: User, label: 'Profil' },
]
interface SidebarProps {
onLogout: () => void
className?: string
}
export default function Sidebar({ onLogout, className = '' }: SidebarProps) {
const profile = useStore(s => s.profile)
return (
<aside className={`bg-surface border-r border-border flex-col z-40 ${className}`}>
{/* Logo */}
<div className="p-6 flex items-center gap-3 border-b border-border">
<div className="w-9 h-9 rounded-lg bg-accent flex items-center justify-center">
<Target size={20} className="text-white" />
</div>
<div>
<div className="font-bold text-text text-lg leading-tight">ShootTracker</div>
<div className="text-xs text-muted">Tir sportif</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all
${isActive
? 'bg-accent/10 text-accent border border-accent/20'
: 'text-text-muted hover:bg-surface2 hover:text-text'}`
}
>
{({ isActive }) => (
<>
<Icon size={18} className={isActive ? 'text-accent' : ''} />
{label}
</>
)}
</NavLink>
))}
</nav>
{/* Profil + déconnexion */}
<div className="p-4 border-t border-border">
<div className="flex items-center gap-3 mb-3 px-2">
<div className="w-8 h-8 rounded-full bg-surface2 border border-border flex items-center justify-center text-xs font-bold text-accent overflow-hidden">
{profile?.avatar_url
? <img src={profile.avatar_url} alt="" className="w-full h-full object-cover" />
: profile?.first_name?.[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text truncate">
{profile?.first_name} {profile?.last_name}
</div>
<div className="text-xs text-muted truncate">{profile?.club || 'Pas de club'}</div>
</div>
</div>
<button
onClick={onLogout}
className="btn-ghost w-full flex items-center gap-2 text-sm justify-start"
>
<LogOut size={16} /> Déconnexion
</button>
</div>
</aside>
)
}
+60
View File
@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
background-color: #0d0d0d;
color: #f4f4f5;
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar personnalisée */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: #1a1a1a; }
::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 2px; }
/* Safe area iOS */
.safe-bottom { padding-bottom: env(safe-area-inset-bottom); }
.safe-top { padding-top: env(safe-area-inset-top); }
}
@layer components {
.btn-primary {
@apply bg-accent hover:bg-accent-hover text-white font-semibold px-4 py-2.5 rounded-lg
transition-all duration-150 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-surface2 hover:bg-border text-text font-medium px-4 py-2.5 rounded-lg
border border-border transition-all duration-150 active:scale-95;
}
.btn-ghost {
@apply hover:bg-surface2 text-text-muted hover:text-text font-medium px-3 py-2 rounded-lg
transition-all duration-150;
}
.card {
@apply bg-surface border border-border rounded-xl p-4;
}
.input {
@apply bg-surface2 border border-border rounded-lg px-3 py-2.5 text-text placeholder-muted
focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent
transition-colors w-full;
}
.label {
@apply block text-sm font-medium text-text-muted mb-1;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.divider {
@apply border-t border-border my-4;
}
.page-header {
@apply text-xl font-bold text-text mb-6;
}
}
+146
View File
@@ -0,0 +1,146 @@
// ─── HTTP client JWT ──────────────────────────────────────────────────────────
const BASE = import.meta.env.VITE_API_URL || ''
export function getToken(): string | null {
return localStorage.getItem('st-token')
}
export function setToken(token: string) {
localStorage.setItem('st-token', token)
}
export function clearToken() {
localStorage.removeItem('st-token')
}
async function req<T = any>(
method: string,
path: string,
body?: Record<string, any> | FormData,
): Promise<T> {
const headers: Record<string, string> = {}
const token = getToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const isFormData = body instanceof FormData
if (body && !isFormData) headers['Content-Type'] = 'application/json'
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? (isFormData ? body : JSON.stringify(body)) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err?.error || `HTTP ${res.status}`)
}
return res.json()
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
export const api = {
auth: {
login: (email: string, password: string) =>
req('POST', '/api/auth/login', { email, password }),
register: (data: {
email: string; password: string
first_name: string; last_name: string
club?: string; invite_token?: string
}) => req('POST', '/api/auth/register', data),
me: () => req('GET', '/api/auth/me'),
updateProfile: (data: {
first_name?: string; last_name?: string
club?: string; disciplines?: string[]
}) => req('PUT', '/api/auth/profile', data),
updatePassword: (current_password: string, new_password: string) =>
req('PUT', '/api/auth/password', { current_password, new_password }),
uploadAvatar: (file: File) => {
const fd = new FormData(); fd.append('avatar', file)
return req('POST', '/api/auth/avatar', fd)
},
},
weapons: {
list: () => req('GET', '/api/weapons'),
get: (id: string) => req('GET', `/api/weapons/${id}`),
create: (fd: FormData) => req('POST', '/api/weapons', fd),
update: (id: string, fd: FormData) => req('PUT', `/api/weapons/${id}`, fd),
delete: (id: string) => req('DELETE', `/api/weapons/${id}`),
archive: (id: string, is_archived: boolean) =>
req('PATCH', `/api/weapons/${id}/archive`, { is_archived }),
},
sessions: {
list: () => req('GET', '/api/sessions'),
get: (id: string) => req('GET', `/api/sessions/${id}`),
create: (data: Record<string, any>) => req('POST', '/api/sessions', data),
update: (id: string, data: Record<string, any>) => req('PUT', `/api/sessions/${id}`, data),
delete: (id: string) => req('DELETE', `/api/sessions/${id}`),
},
series: {
list: (sessionId: string) => req('GET', `/api/sessions/${sessionId}/series`),
create: (sessionId: string, fd: FormData) =>
req('POST', `/api/sessions/${sessionId}/series`, fd),
saveAnnotated: (seriesId: string, image_base64: string) =>
req('POST', `/api/series/${seriesId}/annotated`, { image_base64 }),
},
analyze: {
target: (data: {
image_base64: string
target_type: string
previous_image_base64?: string
}) => req('POST', '/api/analyze', data),
},
export: {
pdf: async (sessionId: string): Promise<Blob> => {
const res = await fetch(`${BASE}/api/export/pdf/${sessionId}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Export PDF échoué')
return res.blob()
},
excel: async (params: { period?: string; weaponId?: string }): Promise<Blob> => {
const qs = new URLSearchParams(params as Record<string, string>).toString()
const res = await fetch(`${BASE}/api/export/excel?${qs}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Export Excel échoué')
return res.blob()
},
},
misc: {
locations: () => req('GET', '/api/locations'),
invitations: {
list: () => req('GET', '/api/invitations'),
create: (email?: string) => req('POST', '/api/invitations', email ? { email } : {}),
delete: (id: string) => req('DELETE', `/api/invitations/${id}`),
validate: (token: string) => req('GET', `/api/invitations/validate/${token}`),
},
},
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename; a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+59
View File
@@ -0,0 +1,59 @@
import { create } from 'zustand'
import type { Profile, Weapon, TrainingSession } from '../types'
import { api } from './api'
interface AppStore {
profile: Profile | null
weapons: Weapon[]
sessions: TrainingSession[]
activeSession: TrainingSession | null
loading: boolean
setProfile: (p: Profile | null) => void
setWeapons: (w: Weapon[]) => void
setSessions: (s: TrainingSession[]) => void
setActiveSession: (s: TrainingSession | null) => void
setLoading: (v: boolean) => void
fetchProfile: () => Promise<void>
fetchWeapons: () => Promise<void>
fetchSessions: () => Promise<void>
reset: () => void
}
export const useStore = create<AppStore>((set) => ({
profile: null,
weapons: [],
sessions: [],
activeSession: null,
loading: false,
setProfile: (p) => set({ profile: p }),
setWeapons: (w) => set({ weapons: w }),
setSessions: (s) => set({ sessions: s }),
setActiveSession: (s) => set({ activeSession: s }),
setLoading: (v) => set({ loading: v }),
fetchProfile: async () => {
try {
const data = await api.auth.me()
if (data) set({ profile: data as Profile })
} catch { /* token expiré */ }
},
fetchWeapons: async () => {
try {
const data = await api.weapons.list()
if (data) set({ weapons: data as Weapon[] })
} catch { /* ignore */ }
},
fetchSessions: async () => {
try {
const data = await api.sessions.list()
if (data) set({ sessions: (data as TrainingSession[]).filter(s => s.is_completed) })
} catch { /* ignore */ }
},
reset: () => set({ profile: null, weapons: [], sessions: [], activeSession: null }),
}))
+61
View File
@@ -0,0 +1,61 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Variables Supabase manquantes : VITE_SUPABASE_URL et VITE_SUPABASE_ANON_KEY requis')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
})
// ─── Upload helpers ───────────────────────────────────────────────────────────
export async function uploadFile(
bucket: string,
userId: string,
file: File,
folder = ''
): Promise<string | null> {
const ext = file.name.split('.').pop()
const path = `${userId}/${folder ? folder + '/' : ''}${Date.now()}.${ext}`
const { error } = await supabase.storage.from(bucket).upload(path, file, { upsert: true })
if (error) { console.error('Upload error:', error); return null }
const { data } = supabase.storage.from(bucket).getPublicUrl(path)
return data.publicUrl
}
export async function getSignedUrl(bucket: string, path: string): Promise<string | null> {
// path = 'userId/...'
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(path, 3600)
if (error || !data) return null
return data.signedUrl
}
export async function uploadBase64Image(
bucket: string,
userId: string,
base64: string,
folder = 'annotated'
): Promise<string | null> {
try {
const base64Data = base64.replace(/^data:image\/\w+;base64,/, '')
const byteCharacters = atob(base64Data)
const byteArray = new Uint8Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteArray[i] = byteCharacters.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: 'image/jpeg' })
const file = new File([blob], `annotated_${Date.now()}.jpg`, { type: 'image/jpeg' })
return uploadFile(bucket, userId, file, folder)
} catch (e) {
console.error('uploadBase64Image error:', e)
return null
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)
+138
View File
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Search, Shield, Archive, Edit2, ArchiveRestore } from 'lucide-react'
import { api } from '../lib/api'
import { useStore } from '../lib/store'
import type { Weapon, WeaponType } from '../types'
const TYPE_LABELS: Record<WeaponType, string> = {
pistolet: 'Pistolet', carabine: 'Carabine', fusil: 'Fusil',
arc: 'Arc', arbalète: 'Arbalète', autre: 'Autre',
}
const TYPE_COLORS: Record<WeaponType, string> = {
pistolet: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
carabine: 'text-green-400 bg-green-400/10 border-green-400/20',
fusil: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
arc: 'text-purple-400 bg-purple-400/10 border-purple-400/20',
arbalète: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20',
autre: 'text-muted bg-surface2 border-border',
}
export default function Arsenal() {
const { weapons, fetchWeapons } = useStore()
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<WeaponType | 'all'>('all')
const [showArchived, setShowArchived] = useState(false)
const filtered = weapons.filter(w => {
if (!showArchived && w.is_archived) return false
if (typeFilter !== 'all' && w.type !== typeFilter) return false
const q = search.toLowerCase()
return !q || w.name.toLowerCase().includes(q) || w.brand?.toLowerCase().includes(q) || w.model?.toLowerCase().includes(q)
})
async function toggleArchive(w: Weapon) {
try {
await api.weapons.archive(w.id, !w.is_archived)
fetchWeapons()
} catch (e) { console.error(e) }
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<h1 className="page-header mb-0">Arsenal</h1>
<Link to="/arsenal/new" className="btn-primary flex items-center gap-2 text-sm">
<Plus size={16} /> Ajouter
</Link>
</div>
{/* Filtres */}
<div className="space-y-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input type="text" className="input pl-9" placeholder="Rechercher une arme..."
value={search} onChange={e => setSearch(e.target.value)} />
</div>
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{(['all', 'pistolet', 'carabine', 'fusil', 'arc', 'arbalète', 'autre'] as const).map(t => (
<button key={t} onClick={() => setTypeFilter(t)}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium border transition-all
${typeFilter === t ? 'bg-accent text-white border-accent' : 'border-border text-text-muted hover:border-accent/30'}`}>
{t === 'all' ? 'Tout' : TYPE_LABELS[t]}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)}
className="rounded bg-surface2 border-border accent-accent" />
Afficher les armes archivées
</label>
</div>
{/* Liste */}
{filtered.length === 0 ? (
<div className="card text-center py-12">
<Shield size={36} className="text-muted mx-auto mb-3" />
<p className="text-text-muted font-medium">Aucune arme</p>
<p className="text-sm text-muted mt-1 mb-4">Ajoutez votre première arme au coffre</p>
<Link to="/arsenal/new" className="btn-primary inline-flex items-center gap-2">
<Plus size={16} /> Ajouter une arme
</Link>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map(w => <WeaponCard key={w.id} weapon={w} onToggleArchive={() => toggleArchive(w)} />)}
</div>
)}
</div>
)
}
function WeaponCard({ weapon: w, onToggleArchive }: { weapon: Weapon; onToggleArchive: () => void }) {
return (
<div className={`card relative overflow-hidden transition-all ${w.is_archived ? 'opacity-60' : 'hover:border-accent/20'}`}>
{w.is_archived && (
<div className="absolute top-2 right-2 badge bg-surface2 border border-border text-muted">
<Archive size={10} className="mr-1" /> Archivée
</div>
)}
<div className="flex gap-3">
<div className="w-20 h-20 rounded-lg bg-surface2 border border-border overflow-hidden shrink-0 flex items-center justify-center">
{w.photo_url ? (
<img src={w.photo_url} alt={w.name} className="w-full h-full object-cover" />
) : (
<Shield size={28} className="text-muted" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold text-text truncate">{w.name}</div>
{w.nickname && <div className="text-xs text-muted italic">"{w.nickname}"</div>}
</div>
</div>
<div className={`badge border text-xs mt-1.5 ${TYPE_COLORS[w.type]}`}>
{TYPE_LABELS[w.type]}
</div>
{(w.brand || w.model) && (
<div className="text-xs text-text-muted mt-1 truncate">
{[w.brand, w.model].filter(Boolean).join(' ')}
</div>
)}
{w.caliber && <div className="text-xs text-muted">{w.caliber}</div>}
</div>
</div>
<div className="flex gap-2 mt-3 pt-3 border-t border-border">
<Link to={`/arsenal/${w.id}/edit`}
className="flex-1 btn-secondary text-xs flex items-center justify-center gap-1.5 py-2">
<Edit2 size={13} /> Modifier
</Link>
<button onClick={onToggleArchive}
className="flex-1 btn-secondary text-xs flex items-center justify-center gap-1.5 py-2">
{w.is_archived ? <><ArchiveRestore size={13} /> Restaurer</> : <><Archive size={13} /> Archiver</>}
</button>
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Target, Shield, TrendingUp, Plus, ChevronRight, Activity, Crosshair, Clock } from 'lucide-react'
import { useStore } from '../lib/store'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import type { TrainingSession } from '../types'
export default function Dashboard() {
const { profile, sessions, weapons } = useStore()
const [recentSessions, setRecentSessions] = useState<TrainingSession[]>([])
const [stats, setStats] = useState({ total: 0, shots: 0, avg: 0, best: 0 })
useEffect(() => {
setRecentSessions(sessions.slice(0, 5))
if (sessions.length > 0) {
const total = sessions.length
const shots = sessions.reduce((a, s) => a + s.total_shots_declared, 0)
const avg = sessions.reduce((a, s) => a + s.avg_score, 0) / total
const best = Math.max(...sessions.map(s => s.best_series_score))
setStats({ total, shots, avg: Math.round(avg * 10) / 10, best })
}
}, [sessions])
const activeWeapons = weapons.filter(w => !w.is_archived)
const displayName = profile?.first_name || 'Tireur'
const hour = new Date().getHours()
const greeting = hour < 12 ? 'Bonjour' : hour < 18 ? 'Bon après-midi' : 'Bonsoir'
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<p className="text-text-muted text-sm">{greeting}</p>
<h1 className="text-2xl font-bold text-text">{displayName} 👋</h1>
{profile?.club && <p className="text-text-muted text-sm mt-0.5">{profile.club}</p>}
</div>
<Link to="/session/new"
className="flex items-center gap-2 bg-accent hover:bg-accent-hover text-white text-sm font-semibold px-4 py-2.5 rounded-lg transition-all active:scale-95 shadow-lg shadow-accent/20">
<Plus size={16} /> Séance
</Link>
</div>
{/* Stats rapides */}
<div className="grid grid-cols-2 gap-3">
<StatCard icon={<Activity size={18} className="text-accent" />} label="Séances" value={stats.total} />
<StatCard icon={<Crosshair size={18} className="text-orange" />} label="Tirs totaux" value={stats.shots.toLocaleString()} />
<StatCard icon={<TrendingUp size={18} className="text-green-400" />} label="Score moyen" value={stats.avg > 0 ? stats.avg : '—'} />
<StatCard icon={<Target size={18} className="text-yellow-400" />} label="Meilleur score" value={stats.best > 0 ? stats.best : '—'} />
</div>
{/* Accès rapides */}
<div className="grid grid-cols-3 gap-3">
<QuickAction to="/session/new" icon={<Crosshair size={20} />} label="Nouvelle séance" color="accent" />
<QuickAction to="/arsenal" icon={<Shield size={20} />} label={`Arsenal (${activeWeapons.length})`} color="orange" />
<QuickAction to="/progress" icon={<TrendingUp size={20} />} label="Progression" color="green-500" />
</div>
{/* Séances récentes */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-text">Séances récentes</h2>
<Link to="/progress" className="text-sm text-accent hover:underline">Voir tout</Link>
</div>
{recentSessions.length === 0 ? (
<div className="card text-center py-10">
<Crosshair size={32} className="text-muted mx-auto mb-3" />
<p className="text-text-muted font-medium">Aucune séance enregistrée</p>
<p className="text-sm text-muted mt-1 mb-4">Commencez par créer votre première séance</p>
<Link to="/session/new" className="btn-primary inline-flex items-center gap-2">
<Plus size={16} /> Créer une séance
</Link>
</div>
) : (
<div className="space-y-2">
{recentSessions.map(session => (
<Link key={session.id} to={`/session/${session.id}`}
className="card flex items-center gap-3 hover:border-accent/30 transition-colors group">
<div className="w-10 h-10 rounded-lg bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Target size={18} className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-text text-sm truncate">
{(session.weapon as any)?.name || 'Arme inconnue'} {session.location || 'Lieu non précisé'}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-muted flex items-center gap-1">
<Clock size={11} />
{format(new Date(session.session_date), 'd MMM yyyy', { locale: fr })}
</span>
<span className="text-xs text-text-muted">{session.total_shots_declared} tirs</span>
{session.total_score > 0 && (
<span className="text-xs font-semibold text-accent">{session.total_score} pts</span>
)}
</div>
</div>
<ChevronRight size={16} className="text-muted group-hover:text-text-muted transition-colors shrink-0" />
</Link>
))}
</div>
)}
</div>
</div>
)
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) {
return (
<div className="card flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-surface2 flex items-center justify-center shrink-0">{icon}</div>
<div>
<div className="text-lg font-bold text-text leading-tight">{value}</div>
<div className="text-xs text-muted">{label}</div>
</div>
</div>
)
}
function QuickAction({ to, icon, label, color }: { to: string; icon: React.ReactNode; label: string; color: string }) {
return (
<Link to={to} className="card flex flex-col items-center gap-2 py-4 hover:border-border/80 transition-colors active:scale-95 text-center">
<div className={`w-10 h-10 rounded-xl bg-${color}/10 flex items-center justify-center text-${color}`}>{icon}</div>
<span className="text-xs font-medium text-text-muted leading-tight">{label}</span>
</Link>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { CheckCircle2, XCircle, Target } from 'lucide-react'
import { api } from '../lib/api'
export default function InvitationAccept() {
const { token } = useParams()
const navigate = useNavigate()
const [status, setStatus] = useState<'checking' | 'valid' | 'invalid' | 'expired'>('checking')
useEffect(() => {
if (!token) { setStatus('invalid'); return }
api.misc.invitations.validate(token)
.then(data => {
if (!data.valid) setStatus(data.reason === 'used' ? 'invalid' : 'invalid')
else setStatus('valid')
})
.catch(() => setStatus('invalid'))
}, [token])
if (status === 'checking') return (
<div className="min-h-screen bg-bg flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full" />
</div>
)
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<div className="w-full max-w-sm text-center animate-slide-up">
<div className="w-16 h-16 rounded-2xl bg-accent flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
<Target size={32} className="text-white" />
</div>
{status === 'valid' ? (
<>
<div className="w-12 h-12 rounded-full bg-green-500/10 border border-green-500/30 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 size={24} className="text-green-400" />
</div>
<h2 className="text-xl font-bold text-text mb-2">Invitation valide !</h2>
<p className="text-text-muted text-sm mb-6">
Vous avez é invité à rejoindre ShootTracker. Créez votre compte pour commencer à suivre vos performances.
</p>
<button onClick={() => navigate(`/register?token=${token}`)} className="btn-primary w-full">
Créer mon compte gratuit
</button>
</>
) : (
<>
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center mx-auto mb-4">
<XCircle size={24} className="text-red-400" />
</div>
<h2 className="text-xl font-bold text-text mb-2">
{status === 'expired' ? 'Invitation expirée' : 'Invitation invalide'}
</h2>
<p className="text-text-muted text-sm mb-6">
{status === 'expired'
? "Ce lien d'invitation a expiré. Demandez un nouveau lien à votre ami."
: "Ce lien d'invitation n'est pas valide ou a déjà été utilisé."}
</p>
<button onClick={() => navigate('/login')} className="btn-secondary w-full">
Aller à la connexion
</button>
</>
)}
</div>
</div>
)
}
+222
View File
@@ -0,0 +1,222 @@
import { useState, useEffect, useRef } from 'react'
import { User, LogOut, Save, Camera, Mail, Copy, CheckCircle2, Plus, Trash2 } from 'lucide-react'
import { api } from '../lib/api'
import { useStore } from '../lib/store'
import type { Invitation } from '../types'
const DISCIPLINES = [
'Pistolet 25m', 'Pistolet 50m', 'Carabine 10m', 'Carabine 50m',
'Tir de défense', 'Bench rest', 'Longue distance', 'Arc traditionnel',
'Arc à poulies', 'Arbalète', 'Fusil', 'Trap', 'Skeet', 'Autre'
]
interface ProfileProps {
onLogout: () => void
}
export default function Profile({ onLogout }: ProfileProps) {
const { profile, fetchProfile } = useStore()
const fileRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
first_name: '', last_name: '', club: '', disciplines: [] as string[],
})
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [avatarPreview, setAvatarPreview] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [copiedId, setCopiedId] = useState<string>('')
useEffect(() => { loadInvitations() }, [])
useEffect(() => {
if (profile) {
setForm({
first_name: profile.first_name || '',
last_name: profile.last_name || '',
club: profile.club || '',
disciplines: profile.disciplines || [],
})
if (profile.avatar_url) setAvatarPreview(profile.avatar_url)
}
}, [profile])
async function loadInvitations() {
try {
const data = await api.misc.invitations.list()
setInvitations(data as Invitation[])
} catch { /* ignore */ }
}
function toggleDiscipline(d: string) {
setForm(f => ({
...f, disciplines: f.disciplines.includes(d)
? f.disciplines.filter(x => x !== d)
: [...f.disciplines, d]
}))
}
function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (!f) return
setAvatarFile(f)
setAvatarPreview(URL.createObjectURL(f))
}
async function handleSave() {
setSaving(true)
try {
if (avatarFile) {
const res = await api.auth.uploadAvatar(avatarFile)
if (res?.avatar_url) setAvatarPreview(res.avatar_url)
}
await api.auth.updateProfile(form)
await fetchProfile()
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) { console.error(e) }
setSaving(false)
}
async function createInvitation() {
const active = invitations.filter(i => !i.is_used)
if (active.length >= 5) { alert('Maximum 5 invitations actives'); return }
try {
await api.misc.invitations.create()
loadInvitations()
} catch { /* ignore */ }
}
async function deleteInvitation(id: string) {
try {
await api.misc.invitations.delete(id)
loadInvitations()
} catch { /* ignore */ }
}
function copyInviteLink(token: string, id: string) {
const url = `${window.location.origin}/register?token=${token}`
navigator.clipboard.writeText(url)
setCopiedId(id)
setTimeout(() => setCopiedId(''), 2000)
}
const activeInvites = invitations.filter(i => !i.is_used)
return (
<div className="space-y-5 animate-fade-in">
<h1 className="page-header">Profil</h1>
{/* Avatar */}
<div className="card flex items-center gap-4">
<div className="relative cursor-pointer" onClick={() => fileRef.current?.click()}>
{avatarPreview ? (
<img src={avatarPreview} alt="" className="w-20 h-20 rounded-full object-cover border-2 border-accent/30" />
) : (
<div className="w-20 h-20 rounded-full bg-surface2 border-2 border-border flex items-center justify-center">
<User size={32} className="text-muted" />
</div>
)}
<div className="absolute bottom-0 right-0 w-7 h-7 bg-accent rounded-full flex items-center justify-center border-2 border-surface">
<Camera size={13} className="text-white" />
</div>
</div>
<div>
<div className="font-semibold text-text">{form.first_name} {form.last_name}</div>
<div className="text-sm text-text-muted flex items-center gap-1.5 mt-0.5">
<Mail size={12} /> {profile?.email || ''}
</div>
{form.club && <div className="text-xs text-muted mt-0.5">{form.club}</div>}
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onAvatarChange} />
</div>
{/* Infos */}
<div className="card space-y-4">
<h3 className="font-semibold text-text text-sm uppercase tracking-wide text-muted">Informations</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Prénom</label>
<input type="text" className="input" value={form.first_name}
onChange={e => setForm(f => ({ ...f, first_name: e.target.value }))} />
</div>
<div>
<label className="label">Nom</label>
<input type="text" className="input" value={form.last_name}
onChange={e => setForm(f => ({ ...f, last_name: e.target.value }))} />
</div>
</div>
<div>
<label className="label">Club</label>
<input type="text" className="input" placeholder="Club de tir..."
value={form.club} onChange={e => setForm(f => ({ ...f, club: e.target.value }))} />
</div>
<div>
<label className="label">Disciplines pratiquées</label>
<div className="flex flex-wrap gap-2 mt-1">
{DISCIPLINES.map(d => (
<button key={d} type="button" onClick={() => toggleDiscipline(d)}
className={`text-xs px-2.5 py-1 rounded-full border transition-all
${form.disciplines.includes(d) ? 'bg-accent text-white border-accent' : 'border-border text-muted hover:border-accent/30'}`}>
{d}
</button>
))}
</div>
</div>
<button onClick={handleSave} disabled={saving}
className="btn-primary w-full flex items-center justify-center gap-2">
{saved ? <><CheckCircle2 size={16} /> Sauvegardé !</> : <><Save size={16} /> {saving ? 'Sauvegarde...' : 'Sauvegarder'}</>}
</button>
</div>
{/* Invitations */}
<div className="card space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-text text-sm uppercase tracking-wide text-muted">
Invitations ({activeInvites.length}/5)
</h3>
<button onClick={createInvitation} disabled={activeInvites.length >= 5}
className="btn-secondary text-xs flex items-center gap-1 py-1.5 px-2.5">
<Plus size={13} /> Créer
</button>
</div>
<p className="text-xs text-muted">Invitez jusqu'à 5 amis. Chacun crée son propre compte indépendant.</p>
{invitations.length === 0 ? (
<p className="text-sm text-text-muted text-center py-3">Aucune invitation créée</p>
) : (
<div className="space-y-2">
{invitations.map(inv => (
<div key={inv.id} className={`flex items-center gap-2 p-2.5 rounded-lg border text-sm
${inv.is_used ? 'border-border opacity-50' : 'border-accent/20 bg-accent/5'}`}>
<div className="flex-1 min-w-0">
<div className="text-xs text-muted font-mono truncate">{inv.token.slice(0, 16)}...</div>
{inv.is_used
? <div className="text-xs text-green-400 flex items-center gap-1"><CheckCircle2 size={11} /> Utilisée</div>
: <div className="text-xs text-text-muted">Expire {new Date(inv.expires_at).toLocaleDateString('fr')}</div>
}
</div>
{!inv.is_used && (
<button onClick={() => copyInviteLink(inv.token, inv.id)}
className="btn-secondary text-xs py-1 px-2 flex items-center gap-1 shrink-0">
{copiedId === inv.id ? <CheckCircle2 size={12} className="text-green-400" /> : <Copy size={12} />}
{copiedId === inv.id ? 'Copié' : 'Copier'}
</button>
)}
<button onClick={() => deleteInvitation(inv.id)}
className="w-7 h-7 flex items-center justify-center rounded text-muted hover:text-red-400 transition-colors shrink-0">
<Trash2 size={13} />
</button>
</div>
))}
</div>
)}
</div>
{/* Déconnexion */}
<button onClick={onLogout}
className="w-full flex items-center justify-center gap-2 p-3 rounded-lg border border-red-400/20 text-red-400 hover:bg-red-400/10 transition-colors text-sm font-medium">
<LogOut size={16} /> Se déconnecter
</button>
</div>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { TrendingUp, Download, Filter, ChevronRight, Clock, Target } from 'lucide-react'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Area, AreaChart
} from 'recharts'
import { api, downloadBlob } from '../lib/api'
import { useStore } from '../lib/store'
import { format, subDays, subYears } from 'date-fns'
import { fr } from 'date-fns/locale'
import type { TrainingSession, ChartPoint } from '../types'
type Period = '30j' | '90j' | '1an' | 'tout'
export default function Progress() {
const { weapons } = useStore()
const [sessions, setSessions] = useState<TrainingSession[]>([])
const [filtered, setFiltered] = useState<TrainingSession[]>([])
const [period, setPeriod] = useState<Period>('90j')
const [weaponFilter, setWeaponFilter] = useState<string>('all')
const [chartData, setChartData] = useState<ChartPoint[]>([])
const [stats, setStats] = useState({ total: 0, shots: 0, avgScore: 0, bestScore: 0, progress: 0 })
const [exporting, setExporting] = useState(false)
useEffect(() => { loadSessions() }, [])
useEffect(() => { applyFilters() }, [sessions, period, weaponFilter])
useEffect(() => { buildChartData() }, [filtered])
async function loadSessions() {
try {
const data = await api.sessions.list()
const completed = (data as TrainingSession[])
.filter(s => s.is_completed)
.sort((a, b) => new Date(a.session_date).getTime() - new Date(b.session_date).getTime())
setSessions(completed)
} catch { /* ignore */ }
}
function applyFilters() {
let f = [...sessions]
const now = new Date()
const cutoff = period === '30j' ? subDays(now, 30)
: period === '90j' ? subDays(now, 90)
: period === '1an' ? subYears(now, 1)
: new Date('2000-01-01')
f = f.filter(s => new Date(s.session_date) >= cutoff)
if (weaponFilter !== 'all') f = f.filter(s => s.weapon_id === weaponFilter)
setFiltered(f)
}
function buildChartData() {
const pts: ChartPoint[] = filtered.map(s => ({
date: format(new Date(s.session_date), 'dd/MM', { locale: fr }),
score: s.avg_score,
best: s.best_series_score,
shots: s.total_shots_declared,
sessions: 1,
dispersion: s.avg_dispersion,
}))
const total = filtered.length
const shots = filtered.reduce((a, s) => a + s.total_shots_declared, 0)
const avgScore = total > 0 ? filtered.reduce((a, s) => a + s.avg_score, 0) / total : 0
const bestScore = total > 0 ? Math.max(...filtered.map(s => s.best_series_score)) : 0
let progress = 0
if (filtered.length >= 2) {
const first = filtered[0].avg_score
const last = filtered[filtered.length - 1].avg_score
progress = first > 0 ? Math.round((last - first) / first * 100) : 0
}
setChartData(pts)
setStats({ total, shots, avgScore: Math.round(avgScore * 10) / 10, bestScore, progress })
}
async function handleExcelExport() {
setExporting(true)
try {
const blob = await api.export.excel({
period: period !== 'tout' ? period : undefined,
weaponId: weaponFilter !== 'all' ? weaponFilter : undefined,
})
downloadBlob(blob, `shoottracker_progression_${format(new Date(), 'yyyy-MM-dd')}.xlsx`)
} catch (e) { console.error(e) }
setExporting(false)
}
const PERIODS: { value: Period; label: string }[] = [
{ value: '30j', label: '30 jours' }, { value: '90j', label: '90 jours' },
{ value: '1an', label: '1 an' }, { value: 'tout', label: 'Tout' },
]
const tooltipStyle = {
backgroundColor: '#1a1a1a', border: '1px solid #2e2e2e',
borderRadius: '8px', color: '#f4f4f5', fontSize: '12px'
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<h1 className="page-header mb-0">Progression</h1>
<button onClick={handleExcelExport} disabled={exporting}
className="btn-secondary flex items-center gap-1.5 text-sm py-2">
<Download size={14} /> {exporting ? '...' : 'Excel'}
</button>
</div>
{/* Filtres */}
<div className="space-y-3">
<div className="flex gap-2 overflow-x-auto pb-1">
{PERIODS.map(p => (
<button key={p.value} onClick={() => setPeriod(p.value)}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium border transition-all
${period === p.value ? 'bg-accent text-white border-accent' : 'border-border text-text-muted hover:border-accent/30'}`}>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<Filter size={14} className="text-muted shrink-0" />
<select className="input text-sm py-2" value={weaponFilter}
onChange={e => setWeaponFilter(e.target.value)}>
<option value="all">Toutes les armes</option>
{weapons.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3">
<div className="card text-center">
<div className="text-2xl font-bold text-text">{stats.total}</div>
<div className="text-xs text-muted">Séances</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{stats.shots.toLocaleString()}</div>
<div className="text-xs text-muted">Tirs</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{stats.avgScore || '—'}</div>
<div className="text-xs text-muted">Score moyen</div>
</div>
<div className="card text-center">
<div className={`text-2xl font-bold ${stats.progress > 0 ? 'text-green-400' : stats.progress < 0 ? 'text-red-400' : 'text-text'}`}>
{stats.progress > 0 ? '+' : ''}{stats.progress}%
</div>
<div className="text-xs text-muted">Progression</div>
</div>
</div>
{filtered.length === 0 ? (
<div className="card text-center py-12">
<TrendingUp size={32} className="text-muted mx-auto mb-3" />
<p className="text-text-muted">Aucune donnée pour cette période</p>
</div>
) : (
<>
<div className="card">
<h3 className="font-semibold text-text text-sm mb-3">Score moyen par séance</h3>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 0 }}>
<defs>
<linearGradient id="scoreGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#dc2626" stopOpacity={0.3} />
<stop offset="95%" stopColor="#dc2626" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2e2e2e" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} width={30} />
<Tooltip contentStyle={tooltipStyle} />
<Area type="monotone" dataKey="score" stroke="#dc2626" fill="url(#scoreGrad)"
name="Score moyen" strokeWidth={2} dot={{ fill: '#dc2626', r: 3 }} />
<Line type="monotone" dataKey="best" stroke="#ea580c"
name="Meilleur" strokeWidth={2} dot={false} strokeDasharray="4 4" />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="card">
<h3 className="font-semibold text-text text-sm mb-3">Tirs par séance</h3>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2e2e2e" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} width={30} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="shots" fill="#ea580c" name="Tirs" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
{/* Historique */}
<div>
<h3 className="font-semibold text-text mb-3">Historique des séances</h3>
{filtered.length === 0 ? (
<p className="text-text-muted text-sm text-center py-4">Aucune séance</p>
) : (
<div className="space-y-2">
{[...filtered].reverse().map(s => (
<Link key={s.id} to={`/session/${s.id}`}
className="card flex items-center gap-3 hover:border-accent/30 transition-colors group">
<div className="w-9 h-9 rounded-lg bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
<Target size={16} className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-text text-sm truncate">
{(s.weapon as any)?.name || 'Arme inconnue'}{s.location ? `${s.location}` : ''}
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-text-muted">
<span className="flex items-center gap-0.5"><Clock size={10} /> {format(new Date(s.session_date), 'dd MMM yyyy', { locale: fr })}</span>
<span>{s.total_shots_declared} tirs</span>
{s.total_score > 0 && <span className="text-accent font-semibold">{s.total_score} pts</span>}
<span className={s.ai_detection_rate > 80 ? 'text-green-400' : 'text-yellow-400'}>IA {s.ai_detection_rate}%</span>
</div>
</div>
<ChevronRight size={16} className="text-muted group-hover:text-text-muted shrink-0" />
</Link>
))}
</div>
)}
</div>
</div>
)
}
+198
View File
@@ -0,0 +1,198 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ChevronLeft, Camera, Trash2, Save } from 'lucide-react'
import { api } from '../lib/api'
import { useStore } from '../lib/store'
import type { WeaponType } from '../types'
const TYPES: { value: WeaponType; label: string }[] = [
{ value: 'pistolet', label: 'Pistolet' },
{ value: 'carabine', label: 'Carabine' },
{ value: 'fusil', label: 'Fusil' },
{ value: 'arc', label: 'Arc' },
{ value: 'arbalète', label: 'Arbalète' },
{ value: 'autre', label: 'Autre' },
]
export default function WeaponForm() {
const navigate = useNavigate()
const { id } = useParams()
const isEdit = !!id
const { fetchWeapons } = useStore()
const fileRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
name: '', nickname: '', type: 'pistolet' as WeaponType,
caliber: '', brand: '', model: '', serial_number: '', notes: '',
})
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [photoPreview, setPhotoPreview] = useState<string>('')
const [currentPhotoUrl, setCurrentPhotoUrl] = useState<string>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (isEdit) {
api.weapons.get(id!).then(data => {
if (data) {
setForm({
name: data.name || '', nickname: data.nickname || '',
type: data.type, caliber: data.caliber || '',
brand: data.brand || '', model: data.model || '',
serial_number: data.serial_number || '', notes: data.notes || '',
})
setCurrentPhotoUrl(data.photo_url || '')
}
}).catch(console.error)
}
}, [id])
function update(field: string, val: string) { setForm(f => ({ ...f, [field]: val })) }
function onPhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setPhotoFile(file)
setPhotoPreview(URL.createObjectURL(file))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!form.name) { setError('Le nom est requis'); return }
setError(''); setLoading(true)
try {
const fd = new FormData()
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v) })
if (photoFile) fd.append('photo', photoFile)
if (isEdit) {
await api.weapons.update(id!, fd)
} else {
await api.weapons.create(fd)
}
await fetchWeapons()
navigate('/arsenal')
} catch (err: any) {
setError(err.message || 'Erreur')
} finally {
setLoading(false)
}
}
async function handleDelete() {
if (!window.confirm('Supprimer définitivement cette arme ?')) return
try {
await api.weapons.delete(id!)
await fetchWeapons()
navigate('/arsenal')
} catch (err: any) {
setError(err.message)
}
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="btn-ghost p-2"><ChevronLeft size={20} /></button>
<h1 className="page-header mb-0">{isEdit ? "Modifier l'arme" : 'Ajouter une arme'}</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Photo */}
<div className="card">
<label className="label">Photo de l'arme</label>
<div className="flex items-center gap-4">
<div
className="w-24 h-24 rounded-lg bg-surface2 border-2 border-dashed border-border flex items-center justify-center overflow-hidden cursor-pointer hover:border-accent/50 transition-colors"
onClick={() => fileRef.current?.click()}
>
{photoPreview || currentPhotoUrl ? (
<img src={photoPreview || currentPhotoUrl} alt="" className="w-full h-full object-cover" />
) : (
<Camera size={24} className="text-muted" />
)}
</div>
<div>
<button type="button" onClick={() => fileRef.current?.click()}
className="btn-secondary text-sm flex items-center gap-2">
<Camera size={15} /> {photoPreview || currentPhotoUrl ? 'Changer' : 'Ajouter une photo'}
</button>
<p className="text-xs text-muted mt-1.5">JPG, PNG, WebP — max 20 Mo</p>
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onPhotoChange} />
</div>
</div>
{/* Infos */}
<div className="card space-y-4">
<h3 className="font-semibold text-text text-sm uppercase tracking-wide text-muted">Informations</h3>
<div>
<label className="label">Nom de l'arme *</label>
<input type="text" required className="input" placeholder="Ex: Mon Glock 17"
value={form.name} onChange={e => update('name', e.target.value)} />
</div>
<div>
<label className="label">Surnom / Alias</label>
<input type="text" className="input" placeholder='Ex: "Le fidèle"'
value={form.nickname} onChange={e => update('nickname', e.target.value)} />
</div>
<div>
<label className="label">Type *</label>
<select className="input" value={form.type} onChange={e => update('type', e.target.value)}>
{TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Marque</label>
<input type="text" className="input" placeholder="Glock, S&W..."
value={form.brand} onChange={e => update('brand', e.target.value)} />
</div>
<div>
<label className="label">Modèle</label>
<input type="text" className="input" placeholder="17, M&P..."
value={form.model} onChange={e => update('model', e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Calibre</label>
<input type="text" className="input" placeholder="9mm, .308..."
value={form.caliber} onChange={e => update('caliber', e.target.value)} />
</div>
<div>
<label className="label">N° de série</label>
<input type="text" className="input" placeholder="Optionnel"
value={form.serial_number} onChange={e => update('serial_number', e.target.value)} />
</div>
</div>
</div>
{/* Notes */}
<div className="card">
<label className="label">Notes (réglages, entretien, historique)</label>
<textarea className="input resize-none" rows={4}
placeholder="Réglages de visée, historique des réparations..."
value={form.notes} onChange={e => update('notes', e.target.value)} />
</div>
{error && (
<div className="bg-red-400/10 border border-red-400/20 text-red-400 text-sm rounded-lg p-3">{error}</div>
)}
<div className="flex gap-3">
<button type="submit" disabled={loading} className="btn-primary flex-1 flex items-center justify-center gap-2">
<Save size={16} /> {loading ? 'Enregistrement...' : isEdit ? 'Sauvegarder' : "Ajouter l'arme"}
</button>
{isEdit && (
<button type="button" onClick={handleDelete}
className="btn-secondary flex items-center gap-2 text-red-400 hover:bg-red-400/10 border-red-400/20">
<Trash2 size={16} />
</button>
)}
</div>
</form>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Target, Eye, EyeOff, AlertCircle } from 'lucide-react'
import { api, setToken } from '../../lib/api'
interface LoginProps {
onLogin: () => void
}
export default function Login({ onLogin }: LoginProps) {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPwd, setShowPwd] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await api.auth.login(email, password)
setToken(data.token)
onLogin()
navigate('/')
} catch (err: any) {
setError(err.message || 'Identifiants incorrects')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-slide-up">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-accent flex items-center justify-center mb-4 shadow-lg shadow-accent/20">
<Target size={32} className="text-white" />
</div>
<h1 className="text-2xl font-bold text-text">ShootTracker</h1>
<p className="text-text-muted text-sm mt-1">Performance en tir sportif</p>
</div>
<div className="card">
<h2 className="text-lg font-semibold text-text mb-6">Connexion</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Adresse email</label>
<input
type="email" required className="input"
placeholder="vous@exemple.com"
value={email} onChange={e => setEmail(e.target.value)}
/>
</div>
<div>
<label className="label">Mot de passe</label>
<div className="relative">
<input
type={showPwd ? 'text' : 'password'} required
className="input pr-10" placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)}
/>
<button type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-text"
onClick={() => setShowPwd(v => !v)}>
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-400/10 rounded-lg p-3">
<AlertCircle size={16} className="shrink-0" />{error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
<p className="text-center text-sm text-text-muted mt-4">
Pas encore de compte ?{' '}
<Link to="/register" className="text-accent hover:underline font-medium">
Créer un compte
</Link>
</p>
</div>
</div>
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Target, Eye, EyeOff, AlertCircle, CheckCircle2 } from 'lucide-react'
import { api, setToken } from '../../lib/api'
export default function Register() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const inviteToken = searchParams.get('token') || undefined
const [form, setForm] = useState({
firstName: '', lastName: '', email: '', password: '', confirmPwd: '', club: ''
})
const [showPwd, setShowPwd] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
function update(field: string, val: string) {
setForm(f => ({ ...f, [field]: val }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (form.password !== form.confirmPwd) { setError('Les mots de passe ne correspondent pas'); return }
if (form.password.length < 8) { setError('Mot de passe minimum 8 caractères'); return }
setLoading(true)
try {
const data = await api.auth.register({
email: form.email,
password: form.password,
first_name: form.firstName,
last_name: form.lastName,
club: form.club || undefined,
invite_token: inviteToken,
})
setToken(data.token)
navigate('/')
} catch (err: any) {
setError(err.message || 'Erreur lors de la création du compte')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-accent flex items-center justify-center mb-4 shadow-lg shadow-accent/20">
<Target size={32} className="text-white" />
</div>
<h1 className="text-2xl font-bold text-text">ShootTracker</h1>
{inviteToken && (
<div className="mt-2 text-xs text-green-400 bg-green-400/10 px-3 py-1 rounded-full">
Inscription par invitation
</div>
)}
</div>
<div className="card">
<h2 className="text-lg font-semibold text-text mb-6">Créer un compte</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Prénom</label>
<input type="text" required className="input" placeholder="Jean"
value={form.firstName} onChange={e => update('firstName', e.target.value)} />
</div>
<div>
<label className="label">Nom</label>
<input type="text" required className="input" placeholder="Dupont"
value={form.lastName} onChange={e => update('lastName', e.target.value)} />
</div>
</div>
<div>
<label className="label">Club (optionnel)</label>
<input type="text" className="input" placeholder="Club de tir de Paris"
value={form.club} onChange={e => update('club', e.target.value)} />
</div>
<div>
<label className="label">Email</label>
<input type="email" required className="input" placeholder="vous@exemple.com"
value={form.email} onChange={e => update('email', e.target.value)} />
</div>
<div>
<label className="label">Mot de passe</label>
<div className="relative">
<input type={showPwd ? 'text' : 'password'} required className="input pr-10"
placeholder="8 caractères minimum"
value={form.password} onChange={e => update('password', e.target.value)} />
<button type="button" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-text"
onClick={() => setShowPwd(v => !v)}>
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="label">Confirmer le mot de passe</label>
<input type={showPwd ? 'text' : 'password'} required className="input"
placeholder="••••••••"
value={form.confirmPwd} onChange={e => update('confirmPwd', e.target.value)} />
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-400/10 rounded-lg p-3">
<AlertCircle size={16} className="shrink-0" />{error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Création...' : 'Créer mon compte'}
</button>
</form>
<p className="text-center text-sm text-text-muted mt-4">
Déjà un compte ?{' '}
<Link to="/login" className="text-accent hover:underline font-medium">Se connecter</Link>
</p>
</div>
</div>
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, MapPin, Calendar, Crosshair, Ruler, FileText } from 'lucide-react'
import { api } from '../../lib/api'
import { useStore } from '../../lib/store'
import type { TargetType } from '../../types'
import { format } from 'date-fns'
const TARGET_TYPES: { value: TargetType; label: string; desc: string }[] = [
{ value: 'issf', label: 'ISSF', desc: 'Cible réglementaire cercles 110' },
{ value: 'silhouette', label: 'Silhouette', desc: 'Cible silhouette — groupement' },
{ value: 'libre', label: 'Libre', desc: 'Cible libre — groupement seul' },
]
export default function NewSession() {
const navigate = useNavigate()
const { weapons, fetchSessions } = useStore()
const [locations, setLocations] = useState<string[]>([])
const [form, setForm] = useState({
session_date: format(new Date(), "yyyy-MM-dd'T'HH:mm"),
location: '',
weapon_id: '',
target_type: 'issf' as TargetType,
distance_m: '',
notes_start: '',
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const activeWeapons = weapons.filter(w => !w.is_archived)
useEffect(() => {
api.misc.locations()
.then(data => setLocations((data as any[]).map(l => l.name)))
.catch(() => {})
}, [])
function update(field: string, val: string) { setForm(f => ({ ...f, [field]: val })) }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!form.weapon_id) { setError('Sélectionnez une arme'); return }
setLoading(true)
try {
const session = await api.sessions.create({
weapon_id: form.weapon_id,
session_date: new Date(form.session_date).toISOString(),
location: form.location || null,
target_type: form.target_type,
distance_m: form.distance_m ? parseInt(form.distance_m) : null,
notes_start: form.notes_start || null,
})
navigate(`/session/${session.id}/capture`)
} catch (err: any) {
setError(err.message || 'Erreur lors de la création de la séance')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="btn-ghost p-2"><ChevronLeft size={20} /></button>
<h1 className="page-header mb-0">Nouvelle séance</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Date & lieu */}
<div className="card space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-text-muted uppercase tracking-wide">
<Calendar size={14} /> Quand et
</div>
<div>
<label className="label">Date et heure</label>
<input type="datetime-local" className="input"
value={form.session_date} onChange={e => update('session_date', e.target.value)} />
</div>
<div>
<label className="label">Lieu / Stand</label>
<div className="relative">
<MapPin size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input type="text" className="input pl-9" placeholder="Stand de tir de..."
list="locations-list"
value={form.location} onChange={e => update('location', e.target.value)} />
<datalist id="locations-list">
{locations.map(l => <option key={l} value={l} />)}
</datalist>
</div>
</div>
</div>
{/* Arme & cible */}
<div className="card space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-text-muted uppercase tracking-wide">
<Crosshair size={14} /> Configuration
</div>
<div>
<label className="label">Arme *</label>
{activeWeapons.length === 0 ? (
<div className="bg-orange/10 border border-orange/20 text-orange text-sm rounded-lg p-3">
Aucune arme active. <a href="/arsenal/new" className="underline">Ajouter une arme</a>
</div>
) : (
<select className="input" required value={form.weapon_id}
onChange={e => update('weapon_id', e.target.value)}>
<option value="">Sélectionner une arme</option>
{activeWeapons.map(w => (
<option key={w.id} value={w.id}>
{w.name}{w.caliber ? `${w.caliber}` : ''}
</option>
))}
</select>
)}
</div>
<div>
<label className="label">Type de cible *</label>
<div className="space-y-2">
{TARGET_TYPES.map(t => (
<label key={t.value} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all
${form.target_type === t.value ? 'border-accent bg-accent/5' : 'border-border hover:border-accent/30'}`}>
<input type="radio" name="target_type" value={t.value}
checked={form.target_type === t.value}
onChange={e => update('target_type', e.target.value)}
className="accent-accent" />
<div>
<div className="font-medium text-text text-sm">{t.label}</div>
<div className="text-xs text-muted">{t.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<label className="label flex items-center gap-1.5"><Ruler size={13} /> Distance de tir (mètres)</label>
<input type="number" className="input" placeholder="Ex: 25"
min="1" max="1000"
value={form.distance_m} onChange={e => update('distance_m', e.target.value)} />
</div>
</div>
{/* Notes */}
<div className="card">
<div className="flex items-center gap-2 text-sm font-medium text-text-muted uppercase tracking-wide mb-3">
<FileText size={14} /> Notes de début de séance
</div>
<textarea className="input resize-none" rows={3}
placeholder="Conditions météo, état de forme, objectifs..."
value={form.notes_start} onChange={e => update('notes_start', e.target.value)} />
</div>
{error && (
<div className="bg-red-400/10 border border-red-400/20 text-red-400 text-sm rounded-lg p-3">{error}</div>
)}
<button type="submit" disabled={loading || activeWeapons.length === 0}
className="btn-primary w-full py-3 text-base">
{loading ? 'Création...' : 'Commencer la séance →'}
</button>
</form>
</div>
)
}
@@ -0,0 +1,429 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Camera, Upload, Crosshair, CheckCircle2, AlertTriangle, Plus, X, ChevronRight, Flag } from 'lucide-react'
import { api, fileToBase64 } from '../../lib/api'
import { useStore } from '../../lib/store'
import type { TrainingSession, Series, AIAnalysisResult } from '../../types'
type Step = 'saisie' | 'analyse' | 'resultat' | 'cloture'
export default function SessionCapture() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { fetchSessions } = useStore()
const fileRef = useRef<HTMLInputElement>(null)
const [session, setSession] = useState<TrainingSession | null>(null)
const [step, setStep] = useState<Step>('saisie')
const [seriesList, setSeriesList] = useState<Series[]>([])
// Saisie
const [shotsDeclared, setShotsDeclared] = useState(10)
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [photoPreview, setPhotoPreview] = useState('')
const [prevPhotoB64, setPrevPhotoB64] = useState<string | undefined>()
// Analyse
const [aiResult, setAiResult] = useState<AIAnalysisResult | null>(null)
const [analysisError, setAnalysisError] = useState('')
const [manualAdjustments, setManualAdjustments] = useState<number[]>([])
// Clôture
const [notesEnd, setNotesEnd] = useState('')
const [closing, setClosing] = useState(false)
useEffect(() => { loadSession() }, [id])
async function loadSession() {
if (!id) return
try {
const data = await api.sessions.get(id)
setSession(data as TrainingSession)
const seriesData = await api.series.list(id)
setSeriesList((seriesData as Series[]).sort((a, b) => a.series_number - b.series_number))
} catch { /* ignore */ }
}
function onPhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (!f) return
setPhotoFile(f)
setPhotoPreview(URL.createObjectURL(f))
}
async function handleAnalyze() {
if (!photoFile || !session) return
setStep('analyse')
setAnalysisError('')
try {
const imageB64 = await fileToBase64(photoFile)
const result = await api.analyze.target({
image_base64: imageB64,
target_type: session.target_type,
previous_image_base64: prevPhotoB64,
})
setAiResult(result as AIAnalysisResult)
const undetected = shotsDeclared - (result.total_detected || 0)
setManualAdjustments(new Array(Math.max(0, undetected)).fill(0))
setStep('resultat')
} catch (err: any) {
setAnalysisError(err.message || "Erreur lors de l'analyse IA")
setStep('resultat')
}
}
const manualTotal = manualAdjustments.reduce((a, v) => a + v, 0)
const finalScore = (aiResult?.score_total || 0) + manualTotal
const shotsDetected = aiResult?.total_detected || 0
async function saveSeries(startNew: boolean, newTarget: boolean) {
if (!session) return
const fd = new FormData()
fd.append('series_number', String(seriesList.length + 1))
fd.append('shots_declared', String(shotsDeclared))
fd.append('shots_detected', String(shotsDetected))
fd.append('shots_manual', String(manualAdjustments.length))
fd.append('score_zone', String(aiResult?.score_zone || 0))
fd.append('score_groupement', String(aiResult?.score_groupement || 0))
fd.append('score_total', String(finalScore))
if (aiResult?.dispersion_radius) fd.append('dispersion_radius', String(aiResult.dispersion_radius))
if (aiResult?.center_x != null) fd.append('center_x', String(aiResult.center_x))
if (aiResult?.center_y != null) fd.append('center_y', String(aiResult.center_y))
if (aiResult) fd.append('ai_data', JSON.stringify(aiResult))
// Impacts: AI + manual
const impacts: any[] = []
if (aiResult?.impacts) {
for (const imp of aiResult.impacts) {
impacts.push({ x: imp.x, y: imp.y, zone: imp.zone, points: imp.points, is_manual: false })
}
}
for (const pts of manualAdjustments) {
impacts.push({ x: -1, y: -1, zone: pts, points: pts, is_manual: true })
}
fd.append('impacts', JSON.stringify(impacts))
if (photoFile) fd.append('photo', photoFile)
try {
const newSeries = await api.series.create(session.id, fd)
// Upload annotated image separately if present
if (aiResult?.annotated_image_base64) {
await api.series.saveAnnotated(newSeries.id, aiResult.annotated_image_base64)
}
const updatedList = [...seriesList, newSeries as Series]
setSeriesList(updatedList)
// Keep previous photo for subtraction if same target
if (!newTarget && photoFile) {
const b64 = await fileToBase64(photoFile)
setPrevPhotoB64(b64)
} else {
setPrevPhotoB64(undefined)
}
// Reset
setPhotoFile(null)
setPhotoPreview('')
setAiResult(null)
setManualAdjustments([])
setShotsDeclared(10)
if (startNew) setStep('saisie')
else setStep('cloture')
} catch (err) {
console.error(err)
}
}
async function handleClose() {
if (!session) return
setClosing(true)
const totalDeclared = seriesList.reduce((a, s) => a + s.shots_declared, 0)
const totalDetected = seriesList.reduce((a, s) => a + s.shots_detected, 0)
const totalScore = seriesList.reduce((a, s) => a + s.score_total, 0)
const avgScore = seriesList.length > 0 ? totalScore / seriesList.length : 0
const bestScore = Math.max(...seriesList.map(s => s.score_total), 0)
const dispSeries = seriesList.filter(s => s.dispersion_radius)
const avgDisp = dispSeries.length > 0 ? dispSeries.reduce((a, s) => a + (s.dispersion_radius || 0), 0) / dispSeries.length : null
try {
await api.sessions.update(session.id, {
is_completed: true,
notes_end: notesEnd || null,
total_shots_declared: totalDeclared,
total_shots_detected: totalDetected,
total_score: totalScore,
avg_score: Math.round(avgScore * 10) / 10,
best_series_score: bestScore,
ai_detection_rate: totalDeclared > 0 ? Math.round((totalDetected / totalDeclared) * 100) : 0,
avg_dispersion: avgDisp ? Math.round(avgDisp * 10) / 10 : null,
})
await fetchSessions()
navigate(`/session/${session.id}`)
} catch (err) {
console.error(err)
} finally {
setClosing(false)
}
}
if (!session) return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full" />
</div>
)
return (
<div className="space-y-5 animate-fade-in">
{/* En-tête */}
<div className="card">
<div className="flex items-start justify-between">
<div>
<div className="text-sm text-muted mb-1">Séance en cours</div>
<div className="font-semibold text-text">{(session.weapon as any)?.name || 'Arme'}</div>
<div className="text-xs text-text-muted">
{session.location && `${session.location} · `}
{session.distance_m && `${session.distance_m}m · `}
{session.target_type.toUpperCase()}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-accent">{seriesList.length}</div>
<div className="text-xs text-muted">série{seriesList.length !== 1 ? 's' : ''}</div>
</div>
</div>
{seriesList.length > 0 && (
<div className="mt-3 pt-3 border-t border-border flex gap-4 text-sm">
<span className="text-text-muted">Total : <b className="text-text">{seriesList.reduce((a,s)=>a+s.score_total,0)} pts</b></span>
<span className="text-text-muted">Tirs : <b className="text-text">{seriesList.reduce((a,s)=>a+s.shots_declared,0)}</b></span>
</div>
)}
</div>
{/* ─── SAISIE ─── */}
{step === 'saisie' && (
<div className="space-y-4">
<h2 className="font-semibold text-text flex items-center gap-2">
<span className="w-6 h-6 bg-accent rounded-full flex items-center justify-center text-xs font-bold text-white">1</span>
Série {seriesList.length + 1} Saisie
</h2>
<div className="card space-y-4">
<div>
<label className="label">Nombre de tirs</label>
<div className="flex items-center gap-3">
<button type="button" onClick={() => setShotsDeclared(v => Math.max(1, v - 1))}
className="w-10 h-10 rounded-lg bg-surface2 border border-border flex items-center justify-center text-xl font-bold hover:border-accent/50"></button>
<input type="number" className="input text-center text-xl font-bold w-20" min="1" max="100"
value={shotsDeclared} onChange={e => setShotsDeclared(Math.max(1, parseInt(e.target.value) || 1))} />
<button type="button" onClick={() => setShotsDeclared(v => Math.min(100, v + 1))}
className="w-10 h-10 rounded-lg bg-surface2 border border-border flex items-center justify-center text-xl font-bold hover:border-accent/50">+</button>
</div>
</div>
<div>
<label className="label">Photo de la cible</label>
{photoPreview ? (
<div className="relative">
<img src={photoPreview} alt="Cible" className="w-full rounded-lg border border-border max-h-64 object-contain bg-black" />
<button onClick={() => { setPhotoFile(null); setPhotoPreview('') }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center">
<X size={14} className="text-white" />
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={() => { fileRef.current!.setAttribute('capture', 'environment'); fileRef.current?.click() }}
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-dashed border-border hover:border-accent/50 transition-colors">
<Camera size={24} className="text-muted" />
<span className="text-xs text-muted">Prendre une photo</span>
</button>
<button type="button" onClick={() => { fileRef.current!.removeAttribute('capture'); fileRef.current?.click() }}
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-dashed border-border hover:border-accent/50 transition-colors">
<Upload size={24} className="text-muted" />
<span className="text-xs text-muted">Importer</span>
</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onPhotoChange} />
</div>
</div>
<button onClick={handleAnalyze} disabled={!photoFile}
className="btn-primary w-full py-3 flex items-center justify-center gap-2">
<Crosshair size={18} /> Analyser les impacts
</button>
{seriesList.length > 0 && (
<button onClick={() => setStep('cloture')}
className="btn-secondary w-full flex items-center justify-center gap-2">
<Flag size={16} /> Terminer la séance
</button>
)}
</div>
)}
{/* ─── ANALYSE ─── */}
{step === 'analyse' && (
<div className="card text-center py-12 space-y-4">
<div className="relative w-20 h-20 mx-auto">
<div className="absolute inset-0 rounded-full border-4 border-surface2" />
<div className="absolute inset-0 rounded-full border-4 border-t-accent animate-spin" />
<Crosshair size={24} className="absolute inset-0 m-auto text-accent" />
</div>
<div>
<p className="font-semibold text-text">Analyse des impacts...</p>
<p className="text-sm text-text-muted mt-1">YOLOv8 détecte les impacts sur la cible</p>
</div>
</div>
)}
{/* ─── RÉSULTAT ─── */}
{step === 'resultat' && (
<div className="space-y-4">
<h2 className="font-semibold text-text flex items-center gap-2">
<span className="w-6 h-6 bg-accent rounded-full flex items-center justify-center text-xs font-bold text-white">3</span>
Résultat de l'analyse
</h2>
{analysisError ? (
<div className="card border-red-400/20 bg-red-400/5 text-center py-6">
<AlertTriangle size={28} className="text-red-400 mx-auto mb-2" />
<p className="font-medium text-red-400">Analyse échouée</p>
<p className="text-sm text-text-muted mt-1">{analysisError}</p>
</div>
) : aiResult && (
<>
{aiResult.annotated_image_base64 && (
<div className="card p-2">
<img src={aiResult.annotated_image_base64} alt="Cible annotée"
className="w-full rounded-lg max-h-72 object-contain bg-black" />
</div>
)}
<div className="card">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-2xl font-bold text-text">{shotsDeclared}</div>
<div className="text-xs text-muted">Déclarés</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{shotsDetected}</div>
<div className="text-xs text-muted">Détectés</div>
</div>
<div>
<div className={`text-2xl font-bold ${manualAdjustments.length > 0 ? 'text-orange' : 'text-text-muted'}`}>
{manualAdjustments.length}
</div>
<div className="text-xs text-muted">Non détectés</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-border grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-lg font-bold text-text">{aiResult.score_zone}</div>
<div className="text-xs text-muted">Zones</div>
</div>
<div>
<div className="text-lg font-bold text-text">{aiResult.score_groupement}</div>
<div className="text-xs text-muted">Groupement</div>
</div>
<div>
<div className="text-lg font-bold text-accent">{finalScore}</div>
<div className="text-xs text-muted">Score</div>
</div>
</div>
</div>
{manualAdjustments.length > 0 && (
<div className="card space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle size={15} className="text-orange" />
<span className="text-sm font-medium text-text">
{manualAdjustments.length} tir{manualAdjustments.length > 1 ? 's' : ''} non détecté{manualAdjustments.length > 1 ? 's' : ''}
</span>
</div>
<p className="text-xs text-muted">Saisissez la valeur de zone :</p>
<div className="flex flex-wrap gap-2">
{manualAdjustments.map((pts, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-xs text-muted">Tir {i + 1} :</span>
<select className="input w-16 text-sm py-1 px-2" value={pts}
onChange={e => {
const v = [...manualAdjustments]
v[i] = parseInt(e.target.value)
setManualAdjustments(v)
}}>
{session?.target_type === 'issf'
? [0,1,2,3,4,5,6,7,8,9,10].map(n => <option key={n} value={n}>{n}</option>)
: [0,1,2,3,4,5].map(n => <option key={n} value={n}>{n}</option>)
}
</select>
</div>
))}
</div>
<div className="text-sm text-text-muted">
Score final : <b className="text-accent">{finalScore} pts</b>
</div>
</div>
)}
</>
)}
<div className="space-y-2">
<button onClick={() => saveSeries(true, false)}
className="btn-primary w-full flex items-center justify-center gap-2">
<Plus size={16} /> Nouvelle série — même cible
</button>
<button onClick={() => saveSeries(true, true)}
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronRight size={16} /> Nouvelle série — nouvelle cible
</button>
<button onClick={() => saveSeries(false, false)}
className="btn-secondary w-full flex items-center justify-center gap-2 text-orange">
<Flag size={16} /> Terminer la séance
</button>
</div>
</div>
)}
{/* ─── CLÔTURE ─── */}
{step === 'cloture' && (
<div className="space-y-4">
<h2 className="font-semibold text-text flex items-center gap-2">
<Flag size={18} className="text-orange" /> Clôture de séance
</h2>
<div className="card space-y-3">
<h3 className="font-medium text-text text-sm">Résumé</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Séries</div>
<div className="text-xl font-bold text-text">{seriesList.length}</div>
</div>
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Tirs déclarés</div>
<div className="text-xl font-bold text-text">{seriesList.reduce((a,s)=>a+s.shots_declared,0)}</div>
</div>
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Score total</div>
<div className="text-xl font-bold text-accent">{seriesList.reduce((a,s)=>a+s.score_total,0)} pts</div>
</div>
<div className="bg-surface2 rounded-lg p-3">
<div className="text-text-muted text-xs">Meilleure série</div>
<div className="text-xl font-bold text-text">{Math.max(...seriesList.map(s=>s.score_total), 0)} pts</div>
</div>
</div>
</div>
<div className="card">
<label className="label">Notes de fin de séance</label>
<textarea className="input resize-none" rows={3}
placeholder="Sensations, axes d'amélioration..."
value={notesEnd} onChange={e => setNotesEnd(e.target.value)} />
</div>
<button onClick={handleClose} disabled={closing}
className="btn-primary w-full py-3 flex items-center justify-center gap-2">
<CheckCircle2 size={18} /> {closing ? 'Enregistrement...' : 'Valider et enregistrer la séance'}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ChevronLeft, Target, Clock, MapPin, Crosshair, Download, TrendingUp, Zap } from 'lucide-react'
import { api, downloadBlob } from '../../lib/api'
import type { TrainingSession, Series, Impact } from '../../types'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Cell, ResponsiveContainer } from 'recharts'
export default function SessionDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [session, setSession] = useState<TrainingSession | null>(null)
const [series, setSeries] = useState<Series[]>([])
const [allImpacts, setAllImpacts] = useState<Impact[]>([])
const [exporting, setExporting] = useState(false)
useEffect(() => { if (id) loadDetail() }, [id])
async function loadDetail() {
try {
const data = await api.sessions.get(id!)
setSession(data as TrainingSession)
setSeries((data.series || []) as Series[])
const impacts: Impact[] = []
for (const s of (data.series || [])) {
if (s.impacts) impacts.push(...s.impacts)
}
setAllImpacts(impacts)
} catch { /* ignore */ }
}
async function handleExportPDF() {
if (!session) return
setExporting(true)
try {
const blob = await api.export.pdf(id!)
downloadBlob(blob, `shoottracker_${format(new Date(session.session_date), 'yyyy-MM-dd')}.pdf`)
} catch {
alert('Export PDF non disponible pour le moment')
}
setExporting(false)
}
function getZoneColor(zone: number | null): string {
if (!zone) return '#6b7280'
if (zone >= 9) return '#22c55e'
if (zone >= 7) return '#84cc16'
if (zone >= 5) return '#eab308'
if (zone >= 3) return '#f97316'
return '#ef4444'
}
if (!session) return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full" />
</div>
)
const weapon = session.weapon as any
const detectionRate = session.ai_detection_rate || 0
return (
<div className="space-y-5 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="btn-ghost p-2"><ChevronLeft size={20} /></button>
<h1 className="page-header mb-0 flex-1">Détail de séance</h1>
<button onClick={handleExportPDF} disabled={exporting}
className="btn-secondary flex items-center gap-1.5 text-sm py-2">
<Download size={14} /> {exporting ? '...' : 'PDF'}
</button>
</div>
{/* Infos séance */}
<div className="card space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-bold text-text">{weapon?.name || 'Arme inconnue'}</div>
<div className="text-sm text-text-muted">
{[weapon?.brand, weapon?.model].filter(Boolean).join(' ')}
{weapon?.caliber ? ` · ${weapon.caliber}` : ''}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-accent">{session.total_score}</div>
<div className="text-xs text-muted">pts total</div>
</div>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-text-muted">
<span className="flex items-center gap-1">
<Clock size={13} /> {format(new Date(session.session_date), 'dd MMMM yyyy · HH:mm', { locale: fr })}
</span>
{session.location && <span className="flex items-center gap-1"><MapPin size={13} /> {session.location}</span>}
{session.distance_m && <span className="flex items-center gap-1"><Crosshair size={13} /> {session.distance_m}m</span>}
<span className="flex items-center gap-1"><Target size={13} /> {session.target_type.toUpperCase()}</span>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3">
<div className="card text-center">
<div className="text-2xl font-bold text-text">{series.length}</div>
<div className="text-xs text-muted">Séries</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.total_shots_declared}</div>
<div className="text-xs text-muted">Tirs</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.avg_score > 0 ? session.avg_score : '—'}</div>
<div className="text-xs text-muted">Score moy./série</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.best_series_score > 0 ? session.best_series_score : '—'}</div>
<div className="text-xs text-muted">Meilleure série</div>
</div>
<div className="card text-center">
<div className={`text-2xl font-bold ${detectionRate > 80 ? 'text-green-400' : detectionRate > 50 ? 'text-yellow-400' : 'text-red-400'}`}>
{detectionRate}%
</div>
<div className="text-xs text-muted">Taux détection IA</div>
</div>
{session.avg_dispersion ? (
<div className="card text-center">
<div className="text-2xl font-bold text-text">{session.avg_dispersion}</div>
<div className="text-xs text-muted">Dispersion moy.</div>
</div>
) : <div />}
</div>
{/* Impacts scatter */}
{allImpacts.filter(i => i.x >= 0).length > 0 && (
<div className="card">
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
<Zap size={16} className="text-accent" /> Dispersion des impacts
</h3>
<ResponsiveContainer width="100%" height={240}>
<ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2e2e2e" />
<XAxis type="number" dataKey="x" domain={[0, 100]} hide />
<YAxis type="number" dataKey="y" domain={[0, 100]} hide />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ payload }) => {
if (!payload?.length) return null
const d = payload[0].payload as Impact
return (
<div className="bg-surface border border-border rounded-lg p-2 text-xs">
Zone {d.zone ?? '?'} · {d.points} pts
{d.is_manual ? ' (manuel)' : ''}
</div>
)
}}
/>
<Scatter data={allImpacts.filter(i => i.x >= 0)}>
{allImpacts.filter(i => i.x >= 0).map((imp, i) => (
<Cell key={i} fill={getZoneColor(imp.zone)} opacity={imp.is_manual ? 0.5 : 1} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
<div className="flex items-center gap-3 mt-2 justify-center flex-wrap text-xs text-muted">
<span><span className="inline-block w-3 h-3 rounded-full bg-green-500 mr-1" />910</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-lime-400 mr-1" />78</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-yellow-400 mr-1" />56</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-orange-400 mr-1" />34</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-red-500 mr-1" />12</span>
</div>
</div>
)}
{/* Séries */}
<div>
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
<TrendingUp size={16} className="text-accent" /> Séries ({series.length})
</h3>
<div className="space-y-3">
{series.map(s => (
<div key={s.id} className="card">
<div className="flex items-start gap-3">
{(s.annotated_photo_url || s.photo_url) && (
<img src={s.annotated_photo_url || s.photo_url || ''} alt=""
className="w-20 h-20 rounded-lg object-contain bg-black border border-border shrink-0" />
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-semibold text-text">Série {s.series_number}</span>
<span className="text-xl font-bold text-accent">{s.score_total} pts</span>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-xs text-text-muted">
<span>{s.shots_declared} déclarés</span>
<span className="text-green-400">{s.shots_detected} détectés</span>
{s.shots_manual > 0 && <span className="text-orange">{s.shots_manual} manuels</span>}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-xs text-muted">
<span>Zones : {s.score_zone} pts</span>
<span>Groupement : {s.score_groupement} pts</span>
{s.dispersion_radius != null && <span>Dispersion : {Number(s.dispersion_radius).toFixed(1)}</span>}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Notes */}
{(session.notes_start || session.notes_end) && (
<div className="card space-y-2">
<h3 className="font-semibold text-text text-sm">Notes</h3>
{session.notes_start && (
<div><span className="text-xs text-muted uppercase">Début : </span><span className="text-sm text-text-muted">{session.notes_start}</span></div>
)}
{session.notes_end && (
<div><span className="text-xs text-muted uppercase">Fin : </span><span className="text-sm text-text-muted">{session.notes_end}</span></div>
)}
</div>
)}
</div>
)
}
+178
View File
@@ -0,0 +1,178 @@
// ─── Types de base ───────────────────────────────────────────────────────────
export interface Profile {
id: string
first_name: string | null
last_name: string | null
club: string | null
disciplines: string[]
avatar_url: string | null
created_at: string
updated_at: string
}
export type WeaponType = 'pistolet' | 'carabine' | 'fusil' | 'arc' | 'arbalète' | 'autre'
export interface Weapon {
id: string
user_id: string
name: string
nickname: string | null
type: WeaponType
caliber: string | null
brand: string | null
model: string | null
serial_number: string | null
photo_url: string | null
notes: string | null
is_archived: boolean
created_at: string
updated_at: string
}
export type TargetType = 'issf' | 'silhouette' | 'libre'
export interface TrainingSession {
id: string
user_id: string
weapon_id: string | null
session_date: string
location: string | null
target_type: TargetType
distance_m: number | null
notes_start: string | null
notes_end: string | null
total_shots_declared: number
total_shots_detected: number
total_score: number
avg_score: number
best_series_score: number
ai_detection_rate: number
avg_dispersion: number | null
is_completed: boolean
created_at: string
updated_at: string
// Joined
weapon?: Weapon
series?: Series[]
}
export interface Series {
id: string
session_id: string
user_id: string
series_number: number
shots_declared: number
shots_detected: number
shots_manual: number
photo_url: string | null
annotated_photo_url: string | null
score_zone: number
score_groupement: number
score_total: number
dispersion_radius: number | null
center_x: number | null
center_y: number | null
ai_data: AIAnalysisResult | null
created_at: string
// Joined
impacts?: Impact[]
}
export interface Impact {
id: string
series_id: string
user_id: string
x: number
y: number
zone: number | null
points: number
is_manual: boolean
created_at: string
}
export interface Invitation {
id: string
inviter_id: string
email: string | null
token: string
is_used: boolean
used_by: string | null
created_at: string
expires_at: string
}
export interface UserLocation {
id: string
user_id: string
name: string
used_count: number
last_used_at: string
}
// ─── Résultats IA ────────────────────────────────────────────────────────────
export interface ImpactPoint {
x: number
y: number
zone: number | null
points: number
confidence: number
}
export interface AIAnalysisResult {
impacts: ImpactPoint[]
total_detected: number
score_zone: number
score_groupement: number
score_total: number
dispersion_radius: number
center_x: number
center_y: number
annotated_image_base64: string | null
error: string | null
warning: string | null
}
// ─── Forms ───────────────────────────────────────────────────────────────────
export interface SessionFormData {
session_date: string
location: string
weapon_id: string
target_type: TargetType
distance_m: number | string
notes_start: string
}
export interface WeaponFormData {
name: string
nickname: string
type: WeaponType
caliber: string
brand: string
model: string
serial_number: string
notes: string
photoFile?: File
}
// ─── Stats ───────────────────────────────────────────────────────────────────
export interface ProgressStats {
totalSessions: number
totalShots: number
avgScore: number
bestScore: number
progressPercent: number
avgDispersion: number | null
}
export interface ChartPoint {
date: string
score: number
best: number
shots: number
sessions: number
dispersion: number | null
}
+34
View File
@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
bg: '#0d0d0d',
surface: '#1a1a1a',
surface2: '#242424',
border: '#2e2e2e',
accent: '#dc2626',
'accent-hover': '#b91c1c',
orange: '#ea580c',
muted: '#6b7280',
text: '#f4f4f5',
'text-muted': '#9ca3af',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.2s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: { '0%': { opacity: 0 }, '100%': { opacity: 1 } },
slideUp: { '0%': { transform: 'translateY(20px)', opacity: 0 }, '100%': { transform: 'translateY(0)', opacity: 1 } },
},
},
},
plugins: [],
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icons/*.png', 'icons/*.svg'],
manifest: {
name: 'ShootTracker',
short_name: 'ShootTracker',
description: 'Suivi de performance en tir sportif',
theme_color: '#0d0d0d',
background_color: '#0d0d0d',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
networkTimeoutSeconds: 10,
}
}
]
}
})
],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3001', changeOrigin: true },
'/uploads': { target: 'http://localhost:3001', changeOrigin: true }
}
},
build: {
outDir: 'dist',
sourcemap: false
}
})