feat: ShootTracker SQLite+JWT+YOLOv8
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user