mirror of
https://github.com/karpathy/nanochat.git
synced 2026-05-09 17:30:14 +00:00
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>
149 lines
5.5 KiB
TypeScript
149 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { useSession, signOut } from 'next-auth/react';
|
|
import { Plus, PanelLeftClose, PanelLeftOpen, LogOut, ChevronDown } from 'lucide-react';
|
|
import SamosaLogo from '@/components/svg/SamosaLogo';
|
|
import { useChatStore, groupConversations, MODEL_OPTIONS } from '@/store/chatStore';
|
|
import clsx from 'clsx';
|
|
|
|
export default function Sidebar() {
|
|
const { data: session } = useSession();
|
|
const {
|
|
conversations,
|
|
currentConversationId,
|
|
sidebarOpen,
|
|
model,
|
|
setModel,
|
|
toggleSidebar,
|
|
newConversation,
|
|
selectConversation,
|
|
hydrateMockConversations,
|
|
} = useChatStore();
|
|
|
|
useEffect(() => {
|
|
hydrateMockConversations();
|
|
}, [hydrateMockConversations]);
|
|
|
|
const grouped = groupConversations(conversations);
|
|
|
|
return (
|
|
<aside
|
|
className={clsx(
|
|
'flex flex-col bg-cream-light border-r border-cream-border transition-all duration-300 ease-in-out overflow-hidden',
|
|
sidebarOpen ? 'w-[260px]' : 'w-0 md:w-[56px]',
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between px-3 py-3 border-b border-cream-border">
|
|
<Link href="/" className={clsx('flex items-center gap-2 overflow-hidden', !sidebarOpen && 'md:hidden')}>
|
|
<SamosaLogo size={28} />
|
|
<span className="font-baloo font-bold text-base text-gray-900 whitespace-nowrap">samosaChaat</span>
|
|
</Link>
|
|
<button
|
|
aria-label="Toggle sidebar"
|
|
onClick={toggleSidebar}
|
|
className="p-1.5 rounded hover:bg-cream text-brown-light"
|
|
>
|
|
{sidebarOpen ? <PanelLeftClose size={18} /> : <PanelLeftOpen size={18} />}
|
|
</button>
|
|
</div>
|
|
|
|
{sidebarOpen && (
|
|
<>
|
|
<div className="px-3 py-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => newConversation()}
|
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-gold/60 bg-white hover:bg-cream text-brown font-medium text-sm transition-colors"
|
|
>
|
|
<Plus size={16} className="text-gold" />
|
|
New chat
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-2 nice-scrollbar">
|
|
{Object.entries(grouped).map(([group, items]) => {
|
|
if (items.length === 0) return null;
|
|
return (
|
|
<div key={group} className="mb-4">
|
|
<div className="px-2 mb-1 text-[11px] uppercase tracking-wider text-gray-400 font-medium">
|
|
{group}
|
|
</div>
|
|
<ul className="space-y-0.5">
|
|
{items.map((c) => (
|
|
<li key={c.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectConversation(c.id)}
|
|
className={clsx(
|
|
'w-full text-left px-2.5 py-1.5 rounded text-sm truncate transition-colors',
|
|
c.id === currentConversationId
|
|
? 'bg-cream text-brown font-medium'
|
|
: 'text-gray-700 hover:bg-cream/70',
|
|
)}
|
|
title={c.title}
|
|
>
|
|
{c.title}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="px-3 py-3 border-t border-cream-border space-y-3">
|
|
<div>
|
|
<label htmlFor="model-select" className="block text-[11px] uppercase tracking-wider text-gray-400 mb-1">
|
|
Model
|
|
</label>
|
|
<div className="relative">
|
|
<select
|
|
id="model-select"
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
className="w-full appearance-none px-3 py-2 pr-8 rounded-lg border border-cream-border bg-white text-sm text-gray-800 focus:outline-none focus:border-gold"
|
|
>
|
|
{MODEL_OPTIONS.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown
|
|
size={14}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<div className="h-8 w-8 rounded-full bg-gold/20 text-brown flex items-center justify-center text-sm font-semibold">
|
|
{(session?.user?.name ?? 'G')[0].toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-gray-800 truncate">
|
|
{session?.user?.name ?? 'Guest'}
|
|
</div>
|
|
<div className="text-xs text-gray-500 truncate">
|
|
{session?.user?.email ?? 'Not signed in'}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Sign out"
|
|
onClick={() => signOut({ callbackUrl: '/' })}
|
|
className="p-1.5 rounded hover:bg-cream text-gray-500 hover:text-brown"
|
|
>
|
|
<LogOut size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|