CUD-Traffic-AI / frontend /src /components /SummarizerWidget.jsx
Rajeev Ranjan Pandey
Refine UI alignment, fix dark mode toggle, and improve model selection logic
e58b2bd
import { useState, useMemo } from "react";
import { Copy, UploadCloud, FileText, Zap, Cpu, Sun, Layers, ArrowRight, Download, CheckCircle2 } from "lucide-react";
export const MODELS = [
{
id: "bart_large_cnn",
name: "BART",
badgeLabel: "Top Pick",
badgeColor: "text-orange-600 bg-orange-100 border-orange-200 dark:text-orange-400 dark:border-orange-500/30 dark:bg-orange-500/10",
speed: 2,
icon: Cpu,
description: "Meta's BART model fine-tuned on CNN/DailyMail. It produces highly abstractive, narrative-style summaries, making it the best overall for rewriting raw incident descriptions into fluent reports."
},
{
id: "flan_t5_small",
name: "Flan-T5",
badgeLabel: "Fast",
badgeColor: "text-blue-600 bg-blue-100 border-blue-200 dark:text-blue-400 dark:border-blue-500/30 dark:bg-blue-500/10",
speed: 3,
icon: Zap,
description: "Google's instruction-tuned T5 model. It's incredibly fast and lightweight to run locally on CPU, though its summaries can occasionally be more concise and rigid than BART."
},
{
id: "pegasus_cnn",
name: "PEGASUS",
badgeLabel: "Precise",
badgeColor: "text-emerald-600 bg-emerald-100 border-emerald-200 dark:text-emerald-400 dark:border-emerald-500/30 dark:bg-emerald-500/10",
speed: 1,
icon: Layers,
description: "Google's PEGASUS model designed specifically for summarization. It is highly precise but computationally heavy, resulting in slower generation times."
},
{
id: "textrank",
name: "TextRank",
badgeLabel: "Offline",
badgeColor: "text-purple-600 bg-purple-100 border-purple-200 dark:text-purple-400 dark:border-purple-500/30 dark:bg-purple-500/10",
speed: 3,
icon: Sun,
description: "A classic non-neural extractive model based on PageRank. It works entirely offline without a GPU by identifying and extracting the most important exact sentences from the text."
}
];
function downloadTextFile(filename, content) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function extractTags(text) {
if (!text) return [];
try {
const tags = [];
// Vehicles
const vehicleMatch = text.match(/\b(\d+|two|three|four|five|several|multiple)\s+(?:vehicles?|cars?|trucks?)\b/i);
if (vehicleMatch) tags.push(vehicleMatch[1].toLowerCase() + " vehicles");
// Duration (minutes)
const minMatch = text.match(/\b(\d+)\s*(?:min|minutes?)\b/i);
if (minMatch) tags.push(minMatch[1] + " min");
// Distance (miles/km)
const miMatch = text.match(/\b(\d+(?:\.\d+)?)\s*(?:miles?|mi|km)\b/i);
if (miMatch) tags.push(miMatch[1] + " mi backup");
// Road codes like D71, E11
const codeMatches = text.match(/\b[A-Z]\d{1,3}\b/g);
if (codeMatches) {
codeMatches.forEach(c => { if (!tags.includes(c)) tags.push(c); });
}
// Named roads (safe version)
const roadMatch = text.match(/\b(?:Sheikh\s+Zayed|Hessa|Al\s+Khail|E[0-9]+|Highway\s+\d+|I-\d+|Route\s+\d+)[^,.]*(?:Road|Rd|Street|St|Hwy)?\b/gi);
if (roadMatch) {
const shortRoad = roadMatch[0].trim().split(' ').slice(0, 3).join(' ');
if (!tags.some(t => t.toLowerCase() === shortRoad.toLowerCase())) tags.push(shortRoad);
}
return Array.from(new Set(tags)).slice(0, 4);
} catch (_) {
return [];
}
}
export default function SummarizerWidget({
text,
setText,
modelChoice,
setModelChoice,
onSummarize,
loading,
summary
}) {
const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
const summaryWordCount = summary ? summary.split(/\s+/).filter(Boolean).length : 0;
const [copied, setCopied] = useState(false);
const extractedTags = useMemo(() => {
return summary ? extractTags(summary + " " + text) : [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [summary]);
const handleCopy = () => {
if (!summary) return;
navigator.clipboard.writeText(summary);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="w-full h-full flex flex-col">
<div className="rounded-2xl border border-slate-300 dark:border-white/[0.07] bg-white dark:bg-[#0d1326] shadow-sm dark:shadow-2xl flex-1 flex flex-col">
{/* Input and Output Split Area */}
<div className="grid gap-0 lg:grid-cols-2 flex-1">
{/* LEFT PANE: INPUT */}
<div className="p-4 md:p-5 flex flex-col relative min-h-[320px]">
<div className="flex items-center justify-between border-b border-slate-100 dark:border-white/[0.05] pb-2.5 mb-3">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500">Original Incident</h3>
<span className="rounded-full bg-slate-100 dark:bg-white/[0.05] px-2.5 py-0.5 text-[10px] font-bold text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/[0.06]">
{wordCount} words
</span>
</div>
<textarea
className="w-full resize-none bg-transparent p-0 text-lg leading-[1.85] text-slate-800 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 focus:outline-none focus:ring-0 flex-1"
placeholder="Paste a traffic incident report here, or click a sample on the right..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<div className="mt-2.5 pt-2.5 border-t border-slate-100 dark:border-white/[0.05] border-dashed">
<button
className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
onClick={() => setText("")}
>
Clear Text
</button>
</div>
</div>
{/* RIGHT PANE: OUTPUT */}
<div className="p-4 md:p-5 flex flex-col relative border-t lg:border-t-0 lg:border-l border-slate-100 dark:border-white/[0.05] bg-slate-50/50 dark:bg-white/[0.01] rounded-b-2xl lg:rounded-bl-none lg:rounded-tr-2xl min-h-[320px]">
<div className="flex items-center justify-between border-b border-orange-300/40 dark:border-orange-400/20 pb-2.5 mb-3">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-orange-500 dark:text-orange-400">Generated Output · {modelChoice.replace(/_/g, ' ').toUpperCase()}</h3>
{summary ? (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-500/20 text-[9px] font-bold text-orange-400 border border-orange-500/30">
{summaryWordCount}
</span>
) : null}
</div>
<div className="flex flex-col custom-scroll">
{summary ? (
<>
<p className="text-lg leading-[1.85] text-slate-800 dark:text-slate-100 whitespace-pre-wrap">{summary.replace(/<n>/gi, '\n\n').replace(/[ \t]+/g, ' ').trim()}</p>
{extractedTags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{extractedTags.map((tag, idx) => (
<span key={idx} className="inline-flex items-center rounded-full border border-orange-500/20 bg-orange-500/5 px-2.5 py-0.5 text-[10px] font-bold text-orange-400 capitalize tracking-wide">
{tag}
</span>
))}
</div>
)}
</>
) : (
<div className="h-full flex-1 flex items-center justify-center text-sm font-medium text-slate-400 dark:text-slate-600 italic">
{loading ? "Generating summary..." : "No summary generated yet."}
</div>
)}
</div>
{summary && (
<div className="mt-5 flex flex-wrap gap-3 border-t border-white/5 pt-4">
<button
onClick={handleCopy}
className="flex-1 flex justify-center items-center gap-2 rounded-xl bg-slate-100 dark:bg-white/[0.08] hover:bg-slate-200 dark:hover:bg-white/[0.12] py-3 text-sm font-bold text-slate-700 dark:text-white transition border border-slate-200 dark:border-white/10"
>
{copied ? <CheckCircle2 size={16}/> : <Copy size={16} />} {copied ? "Copied" : "Copy Summary"}
</button>
<button
onClick={() => downloadTextFile(`summary_${modelChoice}.txt`, summary)}
className="flex-1 flex justify-center items-center gap-2 rounded-xl border border-slate-200 dark:border-white/10 bg-white dark:bg-transparent py-3 text-sm font-bold text-slate-600 dark:text-slate-300 transition hover:bg-slate-50 dark:hover:bg-white/[0.05]"
>
<Download size={16} /> Save
</button>
</div>
)}
</div>
</div>
{/* Separator */}
<div className="h-px w-full bg-slate-100 dark:bg-white/[0.05]"></div>
{/* Controls block */}
<div className="p-4 md:p-6 space-y-5">
{/* Model Selection */}
<div className="space-y-4">
<h3 className="text-xs font-bold text-slate-500 dark:text-slate-500 uppercase tracking-widest">Select Model</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{MODELS.map((model) => {
const Icon = model.icon;
const isSelected = modelChoice === model.id;
const cardCls = isSelected
? 'ring-2 ring-orange-500 bg-orange-50 dark:bg-orange-500/[0.08] border-orange-400 dark:border-orange-500/40'
: 'bg-slate-50 dark:bg-white/[0.03] hover:bg-slate-100 dark:hover:bg-white/[0.06] border-slate-200 dark:border-white/[0.08] hover:border-slate-300 dark:hover:border-white/15';
return (
<button
key={model.id}
onClick={() => !loading && setModelChoice(model.id)}
disabled={loading}
className={`relative flex flex-col items-start rounded-xl border p-4 text-left transition-all ${cardCls} ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isSelected && <div className="absolute top-4 right-4 h-2 w-2 rounded-full bg-orange-500 shadow-[0_0_8px_rgba(249,115,22,0.6)]" />}
<div className="flex w-full items-start justify-between mb-4">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-700 shadow-sm dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300">
<Icon size={18} />
</span>
<div className="group/tooltip relative">
<div className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-orange-50 dark:hover:bg-white/10 transition cursor-help">
<span className="font-serif italic border-2 border-slate-400 dark:border-slate-500 text-slate-500 dark:text-slate-400 hover:border-orange-500 hover:text-orange-600 dark:hover:text-white rounded-full w-4 h-4 flex items-center justify-center text-[10px] transition">
i
</span>
</div>
<div className="absolute right-0 lg:right-auto lg:left-0 top-8 z-50 w-64 opacity-0 scale-95 origin-top-right lg:origin-top-left transition-all group-hover/tooltip:opacity-100 group-hover/tooltip:scale-100 pointer-events-none group-hover/tooltip:pointer-events-auto rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 p-3 shadow-xl">
<p className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed font-normal">{model.description}</p>
</div>
</div>
</div>
<h4 className="text-base font-bold text-slate-900 dark:text-white mb-1.5">{model.name}</h4>
<span className={`inline-block rounded-full border px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider ${model.badgeColor}`}>
{model.badgeLabel}
</span>
<div className="mt-4 flex w-full items-center justify-between">
<span className="text-[10px] font-semibold text-slate-500 dark:text-slate-600">Speed</span>
<div className="flex gap-1">
{[1, 2, 3].map((i) => (
<div key={i} className={`h-1.5 w-5 rounded-full ${i <= model.speed ? 'bg-orange-500' : 'bg-white/10'}`} />
))}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* Button */}
<div className="flex gap-4">
<button
onClick={onSummarize}
disabled={loading}
className="group flex-1 flex items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-orange-500 to-orange-600 py-4 text-base font-bold text-white shadow-lg shadow-orange-500/25 transition hover:from-orange-400 hover:to-orange-500 focus:ring-4 focus:ring-orange-500/30 disabled:opacity-50"
>
{loading ? "Generating Output..." : "Summarize Now"}
{!loading && <ArrowRight size={18} className="text-white/80 group-hover:translate-x-0.5 transition-transform" />}
</button>
</div>
</div>
</div>
</div>
);
}