nanochat/services/frontend/components/chat/ChatInput.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

86 lines
2.8 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
import { Send, Square } from 'lucide-react';
import clsx from 'clsx';
interface Props {
value: string;
onChange: (v: string) => void;
onSubmit: () => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
}
export default function ChatInput({ value, onChange, onSubmit, onStop, isStreaming, disabled }: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}, [value]);
useEffect(() => {
ref.current?.focus();
}, []);
const canSend = value.trim().length > 0 && !disabled && !isStreaming;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (canSend) onSubmit();
}
};
return (
<div className="sticky bottom-0 bg-white pt-3 pb-[calc(1rem+env(safe-area-inset-bottom))] px-4">
<div className="max-w-3xl mx-auto flex items-end gap-3">
<div className="flex-1 relative">
<textarea
ref={ref}
rows={1}
placeholder="What's on your mind?"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
className="w-full resize-none px-4 py-3 pr-12 rounded-2xl border border-warm-grey bg-white text-gray-900 placeholder-[#b8a88a] focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 min-h-[54px] max-h-[200px] leading-relaxed text-[0.95rem]"
/>
</div>
{isStreaming && onStop ? (
<button
type="button"
onClick={onStop}
className="w-12 h-12 flex-shrink-0 rounded-full bg-chutney-red text-white flex items-center justify-center hover:brightness-110 transition"
aria-label="Stop generating"
>
<Square size={18} fill="currentColor" />
</button>
) : (
<button
type="button"
onClick={onSubmit}
disabled={!canSend}
className={clsx(
'w-12 h-12 flex-shrink-0 rounded-full flex items-center justify-center transition',
canSend
? 'bg-gold hover:bg-gold-dark text-white'
: 'bg-gold/30 text-white cursor-not-allowed',
)}
aria-label="Send message"
>
<Send size={18} />
</button>
)}
</div>
<p className="max-w-3xl mx-auto mt-2 text-[11px] text-gray-400 text-center">
Tip: try <code className="text-brown">/temperature 0.7</code>, <code className="text-brown">/topk 40</code>, or <code className="text-brown">/clear</code>.
</p>
</div>
);
}