fix(ui): fix crashes, dark theme tokens, Invalid Date bug
- FocusGroupSession: add missing DiscussionGuideViewer import (was crashing) - PersonaProfile: wrap TabsTrigger in TabsList (RovingFocusGroup crash) - Dashboard: fix Invalid Date bug — tx.ts vs tx.created_at field mismatch - ParticipantPanel, UserCard, SyntheticUsers: replace hardcoded light-mode colors (bg-blue-50, text-slate-*, bg-white) with dark theme tokens - AutonomousDashboard, NotesPanel, QuickNoteModal: same dark theme sweep Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4c70bc8aa6
commit
0f7b8a5a9e
15 changed files with 1328 additions and 628 deletions
|
|
@ -139,7 +139,7 @@ export default function UserCard({
|
|||
{/* AI-Synthesized Bio */}
|
||||
<div className="mt-2">
|
||||
{currentPersona.aiSynthesizedBio ? (
|
||||
<p className="text-xs text-slate-700 line-clamp-3 leading-relaxed">
|
||||
<p className="text-xs text-foreground/75 line-clamp-3 leading-relaxed">
|
||||
{currentPersona.aiSynthesizedBio}
|
||||
{currentPersona.aiSynthesizedBio.length > 150 && '...'}
|
||||
</p>
|
||||
|
|
@ -157,7 +157,7 @@ export default function UserCard({
|
|||
{currentPersona.qualitativeAttributes.slice(0, 3).map((attribute, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary text-xs rounded-full"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{attribute}
|
||||
|
|
@ -177,7 +177,7 @@ export default function UserCard({
|
|||
return (
|
||||
<span
|
||||
key={folderId}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-secondary text-muted-foreground text-xs rounded-full"
|
||||
title={`In folder: ${folder.name}`}
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
|
|
@ -186,7 +186,7 @@ export default function UserCard({
|
|||
);
|
||||
})}
|
||||
{currentPersona.folder_ids.length > 2 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-secondary text-muted-foreground text-xs rounded-full">
|
||||
<Plus className="h-3 w-3" />
|
||||
{currentPersona.folder_ids.length - 2} more
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ interface Transaction {
|
|||
type: string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
created_at: string;
|
||||
ts?: string;
|
||||
created_at?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
|
@ -71,8 +72,10 @@ const TX_TYPE_COLORS: Record<string, string> = {
|
|||
refund: 'text-blue-400',
|
||||
};
|
||||
|
||||
function formatDate(iso: string) {
|
||||
function formatDate(iso: string | undefined) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +365,7 @@ const Dashboard = () => {
|
|||
<p className="text-sm font-medium text-foreground">
|
||||
{TX_TYPE_LABELS[tx.type] || tx.type}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(tx.created_at)}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(tx.ts ?? tx.created_at)}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-4">
|
||||
<p className={`text-sm font-semibold ${TX_TYPE_COLORS[tx.type] || 'text-foreground'}`}>
|
||||
|
|
|
|||
|
|
@ -202,30 +202,30 @@ const AutonomousDashboard = ({
|
|||
switch (sentiment) {
|
||||
case 'positive': return 'text-green-600';
|
||||
case 'negative': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthColor = (health: string) => {
|
||||
switch (health) {
|
||||
case 'excellent': return 'text-green-600';
|
||||
case 'good': return 'text-blue-600';
|
||||
case 'good': return 'text-primary';
|
||||
case 'fair': return 'text-amber-600';
|
||||
case 'poor': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-4 bottom-4 w-80 bg-white rounded-lg shadow-lg border border-gray-200 flex flex-col overflow-hidden z-50">
|
||||
<div className="fixed right-4 top-4 bottom-4 w-80 bg-card rounded-lg shadow-lg border border-border flex flex-col overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="p-4 border-b border-border bg-secondary/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-900">AI Dashboard</h3>
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold text-foreground">AI Dashboard</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
|
@ -249,7 +249,7 @@ const AutonomousDashboard = ({
|
|||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -317,7 +317,7 @@ const AutonomousDashboard = ({
|
|||
<Progress value={conversationState.conversation_health.score} className="h-2" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-gray-600">Indicators:</span>
|
||||
<span className="text-xs text-muted-foreground">Indicators:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{conversationState.conversation_health.indicators.map((indicator, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
|
|
@ -344,16 +344,16 @@ const AutonomousDashboard = ({
|
|||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-blue-600">
|
||||
<div className="text-lg font-semibold text-primary">
|
||||
{analytics.overview.active_participants}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Active</div>
|
||||
<div className="text-xs text-muted-foreground">Active</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-green-600">
|
||||
{analytics.overview.participant_messages}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Messages</div>
|
||||
<div className="text-xs text-muted-foreground">Messages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -372,7 +372,7 @@ const AutonomousDashboard = ({
|
|||
)}
|
||||
|
||||
{analytics.participation.quiet_participants.length > 0 && (
|
||||
<div className="text-xs text-blue-600">
|
||||
<div className="text-xs text-primary">
|
||||
Quiet: {analytics.participation.quiet_participants.length} participant(s)
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -482,8 +482,8 @@ const AutonomousDashboard = ({
|
|||
</div>
|
||||
|
||||
{insights.next_suggested_action && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 mt-2">
|
||||
<div className="text-xs text-blue-800">
|
||||
<div className="bg-primary/10 border border-primary/30 rounded-lg p-2 mt-2">
|
||||
<div className="text-xs text-primary">
|
||||
<strong>Suggestion:</strong> {insights.next_suggested_action}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -505,7 +505,7 @@ const AutonomousDashboard = ({
|
|||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
{analytics.recommendations.map((rec, index) => (
|
||||
<div key={index} className="bg-amber-50 border border-amber-200 rounded-lg p-2">
|
||||
<div key={index} className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-2">
|
||||
<div className="text-xs text-amber-800">{rec}</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -84,28 +84,28 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
|
|||
const hasStructuredGuide = discussionGuide && typeof discussionGuide === 'object' && discussionGuide.sections;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full border-b bg-white shadow-sm", className)}>
|
||||
<div className={cn("w-full border-b bg-card border-border", className)}>
|
||||
<Collapsible open={isOpen} onOpenChange={onToggle}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className={cn(
|
||||
"w-full px-4 py-3 flex items-center justify-between transition-all cursor-pointer",
|
||||
isOpen
|
||||
? "bg-amber-50 border-l-4 border-l-amber-400"
|
||||
: "bg-slate-50 hover:bg-amber-50 border-l-4 border-l-transparent hover:border-l-amber-300"
|
||||
? "bg-primary/10 border-b border-primary/40"
|
||||
: "bg-secondary/30 hover:bg-primary/8 border-b border-border hover:border-primary/30"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardList className={cn("h-5 w-5", isOpen ? "text-amber-600" : "text-slate-600")} />
|
||||
<ClipboardList className={cn("h-5 w-5", isOpen ? "text-primary" : "text-muted-foreground")} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-slate-900">Discussion Guide</h2>
|
||||
<h2 className="font-semibold text-foreground">Discussion Guide</h2>
|
||||
{!isOpen && (
|
||||
<Badge variant="secondary" className="text-xs bg-amber-100 text-amber-700">
|
||||
<Badge variant="secondary" className="text-xs bg-primary/15 text-primary border-primary/20">
|
||||
Click to Edit
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasStructuredGuide && (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{discussionGuide.title} • {discussionGuide.total_duration} minutes
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -130,16 +130,16 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
|
|||
)}
|
||||
</Button>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-amber-600" />
|
||||
<ChevronUp className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-t bg-slate-50">
|
||||
<div className="border-t border-border bg-card">
|
||||
<Card className="mx-4 mb-4 mt-2">
|
||||
<CardContent className="p-4">
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -254,8 +254,8 @@ const DroppableContainer: React.FC<DroppableContainerProps> = ({
|
|||
ref={setNodeRef}
|
||||
className={cn(
|
||||
className,
|
||||
isOver && allowed && "ring-2 ring-blue-500 bg-blue-50/50",
|
||||
isOver && !allowed && "ring-2 ring-red-400 opacity-50"
|
||||
isOver && allowed && "ring-2 ring-primary bg-primary/10",
|
||||
isOver && !allowed && "ring-2 ring-red-500/50 opacity-50"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -1035,16 +1035,16 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
return (
|
||||
<SortableItem key={`edit-item-${item.id}`} id={uid} disabled={!containerId} className="mb-2" data={itemData}>
|
||||
{({ setActivatorNodeRef, attributes, listeners, isDragging }) => (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border bg-white border-blue-200">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border bg-card border-primary/30">
|
||||
{/* Drag handle replaces the arrow buttons */}
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 h-8 w-8 inline-flex items-center justify-center rounded-md hover:bg-gray-50 cursor-grab active:cursor-grabbing"
|
||||
className="flex-shrink-0 h-8 w-8 inline-flex items-center justify-center rounded-md hover:bg-secondary/50 cursor-grab active:cursor-grabbing"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-gray-500" />
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
|
|
@ -1064,7 +1064,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
</Badge>
|
||||
|
||||
{item.time_limit && (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -1087,7 +1087,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
|
||||
{itemType === 'question' && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
<label className="text-sm font-medium text-foreground/80 mb-1 block">
|
||||
Probe Questions (one per line)
|
||||
</label>
|
||||
<Textarea
|
||||
|
|
@ -1105,26 +1105,26 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{(item.metadata?.image_url || item.metadata?.image_id || imageFilename) && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ImageIcon className="h-4 w-4 text-slate-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground/80">Visual Aid</span>
|
||||
</div>
|
||||
{item.metadata?.image_url ? (
|
||||
<img
|
||||
src={item.metadata.image_url}
|
||||
alt="Visual aid for item"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : item.metadata?.image_id && focusGroupId ? (
|
||||
<img
|
||||
src={focusGroupsApi.getAssetUrl(focusGroupId, item.metadata.image_id)}
|
||||
alt="Visual aid for item"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : imageFilename && focusGroupId ? (
|
||||
<img
|
||||
src={focusGroupsApi.getAssetUrl(focusGroupId, imageFilename)}
|
||||
alt="Visual aid for item"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1158,10 +1158,10 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border bg-white border-blue-300 ring-2 ring-blue-200"
|
||||
className="flex items-start gap-3 p-3 rounded-lg border bg-card border-primary/30 ring-2 ring-primary/30"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<Edit3 className="h-4 w-4 text-blue-600" />
|
||||
<Edit3 className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
|
|
@ -1181,7 +1181,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
)}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -1216,7 +1216,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{/* Probes for questions */}
|
||||
{itemType === 'question' && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-700 mb-1 block">
|
||||
<label className="text-xs font-medium text-foreground/80 mb-1 block">
|
||||
Probe Questions (one per line)
|
||||
</label>
|
||||
<Textarea
|
||||
|
|
@ -1262,7 +1262,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
<X className="h-3 w-3 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
<span className="text-xs text-slate-400 ml-auto">
|
||||
<span className="text-xs text-muted-foreground/60 ml-auto">
|
||||
Esc to cancel, {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to save
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1277,20 +1277,20 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
key={item.id}
|
||||
className={cn(
|
||||
"group flex items-start gap-3 p-3 rounded-lg border transition-colors",
|
||||
isCurrent && "bg-blue-50 border-blue-200",
|
||||
isCompleted && "bg-green-50 border-green-200",
|
||||
!isCurrent && !isCompleted && "bg-slate-50 border-slate-200",
|
||||
onSectionSelect && "cursor-pointer hover:bg-slate-100"
|
||||
isCurrent && "bg-primary/10 border-primary/30",
|
||||
isCompleted && "bg-green-500/10 border-green-500/30",
|
||||
!isCurrent && !isCompleted && "bg-secondary/30 border-border",
|
||||
onSectionSelect && "cursor-pointer hover:bg-secondary/50"
|
||||
)}
|
||||
onClick={() => onSectionSelect?.(structuredGuide!.sections[sectionIndex].id, item.id)}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
) : isCurrent ? (
|
||||
<PlayCircle className="h-4 w-4 text-blue-600" />
|
||||
<PlayCircle className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-slate-400" />
|
||||
<Circle className="h-4 w-4 text-muted-foreground/60" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1311,7 +1311,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
</Badge>
|
||||
|
||||
{item.time_limit && (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<Clock className="h-3 w-3" />
|
||||
{item.time_limit} min
|
||||
</div>
|
||||
|
|
@ -1359,14 +1359,14 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-700 whitespace-pre-wrap">{item.content}</p>
|
||||
<p className="text-sm text-foreground/80 whitespace-pre-wrap">{item.content}</p>
|
||||
|
||||
{item.probes && item.probes.length > 0 && (
|
||||
<div className="mt-2 pl-4 border-l-2 border-slate-200">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Probe Questions:</p>
|
||||
<div className="mt-2 pl-4 border-l-2 border-border/60">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Probe Questions:</p>
|
||||
<ul className="space-y-1">
|
||||
{item.probes.map((probe, idx) => (
|
||||
<li key={idx} className="text-xs text-slate-600">• {probe}</li>
|
||||
<li key={idx} className="text-xs text-muted-foreground">• {probe}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -1375,26 +1375,26 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{(item.metadata?.image_url || item.metadata?.image_id || imageFilename) && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ImageIcon className="h-4 w-4 text-slate-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground/80">Visual Aid</span>
|
||||
</div>
|
||||
{item.metadata?.image_url ? (
|
||||
<img
|
||||
<img
|
||||
src={item.metadata.image_url}
|
||||
alt="Visual aid for item"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : item.metadata?.image_id && focusGroupId ? (
|
||||
<img
|
||||
<img
|
||||
src={focusGroupsApi.getAssetUrl(focusGroupId, item.metadata.image_id)}
|
||||
alt="Visual aid for item"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : imageFilename && focusGroupId ? (
|
||||
<img
|
||||
<img
|
||||
src={focusGroupsApi.getAssetUrl(focusGroupId, imageFilename)}
|
||||
alt="Visual aid for item"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1416,22 +1416,22 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
key={section.id}
|
||||
className={cn(
|
||||
"border rounded-lg overflow-hidden transition-colors",
|
||||
isCurrentSection && "border-blue-500 shadow-md",
|
||||
!isCurrentSection && "border-slate-200"
|
||||
isCurrentSection && "border-primary shadow-primary/20",
|
||||
!isCurrentSection && "border-border"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-slate-50 transition-colors",
|
||||
isCurrentSection && "bg-blue-50"
|
||||
"px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-secondary/30 transition-colors",
|
||||
isCurrentSection && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => !isEditing && toggleSection(section.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
<ChevronRight className="h-5 w-5 text-slate-500" />
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
<h3 className="font-semibold text-foreground/80">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={displaySection.title}
|
||||
|
|
@ -1497,7 +1497,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 py-3 border-t border-slate-200 space-y-4">
|
||||
<div className="px-4 py-3 border-t border-border space-y-4">
|
||||
{displaySection.content && (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{isEditing ? (
|
||||
|
|
@ -1508,7 +1508,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
className="min-h-[80px] w-full"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-slate-700">{displaySection.content}</p>
|
||||
<p className="text-foreground/80">{displaySection.content}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1516,7 +1516,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{(displaySection.activities && displaySection.activities.length > 0) || isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="font-medium text-slate-700 flex items-center gap-2">
|
||||
<h5 className="font-medium text-foreground/80 flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Activities
|
||||
</h5>
|
||||
|
|
@ -1556,7 +1556,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{(displaySection.questions && displaySection.questions.length > 0) || isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="font-medium text-slate-700 flex items-center gap-2">
|
||||
<h5 className="font-medium text-foreground/80 flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Questions
|
||||
</h5>
|
||||
|
|
@ -1596,7 +1596,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{isEditing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="font-medium text-slate-700 flex items-center gap-2">
|
||||
<h5 className="font-medium text-foreground/80 flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Subsections
|
||||
</h5>
|
||||
|
|
@ -1616,7 +1616,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{displaySection.subsections && displaySection.subsections.length > 0 && (
|
||||
<div className="space-y-3 ml-4">
|
||||
{displaySection.subsections.map((subsection, idx) => (
|
||||
<div key={subsection.id} className="border-l-2 border-slate-200 pl-4">
|
||||
<div key={subsection.id} className="border-l-2 border-primary/30 pl-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{isEditing && (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
@ -1666,10 +1666,10 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<h5
|
||||
<h5
|
||||
className={cn(
|
||||
"font-medium text-slate-700",
|
||||
isEditing && "cursor-pointer hover:text-blue-600"
|
||||
"font-medium text-foreground/80",
|
||||
isEditing && "cursor-pointer hover:text-primary"
|
||||
)}
|
||||
onClick={() => isEditing && startEditingSubsectionTitle(subsection.id, subsection.title)}
|
||||
>
|
||||
|
|
@ -1703,7 +1703,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{(subsection.questions && subsection.questions.length > 0) || isEditing ? (
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h6 className="text-sm font-medium text-slate-600 flex items-center gap-1">
|
||||
<h6 className="text-sm font-medium text-muted-foreground flex items-center gap-1">
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
Questions
|
||||
</h6>
|
||||
|
|
@ -1743,7 +1743,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{(subsection.activities && subsection.activities.length > 0) || isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h6 className="text-sm font-medium text-slate-600 flex items-center gap-1">
|
||||
<h6 className="text-sm font-medium text-muted-foreground flex items-center gap-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
Activities
|
||||
</h6>
|
||||
|
|
@ -1787,20 +1787,20 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
{(section.metadata?.image_url || section.metadata?.image_id) && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ImageIcon className="h-4 w-4 text-slate-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground/80">Visual Aid</span>
|
||||
</div>
|
||||
{section.metadata.image_url ? (
|
||||
<img
|
||||
<img
|
||||
src={section.metadata.image_url}
|
||||
alt="Visual aid for section"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : section.metadata.image_id && focusGroupId ? (
|
||||
<img
|
||||
<img
|
||||
src={focusGroupsApi.getAssetUrl(focusGroupId, section.metadata.image_id)}
|
||||
alt="Visual aid for section"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
|
||||
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1817,22 +1817,22 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
<div className={cn("space-y-4", className)}>
|
||||
{showProgress && moderatorStatus && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm text-slate-600 mb-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>Progress</span>
|
||||
<span>{Math.round(moderatorStatus.progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${moderatorStatus.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-slate-800">Discussion Guide</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground">Discussion Guide</h2>
|
||||
{onDownload && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -1851,17 +1851,17 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
</div>
|
||||
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<pre className="whitespace-pre-wrap text-sm text-slate-700 font-sans">
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground/80 font-sans">
|
||||
{discussionGuide as string}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
{moderatorStatus && (
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="font-medium text-blue-900 mb-2">Current Position</h3>
|
||||
<p className="text-sm text-blue-800">{moderatorStatus.current_section}</p>
|
||||
<div className="mt-6 p-4 bg-primary/10 rounded-lg border border-primary/30">
|
||||
<h3 className="font-medium text-primary mb-2">Current Position</h3>
|
||||
<p className="text-sm text-primary">{moderatorStatus.current_section}</p>
|
||||
{moderatorStatus.current_item && (
|
||||
<p className="text-sm text-blue-700 mt-1">{moderatorStatus.current_item}</p>
|
||||
<p className="text-sm text-primary/80 mt-1">{moderatorStatus.current_item}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1873,8 +1873,8 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
// Render structured format
|
||||
if (!structuredGuide) {
|
||||
return (
|
||||
<div className={cn("bg-slate-50 rounded-lg p-8 text-center", className)}>
|
||||
<p className="text-slate-600">No discussion guide available</p>
|
||||
<div className={cn("bg-secondary/30 rounded-lg p-8 text-center", className)}>
|
||||
<p className="text-muted-foreground">No discussion guide available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1893,18 +1893,18 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
<div className="space-y-4">
|
||||
{showProgress && moderatorStatus && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm text-slate-600 mb-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>Overall Progress</span>
|
||||
<span>{Math.round(moderatorStatus.progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${moderatorStatus.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 mt-2">
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-2">
|
||||
<span>Section {moderatorStatus.moderator_position.section_index + 1} of {moderatorStatus.total_sections}</span>
|
||||
<span>{Math.round(moderatorStatus.section_progress)}% of current section</span>
|
||||
</div>
|
||||
|
|
@ -1927,10 +1927,10 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
|
|||
return (
|
||||
<Collapsible defaultOpen={defaultExpanded} className={className}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 bg-white rounded-lg border border-slate-200 cursor-pointer hover:bg-slate-50 transition-colors">
|
||||
<div className="flex items-center justify-between p-4 bg-card rounded-lg border border-border cursor-pointer hover:bg-secondary/30 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<ChevronRight className="h-5 w-5 text-slate-500 transition-transform data-[state=open]:rotate-90" />
|
||||
<h2 className="text-lg font-semibold text-slate-800">
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform data-[state=open]:rotate-90" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{structuredGuide.title || 'Discussion Guide'}
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
|
|
|||
|
|
@ -1164,12 +1164,12 @@ const DiscussionPanel = ({
|
|||
return (
|
||||
<div className="glass-panel rounded-xl p-4 flex flex-col h-full">
|
||||
{/* Messages area - takes remaining space after bottom panels */}
|
||||
<div className="flex-1 min-h-0 mb-4">
|
||||
<div className="flex-1 min-h-0 mb-4 relative">
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-4">
|
||||
{getTimelineItems().map((item) => (
|
||||
item.type === 'message' ? (
|
||||
<ChatMessage
|
||||
<ChatMessage
|
||||
key={item.data.id}
|
||||
message={item.data as Message}
|
||||
persona={(item.data as Message).senderId !== 'moderator' && (item.data as Message).senderId !== 'facilitator' ? getPersona((item.data as Message).senderId) : null}
|
||||
|
|
@ -1178,7 +1178,7 @@ const DiscussionPanel = ({
|
|||
focusGroupId={focusGroupId}
|
||||
/>
|
||||
) : (
|
||||
<ModeSwitchMarker
|
||||
<ModeSwitchMarker
|
||||
key={item.data.id}
|
||||
modeEvent={item.data as ModeEvent}
|
||||
/>
|
||||
|
|
@ -1203,21 +1203,21 @@ const DiscussionPanel = ({
|
|||
{/* Place at the end of the last message to provide better scroll reference */}
|
||||
<div ref={messagesEndRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
{/* Manual scroll button that only appears when not at the bottom */}
|
||||
{!autoScroll && filteredMessages.length > 6 && (
|
||||
<div className="sticky bottom-5 ml-auto mr-5 z-10 w-fit">
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full shadow-md h-10 w-10 p-0"
|
||||
onClick={scrollToBottom}
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Manual scroll button — absolute inside relative wrapper, outside ScrollArea to avoid bleeding */}
|
||||
{!autoScroll && filteredMessages.length > 6 && (
|
||||
<div className="absolute bottom-4 right-6 z-20 pointer-events-none">
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full shadow-lg h-9 w-9 p-0 pointer-events-auto bg-card/90 border border-border hover:border-primary/50 text-muted-foreground hover:text-primary backdrop-blur-sm"
|
||||
onClick={scrollToBottom}
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Reasoning Panel - pinned above controls */}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ const NotesPanel = ({
|
|||
<StickyNote className="h-5 w-5 text-primary mr-2" />
|
||||
<h2 className="font-sf text-xl font-semibold">Notes</h2>
|
||||
{notes.length > 0 && (
|
||||
<span className="ml-2 text-sm text-slate-500">({notes.length})</span>
|
||||
<span className="ml-2 text-sm text-muted-foreground">({notes.length})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -203,10 +203,10 @@ const NotesPanel = ({
|
|||
{/* Notes list */}
|
||||
<ScrollArea className="flex-1">
|
||||
{notes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center bg-slate-50 rounded-lg">
|
||||
<StickyNote className="h-8 w-8 text-slate-400 mb-3" />
|
||||
<p className="text-slate-600">No notes yet.</p>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center bg-secondary/30 rounded-lg">
|
||||
<StickyNote className="h-8 w-8 text-muted-foreground/60 mb-3" />
|
||||
<p className="text-muted-foreground">No notes yet.</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Click the "Note" button during the session to add contextual notes.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -221,11 +221,11 @@ const NotesPanel = ({
|
|||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm font-medium text-slate-600">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{formatTimestamp(note.createdAt)}
|
||||
</CardTitle>
|
||||
{note.sectionInfo?.sectionTitle && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<span>{note.sectionInfo.sectionTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -265,7 +265,7 @@ const NotesPanel = ({
|
|||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-slate-700 whitespace-pre-wrap">
|
||||
<p className="text-sm text-foreground/80 whitespace-pre-wrap">
|
||||
{note.content}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ const ParticipantPanel = ({
|
|||
<Bot className="h-8 w-8 text-primary mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-primary">AI Moderator</p>
|
||||
<p className="text-xs text-slate-500">Session facilitator</p>
|
||||
<p className="text-xs text-muted-foreground">Session facilitator</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ const ParticipantPanel = ({
|
|||
<div
|
||||
key={participantId}
|
||||
className={`flex items-center p-2 rounded-lg transition-colors ${
|
||||
isSelected && !isEditable ? 'bg-blue-50 border border-blue-200' : 'hover:bg-slate-100'
|
||||
isSelected && !isEditable ? 'bg-primary/10 border border-primary/30' : 'hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
|
|
@ -124,23 +124,23 @@ const ParticipantPanel = ({
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={`font-medium truncate ${isEditable ? '' : 'cursor-pointer hover:text-blue-600 transition-colors'}`}
|
||||
className={`font-medium truncate ${isEditable ? '' : 'cursor-pointer hover:text-primary transition-colors'}`}
|
||||
onClick={() => handleNameClick(participant)}
|
||||
title={isEditable ? undefined : `Filter to show only ${participant.name}'s messages`}
|
||||
>
|
||||
{participant.name}
|
||||
</p>
|
||||
{isSelected && !isEditable && (
|
||||
<Check className="h-4 w-4 text-blue-600 ml-2 shrink-0" />
|
||||
<Check className="h-4 w-4 text-primary ml-2 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">{participant.occupation}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{participant.occupation}</p>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<button
|
||||
onClick={() => handleRemove(participant)}
|
||||
disabled={isRemoving}
|
||||
className="ml-2 shrink-0 text-slate-400 hover:text-red-500 transition-colors disabled:opacity-40"
|
||||
className="ml-2 shrink-0 text-muted-foreground/60 hover:text-destructive transition-colors disabled:opacity-40"
|
||||
title={`Remove ${participant.name}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -170,7 +170,7 @@ const ParticipantPanel = ({
|
|||
<DialogTitle>Add Participant</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/60" />
|
||||
<Input
|
||||
placeholder="Search personas…"
|
||||
value={search}
|
||||
|
|
@ -191,7 +191,7 @@ const ParticipantPanel = ({
|
|||
return (
|
||||
<div
|
||||
key={pid}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-100 cursor-pointer"
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 cursor-pointer"
|
||||
onClick={() => !isAdding && handleAdd(persona)}
|
||||
>
|
||||
<img
|
||||
|
|
@ -204,7 +204,7 @@ const ParticipantPanel = ({
|
|||
<p className="text-xs text-slate-500 truncate">{persona.occupation}</p>
|
||||
</div>
|
||||
{isAdding ? (
|
||||
<span className="text-xs text-slate-400">Adding…</span>
|
||||
<span className="text-xs text-muted-foreground/60">Adding…</span>
|
||||
) : (
|
||||
<UserPlus className="h-4 w-4 text-slate-400 shrink-0" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ const QuickNoteModal = ({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-slate-600">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div>
|
||||
<strong>Section:</strong> {sectionInfo?.sectionTitle || 'Unknown section'}
|
||||
</div>
|
||||
|
|
|
|||
669
src/components/focus-group-session/RoundTable3D.tsx
Normal file
669
src/components/focus-group-session/RoundTable3D.tsx
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Persona } from '@/types/persona';
|
||||
import { Message } from './types';
|
||||
|
||||
interface RoundTable3DProps {
|
||||
personas: Persona[];
|
||||
activeSpeakerId: string | null;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface AvatarObject {
|
||||
mesh: THREE.Mesh;
|
||||
ring: THREE.Mesh;
|
||||
haloLight: THREE.PointLight;
|
||||
inactiveLight: THREE.PointLight;
|
||||
spotLight: THREE.SpotLight;
|
||||
targetScale: number;
|
||||
currentScale: number;
|
||||
}
|
||||
|
||||
interface SpeechBubble {
|
||||
personaId: string;
|
||||
text: string;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
}
|
||||
|
||||
interface NameTag {
|
||||
personaId: string;
|
||||
name: string;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
}
|
||||
|
||||
function nameToColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
hash = hash & hash;
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return `hsl(${hue}, 60%, 35%)`;
|
||||
}
|
||||
|
||||
function nameToHex(name: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
hash = hash & hash;
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
const h = hue / 360;
|
||||
const s = 0.6;
|
||||
const l = 0.35;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const hue2rgb = (p2: number, q2: number, t: number) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p2 + (q2 - p2) * 6 * tt;
|
||||
if (tt < 1 / 2) return q2;
|
||||
if (tt < 2 / 3) return p2 + (q2 - p2) * (2 / 3 - tt) * 6;
|
||||
return p2;
|
||||
};
|
||||
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
|
||||
const g = Math.round(hue2rgb(p, q, h) * 255);
|
||||
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
function makeAvatarTexture(initial: string, color: string): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Background circle with gradient
|
||||
const gradient = ctx.createRadialGradient(128, 100, 0, 128, 128, 128);
|
||||
gradient.addColorStop(0, lightenColor(color, 20));
|
||||
gradient.addColorStop(1, darkenColor(color, 20));
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(128, 128, 128, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Rim highlight
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(128, 128, 122, Math.PI * 1.1, Math.PI * 1.9);
|
||||
ctx.stroke();
|
||||
|
||||
// Letter
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
||||
ctx.font = 'bold 110px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(initial, 128, 136);
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
function lightenColor(hsl: string, amount: number): string {
|
||||
return hsl.replace(/(\d+)%\)$/, (_, l) => `${Math.min(100, parseInt(l) + amount)}%)`);
|
||||
}
|
||||
|
||||
function darkenColor(hsl: string, amount: number): string {
|
||||
return hsl.replace(/(\d+)%\)$/, (_, l) => `${Math.max(0, parseInt(l) - amount)}%)`);
|
||||
}
|
||||
|
||||
function makeTableTexture(): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Dark wood base
|
||||
ctx.fillStyle = '#120d08';
|
||||
ctx.fillRect(0, 0, 512, 512);
|
||||
|
||||
// Wood grain lines
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const y = Math.random() * 512;
|
||||
ctx.strokeStyle = `rgba(${60 + Math.random() * 20}, ${35 + Math.random() * 10}, ${15 + Math.random() * 8}, ${0.3 + Math.random() * 0.4})`;
|
||||
ctx.lineWidth = 0.5 + Math.random() * 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + Math.sin(0) * 8);
|
||||
for (let x = 0; x < 512; x += 10) {
|
||||
ctx.lineTo(x, y + Math.sin(x * 0.02) * 12 + Math.random() * 3);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Subtle amber center glow
|
||||
const radialGrad = ctx.createRadialGradient(256, 256, 0, 256, 256, 200);
|
||||
radialGrad.addColorStop(0, 'rgba(251, 191, 36, 0.08)');
|
||||
radialGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = radialGrad;
|
||||
ctx.fillRect(0, 0, 512, 512);
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
function makeEmblemTexture(): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
ctx.clearRect(0, 0, 128, 128);
|
||||
|
||||
// Outer circle
|
||||
ctx.strokeStyle = 'rgba(251, 191, 36, 0.7)';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(64, 64, 56, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Inner glow
|
||||
const glow = ctx.createRadialGradient(64, 64, 0, 64, 64, 50);
|
||||
glow.addColorStop(0, 'rgba(251, 191, 36, 0.18)');
|
||||
glow.addColorStop(1, 'rgba(251, 191, 36, 0)');
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(64, 64, 50, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Letter C
|
||||
ctx.strokeStyle = 'rgba(251, 191, 36, 0.9)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(64, 64, 28, Math.PI * 0.25, Math.PI * 1.75);
|
||||
ctx.stroke();
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
function makeFloorTexture(): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
ctx.fillStyle = '#0a0806';
|
||||
ctx.fillRect(0, 0, 512, 512);
|
||||
|
||||
// Very subtle grid
|
||||
ctx.strokeStyle = 'rgba(251, 191, 36, 0.04)';
|
||||
ctx.lineWidth = 0.5;
|
||||
const step = 512 / 20;
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i * step, 0);
|
||||
ctx.lineTo(i * step, 512);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, i * step);
|
||||
ctx.lineTo(512, i * step);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
export default function RoundTable3D({ personas, activeSpeakerId, messages }: RoundTable3DProps) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const avatarsRef = useRef<Map<string, AvatarObject>>(new Map());
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const cameraAngleRef = useRef<number>(0);
|
||||
const disposablesRef = useRef<Array<THREE.BufferGeometry | THREE.Material | THREE.Texture>>([]);
|
||||
const reducedMotionRef = useRef<boolean>(false);
|
||||
|
||||
const CANVAS_HEIGHT = 440;
|
||||
|
||||
const [speechBubbles, setSpeechBubbles] = useState<SpeechBubble[]>([]);
|
||||
const [nameTags, setNameTags] = useState<NameTag[]>([]);
|
||||
|
||||
// Track reduced-motion preference
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotionRef.current = mq.matches;
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
reducedMotionRef.current = e.matches;
|
||||
};
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
const track = useCallback(<T extends THREE.BufferGeometry | THREE.Material | THREE.Texture>(obj: T): T => {
|
||||
disposablesRef.current.push(obj);
|
||||
return obj;
|
||||
}, []);
|
||||
|
||||
const projectToScreen = useCallback((position: THREE.Vector3): { x: number; y: number } | null => {
|
||||
const camera = cameraRef.current;
|
||||
if (!camera) return null;
|
||||
const vec = position.clone();
|
||||
vec.project(camera);
|
||||
return {
|
||||
x: (vec.x + 1) / 2,
|
||||
y: (-vec.y + 1) / 2,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateOverlays = useCallback(() => {
|
||||
// Speech bubbles
|
||||
const bubbles: SpeechBubble[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (!msg.senderId || msg.senderId === 'moderator' || msg.senderId === 'facilitator') continue;
|
||||
if (seen.has(msg.senderId)) continue;
|
||||
seen.add(msg.senderId);
|
||||
|
||||
const avatarObj = avatarsRef.current.get(msg.senderId);
|
||||
if (!avatarObj) continue;
|
||||
|
||||
const worldPos = new THREE.Vector3();
|
||||
avatarObj.mesh.getWorldPosition(worldPos);
|
||||
worldPos.y += 0.8;
|
||||
|
||||
const screen = projectToScreen(worldPos);
|
||||
if (!screen) continue;
|
||||
|
||||
bubbles.push({
|
||||
personaId: msg.senderId,
|
||||
text: msg.text.length > 80 ? msg.text.slice(0, 77) + '…' : msg.text,
|
||||
screenX: screen.x * 100,
|
||||
screenY: screen.y * 100,
|
||||
});
|
||||
}
|
||||
setSpeechBubbles(bubbles);
|
||||
|
||||
// Name tags — always visible, offset below avatar
|
||||
const tags: NameTag[] = [];
|
||||
avatarsRef.current.forEach((avatarObj, id) => {
|
||||
const persona = personas.find((p) => (p._id || p.id) === id);
|
||||
if (!persona) return;
|
||||
|
||||
const worldPos = new THREE.Vector3();
|
||||
avatarObj.mesh.getWorldPosition(worldPos);
|
||||
worldPos.y -= 0.6; // below avatar
|
||||
|
||||
const screen = projectToScreen(worldPos);
|
||||
if (!screen) return;
|
||||
|
||||
tags.push({
|
||||
personaId: id,
|
||||
name: persona.name.split(' ')[0],
|
||||
screenX: screen.x * 100,
|
||||
screenY: screen.y * 100,
|
||||
});
|
||||
});
|
||||
setNameTags(tags);
|
||||
}, [messages, personas, projectToScreen]);
|
||||
|
||||
// Main Three.js setup
|
||||
useEffect(() => {
|
||||
const container = mountRef.current;
|
||||
if (!container || personas.length === 0) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = CANVAS_HEIGHT;
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x080605);
|
||||
scene.fog = new THREE.FogExp2(0x0a0705, 0.04);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// Camera — higher, more dramatic angle
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
|
||||
camera.position.set(0, 7, 6);
|
||||
camera.lookAt(0, 0, 0);
|
||||
cameraRef.current = camera;
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.1;
|
||||
container.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// ── Lighting ──────────────────────────────────────────────────────────────
|
||||
const ambientLight = new THREE.AmbientLight(0x1a1208, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0xfff5e4, 1.8);
|
||||
dirLight.position.set(3, 8, 5);
|
||||
dirLight.castShadow = true;
|
||||
dirLight.shadow.mapSize.width = 1024;
|
||||
dirLight.shadow.mapSize.height = 1024;
|
||||
dirLight.shadow.camera.near = 0.5;
|
||||
dirLight.shadow.camera.far = 30;
|
||||
scene.add(dirLight);
|
||||
|
||||
// Amber center glow
|
||||
const centerLight = new THREE.PointLight(0xfbbf24, 0.8, 6);
|
||||
centerLight.position.set(0, 2, 0);
|
||||
scene.add(centerLight);
|
||||
|
||||
// Hemisphere — warm sky / dark ground
|
||||
const hemiLight = new THREE.HemisphereLight(0xfbbf24, 0x0c0a09, 0.3);
|
||||
scene.add(hemiLight);
|
||||
|
||||
// ── Table surface ─────────────────────────────────────────────────────────
|
||||
const tableTexture = track(makeTableTexture());
|
||||
tableTexture.wrapS = THREE.RepeatWrapping;
|
||||
tableTexture.wrapT = THREE.RepeatWrapping;
|
||||
|
||||
const tableGeo = track(new THREE.CylinderGeometry(2.4, 2.2, 0.12, 64));
|
||||
const tableMat = track(new THREE.MeshStandardMaterial({
|
||||
map: tableTexture,
|
||||
roughness: 0.7,
|
||||
metalness: 0.15,
|
||||
}));
|
||||
const table = new THREE.Mesh(tableGeo, tableMat);
|
||||
table.receiveShadow = true;
|
||||
table.position.y = 0;
|
||||
scene.add(table);
|
||||
|
||||
// ── Amber rim ─────────────────────────────────────────────────────────────
|
||||
const rimGeo = track(new THREE.TorusGeometry(2.4, 0.06, 16, 64));
|
||||
const rimMat = track(new THREE.MeshStandardMaterial({
|
||||
color: 0xfbbf24,
|
||||
emissive: 0xfbbf24,
|
||||
emissiveIntensity: 0.8,
|
||||
roughness: 0.2,
|
||||
metalness: 0.4,
|
||||
}));
|
||||
const rim = new THREE.Mesh(rimGeo, rimMat);
|
||||
rim.rotation.x = Math.PI / 2;
|
||||
rim.position.y = 0.062;
|
||||
scene.add(rim);
|
||||
|
||||
// Thin inner rim
|
||||
const innerRimGeo = track(new THREE.TorusGeometry(2.35, 0.025, 8, 64));
|
||||
const innerRimMat = track(new THREE.MeshStandardMaterial({
|
||||
color: 0xfbbf24,
|
||||
emissive: 0xfbbf24,
|
||||
emissiveIntensity: 0.3,
|
||||
roughness: 0.4,
|
||||
}));
|
||||
const innerRim = new THREE.Mesh(innerRimGeo, innerRimMat);
|
||||
innerRim.rotation.x = Math.PI / 2;
|
||||
innerRim.position.y = 0.058;
|
||||
scene.add(innerRim);
|
||||
|
||||
// ── Center emblem ─────────────────────────────────────────────────────────
|
||||
const emblemTexture = track(makeEmblemTexture());
|
||||
const emblemGeo = track(new THREE.CylinderGeometry(0.25, 0.25, 0.001, 32));
|
||||
const emblemMat = track(new THREE.MeshStandardMaterial({
|
||||
map: emblemTexture,
|
||||
emissive: new THREE.Color(0xfbbf24),
|
||||
emissiveIntensity: 0.6,
|
||||
transparent: true,
|
||||
roughness: 0.3,
|
||||
metalness: 0.2,
|
||||
}));
|
||||
const emblem = new THREE.Mesh(emblemGeo, emblemMat);
|
||||
emblem.position.y = 0.062;
|
||||
scene.add(emblem);
|
||||
|
||||
// ── Floor ─────────────────────────────────────────────────────────────────
|
||||
const floorTexture = track(makeFloorTexture());
|
||||
floorTexture.wrapS = THREE.RepeatWrapping;
|
||||
floorTexture.wrapT = THREE.RepeatWrapping;
|
||||
floorTexture.repeat.set(3, 3);
|
||||
|
||||
const floorGeo = track(new THREE.PlaneGeometry(20, 20));
|
||||
const floorMat = track(new THREE.MeshStandardMaterial({
|
||||
map: floorTexture,
|
||||
color: 0x0a0806,
|
||||
roughness: 0.9,
|
||||
metalness: 0.1,
|
||||
}));
|
||||
const floor = new THREE.Mesh(floorGeo, floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = -0.1;
|
||||
floor.receiveShadow = true;
|
||||
scene.add(floor);
|
||||
|
||||
// ── Avatars ───────────────────────────────────────────────────────────────
|
||||
const count = personas.length;
|
||||
const orbitRadius = 2.85;
|
||||
const newAvatars = new Map<string, AvatarObject>();
|
||||
|
||||
personas.forEach((persona, index) => {
|
||||
const angle = (2 * Math.PI / count) * index - Math.PI / 2;
|
||||
const x = orbitRadius * Math.cos(angle);
|
||||
const z = orbitRadius * Math.sin(angle);
|
||||
|
||||
const color = nameToColor(persona.name);
|
||||
const hexColor = nameToHex(persona.name);
|
||||
const initial = persona.name.charAt(0).toUpperCase();
|
||||
|
||||
// Avatar sphere — larger, higher-res texture
|
||||
const avatarGeo = track(new THREE.SphereGeometry(0.42, 32, 32));
|
||||
const texture = track(makeAvatarTexture(initial, color));
|
||||
const avatarMat = track(new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
roughness: 0.5,
|
||||
metalness: 0.15,
|
||||
}));
|
||||
const avatarMesh = new THREE.Mesh(avatarGeo, avatarMat);
|
||||
avatarMesh.position.set(x, 0.5, z);
|
||||
avatarMesh.castShadow = true;
|
||||
scene.add(avatarMesh);
|
||||
|
||||
// Amber halo ring (active speaker only)
|
||||
const ringGeo = track(new THREE.TorusGeometry(0.52, 0.04, 8, 32));
|
||||
const ringMat = track(new THREE.MeshStandardMaterial({
|
||||
color: 0xfbbf24,
|
||||
emissive: 0xfbbf24,
|
||||
emissiveIntensity: 0.9,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const ringMesh = new THREE.Mesh(ringGeo, ringMat);
|
||||
ringMesh.position.set(x, 0.5, z);
|
||||
scene.add(ringMesh);
|
||||
|
||||
// Active speaker amber point light
|
||||
const haloLight = new THREE.PointLight(0xfbbf24, 0, 4);
|
||||
haloLight.position.set(x, 1.5, z);
|
||||
scene.add(haloLight);
|
||||
|
||||
// Inactive persona soft colored light
|
||||
const inactiveLight = new THREE.PointLight(hexColor, 0.2, 2.5);
|
||||
inactiveLight.position.set(x, 1.0, z);
|
||||
scene.add(inactiveLight);
|
||||
|
||||
// SpotLight from above for each avatar
|
||||
const spotLight = new THREE.SpotLight(0xfff5e4, 0.6, 5, Math.PI / 8, 0.4, 1);
|
||||
spotLight.position.set(x, 3, z);
|
||||
spotLight.target.position.set(x, 0, z);
|
||||
scene.add(spotLight);
|
||||
scene.add(spotLight.target);
|
||||
|
||||
const personaId = persona._id || persona.id;
|
||||
newAvatars.set(personaId, {
|
||||
mesh: avatarMesh,
|
||||
ring: ringMesh,
|
||||
haloLight,
|
||||
inactiveLight,
|
||||
spotLight,
|
||||
targetScale: 1,
|
||||
currentScale: 1,
|
||||
});
|
||||
});
|
||||
|
||||
avatarsRef.current = newAvatars;
|
||||
|
||||
// ── Animation loop ────────────────────────────────────────────────────────
|
||||
const animate = () => {
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
if (!reducedMotionRef.current) {
|
||||
cameraAngleRef.current += 0.0005;
|
||||
}
|
||||
const r = 8.5;
|
||||
const camAngle = cameraAngleRef.current;
|
||||
camera.position.x = r * Math.sin(camAngle);
|
||||
camera.position.z = r * Math.cos(camAngle) * 0.8 + 1.5;
|
||||
camera.position.y = 7;
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
// Update avatars
|
||||
avatarsRef.current.forEach((avatarObj) => {
|
||||
avatarObj.currentScale += (avatarObj.targetScale - avatarObj.currentScale) * 0.07;
|
||||
avatarObj.mesh.scale.setScalar(avatarObj.currentScale);
|
||||
|
||||
if (avatarObj.targetScale > 1.1) {
|
||||
avatarObj.ring.rotation.z += 0.04;
|
||||
}
|
||||
});
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
// ── ResizeObserver ────────────────────────────────────────────────────────
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!container || !rendererRef.current || !cameraRef.current) return;
|
||||
const w = container.clientWidth;
|
||||
rendererRef.current.setSize(w, CANVAS_HEIGHT);
|
||||
cameraRef.current.aspect = w / CANVAS_HEIGHT;
|
||||
cameraRef.current.updateProjectionMatrix();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
resizeObserver.disconnect();
|
||||
disposablesRef.current.forEach((obj) => obj.dispose());
|
||||
disposablesRef.current = [];
|
||||
avatarsRef.current.clear();
|
||||
renderer.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
rendererRef.current = null;
|
||||
};
|
||||
// personas rebuild → recreate scene
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [personas]);
|
||||
|
||||
// ── Active speaker glow ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
avatarsRef.current.forEach((avatarObj, id) => {
|
||||
const isSpeaking = id === activeSpeakerId;
|
||||
avatarObj.targetScale = isSpeaking ? 1.25 : 1.0;
|
||||
avatarObj.haloLight.intensity = isSpeaking ? 3 : 0;
|
||||
avatarObj.inactiveLight.intensity = isSpeaking ? 0 : 0.2;
|
||||
|
||||
const ringMat = avatarObj.ring.material as THREE.MeshStandardMaterial;
|
||||
ringMat.opacity = isSpeaking ? 0.9 : 0;
|
||||
ringMat.needsUpdate = true;
|
||||
});
|
||||
}, [activeSpeakerId]);
|
||||
|
||||
// ── Overlay tick ──────────────────────────────────────────────────────────
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (personas.length === 0) return;
|
||||
const id = setInterval(() => setTick((t) => t + 1), 200);
|
||||
return () => clearInterval(id);
|
||||
}, [personas.length]);
|
||||
|
||||
useEffect(() => {
|
||||
updateOverlays();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tick, messages]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: CANVAS_HEIGHT }}>
|
||||
{/* Three.js canvas mount */}
|
||||
<div ref={mountRef} style={{ width: '100%', height: CANVAS_HEIGHT }} />
|
||||
|
||||
{/* Speech bubble overlay */}
|
||||
{speechBubbles.map((bubble) => (
|
||||
<div
|
||||
key={bubble.personaId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${bubble.screenX}%`,
|
||||
top: `${bubble.screenY}%`,
|
||||
transform: 'translate(-50%, -110%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
maxWidth: 200,
|
||||
animation: 'roundtable-fadein 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(20, 16, 12, 0.95)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.6)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
fontFamily: "'Figtree', system-ui, sans-serif",
|
||||
fontSize: 12,
|
||||
color: '#fef3c7',
|
||||
lineHeight: 1.45,
|
||||
backdropFilter: 'blur(6px)',
|
||||
boxShadow: '0 0 16px rgba(251, 191, 36, 0.15), 0 4px 12px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{bubble.text}
|
||||
</div>
|
||||
{/* Tail */}
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderTop: '6px solid rgba(251, 191, 36, 0.6)',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Floating name tags */}
|
||||
{nameTags.map((tag) => (
|
||||
<div
|
||||
key={`tag-${tag.personaId}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${tag.screenX}%`,
|
||||
top: `${tag.screenY}%`,
|
||||
transform: 'translate(-50%, 0)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 9,
|
||||
color: 'rgba(184, 178, 172, 0.8)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<style>{`
|
||||
@keyframes roundtable-fadein {
|
||||
from { opacity: 0; transform: translate(-50%, -120%); }
|
||||
to { opacity: 1; transform: translate(-50%, -110%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,122 +1,161 @@
|
|||
|
||||
import { Target, Zap, Activity, Brain, Heart } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Persona } from '@/types/persona';
|
||||
|
||||
interface PersonaAttitudinalProfileProps {
|
||||
persona: Persona;
|
||||
}
|
||||
|
||||
export function PersonaAttitudinalProfile({ persona }: PersonaAttitudinalProfileProps) {
|
||||
function SectionHeader({ icon: Icon, label, accent }: { icon: React.ElementType; label: string; accent?: string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Target className="h-5 w-5 text-primary mr-2" />
|
||||
<h3 className="font-sf text-lg font-medium">Goals</h3>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{persona.goals?.map((goal, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5 mr-3">
|
||||
<span className="text-xs text-primary font-medium">{index + 1}</span>
|
||||
</div>
|
||||
<p className="text-sm">{goal}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Zap className="h-5 w-5 text-amber-500 mr-2" />
|
||||
<h3 className="font-sf text-lg font-medium">Frustrations</h3>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{persona.frustrations?.map((item, index) => (
|
||||
<li key={index} className="text-sm flex items-start">
|
||||
<span className="text-amber-500 mr-2">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Activity className="h-5 w-5 text-green-500 mr-2" />
|
||||
<h3 className="font-sf text-lg font-medium">Motivations</h3>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{persona.motivations?.map((item, index) => (
|
||||
<li key={index} className="text-sm flex items-start">
|
||||
<span className="text-green-500 mr-2">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h3
|
||||
className="text-sm font-semibold text-foreground uppercase tracking-widest"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonaAttitudinalProfile({ persona }: PersonaAttitudinalProfileProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Goals */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<SectionHeader icon={Target} label="Goals" />
|
||||
<ul className="space-y-3">
|
||||
{persona.goals?.map((goal, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full bg-primary/15 border border-primary/30 flex items-center justify-center flex-shrink-0 mt-0.5"
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-bold text-primary"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/85 leading-relaxed">{goal}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Frustrations + Motivations */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<SectionHeader icon={Zap} label="Frustrations" />
|
||||
<ul className="space-y-2.5">
|
||||
{persona.frustrations?.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80 leading-relaxed">
|
||||
<span className="text-primary/60 mt-0.5 flex-shrink-0">–</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<SectionHeader icon={Activity} label="Motivations" />
|
||||
<ul className="space-y-2.5">
|
||||
{persona.motivations?.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80 leading-relaxed">
|
||||
<span className="text-primary/60 mt-0.5 flex-shrink-0">+</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Think, Feel, Do */}
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<div className="flex items-center gap-2.5 mb-5">
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
<span
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-widest px-3"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
Think · Feel · Do
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Thinks */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Brain className="h-4 w-4 text-primary/70" />
|
||||
<span
|
||||
className="text-[11px] font-medium text-primary/70 uppercase tracking-widest"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
Thinks
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{persona.thinkFeelDo?.thinks?.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-sm text-foreground/75 bg-secondary/40 rounded-lg px-3 py-2.5 leading-relaxed border border-border/40"
|
||||
>
|
||||
<span className="text-primary/50 mr-1">"</span>{item}<span className="text-primary/50">"</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Feels */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Heart className="h-4 w-4 text-primary/70" />
|
||||
<span
|
||||
className="text-[11px] font-medium text-primary/70 uppercase tracking-widest"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
Feels
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{persona.thinkFeelDo?.feels?.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-sm text-foreground/75 bg-secondary/40 rounded-lg px-3 py-2.5 leading-relaxed border border-border/40"
|
||||
>
|
||||
<span className="text-primary/50 mr-1">"</span>{item}<span className="text-primary/50">"</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Does */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="h-4 w-4 text-primary/70" />
|
||||
<span
|
||||
className="text-[11px] font-medium text-primary/70 uppercase tracking-widest"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
Does
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{persona.thinkFeelDo?.does?.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-sm text-foreground/75 bg-secondary/40 rounded-lg px-3 py-2.5 leading-relaxed border border-border/40"
|
||||
>
|
||||
<span className="text-primary/50 mr-1">"</span>{item}<span className="text-primary/50">"</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-sf text-lg font-medium mb-4">Think, Feel, Do</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center mb-3">
|
||||
<Brain className="h-5 w-5 text-blue-500 mr-2" />
|
||||
<h4 className="font-medium text-sm">Thinks</h4>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{persona.thinkFeelDo?.thinks?.map((item, index) => (
|
||||
<li key={index} className="text-sm bg-blue-500/10 p-2 rounded-md">
|
||||
"{item}"
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center mb-3">
|
||||
<Heart className="h-5 w-5 text-red-500 mr-2" />
|
||||
<h4 className="font-medium text-sm">Feels</h4>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{persona.thinkFeelDo?.feels?.map((item, index) => (
|
||||
<li key={index} className="text-sm bg-red-500/10 p-2 rounded-md">
|
||||
"{item}"
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center mb-3">
|
||||
<Activity className="h-5 w-5 text-green-500 mr-2" />
|
||||
<h4 className="font-medium text-sm">Does</h4>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{persona.thinkFeelDo?.does?.map((item, index) => (
|
||||
<li key={index} className="text-sm bg-green-500/10 p-2 rounded-md">
|
||||
"{item}"
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { ArrowLeft, Edit, Home, Users, User, Download, Bot } from 'lucide-react';
|
||||
import { ArrowLeft, Edit, Home, Users, User, Download, Bot, Target, Sparkles, BookOpen, SlidersHorizontal } from 'lucide-react';
|
||||
import { useNavigation } from '@/contexts/NavigationContext';
|
||||
import { focusGroupsApi, personasApi } from '@/lib/api';
|
||||
import { toastService } from '@/lib/toast';
|
||||
|
|
@ -256,46 +256,56 @@ export default function PersonaProfile() {
|
|||
|
||||
{/* Review Mode Banner */}
|
||||
{isReviewMode && (
|
||||
<div className="mb-6 p-4 bg-amber-500/10 border-l-4 border-amber-400 rounded-r-lg">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-amber-400">
|
||||
📝 <strong>Reviewing proposed changes to {displayPersona?.name}</strong> - Save or cancel to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 p-4 bg-primary/8 border border-primary/40 rounded-xl">
|
||||
<p className="text-sm text-primary/90">
|
||||
<strong>Reviewing proposed changes to {displayPersona?.name}</strong> — save or cancel to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center mb-6 relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isReviewMode) {
|
||||
if (confirm("You have unsaved changes. Your modifications will be lost if you leave. Do you want to continue?")) {
|
||||
exitReviewMode();
|
||||
<div className="flex items-center justify-between mb-6 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isReviewMode) {
|
||||
if (confirm("You have unsaved changes. Your modifications will be lost if you leave. Do you want to continue?")) {
|
||||
exitReviewMode();
|
||||
handleGoBack();
|
||||
}
|
||||
} else {
|
||||
handleGoBack();
|
||||
}
|
||||
} else {
|
||||
handleGoBack();
|
||||
}
|
||||
}}
|
||||
className="absolute left-0 top-0 flex items-center"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="font-sf text-3xl font-bold text-foreground ml-16">Persona Profile</h1>
|
||||
<div className="absolute right-0 top-0 flex items-center gap-3">
|
||||
}}
|
||||
className="h-8 w-8 p-0 rounded-lg border border-border/60 hover:border-primary/40"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<p
|
||||
className="text-[10px] font-medium text-muted-foreground uppercase tracking-widest"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
Persona Profile
|
||||
</p>
|
||||
<h1
|
||||
className="text-2xl font-bold text-foreground leading-tight"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
{isReviewMode ? displayPersona?.name : currentPersona?.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isReviewMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={exitReviewMode}
|
||||
className="hover-transition"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={exitReviewMode} className="hover-transition">
|
||||
Cancel Revision
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveReviewedPersona}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
|
|
@ -304,25 +314,27 @@ export default function PersonaProfile() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportProfile}
|
||||
disabled={isExporting}
|
||||
className="hover-transition"
|
||||
className="hover-transition gap-1.5"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{isExporting ? 'Generating...' : 'Download Profile'}
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isExporting ? 'Generating…' : 'Download Profile'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsModificationModalOpen(true)}
|
||||
className="hover-transition"
|
||||
className="hover-transition gap-1.5"
|
||||
>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Modify with AI
|
||||
</Button>
|
||||
<Button onClick={() => setIsEditing(true)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
<Button size="sm" onClick={() => setIsEditing(true)} className="gap-1.5">
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit Persona
|
||||
</Button>
|
||||
</>
|
||||
|
|
@ -330,33 +342,46 @@ export default function PersonaProfile() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid grid-cols-1 lg:grid-cols-3 gap-6 mt-10 ${isReviewMode ? 'border-2 border-amber-400 rounded-lg p-4 bg-amber-500/5' : ''}`}>
|
||||
<div className="lg:col-span-1">
|
||||
<div className={`grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-6 ${isReviewMode ? 'ring-1 ring-primary/40 rounded-2xl p-4 bg-primary/5' : ''}`}>
|
||||
<div>
|
||||
<PersonaSidebar persona={displayPersona} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Tabs defaultValue="attitudinal-profile">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="attitudinal-profile">Attitudinal Profile</TabsTrigger>
|
||||
<TabsTrigger value="personality">Personality</TabsTrigger>
|
||||
<TabsTrigger value="scenarios">Scenarios</TabsTrigger>
|
||||
<TabsTrigger value="generation-prompts">Persona Inputs</TabsTrigger>
|
||||
|
||||
<div>
|
||||
<Tabs defaultValue="attitudinal-profile" className="w-full">
|
||||
{/* Custom icon-tab nav */}
|
||||
<TabsList className="flex items-center gap-1 p-1 bg-secondary/40 rounded-xl border border-border mb-5 w-fit h-auto">
|
||||
{[
|
||||
{ value: 'attitudinal-profile', icon: Target, label: 'Profile' },
|
||||
{ value: 'personality', icon: Sparkles, label: 'Personality' },
|
||||
{ value: 'scenarios', icon: BookOpen, label: 'Scenarios' },
|
||||
{ value: 'generation-prompts', icon: SlidersHorizontal, label: 'Inputs' },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium text-muted-foreground transition-all data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-primary/25 hover:text-foreground"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="uppercase tracking-wider">{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="attitudinal-profile" className="mt-6">
|
||||
<TabsContent value="attitudinal-profile" className="mt-0">
|
||||
<PersonaAttitudinalProfile persona={displayPersona} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="personality" className="mt-6">
|
||||
|
||||
<TabsContent value="personality" className="mt-0">
|
||||
<PersonaPersonality persona={displayPersona} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scenarios" className="mt-6">
|
||||
|
||||
<TabsContent value="scenarios" className="mt-0">
|
||||
<PersonaScenarios persona={displayPersona} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="generation-prompts" className="mt-6">
|
||||
|
||||
<TabsContent value="generation-prompts" className="mt-0">
|
||||
<PersonaGenerationPrompts persona={displayPersona} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { User, Users, MapPin, BookOpen, Heart, Monitor, ShoppingBag, Info } from 'lucide-react';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Users, MapPin, Heart, BookOpen, Monitor, ShoppingBag, Info } from 'lucide-react';
|
||||
import { Persona } from '@/types/persona';
|
||||
import { getPersonaAvatarSrc } from '@/utils/avatarUtils';
|
||||
|
||||
|
|
@ -8,185 +6,155 @@ interface PersonaSidebarProps {
|
|||
persona: Persona;
|
||||
}
|
||||
|
||||
const BAR_ITEMS = [
|
||||
{ key: 'techSavviness', label: 'Tech Savviness' },
|
||||
{ key: 'brandLoyalty', label: 'Brand Loyalty' },
|
||||
{ key: 'priceConsciousness', label: 'Price Sensitivity' },
|
||||
{ key: 'environmentalConcern', label: 'Env. Concern' },
|
||||
] as const;
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className="text-[10px] font-medium text-primary/70 uppercase tracking-widest mb-2"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonaSidebar({ persona }: PersonaSidebarProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<img
|
||||
src={getPersonaAvatarSrc(persona)}
|
||||
alt={persona.name}
|
||||
className="h-16 w-16 rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-sf text-xl font-semibold">{persona.name}</h2>
|
||||
<p className="text-muted-foreground">{persona.occupation}</p>
|
||||
<div className="rounded-2xl border border-border bg-card overflow-hidden">
|
||||
{/* Avatar + identity block */}
|
||||
<div className="p-6 border-b border-border/60">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-16 h-16 rounded-full ring-2 ring-primary/40 ring-offset-2 ring-offset-card overflow-hidden bg-secondary">
|
||||
<img
|
||||
src={getPersonaAvatarSrc(persona)}
|
||||
alt={persona.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-500/80 border-2 border-card" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className="text-lg font-bold text-foreground leading-tight truncate"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
{persona.name}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground truncate">{persona.occupation}</p>
|
||||
{persona.location && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<MapPin className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
<span className="text-xs text-muted-foreground/70 truncate">{persona.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
|
||||
<div className="sidebar-section">
|
||||
<Users className="sidebar-icon" />
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">Demographics</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{persona.age} {persona.gender ? <>• {persona.gender}</> : null}
|
||||
{persona.ethnicity ? <> • {persona.ethnicity}</> : null}
|
||||
</p>
|
||||
{persona.education && (
|
||||
<p className="sidebar-sub-item">{persona.education}</p>
|
||||
)}
|
||||
{persona.socialGrade && (
|
||||
<p className="sidebar-sub-item">Social Grade: {persona.socialGrade}</p>
|
||||
)}
|
||||
{persona.householdIncome && (
|
||||
<p className="sidebar-sub-item">Household Income: {persona.householdIncome}</p>
|
||||
)}
|
||||
{persona.householdComposition && (
|
||||
<p className="sidebar-sub-item">Household: {persona.householdComposition}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<MapPin className="sidebar-icon" />
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">Location</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{persona.location}</p>
|
||||
{persona.livingSituation && (
|
||||
<p className="sidebar-sub-item">{persona.livingSituation}</p>
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Demographics */}
|
||||
<div>
|
||||
<SectionLabel>Demographics</SectionLabel>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{(persona.age || persona.gender) && (
|
||||
<p>
|
||||
{[persona.age, persona.gender, persona.ethnicity].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{persona.education && <p>{persona.education}</p>}
|
||||
{persona.socialGrade && <p>Social Grade: {persona.socialGrade}</p>}
|
||||
{persona.householdIncome && <p>Income: {persona.householdIncome}</p>}
|
||||
{persona.householdComposition && <p>{persona.householdComposition}</p>}
|
||||
{persona.livingSituation && <p>{persona.livingSituation}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interests */}
|
||||
{persona.interests && (
|
||||
<div className="sidebar-section">
|
||||
<Heart className="sidebar-icon" />
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">Interests</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{persona.interests}</p>
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>Interests</SectionLabel>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{persona.interests}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media */}
|
||||
{persona.mediaConsumption && (
|
||||
<div className="sidebar-section">
|
||||
<BookOpen className="sidebar-icon" />
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">Media</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{persona.mediaConsumption}</p>
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>Media</SectionLabel>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{persona.mediaConsumption}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium text-sm mb-3">Digital Behavior</h3>
|
||||
{/* Digital Behavior — all bars in amber */}
|
||||
<div className="pt-1 border-t border-border/60">
|
||||
<SectionLabel>Digital Behavior</SectionLabel>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Tech Savviness</span>
|
||||
<span>{persona.techSavviness}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${persona.techSavviness}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
{persona.brandLoyalty !== undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Brand Loyalty</span>
|
||||
<span>{persona.brandLoyalty}%</span>
|
||||
{BAR_ITEMS.map(({ key, label }) => {
|
||||
const val = persona[key] as number | undefined;
|
||||
if (val === undefined) return null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className="text-[11px] font-medium text-primary/80"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{val}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-secondary overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${val}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${persona.brandLoyalty}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{persona.priceConsciousness !== undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Price Sensitivity</span>
|
||||
<span>{persona.priceConsciousness}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-amber-500 rounded-full" style={{ width: `${persona.priceConsciousness}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{persona.environmentalConcern !== undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Environmental Concern</span>
|
||||
<span>{persona.environmentalConcern}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500 rounded-full" style={{ width: `${persona.environmentalConcern}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
{persona.deviceUsage && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium mt-3">Device Usage</h4>
|
||||
<p className="sidebar-sub-item text-xs">{persona.deviceUsage}</p>
|
||||
<div className="pt-1">
|
||||
<p className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-widest mb-1" style={{ fontFamily: "'JetBrains Mono', monospace" }}>Device</p>
|
||||
<p className="text-xs text-muted-foreground">{persona.deviceUsage}</p>
|
||||
</div>
|
||||
)}
|
||||
{persona.shoppingHabits && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium mt-3">Shopping Habits</h4>
|
||||
<p className="sidebar-sub-item text-xs">{persona.shoppingHabits}</p>
|
||||
<p className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-widest mb-1" style={{ fontFamily: "'JetBrains Mono', monospace" }}>Shopping</p>
|
||||
<p className="text-xs text-muted-foreground">{persona.shoppingHabits}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium text-sm mb-3">Additional Information</h3>
|
||||
<div className="space-y-2">
|
||||
{persona.brandPreferences && (
|
||||
<div className="sidebar-section">
|
||||
<Heart className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">{persona.brandPreferences}</span>
|
||||
</div>
|
||||
)}
|
||||
{persona.communicationPreferences && (
|
||||
<div className="sidebar-section">
|
||||
<User className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Prefers: {persona.communicationPreferences}</span>
|
||||
</div>
|
||||
)}
|
||||
{persona.deviceUsage && (
|
||||
<div className="sidebar-section">
|
||||
<Monitor className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">{persona.deviceUsage}</span>
|
||||
</div>
|
||||
)}
|
||||
{persona.shoppingHabits && (
|
||||
<div className="sidebar-section">
|
||||
<ShoppingBag className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">{persona.shoppingHabits}</span>
|
||||
</div>
|
||||
)}
|
||||
{persona.additionalInformation && typeof persona.additionalInformation === 'string' && (
|
||||
<div className="sidebar-section">
|
||||
<Info className="sidebar-icon" />
|
||||
<div className="sidebar-sub-item">
|
||||
{persona.additionalInformation.split('\n').map((line, index) => (
|
||||
<div key={index} className="mb-1">
|
||||
{line.trim().startsWith('•') || line.trim().startsWith('-') ? (
|
||||
line.trim().substring(1).trim()
|
||||
) : (
|
||||
line.trim()
|
||||
)}
|
||||
{/* Additional Info */}
|
||||
{(persona.brandPreferences || persona.communicationPreferences || persona.additionalInformation) && (
|
||||
<div className="pt-1 border-t border-border/60">
|
||||
<SectionLabel>Additional</SectionLabel>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
{persona.brandPreferences && <p>{persona.brandPreferences}</p>}
|
||||
{persona.communicationPreferences && <p>Prefers: {persona.communicationPreferences}</p>}
|
||||
{persona.additionalInformation && typeof persona.additionalInformation === 'string' && (
|
||||
<div>
|
||||
{persona.additionalInformation.split('\n').map((line, i) => (
|
||||
<div key={i} className="mb-0.5">
|
||||
{line.trim().replace(/^[•\-]\s*/, '')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import ThemesPanel from '@/components/focus-group-session/ThemesPanel';
|
|||
import AnalyticsPanel from '@/components/focus-group-session/AnalyticsPanel';
|
||||
import AutonomousDashboard from '@/components/focus-group-session/AutonomousDashboard';
|
||||
import CollapsibleDiscussionGuide from '@/components/focus-group-session/CollapsibleDiscussionGuide';
|
||||
import DiscussionGuideViewer from '@/components/focus-group-session/DiscussionGuideViewer';
|
||||
import NotesPanel from '@/components/focus-group-session/NotesPanel';
|
||||
import QuickNoteModal from '@/components/focus-group-session/QuickNoteModal';
|
||||
import { FocusGroup, Message, Theme, Note, QuoteData, ModeEvent } from '@/components/focus-group-session/types';
|
||||
|
|
@ -1811,9 +1812,9 @@ const FocusGroupSession = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
|
||||
|
||||
|
||||
{/* WebSocket Connection Status Bar */}
|
||||
{useWebSocketEnabled && isStatusBarVisible && (
|
||||
<div className={`w-full transition-all duration-300 ${
|
||||
|
|
@ -1890,72 +1891,62 @@ const FocusGroupSession = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/focus-groups')}
|
||||
className="mr-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-sf text-2xl font-bold text-foreground">{focusGroup.name}</h1>
|
||||
<p className="text-muted-foreground">{new Date(focusGroup.date).toLocaleString()}</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<Bot className="h-3 w-3 text-muted-foreground mr-1" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
|
||||
</Badge>
|
||||
</div>
|
||||
{user?.role === 'admin' && fgCostTotal > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
${fgCostTotal.toFixed(4)} • {(fgTokensTotal / 1000).toFixed(1)}k tok
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<main className="flex flex-col overflow-hidden" style={{ height: 'calc(100vh - 4rem)' }}>
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/30 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/focus-groups')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground shrink-0">{new Date(focusGroup.date).toLocaleString()}</p>
|
||||
<Bot className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
|
||||
</Badge>
|
||||
{user?.role === 'admin' && fgCostTotal > 0 && (
|
||||
<Badge variant="outline" className="text-xs font-mono shrink-0">
|
||||
${fgCostTotal.toFixed(4)} • {(fgTokensTotal / 1000).toFixed(1)}k tok
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mt-4 sm:mt-0">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDiscussionGuideOpen(true)}
|
||||
className={isDiscussionGuideOpen ? 'bg-amber-50 text-amber-700 border-amber-200' : ''}
|
||||
>
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
{isDiscussionGuideOpen ? 'Guide Open' : 'Edit Discussion Guide'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAutonomousDashboard(!showAutonomousDashboard)}
|
||||
className={showAutonomousDashboard ? 'bg-blue-50 text-blue-600' : ''}
|
||||
className={showAutonomousDashboard ? 'text-blue-400' : 'text-muted-foreground'}
|
||||
title="AI Dashboard"
|
||||
>
|
||||
<BarChart className="mr-2 h-4 w-4" />
|
||||
AI Dashboard
|
||||
<BarChart className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowModelSettings(true)}
|
||||
className="text-muted-foreground"
|
||||
title="AI Model Settings"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
AI Model
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={downloadTranscript}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Transcript
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTranscript}
|
||||
className="text-muted-foreground"
|
||||
title="Download Transcript"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quota warning banner */}
|
||||
{quotaWarning && (
|
||||
<div className="mx-4 mt-2 p-3 bg-amber-50 border border-amber-200 rounded-md flex items-center justify-between">
|
||||
<div className="px-4 py-2 bg-amber-50 border-b border-amber-200 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<span className="text-sm text-amber-700">
|
||||
Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota
|
||||
|
|
@ -1969,7 +1960,7 @@ const FocusGroupSession = () => {
|
|||
|
||||
{/* Quota exceeded banner */}
|
||||
{quotaExceeded && (
|
||||
<div className="mx-0 mt-2 mb-2 p-3 bg-red-50 border border-red-200 rounded-md flex items-center justify-between">
|
||||
<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">
|
||||
Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used.
|
||||
</span>
|
||||
|
|
@ -1992,86 +1983,105 @@ const FocusGroupSession = () => {
|
|||
onComplete={handleThemeProgressComplete}
|
||||
/>
|
||||
|
||||
{/* Collapsible Discussion Guide Panel */}
|
||||
<CollapsibleDiscussionGuide
|
||||
discussionGuide={focusGroup.discussionGuide}
|
||||
moderatorStatus={moderatorStatus}
|
||||
onSectionSelect={handleSectionSelect}
|
||||
onSetPosition={handleSetPosition}
|
||||
onSave={handleDiscussionGuideSave}
|
||||
focusGroupId={id || ''}
|
||||
isOpen={isDiscussionGuideOpen}
|
||||
onToggle={handleToggleDiscussionGuide}
|
||||
onEditingChange={handleGuideEditingStateChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:h-[calc(100vh-12rem)]">
|
||||
<ParticipantPanel
|
||||
participants={participants}
|
||||
selectedParticipantIds={selectedParticipantIds}
|
||||
onToggleParticipantFilter={toggleParticipantFilter}
|
||||
isEditable={focusGroup?.status === 'new'}
|
||||
allPersonas={allPersonas}
|
||||
onAddParticipant={handleAddParticipant}
|
||||
onRemoveParticipant={handleRemoveParticipant}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<Tabs defaultValue="chat" value={activeTab} onValueChange={setActiveTab} className="w-full h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-4 mb-4">
|
||||
<TabsTrigger value="chat" className="flex items-center gap-1 sm:gap-2">
|
||||
<MessageCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Discussion</span>
|
||||
{/* Session header strip */}
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-border/50 bg-card/80 backdrop-blur shrink-0">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span className="text-xs font-mono uppercase tracking-widest text-muted-foreground shrink-0">
|
||||
Session
|
||||
</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" />
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-column Director's Studio grid */}
|
||||
<div className="flex-1 grid grid-cols-[220px_1fr_300px] overflow-hidden">
|
||||
|
||||
{/* LEFT: Participants */}
|
||||
<div className="bg-background border-r border-border/50 overflow-y-auto">
|
||||
<ParticipantPanel
|
||||
participants={participants}
|
||||
selectedParticipantIds={selectedParticipantIds}
|
||||
onToggleParticipantFilter={toggleParticipantFilter}
|
||||
isEditable={focusGroup?.status === 'new'}
|
||||
allPersonas={allPersonas}
|
||||
onAddParticipant={handleAddParticipant}
|
||||
onRemoveParticipant={handleRemoveParticipant}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CENTER: Discussion */}
|
||||
<div className="bg-background flex flex-col overflow-hidden">
|
||||
{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>
|
||||
<Button onClick={startSession} size="lg" className="flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5" />
|
||||
Start Session
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DiscussionPanel
|
||||
messages={messages}
|
||||
modeEvents={modeEvents}
|
||||
personas={participants}
|
||||
isSpeaking={false}
|
||||
focusGroupId={id || ''}
|
||||
isAiModeActive={isAiModeActive}
|
||||
selectedParticipantIds={selectedParticipantIds}
|
||||
onToggleHighlight={toggleHighlight}
|
||||
onAdvanceDiscussion={() => null}
|
||||
onNewMessage={handleNewMessage}
|
||||
onStatusChange={reloadFocusGroup}
|
||||
isEditingDiscussionGuide={isEditingDiscussionGuide}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Guide + Themes + Notes + Analytics */}
|
||||
<div className="bg-card/30 border-l border-border/50 flex flex-col overflow-hidden">
|
||||
<Tabs defaultValue="guide" className="flex flex-col h-full">
|
||||
<TabsList className="grid grid-cols-4 m-3 h-8 shrink-0">
|
||||
<TabsTrigger value="guide" className="text-[10px]">
|
||||
Guide
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="themes" className="flex items-center gap-1 sm:gap-2">
|
||||
<Lightbulb className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Key Themes</span>
|
||||
<TabsTrigger value="themes" className="text-[10px]">
|
||||
Themes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notes" className="flex items-center gap-1 sm:gap-2">
|
||||
<StickyNote className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Notes</span>
|
||||
<TabsTrigger value="notes" className="text-[10px]">
|
||||
Notes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="flex items-center gap-1 sm:gap-2">
|
||||
<BarChart className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Analytics</span>
|
||||
<TabsTrigger value="analytics" className="text-[10px]">
|
||||
Stats
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="chat" className="m-0 flex-1 flex flex-col h-0">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-4">
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DiscussionPanel
|
||||
messages={messages}
|
||||
modeEvents={modeEvents}
|
||||
personas={participants}
|
||||
isSpeaking={false}
|
||||
focusGroupId={id || ''}
|
||||
isAiModeActive={isAiModeActive}
|
||||
selectedParticipantIds={selectedParticipantIds}
|
||||
onToggleHighlight={toggleHighlight}
|
||||
onAdvanceDiscussion={() => null}
|
||||
onNewMessage={handleNewMessage}
|
||||
onStatusChange={reloadFocusGroup}
|
||||
isEditingDiscussionGuide={isEditingDiscussionGuide}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TabsContent value="guide" className="flex-1 overflow-y-auto px-3 pb-3 m-0">
|
||||
<DiscussionGuideViewer
|
||||
discussionGuide={focusGroup.discussionGuide}
|
||||
moderatorStatus={moderatorStatus}
|
||||
onSectionSelect={handleSectionSelect}
|
||||
onSetPosition={handleSetPosition}
|
||||
onSave={handleDiscussionGuideSave}
|
||||
focusGroupId={id || ''}
|
||||
collapsible={false}
|
||||
defaultExpanded={true}
|
||||
showProgress={true}
|
||||
onEditingChange={handleGuideEditingStateChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="themes" className="m-0">
|
||||
<ThemesPanel
|
||||
themes={themes}
|
||||
messages={messages}
|
||||
|
||||
<TabsContent value="themes" className="flex-1 overflow-y-auto m-0">
|
||||
<ThemesPanel
|
||||
themes={themes}
|
||||
messages={messages}
|
||||
personas={participants}
|
||||
focusGroupId={id || ''}
|
||||
onThemesGenerated={handleThemesGenerated}
|
||||
|
|
@ -2080,19 +2090,17 @@ const FocusGroupSession = () => {
|
|||
onGenerateKeyThemes={generateKeyThemes}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes" className="m-0" style={{ height: 'calc(100% - 3.5rem)' }}>
|
||||
<div className="h-full">
|
||||
<NotesPanel
|
||||
focusGroupId={id || ''}
|
||||
focusGroupName={focusGroup?.name}
|
||||
onNoteClick={handleNoteClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="notes" className="flex-1 overflow-y-auto m-0">
|
||||
<NotesPanel
|
||||
focusGroupId={id || ''}
|
||||
focusGroupName={focusGroup?.name}
|
||||
onNoteClick={handleNoteClick}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="m-0">
|
||||
<AnalyticsPanel
|
||||
|
||||
<TabsContent value="analytics" className="flex-1 overflow-y-auto m-0">
|
||||
<AnalyticsPanel
|
||||
messages={messages}
|
||||
themes={themes}
|
||||
personas={participants}
|
||||
|
|
@ -2100,22 +2108,10 @@ const FocusGroupSession = () => {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Floating Note Button */}
|
||||
{messages.length > 0 && (
|
||||
<div className="fixed bottom-6 right-6 z-40">
|
||||
<Button
|
||||
onClick={handleOpenNoteModal}
|
||||
className="rounded-full h-12 w-12 p-0 shadow-lg"
|
||||
title="Take a quick note"
|
||||
>
|
||||
<StickyNote className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Note Modal */}
|
||||
<QuickNoteModal
|
||||
isOpen={isNoteModalOpen}
|
||||
|
|
|
|||
|
|
@ -1222,7 +1222,7 @@ const SyntheticUsers = () => {
|
|||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t('synthetic_users.search_placeholder')}
|
||||
className="pl-10 bg-white"
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
|
@ -1556,7 +1556,7 @@ const SyntheticUsers = () => {
|
|||
|
||||
const renderFolderOption = (folder: Folder, isChild = false) => (
|
||||
<div key={folder._id}>
|
||||
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-slate-200 pl-4' : ''}`}>
|
||||
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-border pl-4' : ''}`}>
|
||||
<Checkbox
|
||||
id={`folder-${folder._id}`}
|
||||
checked={targetFolders.has(folder._id)}
|
||||
|
|
@ -1564,7 +1564,7 @@ const SyntheticUsers = () => {
|
|||
/>
|
||||
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2 cursor-pointer">
|
||||
<Folder className={`h-4 w-4 ${isChild ? 'opacity-75' : ''}`} />
|
||||
<span className={isChild ? 'text-slate-600' : ''}>{folder.name}</span>
|
||||
<span className={isChild ? 'text-muted-foreground' : ''}>{folder.name}</span>
|
||||
</Label>
|
||||
</div>
|
||||
{/* Render children if any */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue