Initial commit: Portal Brief Inteligente CDC

This commit is contained in:
Isaac Aracena
2026-05-06 17:55:54 -04:00
commit f1284fc405
20 changed files with 6868 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
import React from 'react';
import { motion } from 'motion/react';
import { format } from 'date-fns';
import { AlertCircle, CheckCheck, FileText, Music, ShieldCheck } from 'lucide-react';
import { Message, Attachment } from '../types';
import { cn } from '../lib/utils';
import ReactMarkdown from 'react-markdown';
interface MessageBubbleProps {
message: Message;
}
const AttachmentPreview = ({ attachment }: { attachment: Attachment }) => {
if (attachment.type === 'image') {
return (
<div className="group relative max-w-[320px] overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<img
src={attachment.url}
alt={attachment.file.name}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2 text-[11px] font-medium text-white/90">
<div className="truncate">{attachment.file.name}</div>
</div>
</div>
);
}
const Icon = attachment.type === 'audio' ? Music : FileText;
return (
<div className="flex w-full max-w-[360px] items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-3 py-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-black/25">
<Icon className="h-4 w-4 text-indigo-200" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white/90">{attachment.file.name}</p>
<p className="text-[11px] text-zinc-400">{(attachment.file.size / 1024).toFixed(1)} KB</p>
</div>
</div>
);
};
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
const isUser = message.sender === 'user';
const [isPlaying, setIsPlaying] = React.useState(false);
const audioRef = React.useRef<HTMLAudioElement | null>(null);
const togglePlay = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
React.useEffect(() => {
if (message.audio) {
audioRef.current = new Audio(message.audio.url);
audioRef.current.onended = () => setIsPlaying(false);
}
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, [message.audio]);
return (
<motion.div
initial={{ opacity: 0, y: 14, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.32, ease: [0.23, 1, 0.32, 1] }}
className={cn('mb-5 flex w-full', isUser ? 'justify-end' : 'justify-start')}
>
<div
className={cn(
'flex flex-col gap-1',
isUser ? 'max-w-3xl items-end' : 'w-full max-w-none items-start'
)}
>
{!isUser && (
<div className="mb-1 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-zinc-400">
<ShieldCheck className="h-3.5 w-3.5 text-indigo-300" />
Respuesta del portal
</div>
)}
<div
className={cn(
'relative overflow-hidden border shadow-[0_16px_40px_rgba(0,0,0,0.25)]',
isUser
? 'max-w-[85%] rounded-[26px] rounded-br-md border-indigo-400/20 bg-[linear-gradient(135deg,rgba(79,70,229,0.96),rgba(37,99,235,0.92))] px-4 py-4 text-white sm:px-5'
: 'w-full max-w-none rounded-[28px] rounded-bl-md border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0.03))] px-5 py-5 text-zinc-100 backdrop-blur-xl sm:px-6 sm:py-6'
)}
>
{!isUser && (
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(129,140,248,0.14),transparent_26%)]" />
)}
<div className="relative">
{message.attachments && message.attachments.length > 0 && (
<div className="mb-4 flex flex-wrap gap-3">
{message.attachments.map((att) => (
<AttachmentPreview key={att.id} attachment={att} />
))}
</div>
)}
{message.audio && (
<div className="mb-4 flex items-center gap-3 rounded-2xl bg-black/20 p-3 pr-4">
<button
onClick={togglePlay}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15 transition hover:bg-white/25"
>
{isPlaying ? (
<div className="flex h-3 w-3 gap-1">
<div className="h-full w-1 rounded-sm bg-white" />
<div className="h-full w-1 rounded-sm bg-white" />
</div>
) : (
<div className="ml-0.5 h-0 w-0 border-b-[6px] border-l-[9px] border-t-[6px] border-b-transparent border-l-white border-t-transparent" />
)}
</button>
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/15">
<div
className={cn(
'h-full w-1/3 rounded-full bg-white/65',
isPlaying && 'animate-pulse'
)}
/>
</div>
<span className="text-xs font-mono opacity-80">
0:{message.audio.duration.toString().padStart(2, '0')}
</span>
</div>
)}
{message.text && (
<div
className={cn(
'break-words text-[15px] leading-7 sm:text-base',
isUser ? 'whitespace-pre-wrap' : ''
)}
>
{isUser ? (
message.text
) : (
<div className="prose prose-invert max-w-none prose-p:my-3 prose-p:leading-8 prose-headings:mb-3 prose-headings:mt-1 prose-headings:text-white prose-strong:text-white prose-li:my-1 prose-ul:my-3 prose-ol:my-3 prose-pre:overflow-x-auto prose-pre:rounded-2xl prose-pre:border prose-pre:border-white/10 prose-pre:bg-black/35 prose-pre:p-4 prose-pre:text-[13px] prose-code:text-indigo-200">
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
)}
</div>
)}
</div>
</div>
<div className="mt-1 flex items-center gap-1.5 px-1">
<span className="text-[11px] font-medium text-zinc-500">
{format(message.timestamp, 'HH:mm')}
</span>
{isUser && (
<span className="text-zinc-500">
{message.status === 'sending' && (
<AlertCircle className="h-3.5 w-3.5 animate-pulse text-zinc-400" />
)}
{message.status === 'sent' && (
<CheckCheck className="h-3.5 w-3.5 text-indigo-400" />
)}
{message.status === 'error' && (
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
)}
</span>
)}
</div>
</div>
</motion.div>
);
};