257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
|
|
import React from 'react';
|
|
import { Lightbulb, X, PencilIcon, Brain, MessageCircle } from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { HighlightedTheme, GeneratedTheme, Message, Theme, QuoteData } from '@/components/focus-group-session/types';
|
|
import { Persona } from '@/types/persona';
|
|
import { toast } from 'sonner';
|
|
|
|
interface ThemeHighlighterProps {
|
|
themes: Theme[];
|
|
messages: Message[];
|
|
personas?: Persona[];
|
|
onThemeDelete?: (themeId: string) => void;
|
|
focusGroupId?: string;
|
|
onQuoteClick?: (quote: string | QuoteData, messageId?: string) => void;
|
|
}
|
|
|
|
const ThemeHighlighter = ({
|
|
themes,
|
|
messages,
|
|
personas = [],
|
|
onThemeDelete,
|
|
onQuoteClick
|
|
}: ThemeHighlighterProps) => {
|
|
|
|
const handleThemeDelete = (e: React.MouseEvent, themeId: string) => {
|
|
e.stopPropagation();
|
|
if (onThemeDelete) {
|
|
onThemeDelete(themeId);
|
|
toast.success("Theme deleted successfully");
|
|
}
|
|
};
|
|
|
|
// Helper function to get persona name by ID
|
|
const getPersona = (id: string) => {
|
|
return personas.find(p => p.id === id || p._id === id);
|
|
};
|
|
|
|
// Helper function to parse attributed quotes
|
|
const parseAttributedQuote = (quote: string) => {
|
|
// First, check if quote has message ID format and strip it
|
|
let cleanQuote = quote;
|
|
const msgIdMatch = quote.match(/^\[MSG_ID:[^\]]+\]\s*(.*)$/);
|
|
if (msgIdMatch) {
|
|
cleanQuote = msgIdMatch[1];
|
|
}
|
|
|
|
// Check if quote has attribution format [Name]: text
|
|
const attributionMatch = cleanQuote.match(/^\[([^\]]+)\]:\s*(.*)$/);
|
|
if (attributionMatch) {
|
|
return {
|
|
persona: attributionMatch[1],
|
|
text: attributionMatch[2]
|
|
};
|
|
}
|
|
|
|
// Check if quote has simple attribution format Name: text (without brackets)
|
|
const simpleAttributionMatch = cleanQuote.match(/^([^:]+):\s*(.*)$/);
|
|
if (simpleAttributionMatch && simpleAttributionMatch[1].trim() !== cleanQuote.trim()) {
|
|
return {
|
|
persona: simpleAttributionMatch[1].trim(),
|
|
text: simpleAttributionMatch[2]
|
|
};
|
|
}
|
|
|
|
// Fallback for quotes without attribution
|
|
return {
|
|
persona: null,
|
|
text: cleanQuote
|
|
};
|
|
};
|
|
|
|
|
|
// Split themes into highlighted and generated
|
|
const highlightedThemes = themes.filter(theme =>
|
|
'source' in theme ? theme.source === 'highlight' : true // For backward compatibility
|
|
) as HighlightedTheme[];
|
|
|
|
const generatedThemes = themes.filter(theme =>
|
|
'source' in theme && theme.source === 'generated'
|
|
) as GeneratedTheme[];
|
|
|
|
return (
|
|
<div className="glass-panel rounded-xl p-6 h-[70vh] flex flex-col overflow-hidden">
|
|
<div className="flex items-center mb-4">
|
|
<Lightbulb className="h-5 w-5 text-primary mr-2" />
|
|
<h2 className="font-sf text-xl font-semibold">Key Themes</h2>
|
|
</div>
|
|
|
|
<div className="overflow-auto">
|
|
{generatedThemes.length > 0 && (
|
|
<div className="mb-8">
|
|
<div className="flex items-center mb-3">
|
|
<Brain className="h-4 w-4 text-primary mr-2" />
|
|
<h3 className="font-medium">AI-Generated Themes</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 mb-4">
|
|
{generatedThemes.map((theme) => (
|
|
<Card
|
|
key={theme.id}
|
|
className="hover:shadow-md transition-shadow relative group"
|
|
>
|
|
{onThemeDelete && (
|
|
<button
|
|
className="absolute top-2 right-2 p-1 rounded-full bg-slate-200 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={(e) => handleThemeDelete(e, theme.id)}
|
|
>
|
|
<X className="h-3 w-3 text-slate-700" />
|
|
</button>
|
|
)}
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base">{theme.title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-slate-600 mb-2">{theme.description}</p>
|
|
{theme.quotes && theme.quotes.length > 0 && (
|
|
<div className="mt-3">
|
|
<h4 className="text-xs font-medium text-slate-700 mb-2">Supporting Quotes:</h4>
|
|
<div className="space-y-2">
|
|
{theme.quotes.map((quote, index) => {
|
|
// Handle both string and QuoteData formats
|
|
const isQuoteData = typeof quote === 'object' && quote !== null;
|
|
const quoteText = isQuoteData ? quote.text : quote;
|
|
const speaker = isQuoteData ? quote.speaker : parseAttributedQuote(quote).persona;
|
|
const messageId = isQuoteData ? quote.message_id : undefined;
|
|
const originalQuote = isQuoteData ? quote.original : quote;
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="bg-slate-50 p-2 rounded text-xs text-slate-600 border-l-2 border-slate-200 cursor-pointer hover:bg-slate-100 transition-colors"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (onQuoteClick) {
|
|
onQuoteClick(isQuoteData ? quote as QuoteData : originalQuote, messageId);
|
|
}
|
|
}}
|
|
title={messageId ? `Message ID: ${messageId}` : 'Click to find original message'}
|
|
>
|
|
{speaker && (
|
|
<span className="font-semibold text-slate-700 mr-1">
|
|
{speaker}:
|
|
</span>
|
|
)}
|
|
"{quoteText}"
|
|
{messageId && (
|
|
<span className="ml-2 text-xs text-green-600 opacity-70">
|
|
✓
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{highlightedThemes.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center mb-3">
|
|
<PencilIcon className="h-4 w-4 text-primary mr-2" />
|
|
<h3 className="font-medium">Highlighted Comments</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 mb-4">
|
|
{highlightedThemes.map((theme) => {
|
|
// Find the associated message to get full text and speaker info
|
|
const associatedMessage = theme.messages.length > 0
|
|
? messages.find(msg => msg.id === theme.messages[0])
|
|
: null;
|
|
|
|
// Get full text (up to 200 characters) instead of truncated version
|
|
const fullText = associatedMessage?.text || theme.text;
|
|
const displayText = fullText.length > 200
|
|
? fullText.substring(0, 200) + '...'
|
|
: fullText;
|
|
|
|
// Get speaker information
|
|
const senderId = associatedMessage?.senderId;
|
|
let speakerName = '';
|
|
if (senderId === 'moderator') {
|
|
speakerName = 'AI Moderator';
|
|
} else if (senderId === 'facilitator') {
|
|
speakerName = 'Human Facilitator';
|
|
} else if (senderId) {
|
|
// Get the actual participant name from personas
|
|
const persona = getPersona(senderId);
|
|
speakerName = persona?.name || 'Unknown Participant';
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
key={theme.id}
|
|
className="hover:shadow-md hover:bg-slate-50 transition-all cursor-pointer relative group"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (onQuoteClick && associatedMessage) {
|
|
onQuoteClick(associatedMessage.text, associatedMessage.id);
|
|
}
|
|
}}
|
|
title="Click to view in discussion"
|
|
>
|
|
{onThemeDelete && (
|
|
<button
|
|
className="absolute top-2 right-2 p-1 rounded-full bg-slate-200 opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
|
onClick={(e) => handleThemeDelete(e, theme.id)}
|
|
>
|
|
<X className="h-3 w-3 text-slate-700" />
|
|
</button>
|
|
)}
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-slate-800 line-clamp-2">
|
|
{speakerName && (
|
|
<span className="text-primary font-semibold">
|
|
{speakerName}
|
|
</span>
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<p className="text-sm text-slate-600 leading-relaxed">
|
|
"{displayText}"
|
|
</p>
|
|
<div className="mt-2 flex items-center text-xs text-slate-400">
|
|
<MessageCircle className="h-3 w-3 mr-1" />
|
|
Click to view in discussion
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{themes.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center p-8 text-center bg-slate-50 rounded-lg">
|
|
<Lightbulb className="h-8 w-8 text-slate-400 mb-3" />
|
|
<p className="text-slate-600">No themes have been identified yet.</p>
|
|
<p className="text-sm text-slate-500 mt-2">
|
|
Highlight important messages in the discussion or generate themes automatically.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ThemeHighlighter;
|