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