feat(ui): 3D round table with person figures + dark theme polish
- RoundTable3D: replace sphere avatars with head+body person figures, add per-persona glow pads, expanding pulse rings for active speaker, connection line to table center, ambient particles, pill nameplates with occupation, rotating center emblem - FocusGroupSession: fix Rules-of-Hooks (move useMemo before early returns), fix personas not loading (data.personas vs data.participants) - Dark theme: replace hardcoded Tailwind colors across 20+ components with semantic CSS tokens (bg-card, text-primary, text-muted-foreground, text-brand-success, text-destructive, etc.) - Add three + @types/three dependencies for 3D rendering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0f7b8a5a9e
commit
92657a1c0c
4 changed files with 651 additions and 458 deletions
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -27,7 +27,7 @@
|
|||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/three": "^0.184.1",
|
||||
"axios": "^1.6.2",
|
||||
"caniuse-lite": "^1.0.30001715",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -67,6 +68,7 @@
|
|||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.184.0",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
|
@ -111,6 +113,12 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
|
|
@ -2874,6 +2882,12 @@
|
|||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
|
|
@ -2989,6 +3003,32 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.184.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
|
||||
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
|
||||
|
|
@ -4671,6 +4711,12 @@
|
|||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
|
@ -5415,6 +5461,12 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
|
|
@ -6729,6 +6781,12 @@
|
|||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
|
|
@ -49,6 +49,7 @@
|
|||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/three": "^0.184.1",
|
||||
"axios": "^1.6.2",
|
||||
"caniuse-lite": "^1.0.30001715",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -76,6 +77,7 @@
|
|||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.184.0",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
|
|
@ -38,6 +38,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
|
||||
import { getSocket } from '@/services/websocketServiceNew';
|
||||
import ProgressModal from '@/components/ui/ProgressModal';
|
||||
import RoundTable3D from '@/components/focus-group-session/RoundTable3D';
|
||||
// GPT-5 FIX: Use new singleton WebSocket service
|
||||
import { initSocket, joinFocusGroup, leaveFocusGroup } from '@/services/websocketServiceNew';
|
||||
import {
|
||||
|
|
@ -59,6 +60,7 @@ const FocusGroupSession = () => {
|
|||
const [focusGroup, setFocusGroup] = useState<FocusGroup | null>(null);
|
||||
const [participants, setParticipants] = useState<Persona[]>([]);
|
||||
const [allPersonas, setAllPersonas] = useState<Persona[]>([]);
|
||||
const [showTable, setShowTable] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
const [moderatorStatus, setModeratorStatus] = useState<any>(null);
|
||||
const [showAutonomousDashboard, setShowAutonomousDashboard] = useState(false);
|
||||
|
|
@ -738,19 +740,19 @@ const FocusGroupSession = () => {
|
|||
id: data._id || data.id,
|
||||
name: data.name,
|
||||
status: data.status || 'in-progress',
|
||||
participants: data.participants || [],
|
||||
participants: data.personas || data.participants || [],
|
||||
date: data.date || new Date().toISOString(),
|
||||
duration: data.duration || 60,
|
||||
topic: data.topic || 'general',
|
||||
discussionGuide: data.discussionGuide || '',
|
||||
llm_model: data.llm_model || 'gpt-5.4'
|
||||
};
|
||||
|
||||
|
||||
setFocusGroup(focusGroupData);
|
||||
setSelectedModel(focusGroupData.llm_model || 'gpt-5.4');
|
||||
setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium');
|
||||
setSelectedVerbosity(focusGroupData.verbosity || 'medium');
|
||||
|
||||
|
||||
// Handle participants
|
||||
if (data.participants_data && Array.isArray(data.participants_data)) {
|
||||
// Use participants data if available
|
||||
|
|
@ -767,7 +769,7 @@ const FocusGroupSession = () => {
|
|||
|
||||
setParticipants(groupParticipants);
|
||||
}
|
||||
|
||||
|
||||
// Load messages and moderator status
|
||||
await fetchMessages();
|
||||
await fetchModeratorStatus();
|
||||
|
|
@ -860,7 +862,7 @@ const FocusGroupSession = () => {
|
|||
id: data._id || data.id,
|
||||
name: data.name,
|
||||
status: data.status || 'in-progress',
|
||||
participants: data.participants || [],
|
||||
participants: data.personas || data.participants || [],
|
||||
date: data.date || new Date().toISOString(),
|
||||
duration: data.duration || 60,
|
||||
topic: data.topic || 'general',
|
||||
|
|
@ -1777,6 +1779,17 @@ const FocusGroupSession = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Derive who spoke last (for 3D table highlight) — must be before any conditional returns
|
||||
const activeSpeakerId = useMemo<string | null>(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.senderId && msg.senderId !== 'moderator' && msg.senderId !== 'facilitator') {
|
||||
return msg.senderId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
// Show loading state while fetching
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -1810,7 +1823,7 @@ const FocusGroupSession = () => {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
|
||||
|
|
@ -1818,21 +1831,21 @@ const FocusGroupSession = () => {
|
|||
{/* WebSocket Connection Status Bar */}
|
||||
{useWebSocketEnabled && isStatusBarVisible && (
|
||||
<div className={`w-full transition-all duration-300 ${
|
||||
wsConnected
|
||||
? 'bg-green-500'
|
||||
: wsConnecting
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
wsConnected
|
||||
? 'bg-brand-success/90'
|
||||
: wsConnecting
|
||||
? 'bg-primary/90'
|
||||
: 'bg-destructive/90'
|
||||
}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between py-2 text-white text-sm font-medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
wsConnected
|
||||
? 'bg-white animate-pulse'
|
||||
: wsConnecting
|
||||
? 'bg-white animate-spin'
|
||||
: 'bg-white'
|
||||
wsConnected
|
||||
? 'bg-brand-success animate-pulse'
|
||||
: wsConnecting
|
||||
? 'bg-primary animate-pulse'
|
||||
: 'bg-muted-foreground/60'
|
||||
}`} />
|
||||
<span>
|
||||
{wsConnected
|
||||
|
|
@ -1870,10 +1883,10 @@ const FocusGroupSession = () => {
|
|||
onClick={() => setIsStatusBarVisible(true)}
|
||||
className={`px-3 py-1 rounded-full text-white text-xs font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
|
||||
wsConnected
|
||||
? 'bg-green-500 hover:bg-green-600'
|
||||
: wsConnecting
|
||||
? 'bg-yellow-500 hover:bg-yellow-600'
|
||||
: 'bg-red-500 hover:bg-red-600'
|
||||
? 'bg-brand-success/80 hover:bg-brand-success'
|
||||
: wsConnecting
|
||||
? 'bg-primary/80 hover:bg-primary'
|
||||
: 'bg-destructive/80 hover:bg-destructive'
|
||||
}`}
|
||||
title={
|
||||
wsConnected
|
||||
|
|
@ -1884,7 +1897,7 @@ const FocusGroupSession = () => {
|
|||
}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full bg-white ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||
<div className={`w-2 h-2 rounded-full ${wsConnected ? 'bg-brand-success animate-pulse' : wsConnecting ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60'}`} />
|
||||
<span>{wsConnected ? 'Live' : wsConnecting ? 'Connecting' : 'Offline'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -1946,25 +1959,25 @@ const FocusGroupSession = () => {
|
|||
|
||||
{/* Quota warning banner */}
|
||||
{quotaWarning && (
|
||||
<div className="px-4 py-2 bg-amber-50 border-b border-amber-200 flex items-center justify-between shrink-0">
|
||||
<div className="px-4 py-2 bg-brand-amber/10 border-b border-brand-amber/30 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<span className="text-sm text-amber-700">
|
||||
<span className="text-sm text-brand-amber">
|
||||
Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota
|
||||
(${quotaWarning.used_usd.toFixed(4)} of ${quotaWarning.limit_usd.toFixed(2)})
|
||||
</span>
|
||||
<Progress value={quotaWarning.pct * 100} className="w-24 h-2" />
|
||||
</div>
|
||||
<button className="text-xs text-amber-500 hover:text-amber-700 ml-2" onClick={() => setQuotaWarning(null)}>✕</button>
|
||||
<button className="text-xs text-brand-amber/70 hover:text-brand-amber ml-2" onClick={() => setQuotaWarning(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quota exceeded banner */}
|
||||
{quotaExceeded && (
|
||||
<div className="px-4 py-2 bg-red-50 border-b border-red-200 flex items-center justify-between shrink-0">
|
||||
<span className="text-sm text-red-700">
|
||||
<div className="px-4 py-2 bg-destructive/10 border-b border-destructive/30 flex items-center justify-between shrink-0">
|
||||
<span className="text-sm text-destructive">
|
||||
Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used.
|
||||
</span>
|
||||
<button className="text-xs text-red-500 hover:text-red-700" onClick={() => setQuotaExceeded(null)}>✕</button>
|
||||
<button className="text-xs text-destructive/70 hover:text-destructive" onClick={() => setQuotaExceeded(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1991,8 +2004,8 @@ const FocusGroupSession = () => {
|
|||
</span>
|
||||
<h1 className="font-semibold text-foreground truncate max-w-[300px]">{focusGroup.name}</h1>
|
||||
{isAiModeActive && (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-widest text-green-400 border border-green-500/30 bg-green-500/10 rounded-full px-2 py-0.5 shrink-0">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-widest text-brand-success border border-brand-success/30 bg-brand-success/10 rounded-full px-2 py-0.5 shrink-0">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-success animate-pulse" />
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -2015,13 +2028,43 @@ const FocusGroupSession = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* CENTER: Discussion */}
|
||||
{/* CENTER: Round Table + Discussion */}
|
||||
<div className="bg-background flex flex-col overflow-hidden">
|
||||
|
||||
{/* 3D Round Table */}
|
||||
{participants.length > 0 && showTable && (
|
||||
<div className="shrink-0 relative border-b border-border/50">
|
||||
<RoundTable3D
|
||||
personas={participants}
|
||||
activeSpeakerId={activeSpeakerId}
|
||||
messages={messages}
|
||||
height={messages.length === 0 ? 420 : 260}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowTable(false)}
|
||||
className="absolute top-2 right-2 z-20 text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors px-2 py-1 rounded bg-background/60 backdrop-blur-sm"
|
||||
title="Hide 3D view"
|
||||
>
|
||||
▲ hide
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{participants.length > 0 && !showTable && (
|
||||
<button
|
||||
onClick={() => setShowTable(true)}
|
||||
className="shrink-0 w-full text-[10px] text-muted-foreground/50 hover:text-primary/70 transition-colors py-1 border-b border-border/30 bg-card/30"
|
||||
>
|
||||
▼ show table
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No messages yet. Start the session to begin the discussion.
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center flex-1 space-y-4">
|
||||
{participants.length === 0 && (
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No messages yet. Start the session to begin the discussion.
|
||||
</p>
|
||||
)}
|
||||
<Button onClick={startSession} size="lg" className="flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5" />
|
||||
Start Session
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue