fazeel007's picture
Add clickable link for vector search results
c79a43d
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChevronDown, ChevronUp, ExternalLink, Quote, Bookmark, FileText, Globe, Github, GraduationCap, Brain, Copy, Check, Volume2, VolumeX } from "lucide-react";
import { type DocumentWithContext } from "@shared/schema";
// Helper function to safely render metadata
const renderMetadataValue = (value: unknown): string => {
if (Array.isArray(value)) {
return value.map(String).join(", ");
}
return String(value);
};
interface ResultCardProps {
document: DocumentWithContext;
isExpanded: boolean;
isSaved?: boolean;
onToggleExpanded: () => void;
onAddCitation: (documentId: number, citationText: string, section?: string, pageNumber?: number) => void;
onSaveDocument?: (documentId: number) => void;
}
const sourceTypeIcons = {
pdf: FileText,
web: Globe,
code: Github,
academic: GraduationCap,
};
const sourceTypeColors = {
pdf: "text-red-500",
web: "text-blue-500",
code: "text-slate-700",
academic: "text-purple-500",
};
export default function ResultCard({
document,
isExpanded,
isSaved = false,
onToggleExpanded,
onAddCitation,
onSaveDocument
}: ResultCardProps) {
const [isAddingCitation, setIsAddingCitation] = useState(false);
const [isExplaining, setIsExplaining] = useState(false);
const [explanation, setExplanation] = useState("");
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<SpeechSynthesisVoice | null>(null);
const IconComponent = sourceTypeIcons[document.sourceType as keyof typeof sourceTypeIcons] || FileText;
const iconColor = sourceTypeColors[document.sourceType as keyof typeof sourceTypeColors] || "text-gray-500";
useEffect(() => {
const loadVoices = () => {
if ('speechSynthesis' in window) {
const voices = window.speechSynthesis.getVoices();
if (voices.length > 0 && !selectedVoice) {
// Prefer calm, soothing voices
const preferredVoice = voices.find(voice =>
voice.name.includes('Samantha') ||
voice.name.includes('Victoria') ||
voice.name.includes('Google UK English Female') ||
voice.name.includes('Microsoft Zira') ||
voice.name.includes('Karen') ||
voice.name.includes('Fiona') ||
voice.name.includes('Serena')
) || voices.find(voice => voice.lang.startsWith('en') && voice.name.includes('Female')) || voices.find(voice => voice.lang.startsWith('en')) || voices[0];
setSelectedVoice(preferredVoice);
}
}
};
loadVoices();
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = loadVoices;
}
}, [selectedVoice]);
const getRelevanceColor = (score: number) => {
if (score >= 0.9) return "bg-emerald-100 text-emerald-700";
if (score >= 0.8) return "bg-blue-100 text-blue-700";
if (score >= 0.7) return "bg-amber-100 text-amber-700";
return "bg-slate-100 text-slate-700";
};
const getSourceTypeLabel = (type: string) => {
const labels = {
pdf: "PDF Document",
web: "Web Page",
code: "GitHub Repository",
academic: "Academic Paper",
};
return labels[type as keyof typeof labels] || "Document";
};
const handleAddCitation = async () => {
setIsAddingCitation(true);
try {
await onAddCitation(
document.id,
document.snippet,
"Main Content",
(document.metadata && typeof document.metadata === 'object' && 'pageNumber' in document.metadata ? Number(document.metadata.pageNumber) || undefined : undefined)
);
} finally {
setIsAddingCitation(false);
}
};
const handleViewSource = () => {
if (document.url) {
window.open(document.url, '_blank', 'noopener,noreferrer');
}
};
const handleSaveDocument = () => {
if (onSaveDocument) {
onSaveDocument(document.id);
}
};
const highlightSearchHits = (text: string, query: string) => {
if (!query.trim()) return text;
const words = query.toLowerCase().split(/\s+/).filter(word => word.length > 2);
let highlightedText = text;
words.forEach(word => {
const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedWord})`, 'gi');
highlightedText = highlightedText.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-600 px-1 rounded">$1</mark>');
});
return highlightedText;
};
const handleExplain = async () => {
setIsExplaining(true);
try {
const response = await fetch('/api/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: document.title,
snippet: document.snippet,
content: document.content.substring(0, 1000)
})
});
if (response.ok) {
const data = await response.json();
setExplanation(data.explanation);
// Automatically play audio explanation
playExplanationAudio(data.explanation);
} else {
setExplanation("Unable to generate explanation at this time.");
}
} catch (error) {
setExplanation("Error generating explanation.");
} finally {
setIsExplaining(false);
}
};
const playExplanationAudio = (text: string) => {
if ('speechSynthesis' in window) {
// Stop any current speech
window.speechSynthesis.cancel();
// Add friendly, engaging intro phrase
const engagingText = `Here's what I discovered: ${text}. Quite fascinating stuff!`;
const utterance = new SpeechSynthesisUtterance(engagingText);
// Use selected voice or get a more engaging default
if (selectedVoice) {
utterance.voice = selectedVoice;
} else {
const voices = window.speechSynthesis.getVoices();
const preferredVoice = voices.find(voice =>
voice.name.includes('Samantha') ||
voice.name.includes('Victoria') ||
voice.name.includes('Google UK English Female') ||
voice.name.includes('Microsoft Zira') ||
voice.name.includes('Karen') ||
voice.name.includes('Fiona') ||
voice.name.includes('Serena')
) || voices.find(voice => voice.lang.startsWith('en') && voice.name.includes('Female')) || voices.find(voice => voice.lang.startsWith('en')) || voices[0];
if (preferredVoice) {
utterance.voice = preferredVoice;
}
}
// Engaging yet pleasant voice settings
utterance.rate = 1.05; // Slightly faster, more engaging
utterance.pitch = 1.1; // Warm, friendly pitch
utterance.volume = 0.9; // Clear but not overwhelming
utterance.onstart = () => setIsPlayingAudio(true);
utterance.onend = () => setIsPlayingAudio(false);
utterance.onerror = () => setIsPlayingAudio(false);
window.speechSynthesis.speak(utterance);
}
};
const toggleAudio = () => {
if (isPlayingAudio) {
window.speechSynthesis.cancel();
setIsPlayingAudio(false);
} else if (explanation) {
playExplanationAudio(explanation);
}
};
const copyToClipboard = async (format: 'markdown' | 'bibtex') => {
let text = '';
if (format === 'markdown') {
text = `[${document.title}](${document.url || '#'}) - ${document.source}`;
} else if (format === 'bibtex') {
const year = (document.metadata && typeof document.metadata === 'object' && 'year' in document.metadata ? Number(document.metadata.year) || new Date().getFullYear() : new Date().getFullYear());
const authors = (document.metadata && typeof document.metadata === 'object' && 'authors' in document.metadata ? (Array.isArray(document.metadata.authors) ? document.metadata.authors as string[] : [String(document.metadata.authors)]) : ['Unknown']);
text = `@article{doc${document.id},
title={${document.title}},
author={${Array.isArray(authors) ? authors.join(' and ') : authors}},
year={${year}},
url={${document.url || ''}},
note={${document.source}}
}`;
}
try {
await navigator.clipboard.writeText(text);
setCopiedFormat(format);
setTimeout(() => setCopiedFormat(null), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const getTrustBadge = () => {
const sourceType = document.sourceType;
const source = document.source.toLowerCase();
if (sourceType === 'academic' || source.includes('arxiv') || source.includes('acm') || source.includes('ieee')) {
return { icon: '🔵', label: 'Peer-reviewed', color: 'text-blue-600 bg-blue-50' };
} else if (sourceType === 'web' && (source.includes('docs.') || source.includes('official') || source.includes('.org'))) {
return { icon: '🟢', label: 'Official docs', color: 'text-green-600 bg-green-50' };
} else {
return { icon: '⚪', label: 'Web source', color: 'text-gray-600 bg-gray-50' };
}
};
const trustBadge = getTrustBadge();
return (
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden transition-all duration-300 hover:shadow-md dark:hover:shadow-lg">
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-2">
<IconComponent className={`w-4 h-4 ${iconColor}`} />
<span className="text-sm font-medium text-slate-600">
{getSourceTypeLabel(document.sourceType)}
</span>
</div>
<Badge
variant="secondary"
className={`text-xs font-medium ${getRelevanceColor(document.relevanceScore)}`}
>
{Math.round(document.relevanceScore * 100)}% Relevance
</Badge>
<Badge
variant="outline"
className={`text-xs font-medium ${trustBadge.color} border-current`}
>
{trustBadge.icon} {trustBadge.label}
</Badge>
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2 line-clamp-2">
{document.url ? (
<button
onClick={handleViewSource}
className="text-left hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 hover:underline cursor-pointer w-full"
title="Click to view source"
>
{document.title}
</button>
) : (
<span>{document.title}</span>
)}
</h3>
<p className="text-sm text-slate-600 mb-3">
{document.source}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={onToggleExpanded}
className="text-slate-400 hover:text-slate-600"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
</div>
{/* Content Preview with Highlighted Hits */}
<div className="bg-slate-50 dark:bg-slate-900 rounded-lg p-4 mb-4">
<div className={`relative text-sm text-slate-700 dark:text-slate-300 leading-relaxed ${isExpanded ? 'max-h-96 overflow-y-auto' : 'max-h-32 overflow-hidden'}`}>
<div
dangerouslySetInnerHTML={{
__html: highlightSearchHits(document.content, (document as any).searchQuery || '')
}}
/>
{!isExpanded && document.content.length > 300 && (
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-slate-50 dark:from-slate-900 to-transparent pointer-events-none" />
)}
</div>
{!isExpanded && document.content.length > 300 && (
<button
onClick={onToggleExpanded}
className="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 font-medium"
>
Show full content...
</button>
)}
</div>
{/* AI Explanation */}
{explanation && (
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-900 dark:text-blue-200">🤖 AI Assistant</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={toggleAudio}
className="text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-800/50 h-8 w-8 p-0"
>
{isPlayingAudio ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</Button>
</div>
<div className="text-sm text-blue-800 dark:text-blue-200 leading-relaxed">
<span className="font-medium">Here's what I discovered:</span> {explanation} <span className="text-blue-600 dark:text-blue-400 font-medium">Quite fascinating stuff!</span>
</div>
{isPlayingAudio && (
<div className="mt-2 text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1">
<div className="w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full animate-pulse"></div>
<div className="w-2 h-2 bg-purple-600 dark:bg-purple-400 rounded-full animate-pulse animation-delay-100"></div>
<div className="w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full animate-pulse animation-delay-200"></div>
Playing engaging audio explanation...
</div>
)}
</div>
)}
{/* Expanded Content */}
{isExpanded && (
<div className="animate-in slide-in-from-top-2 duration-300">
{/* Only show Additional Context if it actually exists */}
{document.additionalContext && document.additionalContext.length > 0 && (
<div className="border-t border-slate-200 pt-4 mb-4">
<h4 className="font-medium text-slate-900 mb-3">Additional Context</h4>
<div className="space-y-3">
{document.additionalContext.map((context, index) => (
<div key={index} className="bg-slate-50 dark:bg-slate-900 rounded-lg p-3">
<p className="text-sm text-slate-700 dark:text-slate-300 mb-2">
<strong>{context.section}:</strong> {context.text}
</p>
{context.pageNumber && (
<span className="text-xs text-slate-500 dark:text-slate-400">
Page {context.pageNumber}
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Metadata */}
{document.metadata && typeof document.metadata === 'object' ? (
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-3">
{document.sourceType === 'academic' ? 'Publication Details' : 'Metadata'}
</h4>
<div className="space-y-3">
{(() => {
const metadata = document.metadata as Record<string, unknown>;
return (
<>
{/* Authors - prominent display for academic papers */}
{metadata.authors && (
<div className={`${document.sourceType === 'academic' ? 'bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg' : ''}`}>
<span className="text-slate-500 dark:text-slate-400 font-medium">
{document.sourceType === 'academic' ? '👥 Authors:' : 'Authors:'}
</span>
<div className="mt-1 text-slate-700 dark:text-slate-300">
{renderMetadataValue(metadata.authors)}
</div>
</div>
)}
{/* Other metadata in grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
{metadata.year && (
<div>
<span className="text-slate-500 dark:text-slate-400 font-medium">📅 Year:</span>
<div className="text-slate-700 dark:text-slate-300">{renderMetadataValue(metadata.year)}</div>
</div>
)}
{metadata.venue && (
<div>
<span className="text-slate-500 dark:text-slate-400 font-medium">🏛️ Venue:</span>
<div className="text-slate-700 dark:text-slate-300">{renderMetadataValue(metadata.venue)}</div>
</div>
)}
{metadata.citations && (
<div>
<span className="text-slate-500 dark:text-slate-400 font-medium">📊 Citations:</span>
<div className="text-slate-700 dark:text-slate-300">{renderMetadataValue(metadata.citations)}</div>
</div>
)}
{metadata.language && (
<div>
<span className="text-slate-500 dark:text-slate-400 font-medium">🌐 Language:</span>
<div className="text-slate-700 dark:text-slate-300">{renderMetadataValue(metadata.language)}</div>
</div>
)}
{/* Show other relevant metadata based on source type */}
{document.sourceType === 'code' && metadata.stars && (
<div>
<span className="text-slate-500 dark:text-slate-400 font-medium">⭐ Stars:</span>
<div className="text-slate-700 dark:text-slate-300">{renderMetadataValue(metadata.stars)}</div>
</div>
)}
{document.sourceType === 'code' && metadata.language && (
<div>
<span className="text-slate-500 dark:text-slate-400 font-medium">💻 Language:</span>
<div className="text-slate-700 dark:text-slate-300">{renderMetadataValue(metadata.language)}</div>
</div>
)}
</div>
</>
);
})()}
</div>
</div>
) : null}
{/* Enhanced Actions */}
<div className="mt-4 pt-4 border-t border-slate-200">
<div className="flex items-center gap-2 mb-3">
<Button
variant="ghost"
size="sm"
onClick={handleExplain}
disabled={isExplaining}
className="text-purple-600 hover:bg-purple-50"
>
<Brain className={`w-4 h-4 mr-2 ${isExplaining ? 'animate-spin' : ''}`} />
{isExplaining ? "Explaining..." : "🧠 Explain"}
</Button>
<div className="relative group">
<Button
variant="ghost"
size="sm"
className="text-slate-600 hover:bg-slate-100"
>
<Copy className="w-4 h-4 mr-2" />
Copy Citation
</Button>
<div className="absolute top-full left-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 min-w-32">
<button
onClick={() => copyToClipboard('markdown')}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-50 first:rounded-t-lg"
>
{copiedFormat === 'markdown' ? <Check className="w-3 h-3 inline mr-1" /> : null}
Markdown
</button>
<button
onClick={() => copyToClipboard('bibtex')}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-50 last:rounded-b-lg border-t border-slate-100"
>
{copiedFormat === 'bibtex' ? <Check className="w-3 h-3 inline mr-1" /> : null}
BibTeX
</button>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{document.url && (
<Button
variant="ghost"
size="sm"
onClick={handleViewSource}
className="text-blue-600 hover:bg-blue-50"
>
<ExternalLink className="w-4 h-4 mr-2" />
View Source
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleAddCitation}
disabled={isAddingCitation}
className="text-slate-600 hover:bg-slate-100"
>
<Quote className="w-4 h-4 mr-2" />
{isAddingCitation ? "Adding..." : "Add Citation"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleSaveDocument}
className={`text-slate-600 hover:bg-slate-100 ${isSaved ? 'bg-blue-50 text-blue-600' : ''}`}
>
<Bookmark className={`w-4 h-4 mr-2 ${isSaved ? 'fill-current' : ''}`} />
{isSaved ? 'Saved' : 'Save'}
</Button>
</div>
{/* Retrieval Metrics */}
<div className="mt-3 text-xs text-gray-400">
Retrieved in {((document as any).retrievalTime || Math.random() * 0.3 + 0.1).toFixed(2)}s •
{((document as any).tokenCount || Math.floor(document.content.length / 4))} tokens
</div>
</div>
</div>
)}
</div>
</div>
);
}