diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..3e4af95ea88eca2313576d3e59fb87a2ab177aef
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+# --- FRONTEND (Node/Next.js) ---
+/node_modules
+/.next/
+/out/
+/build
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+.vercel
+*.tsbuildinfo
+next-env.d.ts
+
+# --- BACKEND (Python) ---
+# Ignore le dossier d'environnement virtuel (très lourd)
+venv/
+env/
+.venv/
+
+# Ignore les fichiers compilés Python
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Ignore la base de données locale (on utilise Supabase maintenant)
+startech.db
+*.sqlite3
+
+# --- SÉCURITÉ (Secrets) ---
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+.DS_Store
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..c3b0b46093ffffc3cd044711236f1cae6ac8f633
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+# 1. Image Python stable
+FROM python:3.10
+
+# 2. Dossier de travail dans le conteneur
+WORKDIR /code
+
+# 3. Copie des requirements (On va chercher dans le dossier backend)
+COPY ./backend/requirements.txt /code/requirements.txt
+
+# 4. Installation des dépendances (Mise à jour de pip + installation)
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir -r /code/requirements.txt
+
+# 5. Création du dossier pour les poids DeepFace (évite les erreurs de permission)
+RUN mkdir -p /root/.deepface/weights
+
+# 6. Copie du code backend
+COPY ./backend /code
+
+# 7. Ouverture du port standard Hugging Face
+EXPOSE 7860
+
+# 8. Lancement du serveur (Host 0.0.0.0 est vital pour le cloud)
+CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
\ No newline at end of file
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9a0c01473c4e9736453b058c1cd095a80817bc82
--- /dev/null
+++ b/app/admin/login/page.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { supabase } from "@/lib/supabase"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Shield, Lock, AlertCircle, Mail } from "lucide-react"
+
+export default function AdminLogin() {
+ const [email, setEmail] = useState("")
+ const [password, setPassword] = useState("")
+ const [error, setError] = useState("")
+ const [loading, setLoading] = useState(false)
+ const router = useRouter()
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault(); setLoading(true); setError("")
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password })
+ if (error) { setError("Erreur : " + error.message); setLoading(false) }
+ else { localStorage.setItem("startech_admin_token", "authorized_access_granted"); router.push("/admin") }
+ }
+
+ return (
+
+
+
+
+
+ STARTECH ADMINConnexion Cloud Sécurisée
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7dd5b9e75a8f9c7c13d6339aa8f37cf8519f175f
--- /dev/null
+++ b/app/admin/page.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { ArrowLeft, Calendar, Database, FileText, Trash2, Clock, Activity, ThumbsUp, LogOut, ArrowRightLeft, Trophy } from "lucide-react"
+import Link from "next/link"
+import ComparisonChart from "@/components/neurolink/comparison-chart"
+
+const CircularProgress = ({ value, color, label, icon: Icon }: any) => {
+ const radius = 30; const circumference = 2 * Math.PI * radius; const offset = circumference - (value / 100) * circumference
+ return (
+
+ )
+}
+
+export default function AdminPage() {
+ const [sessions, setSessions] = useState([])
+ const [isAuthorized, setIsAuthorized] = useState(false)
+ const router = useRouter()
+ const [isCompareMode, setIsCompareMode] = useState(false)
+ const [sessionA, setSessionA] = useState(null); const [dataA, setDataA] = useState([]); const [statsA, setStatsA] = useState(null)
+ const [sessionB, setSessionB] = useState(null); const [dataB, setDataB] = useState([]); const [statsB, setStatsB] = useState(null)
+
+ useEffect(() => {
+ const token = localStorage.getItem("startech_admin_token")
+ if (token !== "authorized_access_granted") { router.push("/admin/login") }
+ else { setIsAuthorized(true); fetch('http://localhost:8000/api/sessions').then(res => res.json()).then(data => setSessions(data)) }
+ }, [])
+
+ const handleSelectSession = (sessionId: number) => {
+ fetch(`http://localhost:8000/api/sessions/${sessionId}`).then(res => res.json()).then(response => {
+ const info = response.info; const data = response.data; const stats = calculateStatsInternal(data)
+ if (isCompareMode) {
+ if (!sessionA) { setSessionA(info); setDataA(data); setStatsA(stats) }
+ else if (!sessionB && sessionId !== sessionA.id) { setSessionB(info); setDataB(data); setStatsB(stats) }
+ else if (sessionA && sessionB) { setSessionA(info); setDataA(data); setStatsA(stats); setSessionB(null); setDataB([]); setStatsB(null) }
+ } else { setSessionA(info); setDataA(data); setStatsA(stats); setSessionB(null); setDataB([]); setStatsB(null) }
+ })
+ }
+
+ const calculateStatsInternal = (data: any[]) => {
+ if (data.length === 0) return null
+ const avg = (key: string) => Math.round(data.reduce((acc, curr) => acc + curr[key], 0) / data.length)
+ return { duration: data.length, avg_engagement: avg('engagement_val'), avg_satisfaction: avg('satisfaction_val'), dominant_label: data[data.length - 1]?.satisfaction_lbl || "N/A" }
+ }
+ const toggleCompareMode = () => { setIsCompareMode(!isCompareMode); setSessionB(null); setDataB([]); setStatsB(null) }
+ const handleDeleteSession = async (e: React.MouseEvent, sessionId: number) => {
+ e.stopPropagation(); if (!confirm("Supprimer définitivement ?")) return
+ const res = await fetch(`http://localhost:8000/api/sessions/${sessionId}`, { method: 'DELETE' })
+ if (res.ok) { setSessions(prev => prev.filter(s => s.id !== sessionId)); if (sessionA?.id === sessionId) { setSessionA(null); setDataA([]); setStatsA(null) }; if (sessionB?.id === sessionId) { setSessionB(null); setDataB([]); setStatsB(null) } }
+ }
+ const handleExport = (session: any, data: any) => {
+ if (!data.length) return
+ const headers = ["Temps", "Emotion", "Score IA", "Engagement", "Label Engagement", "Satisfaction", "Label Satisfaction", "Confiance", "Fidelite", "Avis"]
+ const rows = data.map((row: any) => [ String(row.session_time).replace('.', ','), row.emotion, row.emotion_score ? String(row.emotion_score.toFixed(2)).replace('.', ',') : "0", String(row.engagement_val), `"${row.engagement_lbl}"`, String(row.satisfaction_val), `"${row.satisfaction_lbl}"`, String(row.trust_val), String(row.loyalty_val), `"${row.opinion_lbl}"` ])
+ const csvContent = [headers.join(";"), ...rows.map((e: any) => e.join(";"))].join("\n")
+ const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.setAttribute("download", `Rapport_${session.first_name}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link)
+ }
+
+ // CORRECTION DE LA DATE ICI : On gère created_at de Supabase
+ const formatDate = (dateStr: string) => {
+ if (!dateStr) return "Date inconnue";
+ try {
+ return new Date(dateStr).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute:'2-digit' })
+ } catch (e) { return "Erreur date" }
+ }
+
+ if (!isAuthorized) return null
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
Historique
+
+
+ {sessions.map((session) => {
+ const isA = sessionA?.id === session.id; const isB = sessionB?.id === session.id
+ let activeClass = "border-l-4 border-l-transparent"
+ if (isA) activeClass = "bg-blue-50 border-l-blue-500"; if (isB) activeClass = "bg-orange-50 border-l-orange-500"
+ return (
+
handleSelectSession(session.id)} className={`group flex items-center justify-between p-4 border-b border-slate-100 hover:bg-slate-50 cursor-pointer transition-all ${activeClass}`}>
+
+
{session.first_name} {session.last_name} {isA && A} {isB && B}
+ {/* CORRECTION ICI : created_at au lieu de start_time */}
+
{formatDate(session.created_at)}
+
+
+
+ )
+ })}
+
+
+
+
+
+ {!isCompareMode && sessionA && statsA && (
+
+
+
{sessionA.first_name} {sessionA.last_name}
Durée: {statsA.duration}s | ID: {sessionA.client_id || "N/A"}
+
+
+
+
+
+
Verdict{statsA.dominant_label}
+
+
Analyse Temporelle
+
Données Brutes Complètes| Temps | Émotion | Score IA | Engagement | Satisfaction | Confiance | Fidélité | Avis |
{dataA.map((row, i) => (| {row.session_time}s | {row.emotion} | {row.emotion_score?.toFixed(1)}% | {row.engagement_val}%{row.engagement_lbl} | 50 ? "text-green-600" : "text-orange-500"}`}>{row.satisfaction_val}%{row.satisfaction_lbl} | {row.trust_val}%{row.trust_lbl} | {row.loyalty_val}%{row.loyalty_lbl} | {row.opinion_lbl} |
))}
+
+ )}
+ {isCompareMode && sessionA && sessionB && statsA && statsB && (
+
+
{sessionA.first_name}
Session A
{sessionB.first_name}
Session B
+
Duel d'Engagement
+
statsB.avg_engagement ? "bg-blue-50" : ""}`}>{sessionA.first_name}{statsA.avg_engagement > statsB.avg_engagement && Vainqueur} statsA.avg_engagement ? "bg-orange-50" : ""}`}>{sessionB.first_name}{statsB.avg_engagement > statsA.avg_engagement && Vainqueur}
+
+ )}
+ {(!sessionA) &&
Sélectionnez une session à gauche pour commencer.
}
+ {(isCompareMode && sessionA && !sessionB) &&
Session A : {sessionA.first_name} sélectionnée.
Maintenant, sélectionnez la Session B dans la liste.
}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..3d7338df8d17865400e65ebba913d8c0da810f33
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,174 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ /* Light neuromarketing color scheme */
+ --background: oklch(0.98 0.01 0);
+ --foreground: oklch(0.15 0.01 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.15 0.01 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.15 0.01 0);
+ --primary: oklch(0.5 0.15 260);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.7 0.1 260);
+ --secondary-foreground: oklch(0.15 0.01 0);
+ --muted: oklch(0.8 0.02 0);
+ --muted-foreground: oklch(0.45 0.01 0);
+ --accent: oklch(0.6 0.2 280);
+ --accent-foreground: oklch(1 0 0);
+ --destructive: oklch(0.6 0.22 28);
+ --destructive-foreground: oklch(1 0 0);
+ --border: oklch(0.9 0.02 0);
+ --input: oklch(0.95 0.02 0);
+ --ring: oklch(0.5 0.15 260);
+ --chart-1: oklch(0.5 0.15 260);
+ --chart-2: oklch(0.6 0.2 300);
+ --chart-3: oklch(0.6 0.22 28);
+ --chart-4: oklch(0.7 0.15 280);
+ --chart-5: oklch(0.5 0.18 50);
+ --radius: 0.625rem;
+ --sidebar: oklch(1 0 0);
+ --sidebar-foreground: oklch(0.15 0.01 0);
+ --sidebar-primary: oklch(0.5 0.15 260);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.6 0.2 300);
+ --sidebar-accent-foreground: oklch(1 0 0);
+ --sidebar-border: oklch(0.9 0.02 0);
+ --sidebar-ring: oklch(0.5 0.15 260);
+
+ /* NeuroLink specific colors */
+ --neuro-green: #16a34a;
+ --neuro-red: #dc2626;
+ --neuro-blue: #2563eb;
+ --neuro-orange: #ea580c;
+ --neuro-accent: #2563eb;
+ --neuro-text: #1f2937;
+ --neuro-muted: #6b7280;
+}
+
+.dark {
+ --background: oklch(0.08 0 0);
+ --foreground: oklch(0.95 0.01 0);
+ --card: oklch(0.1 0.01 0);
+ --card-foreground: oklch(0.95 0.01 0);
+ --popover: oklch(0.1 0.01 0);
+ --popover-foreground: oklch(0.95 0.01 0);
+ --primary: oklch(0.85 0.15 131);
+ --primary-foreground: oklch(0.08 0 0);
+ --secondary: oklch(0.3 0.1 0);
+ --secondary-foreground: oklch(0.95 0.01 0);
+ --muted: oklch(0.25 0.05 0);
+ --muted-foreground: oklch(0.65 0.01 0);
+ --accent: oklch(0.6 0.2 300);
+ --accent-foreground: oklch(0.08 0 0);
+ --destructive: oklch(0.6 0.22 28);
+ --destructive-foreground: oklch(0.95 0.01 0);
+ --border: oklch(0.2 0.02 0);
+ --input: oklch(0.15 0.02 0);
+ --ring: oklch(0.85 0.15 131);
+ --chart-1: oklch(0.85 0.15 131);
+ --chart-2: oklch(0.6 0.2 300);
+ --chart-3: oklch(0.6 0.22 28);
+ --chart-4: oklch(0.7 0.15 280);
+ --chart-5: oklch(0.5 0.18 50);
+ --sidebar: oklch(0.1 0.01 0);
+ --sidebar-foreground: oklch(0.95 0.01 0);
+ --sidebar-primary: oklch(0.85 0.15 131);
+ --sidebar-primary-foreground: oklch(0.08 0 0);
+ --sidebar-accent: oklch(0.6 0.2 300);
+ --sidebar-accent-foreground: oklch(0.08 0 0);
+ --sidebar-border: oklch(0.2 0.02 0);
+ --sidebar-ring: oklch(0.85 0.15 131);
+
+ --neuro-green: #22c55e;
+ --neuro-red: #ef4444;
+ --neuro-blue: #3b82f6;
+ --neuro-accent: #8b5cf6;
+ --neuro-text: #e2e8f0;
+ --neuro-muted: #94a3b8;
+}
+
+@theme inline {
+ --font-sans: "Geist", "Geist Fallback";
+ --font-mono: "Geist Mono", "Geist Mono Fallback";
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-neuro-green: var(--neuro-green);
+ --color-neuro-red: var(--neuro-red);
+ --color-neuro-blue: var(--neuro-blue);
+ --color-neuro-orange: var(--neuro-orange);
+ --color-neuro-accent: var(--neuro-accent);
+ --color-neuro-text: var(--neuro-text);
+ --color-neuro-muted: var(--neuro-muted);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Custom utilities for NeuroLink dashboard */
+@layer utilities {
+ .neuro-text {
+ @apply text-neuro-text;
+ }
+ .neuro-muted {
+ @apply text-neuro-muted;
+ }
+ .neuro-accent {
+ @apply text-neuro-accent;
+ }
+ .neuro-green {
+ @apply text-neuro-green;
+ }
+ .neuro-red {
+ @apply text-neuro-red;
+ }
+ .neuro-blue {
+ @apply text-neuro-blue;
+ }
+ .neuro-orange {
+ @apply text-neuro-orange;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..104f085c0e90fca575300d20d187c1a8b51c1274
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,47 @@
+import type React from "react"
+import type { Metadata } from "next"
+import { Geist, Geist_Mono } from "next/font/google"
+import { Analytics } from "@vercel/analytics/next"
+import "./globals.css"
+
+const _geist = Geist({ subsets: ["latin"] })
+const _geistMono = Geist_Mono({ subsets: ["latin"] })
+
+export const metadata: Metadata = {
+ title: "NeuroLink MVP - Biometric Ad Analysis",
+ description: "Real-time neuromarketing dashboard for analyzing biometric reactions to video advertisements",
+ generator: "v0.app",
+ icons: {
+ icon: [
+ {
+ url: "/icon-light-32x32.png",
+ media: "(prefers-color-scheme: light)",
+ },
+ {
+ url: "/icon-dark-32x32.png",
+ media: "(prefers-color-scheme: dark)",
+ },
+ {
+ url: "/icon.svg",
+ type: "image/svg+xml",
+ },
+ ],
+ apple: "/apple-icon.png",
+ },
+}
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode
+}>) {
+ return (
+ /* CHANGEMENT ICI : J'ai retiré className="dark" pour revenir au mode clair par défaut */
+
+
+ {children}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..630985cd5d1f3a441ba8196f5758647617f7d894
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,183 @@
+"use client"
+
+import { useState, useEffect, useRef } from "react"
+import MetricsPanel from "@/components/neurolink/metrics-panel"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Play, Square, RotateCcw, Zap, User, Fingerprint, Shield, Target } from "lucide-react"
+import { io, Socket } from "socket.io-client"
+import Link from "next/link"
+
+interface MetricData { timestamp: number; value: number; engagement: number; satisfaction: number; trust: number; }
+interface UserInfo { firstName: string; lastName: string; clientId: string }
+
+export default function Dashboard() {
+ const [userInfo, setUserInfo] = useState(null)
+ const [socket, setSocket] = useState(null)
+ const [isConnected, setIsConnected] = useState(false)
+ const [isRecording, setIsRecording] = useState(false)
+ const [sessionTime, setSessionTime] = useState(0)
+ const [formData, setFormData] = useState({ firstName: "", lastName: "", clientId: "" })
+ const [currentMetrics, setCurrentMetrics] = useState({ engagement: 0, satisfaction: 50, trust: 50, loyalty: 50, opinion: 50, emotion: "neutral" })
+ const [history, setHistory] = useState([])
+
+ const videoRef = useRef(null)
+ const canvasRef = useRef(null)
+ const [faceCoords, setFaceCoords] = useState(null)
+ const [cameraActive, setCameraActive] = useState(false)
+
+ // 1. Démarrer Webcam Locale
+ useEffect(() => {
+ if (userInfo && !cameraActive) {
+ navigator.mediaDevices.getUserMedia({ video: { width: 480, height: 360 } })
+ .then(stream => { if (videoRef.current) { videoRef.current.srcObject = stream; setCameraActive(true) } })
+ .catch(err => console.error("Erreur Webcam:", err))
+ }
+ }, [userInfo, cameraActive])
+
+ // 2. Logique Socket & Envoi d'images
+ useEffect(() => {
+ if (!userInfo) return;
+ const newSocket = io("http://localhost:8000")
+ newSocket.on("connect", () => setIsConnected(true))
+ newSocket.on("disconnect", () => setIsConnected(false))
+
+ newSocket.on("metrics_update", (data: any) => {
+ setSessionTime(data.session_time); setIsRecording(data.is_recording)
+ setFaceCoords(data.face_coords)
+
+ const newMetrics = {
+ emotion: data.emotion,
+ engagement: data.metrics.engagement, satisfaction: data.metrics.satisfaction,
+ trust: data.metrics.trust, loyalty: data.metrics.loyalty, opinion: data.metrics.opinion
+ }
+ setCurrentMetrics(newMetrics)
+
+ if (data.is_recording) {
+ setHistory(prev => [...prev.slice(-29), {
+ timestamp: data.session_time, value: newMetrics.engagement, engagement: newMetrics.engagement,
+ satisfaction: newMetrics.satisfaction, trust: newMetrics.trust
+ }])
+ }
+ })
+
+ setSocket(newSocket)
+
+ // Envoi 5 fois par seconde
+ const interval = setInterval(() => {
+ if (videoRef.current && canvasRef.current && newSocket.connected) {
+ const ctx = canvasRef.current.getContext('2d')
+ if (ctx) {
+ ctx.drawImage(videoRef.current, 0, 0, 480, 360)
+ const dataUrl = canvasRef.current.toDataURL('image/jpeg', 0.5)
+ newSocket.emit('process_frame', dataUrl)
+ }
+ }
+ }, 200)
+
+ return () => { clearInterval(interval); newSocket.close() }
+ }, [userInfo])
+
+ const handleLogin = (e: React.FormEvent) => { e.preventDefault(); if (formData.firstName && formData.lastName) setUserInfo(formData) }
+ const handleStartStop = () => { if (socket && userInfo) { isRecording ? socket.emit("stop_session") : (sessionTime === 0 && setHistory([]), socket.emit("start_session", userInfo)) } }
+ const handleReset = () => { if (socket) socket.emit("stop_session"); setHistory([]); setSessionTime(0); setCurrentMetrics(prev => ({ ...prev, engagement: 0, emotion: "neutral" })) }
+ const handleLogout = () => { setUserInfo(null); setHistory([]); setSessionTime(0); setCameraActive(false); if(socket) socket.disconnect() }
+ const formatTime = (s: number) => `${Math.floor(s/60).toString().padStart(2,'0')}:${(s%60).toString().padStart(2,'0')}`
+ const getEmotionDisplay = (e: string) => { const map: any = { happy: "😄 JOIE", sad: "😢 TRISTESSE", angry: "😠 COLÈRE", surprise: "😲 SURPRISE", fear: "😨 PEUR", neutral: "😐 NEUTRE" }; return map[e] || e.toUpperCase() }
+
+ if (!userInfo) {
+ return (
+
+
+
+
+
+ STARTECH IDIdentification biométrique du sujet
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
{userInfo.firstName.charAt(0)}{userInfo.lastName.charAt(0)}
+
{userInfo.firstName} {userInfo.lastName}{userInfo.clientId || "ID: GUEST"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isRecording && }
+ {isRecording && }
+
+
+
+
+ {faceCoords && (
+
+ )}
+
+
+
+
+
+
+
{isRecording ? "ENREGISTREMENT" : "PRÊT"}
+
{formatTime(sessionTime)}
+
+
+
+ Emotion Dominante
+ {getEmotionDisplay(currentMetrics.emotion)}
+
+
+
+
+
+ {!isRecording ? (
) : (
)}
+
+
+
+
+
+
+
+
+
SERVEUR STARTECH
+
{isConnected ? "ONLINE" : "OFFLINE"}
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f4f94f167150a8ca35b9d14a057fd2f92dd96822
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,9 @@
+fastapi
+uvicorn
+python-socketio
+deepface
+opencv-python-headless<4.10
+numpy<2
+supabase
+tf-keras
+tensorflow
\ No newline at end of file
diff --git a/backend/server.py b/backend/server.py
new file mode 100644
index 0000000000000000000000000000000000000000..26c9026c2fd42c935b7febae5fd09e21976817dc
--- /dev/null
+++ b/backend/server.py
@@ -0,0 +1,191 @@
+import os
+import socketio
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+import asyncio
+import base64
+import cv2
+import numpy as np
+import random
+from datetime import datetime
+from deepface import DeepFace
+from supabase import create_client, Client
+
+# --- CONFIGURATION SUPABASE (BACKEND) ---
+# ⚠️ REMPLACEZ PAR VOS CLÉS (Supabase > Settings > API)
+# ICI IL FAUT LA CLÉ SECRÈTE "SERVICE_ROLE" POUR POUVOIR ECRIRE/SUPPRIMER
+SUPABASE_URL = 'https://gwjrwejdjpctizolfkcz.supabase.co'
+SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd3anJ3ZWpkanBjdGl6b2xma2N6Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2OTA5ODEyNCwiZXhwIjoyMDg0Njc0MTI0fQ.EjU1DGTN-jrdkaC6nJWilFtYZgtu-NKjnfiMVMnHal0"
+
+try:
+ supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
+ print("☁️ Connecté à Supabase (PerseeTech)")
+except Exception as e:
+ print(f"❌ Erreur de connexion Supabase : {e}")
+
+# --- CONFIGURATION SERVEUR ---
+sio = socketio.AsyncServer(async_mode='asgi', cors_allowed_origins='*')
+app = FastAPI()
+app.add_middleware(
+ CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
+)
+socket_app = socketio.ASGIApp(sio, app)
+
+# --- API REST (Lecture pour l'Admin) ---
+@app.get("/api/sessions")
+def get_sessions():
+ response = supabase.table('sessions').select("*").order('id', desc=True).execute()
+ return response.data
+
+@app.get("/api/sessions/{session_id}")
+def get_session_details(session_id: int):
+ sess = supabase.table('sessions').select("*").eq('id', session_id).execute()
+ if not sess.data:
+ raise HTTPException(status_code=404, detail="Session non trouvée")
+ meas = supabase.table('measurements').select("*").eq('session_id', session_id).order('session_time', desc=False).execute()
+ return {"info": sess.data[0], "data": meas.data}
+
+@app.delete("/api/sessions/{session_id}")
+def delete_session(session_id: int):
+ try:
+ supabase.table('sessions').delete().eq('id', session_id).execute()
+ return {"message": "Session supprimée avec succès"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+# --- LOGIQUE MÉTIER (KPIs - SANS BPM) ---
+def calculate_kpis(emotion):
+ valence = 0.0; arousal = 0.0; noise = random.uniform(-0.05, 0.05)
+ if emotion == "happy": valence = 0.8 + noise; arousal = 0.6 + noise
+ elif emotion == "surprise": valence = 0.2 + noise; arousal = 0.9 + noise
+ elif emotion in ["fear", "angry"]: valence = -0.7 + noise; arousal = 0.8 + noise
+ elif emotion == "disgust": valence = -0.8 + noise; arousal = 0.5 + noise
+ elif emotion == "sad": valence = -0.6 + noise; arousal = 0.2 + noise
+ else: valence = 0.0 + noise; arousal = 0.3 + noise
+
+ def clamp(n): return max(0, min(100, int(n)))
+ val_eng = clamp((arousal * 100) + random.uniform(0, 5))
+ val_sat = clamp(((valence + 1) / 2) * 100)
+ val_tru = clamp(50 + (valence * 40) + random.uniform(0, 5)) if valence > 0 else clamp(50 - (abs(valence) * 40) + random.uniform(0, 5))
+ val_loy = clamp((val_sat * 0.7) + (val_tru * 0.3))
+ val_opi = val_sat
+
+ # Labels
+ if val_eng >= 75: lbl_eng = "Engagement Fort 🔥"
+ elif val_eng >= 40: lbl_eng = "Engagement Moyen"
+ else: lbl_eng = "Désengagement 💤"
+
+ if val_sat >= 70: lbl_sat = "Très Satisfait 😃"
+ elif val_sat >= 45: lbl_sat = "Neutre 😐"
+ else: lbl_sat = "Insatisfait 😡"
+
+ if val_tru >= 70: lbl_tru = "Confiance Totale 🤝"
+ elif val_tru >= 40: lbl_tru = "Sceptique 🤔"
+ else: lbl_tru = "Méfiant 🚩"
+
+ if val_loy >= 75: lbl_loy = "Fidèle (Ambassadeur) 💎"
+ elif val_loy >= 50: lbl_loy = "Client Standard"
+ else: lbl_loy = "Infidèle / Volatile 💸"
+
+ if val_opi >= 60: lbl_opi = "Avis Positif 👍"
+ elif val_opi >= 40: lbl_opi = "Indécis"
+ else: lbl_opi = "Avis Négatif 👎"
+
+ return {
+ "engagement": val_eng, "satisfaction": val_sat, "trust": val_tru, "loyalty": val_loy, "opinion": val_opi,
+ "lbl_eng": lbl_eng, "lbl_sat": lbl_sat, "lbl_tru": lbl_tru, "lbl_loy": lbl_loy, "lbl_opi": lbl_opi
+ }
+
+# --- ETAT IA ---
+camera_state = { "emotion": "neutral", "emotion_score": 0, "face_coords": None }
+active_sessions = {}
+
+# TÂCHE 1 : RECEPTION IMAGE (CLIENT -> SERVER)
+@sio.event
+async def process_frame(sid, data_uri):
+ try:
+ encoded_data = data_uri.split(',')[1]
+ nparr = np.frombuffer(base64.b64decode(encoded_data), np.uint8)
+ frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
+
+ # Analyse DeepFace
+ result = DeepFace.analyze(frame, actions=['emotion'], enforce_detection=False, silent=True)
+ data = result[0] if isinstance(result, list) else result
+
+ camera_state["emotion"] = data['dominant_emotion']
+ camera_state["emotion_score"] = data['emotion'][data['dominant_emotion']]
+
+ region = data['region']
+ if region['w'] > 0:
+ camera_state["face_coords"] = {'x': region['x'], 'y': region['y'], 'w': region['w'], 'h': region['h']}
+ else:
+ camera_state["face_coords"] = None
+ except:
+ camera_state["face_coords"] = None
+
+# TÂCHE 2 : GESTION SESSION (Boucle infinie)
+async def session_manager_loop():
+ while True:
+ for sid, user_data in list(active_sessions.items()):
+ kpis = calculate_kpis(camera_state["emotion"])
+
+ if user_data["is_recording"]:
+ user_data["session_time"] += 1
+ t = user_data["session_time"]
+
+ # ENREGISTREMENT DB SUPABASE
+ if user_data["db_id"]:
+ try:
+ data_to_insert = {
+ "session_id": user_data["db_id"],
+ "session_time": t,
+ "emotion": camera_state["emotion"],
+ "emotion_score": camera_state["emotion_score"],
+ "engagement_val": kpis["engagement"], "engagement_lbl": kpis["lbl_eng"],
+ "satisfaction_val": kpis["satisfaction"], "satisfaction_lbl": kpis["lbl_sat"],
+ "trust_val": kpis["trust"], "trust_lbl": kpis["lbl_tru"],
+ "loyalty_val": kpis["loyalty"], "loyalty_lbl": kpis["lbl_loy"],
+ "opinion_val": kpis["opinion"], "opinion_lbl": kpis["lbl_opi"]
+ }
+ supabase.table('measurements').insert(data_to_insert).execute()
+ except Exception as e: print(f"Supabase Insert Error: {e}")
+
+ await sio.emit('metrics_update', {
+ "emotion": camera_state["emotion"], "metrics": kpis,
+ "face_coords": camera_state["face_coords"],
+ "session_time": user_data["session_time"],
+ "is_recording": user_data["is_recording"]
+ }, room=sid)
+ await asyncio.sleep(1)
+
+@sio.event
+async def connect(sid, environ): active_sessions[sid] = { "is_recording": False, "session_time": 0, "db_id": None }
+@sio.event
+async def disconnect(sid):
+ if sid in active_sessions: del active_sessions[sid]
+@sio.event
+async def start_session(sid, data):
+ user_session = active_sessions.get(sid)
+ if user_session:
+ user_session["is_recording"] = True
+ user_session["session_time"] = 0
+
+ new_session = {
+ "first_name": data.get('firstName'),
+ "last_name": data.get('lastName'),
+ "client_id": data.get('clientId')
+ }
+ res = supabase.table('sessions').insert(new_session).execute()
+ user_session["db_id"] = res.data[0]['id']
+
+@sio.event
+async def stop_session(sid):
+ user_session = active_sessions.get(sid)
+ if user_session: user_session["is_recording"] = False
+
+if __name__ == "__main__":
+ @app.on_event("startup")
+ async def startup_event():
+ asyncio.create_task(session_manager_loop())
+ uvicorn.run(socket_app, host="0.0.0.0", port=8000)
\ No newline at end of file
diff --git a/backend/test_face.py b/backend/test_face.py
new file mode 100644
index 0000000000000000000000000000000000000000..4074012f742282ecc83f456198c08daccbfcad4a
--- /dev/null
+++ b/backend/test_face.py
@@ -0,0 +1,60 @@
+import cv2
+from deepface import DeepFace
+import time
+
+# Essaie d'ouvrir la caméra (0 par défaut)
+cap = cv2.VideoCapture(0)
+
+print("📸 Caméra active. Regarde l'objectif...")
+print("Appuie sur la touche 'q' pour quitter.")
+
+last_analysis_time = 0
+current_text = "Recherche..."
+x, y, w, h = 0, 0, 0, 0
+
+while True:
+ ret, frame = cap.read()
+ if not ret:
+ print("Erreur: Impossible de lire la caméra")
+ break
+
+ # Analyse toutes les 0.5 secondes
+ if time.time() - last_analysis_time > 0.5:
+ try:
+ # enforce_detection=False évite le crash si pas de visage
+ result = DeepFace.analyze(frame, actions=['emotion'], enforce_detection=False)
+
+ # DeepFace renvoie une liste
+ if isinstance(result, list):
+ data = result[0]
+ else:
+ data = result
+
+ emotion = data['dominant_emotion']
+ score = data['emotion'][emotion]
+
+ current_text = f"{emotion.upper()} ({int(score)}%)"
+
+ region = data['region']
+ x, y, w, h = region['x'], region['y'], region['w'], region['h']
+
+ last_analysis_time = time.time()
+
+ except Exception as e:
+ pass
+
+ # Dessine le carré et le texte si un visage est trouvé
+ if w > 0:
+ cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2)
+ cv2.putText(frame, current_text, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)
+
+ cv2.imshow('NeuroLink Face Test', frame)
+
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+cap.release()
+cv2.destroyAllWindows()
+# Force la fermeture des fenêtres sur Mac
+for i in range(5):
+ cv2.waitKey(1)
diff --git a/components.json b/components.json
new file mode 100644
index 0000000000000000000000000000000000000000..4ee62ee10547066d7ee70f94d79c786226c3c12c
--- /dev/null
+++ b/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/components/neurolink/comparison-chart.tsx b/components/neurolink/comparison-chart.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..244e4cec464addefd74f35b7c1ddef4cc7748822
--- /dev/null
+++ b/components/neurolink/comparison-chart.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+
+export default function ComparisonChart({ dataA, dataB, nameA, nameB }: any) {
+
+ // Fusion des données pour le graphique
+ // On prend la durée la plus longue
+ const length = Math.max(dataA.length, dataB.length)
+ const chartData = []
+
+ for (let i = 0; i < length; i++) {
+ chartData.push({
+ time: i,
+ // Session A (Bleu)
+ engA: dataA[i] ? dataA[i].engagement_val : null,
+ // Session B (Orange)
+ engB: dataB[i] ? dataB[i].engagement_val : null,
+ })
+ }
+
+ return (
+
+
+
+
+ `${val}s`} />
+
+ `Temps : ${label}s`}
+ />
+
+
+ {/* Ligne A (Bleu) */}
+
+
+ {/* Ligne B (Orange) */}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/neurolink/emotional-intensity-chart.tsx b/components/neurolink/emotional-intensity-chart.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..676bf1a511c30442f9b6aa63d92e3953c9881f1a
--- /dev/null
+++ b/components/neurolink/emotional-intensity-chart.tsx
@@ -0,0 +1,79 @@
+"use client"
+import { motion } from "framer-motion"
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
+
+interface BiometricData {
+ engagement: number
+ satisfaction: number
+ trust: number
+ loyalty: number
+ opinion: number
+ bpm: number
+ timestamp: number
+}
+
+interface EmotionalIntensityChartProps {
+ data: BiometricData[]
+}
+
+export default function EmotionalIntensityChart({ data }: EmotionalIntensityChartProps) {
+ const chartData = data.map((d, idx) => ({
+ time: idx,
+ intensity: (d.engagement + d.satisfaction + d.opinion) / 3,
+ }))
+
+ return (
+
+
+
Emotional Intensity
+
Last 30 seconds
+
+
+ {chartData.length > 0 ? (
+
+
+
+
+
+ value.toFixed(1)}
+ />
+
+
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/components/neurolink/header.tsx b/components/neurolink/header.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..983385d1f20c3bb47011dd1c145a30832bdf9eeb
--- /dev/null
+++ b/components/neurolink/header.tsx
@@ -0,0 +1,64 @@
+"use client"
+import { motion } from "framer-motion"
+import { Activity } from "lucide-react"
+
+interface HeaderProps {
+ onStartSession: () => void
+ sessionState: "idle" | "calibrating" | "running" | "finished"
+}
+
+export default function Header({ onStartSession, sessionState }: HeaderProps) {
+ const getButtonText = () => {
+ switch (sessionState) {
+ case "idle":
+ return "Start Calibration"
+ case "calibrating":
+ return "Calibrating..."
+ case "running":
+ return "Session Running"
+ case "finished":
+ return "Restart"
+ }
+ }
+
+ return (
+
+
+
+
+
+
NeuroLink Bio-Feedback
+
Real-time Biometric Analysis
+
+
+
+
+
+
+
+ {sessionState === "running" ? "CONNECTED" : "STANDBY"}
+
+
+
+
+ {getButtonText()}
+
+
+
+
+ )
+}
diff --git a/components/neurolink/metric-gauge.tsx b/components/neurolink/metric-gauge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0b48806619d299e3c30484875ad72c8fd3012dea
--- /dev/null
+++ b/components/neurolink/metric-gauge.tsx
@@ -0,0 +1,78 @@
+"use client"
+import { motion } from "framer-motion"
+
+interface MetricGaugeProps {
+ label: string
+ value: number
+ minLabel: string
+ maxLabel: string
+ color: string
+ type: "engagement" | "valence" | "trust" | "loyalty" | "opinion"
+}
+
+export default function MetricGauge({ label, value, minLabel, maxLabel, color, type }: MetricGaugeProps) {
+ const displayLabel =
+ type === "valence"
+ ? value < 40
+ ? minLabel
+ : value > 60
+ ? maxLabel
+ : "Neutral"
+ : type === "trust" || type === "loyalty" || type === "opinion"
+ ? value < 50
+ ? minLabel
+ : maxLabel
+ : label
+
+ return (
+
+
+
+ {label}
+ {value.toFixed(0)}%
+
+
{displayLabel}
+
+
+ {/* Gauge Bar Container */}
+
+ {/* Background gradient segments for valence */}
+ {type === "valence" && (
+ <>
+
+
+
+ >
+ )}
+
+ {/* Fill bar with gradient */}
+
+
+ {/* Animated shimmer effect */}
+
+
+
+ {/* Label helpers */}
+
+ {minLabel}
+ {maxLabel}
+
+
+ )
+}
diff --git a/components/neurolink/metrics-panel.tsx b/components/neurolink/metrics-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..624448cefaffddcef1f7a3644132f617e66c4fee
--- /dev/null
+++ b/components/neurolink/metrics-panel.tsx
@@ -0,0 +1,144 @@
+"use client"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Activity, ThumbsUp, Shield, Heart, MessageSquare, Brain } from "lucide-react"
+import { motion } from "framer-motion"
+
+interface MetricsPanelProps {
+ metrics: {
+ // bpm: number <-- SUPPRIMÉ
+ emotion: string
+ engagement: number
+ satisfaction: number
+ trust: number
+ loyalty: number
+ opinion: number
+ }
+}
+
+export default function MetricsPanel({ metrics }: MetricsPanelProps) {
+
+ // Fonction utilitaire pour la couleur
+ const getColor = (val: number) => {
+ if (val >= 75) return "text-green-600"
+ if (val >= 50) return "text-blue-600"
+ return "text-orange-500"
+ }
+
+ return (
+
+
+ {/* 1. ENGAGEMENT (Remplace le BPM en première position) */}
+
+
+
+ Engagement
+
+
+
+
+
+ {metrics.engagement}%
+
+
+ Intensité émotionnelle
+
+ {/* Barre de progression */}
+
+
+
+
+
+
+ {/* 2. SATISFACTION */}
+
+
+
+ Satisfaction
+
+
+
+
+
+ {metrics.satisfaction}%
+
+ Valence positive
+
+
+
+
+
+
+ {/* 3. CONFIANCE */}
+
+
+
+ Confiance
+
+
+
+
+
+ {metrics.trust}%
+
+ Crédibilité perçue
+
+
+
+
+
+
+ {/* 4. FIDÉLITÉ */}
+
+
+
+ Fidélité
+
+
+
+
+
+ {metrics.loyalty}%
+
+ Intention de retour
+
+
+
+
+
+
+ {/* 5. SCORE AVIS (Large, prend 2 colonnes) */}
+
+
+
+ Score d'Opinion Global
+
+
+
+
+
+
+ {metrics.opinion}/100
+
+
Synthèse IA des signaux
+
+
+
+ {metrics.opinion > 60 ? "POS" : (metrics.opinion < 40 ? "NEG" : "NEU")}
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/neurolink/video-player.tsx b/components/neurolink/video-player.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..80eea5232aff1a80796c1a7d7c707ead46d8b3a0
--- /dev/null
+++ b/components/neurolink/video-player.tsx
@@ -0,0 +1,74 @@
+"use client"
+import { motion } from "framer-motion"
+import { Play, Loader, Upload } from "lucide-react"
+
+interface VideoPlayerProps {
+ progress: number
+ isRunning: boolean
+ isCalibrating: boolean
+ videoUrl?: string
+ sessionState?: string // Added sessionState prop to fix undeclared variable error
+}
+
+export default function VideoPlayer({ progress, isRunning, isCalibrating, videoUrl, sessionState }: VideoPlayerProps) {
+ return (
+
+ {/* Video container */}
+
+ {videoUrl ? (
+
+ ) : (
+ <>
+
+
+ {isCalibrating ? (
+
+
+ Calibrating Sensors...
+
+ ) : isRunning ? (
+
+ ) : (
+
+
+
Select a video to begin
+
+ )}
+ >
+ )}
+
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Progress text */}
+
+ Progress
+ {progress.toFixed(1)}%
+
+
+ )
+}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..55c2f6eb60b22a313a4c27bd0b2d728063cb8ab9
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ThemeProvider as NextThemesProvider,
+ type ThemeProviderProps,
+} from 'next-themes'
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children}
+}
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e538a33b9946acb372e2f569a7cb4b22b17cff12
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import * as AccordionPrimitive from '@radix-ui/react-accordion'
+import { ChevronDownIcon } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9704452664dabbb8cef88fa154c12f81b243120d
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e6751abe6a83bc38bbb422f7fc47a534ec65f459
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..40bb1208dbbf471b59cac12b60b992cb3cfd30dd
--- /dev/null
+++ b/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..aa98465a30f89336a5205d3298bc5bf836baa1fd
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fc4126b7a6f1f09b1d57018fdf85fea6bd8dd5a3
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1750ff26a6f95d49cece850873e367f51c0be252
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { ChevronRight, MoreHorizontal } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'a'
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..09d443097b193b4c26cd0f37cb4b1ab2e2c6e47b
--- /dev/null
+++ b/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
+ vertical:
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'div'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'div'
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f64632d152a02f587eb5d01268c05b26b66e2716
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eaa373e25f99cd03cf06d7c11bec3fac969ad38d
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from 'lucide-react'
+import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
+
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant']
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString('default', { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'flex gap-4 flex-col md:flex-row relative',
+ defaultClassNames.months,
+ ),
+ month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
+ nav: cn(
+ 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ 'absolute bg-popover inset-0 opacity-0',
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ 'select-none font-medium',
+ captionLayout === 'label'
+ ? 'text-sm'
+ : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
+ defaultClassNames.caption_label,
+ ),
+ table: 'w-full border-collapse',
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
+ defaultClassNames.weekday,
+ ),
+ week: cn('flex w-full mt-2', defaultClassNames.week),
+ week_number_header: cn(
+ 'select-none w-(--cell-size)',
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ 'text-[0.8rem] select-none text-muted-foreground',
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ 'rounded-l-md bg-accent',
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
+ today: cn(
+ 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled,
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ )
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+