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
+
+
+ +
setEmail(e.target.value)} required />
+
setPassword(e.target.value)} required />
+ {error &&
{error}
} +
+ +
+
+
+ ) +} \ 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 ( +
+
+ + + + +
{value}%
+
+
{Icon && } {label}
+
+ ) +} + +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 ( +
+
+
+ +

STARTECH ADMIN

+
+
+ + +
+
+ +
+
+

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
{dataA.map((row, i) => ())}
TempsÉmotionScore IAEngagementSatisfactionConfianceFidélitéAvis
{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
VS
{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
+
+
+ +
setFormData({...formData, firstName: e.target.value})} required />
+
setFormData({...formData, lastName: e.target.value})} required />
+
setFormData({...formData, clientId: e.target.value})} />
+
+ +
+
+
+
+ ) + } + + return ( +
+
+
+
STARTECH VISION
+
+
+
{userInfo.firstName.charAt(0)}{userInfo.lastName.charAt(0)}
+
{userInfo.firstName} {userInfo.lastName}{userInfo.clientId || "ID: GUEST"}
+
+ +
+
+
+
+
+
+ +
+
+
+
+ {isRecording &&
} + {isRecording &&
} + + +
+
+
+ +
+ ) +} \ 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)} + /> + + + + ) : ( +
+

Waiting for data...

+
+ )} +
+ ) +} 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 ? ( +