nanochat/services/frontend/components/chat/MessageBubble.tsx
Manmohan Sharma 634be4080b
feat(frontend): Next.js 14 frontend service for samosaChaat (#2)
Build services/frontend/ replacing the legacy nanochat/ui.html single-file UI.
Landing, login, and chat pages ported with full design system: Devanagari +
Great Vibes hero, samosa/chai/toran SVG animations, gold/cream palette.

- App Router pages: / (hero + floating illustrations), /login (split-screen
  OAuth with mandala motif), /chat (260px collapsible sidebar, suggestion
  chips, markdown + code-copy, auto-expanding input, slash commands)
- SSE streaming via useSSE hook and /api/chat/stream BFF route (proxies to
  CHAT_API_URL when set, falls back to mock echo for local dev)
- NextAuth.js v5 with Google + GitHub providers; middleware gates /chat/*
- Zustand store with localStorage persistence for conversations/settings
- Tailwind theme carries all ui.html tokens + keyframes (pendulum, float,
  wobble, steamFloat, steamType); SVG assets componentized under components/svg
- Multi-stage node:20-alpine Dockerfile with Next standalone output

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 11:26:57 -07:00

108 lines
3.0 KiB
TypeScript

'use client';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
import { Check, Copy } from 'lucide-react';
import clsx from 'clsx';
import type { Message } from '@/types/chat';
import SteamTyping from '@/components/svg/SteamTyping';
interface Props {
message: Message;
isStreaming?: boolean;
}
function CodeBlock({ inline, className, children, ...props }: {
inline?: boolean;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLElement>) {
const [copied, setCopied] = useState(false);
const content = String(children ?? '').replace(/\n$/, '');
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
const copy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
return (
<div className="relative group">
<button
type="button"
onClick={copy}
aria-label="Copy code"
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700/70 text-slate-100 opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-opacity"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
<pre>
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}
export default function MessageBubble({ message, isStreaming }: Props) {
const isUser = message.role === 'user';
const isConsole = message.role === 'console';
if (isConsole) {
return (
<div className="flex justify-start mb-2 animate-fade-in">
<div className="font-mono text-sm bg-cream-light border border-cream-border text-brown-light px-4 py-3 rounded-xl max-w-[80%]">
{message.content}
</div>
</div>
);
}
return (
<div className={clsx('flex mb-3 animate-fade-in', isUser ? 'justify-end' : 'justify-start')}>
<div
className={clsx(
'max-w-[85%] md:max-w-[75%]',
isUser
? 'bg-cream border border-cream-border rounded-[1.25rem] px-4 py-3'
: 'bg-white px-2 py-1',
)}
>
{!isUser && isStreaming && message.content.length === 0 ? (
<SteamTyping />
) : isUser ? (
<div className="whitespace-pre-wrap leading-relaxed text-[0.95rem] text-gray-900">
{message.content}
</div>
) : (
<div className="markdown-body text-[0.95rem] text-gray-900 leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{ code: CodeBlock as never }}
>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
);
}