feat: ShootTracker SQLite+JWT+YOLOv8

This commit is contained in:
ShootTracker Deploy
2026-04-30 22:44:27 +02:00
commit 2578cb6ec2
55 changed files with 5759 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
// ─── HTTP client JWT ──────────────────────────────────────────────────────────
const BASE = import.meta.env.VITE_API_URL || ''
export function getToken(): string | null {
return localStorage.getItem('st-token')
}
export function setToken(token: string) {
localStorage.setItem('st-token', token)
}
export function clearToken() {
localStorage.removeItem('st-token')
}
async function req<T = any>(
method: string,
path: string,
body?: Record<string, any> | FormData,
): Promise<T> {
const headers: Record<string, string> = {}
const token = getToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const isFormData = body instanceof FormData
if (body && !isFormData) headers['Content-Type'] = 'application/json'
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? (isFormData ? body : JSON.stringify(body)) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err?.error || `HTTP ${res.status}`)
}
return res.json()
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
export const api = {
auth: {
login: (email: string, password: string) =>
req('POST', '/api/auth/login', { email, password }),
register: (data: {
email: string; password: string
first_name: string; last_name: string
club?: string; invite_token?: string
}) => req('POST', '/api/auth/register', data),
me: () => req('GET', '/api/auth/me'),
updateProfile: (data: {
first_name?: string; last_name?: string
club?: string; disciplines?: string[]
}) => req('PUT', '/api/auth/profile', data),
updatePassword: (current_password: string, new_password: string) =>
req('PUT', '/api/auth/password', { current_password, new_password }),
uploadAvatar: (file: File) => {
const fd = new FormData(); fd.append('avatar', file)
return req('POST', '/api/auth/avatar', fd)
},
},
weapons: {
list: () => req('GET', '/api/weapons'),
get: (id: string) => req('GET', `/api/weapons/${id}`),
create: (fd: FormData) => req('POST', '/api/weapons', fd),
update: (id: string, fd: FormData) => req('PUT', `/api/weapons/${id}`, fd),
delete: (id: string) => req('DELETE', `/api/weapons/${id}`),
archive: (id: string, is_archived: boolean) =>
req('PATCH', `/api/weapons/${id}/archive`, { is_archived }),
},
sessions: {
list: () => req('GET', '/api/sessions'),
get: (id: string) => req('GET', `/api/sessions/${id}`),
create: (data: Record<string, any>) => req('POST', '/api/sessions', data),
update: (id: string, data: Record<string, any>) => req('PUT', `/api/sessions/${id}`, data),
delete: (id: string) => req('DELETE', `/api/sessions/${id}`),
},
series: {
list: (sessionId: string) => req('GET', `/api/sessions/${sessionId}/series`),
create: (sessionId: string, fd: FormData) =>
req('POST', `/api/sessions/${sessionId}/series`, fd),
saveAnnotated: (seriesId: string, image_base64: string) =>
req('POST', `/api/series/${seriesId}/annotated`, { image_base64 }),
},
analyze: {
target: (data: {
image_base64: string
target_type: string
previous_image_base64?: string
}) => req('POST', '/api/analyze', data),
},
export: {
pdf: async (sessionId: string): Promise<Blob> => {
const res = await fetch(`${BASE}/api/export/pdf/${sessionId}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Export PDF échoué')
return res.blob()
},
excel: async (params: { period?: string; weaponId?: string }): Promise<Blob> => {
const qs = new URLSearchParams(params as Record<string, string>).toString()
const res = await fetch(`${BASE}/api/export/excel?${qs}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Export Excel échoué')
return res.blob()
},
},
misc: {
locations: () => req('GET', '/api/locations'),
invitations: {
list: () => req('GET', '/api/invitations'),
create: (email?: string) => req('POST', '/api/invitations', email ? { email } : {}),
delete: (id: string) => req('DELETE', `/api/invitations/${id}`),
validate: (token: string) => req('GET', `/api/invitations/validate/${token}`),
},
},
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename; a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+59
View File
@@ -0,0 +1,59 @@
import { create } from 'zustand'
import type { Profile, Weapon, TrainingSession } from '../types'
import { api } from './api'
interface AppStore {
profile: Profile | null
weapons: Weapon[]
sessions: TrainingSession[]
activeSession: TrainingSession | null
loading: boolean
setProfile: (p: Profile | null) => void
setWeapons: (w: Weapon[]) => void
setSessions: (s: TrainingSession[]) => void
setActiveSession: (s: TrainingSession | null) => void
setLoading: (v: boolean) => void
fetchProfile: () => Promise<void>
fetchWeapons: () => Promise<void>
fetchSessions: () => Promise<void>
reset: () => void
}
export const useStore = create<AppStore>((set) => ({
profile: null,
weapons: [],
sessions: [],
activeSession: null,
loading: false,
setProfile: (p) => set({ profile: p }),
setWeapons: (w) => set({ weapons: w }),
setSessions: (s) => set({ sessions: s }),
setActiveSession: (s) => set({ activeSession: s }),
setLoading: (v) => set({ loading: v }),
fetchProfile: async () => {
try {
const data = await api.auth.me()
if (data) set({ profile: data as Profile })
} catch { /* token expiré */ }
},
fetchWeapons: async () => {
try {
const data = await api.weapons.list()
if (data) set({ weapons: data as Weapon[] })
} catch { /* ignore */ }
},
fetchSessions: async () => {
try {
const data = await api.sessions.list()
if (data) set({ sessions: (data as TrainingSession[]).filter(s => s.is_completed) })
} catch { /* ignore */ }
},
reset: () => set({ profile: null, weapons: [], sessions: [], activeSession: null }),
}))
+61
View File
@@ -0,0 +1,61 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Variables Supabase manquantes : VITE_SUPABASE_URL et VITE_SUPABASE_ANON_KEY requis')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
})
// ─── Upload helpers ───────────────────────────────────────────────────────────
export async function uploadFile(
bucket: string,
userId: string,
file: File,
folder = ''
): Promise<string | null> {
const ext = file.name.split('.').pop()
const path = `${userId}/${folder ? folder + '/' : ''}${Date.now()}.${ext}`
const { error } = await supabase.storage.from(bucket).upload(path, file, { upsert: true })
if (error) { console.error('Upload error:', error); return null }
const { data } = supabase.storage.from(bucket).getPublicUrl(path)
return data.publicUrl
}
export async function getSignedUrl(bucket: string, path: string): Promise<string | null> {
// path = 'userId/...'
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(path, 3600)
if (error || !data) return null
return data.signedUrl
}
export async function uploadBase64Image(
bucket: string,
userId: string,
base64: string,
folder = 'annotated'
): Promise<string | null> {
try {
const base64Data = base64.replace(/^data:image\/\w+;base64,/, '')
const byteCharacters = atob(base64Data)
const byteArray = new Uint8Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteArray[i] = byteCharacters.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: 'image/jpeg' })
const file = new File([blob], `annotated_${Date.now()}.jpg`, { type: 'image/jpeg' })
return uploadFile(bucket, userId, file, folder)
} catch (e) {
console.error('uploadBase64Image error:', e)
return null
}
}