From 634be4080bf47324c9a2da9f19f80052c2faf5b6 Mon Sep 17 00:00:00 2001 From: Manmohan Sharma Date: Thu, 16 Apr 2026 11:19:11 -0700 Subject: [PATCH] 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) --- services/frontend/.dockerignore | 11 + services/frontend/.env.example | 16 + services/frontend/.gitignore | 13 + services/frontend/Dockerfile | 33 +- services/frontend/README.md | 70 + .../app/api/auth/[...nextauth]/route.ts | 3 + .../frontend/app/api/chat/stream/route.ts | 95 + .../frontend/app/api/conversations/route.ts | 33 + services/frontend/app/api/health/route.ts | 10 + services/frontend/app/chat/page.tsx | 13 + services/frontend/app/globals.css | 50 + services/frontend/app/layout.tsx | 54 + services/frontend/app/login/page.tsx | 78 + services/frontend/app/page.tsx | 19 + services/frontend/auth.ts | 36 + .../frontend/components/LandingFooter.tsx | 20 + services/frontend/components/LandingNav.tsx | 47 + .../frontend/components/SessionBoundary.tsx | 8 + .../frontend/components/chat/ChatInput.tsx | 85 + .../frontend/components/chat/ChatWindow.tsx | 187 + .../frontend/components/chat/EmptyState.tsx | 39 + .../components/chat/MessageBubble.tsx | 107 + services/frontend/components/chat/Sidebar.tsx | 148 + services/frontend/components/landing/Hero.tsx | 58 + .../components/landing/Illustrations.tsx | 30 + .../frontend/components/login/MandalaArt.tsx | 70 + .../components/login/OAuthButtons.tsx | 66 + services/frontend/components/svg/Doodles.tsx | 33 + .../frontend/components/svg/KettleSteam.tsx | 15 + .../frontend/components/svg/KettleSvg.tsx | 35 + .../frontend/components/svg/SamosaLogo.tsx | 18 + .../frontend/components/svg/SamosaSvg.tsx | 34 + .../frontend/components/svg/SteamTyping.tsx | 10 + services/frontend/components/svg/ToranSvg.tsx | 20 + services/frontend/hooks/useSSE.ts | 93 + services/frontend/lib/slashCommands.ts | 49 + services/frontend/middleware.ts | 19 + services/frontend/next-env.d.ts | 5 + services/frontend/next.config.mjs | 16 + services/frontend/package-lock.json | 7960 +++++++++++++++++ services/frontend/package.json | 37 + services/frontend/postcss.config.mjs | 6 + services/frontend/public/logo.svg | 20 + services/frontend/store/chatStore.ts | 191 + services/frontend/tailwind.config.ts | 74 + services/frontend/tsconfig.json | 23 + services/frontend/types/chat.ts | 24 + 47 files changed, 10078 insertions(+), 3 deletions(-) create mode 100644 services/frontend/.dockerignore create mode 100644 services/frontend/.env.example create mode 100644 services/frontend/.gitignore create mode 100644 services/frontend/README.md create mode 100644 services/frontend/app/api/auth/[...nextauth]/route.ts create mode 100644 services/frontend/app/api/chat/stream/route.ts create mode 100644 services/frontend/app/api/conversations/route.ts create mode 100644 services/frontend/app/api/health/route.ts create mode 100644 services/frontend/app/chat/page.tsx create mode 100644 services/frontend/app/globals.css create mode 100644 services/frontend/app/layout.tsx create mode 100644 services/frontend/app/login/page.tsx create mode 100644 services/frontend/app/page.tsx create mode 100644 services/frontend/auth.ts create mode 100644 services/frontend/components/LandingFooter.tsx create mode 100644 services/frontend/components/LandingNav.tsx create mode 100644 services/frontend/components/SessionBoundary.tsx create mode 100644 services/frontend/components/chat/ChatInput.tsx create mode 100644 services/frontend/components/chat/ChatWindow.tsx create mode 100644 services/frontend/components/chat/EmptyState.tsx create mode 100644 services/frontend/components/chat/MessageBubble.tsx create mode 100644 services/frontend/components/chat/Sidebar.tsx create mode 100644 services/frontend/components/landing/Hero.tsx create mode 100644 services/frontend/components/landing/Illustrations.tsx create mode 100644 services/frontend/components/login/MandalaArt.tsx create mode 100644 services/frontend/components/login/OAuthButtons.tsx create mode 100644 services/frontend/components/svg/Doodles.tsx create mode 100644 services/frontend/components/svg/KettleSteam.tsx create mode 100644 services/frontend/components/svg/KettleSvg.tsx create mode 100644 services/frontend/components/svg/SamosaLogo.tsx create mode 100644 services/frontend/components/svg/SamosaSvg.tsx create mode 100644 services/frontend/components/svg/SteamTyping.tsx create mode 100644 services/frontend/components/svg/ToranSvg.tsx create mode 100644 services/frontend/hooks/useSSE.ts create mode 100644 services/frontend/lib/slashCommands.ts create mode 100644 services/frontend/middleware.ts create mode 100644 services/frontend/next-env.d.ts create mode 100644 services/frontend/next.config.mjs create mode 100644 services/frontend/package-lock.json create mode 100644 services/frontend/package.json create mode 100644 services/frontend/postcss.config.mjs create mode 100644 services/frontend/public/logo.svg create mode 100644 services/frontend/store/chatStore.ts create mode 100644 services/frontend/tailwind.config.ts create mode 100644 services/frontend/tsconfig.json create mode 100644 services/frontend/types/chat.ts diff --git a/services/frontend/.dockerignore b/services/frontend/.dockerignore new file mode 100644 index 00000000..7122a883 --- /dev/null +++ b/services/frontend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.git +.env +.env*.local +Dockerfile +.dockerignore +README.md +npm-debug.log* +coverage +.DS_Store diff --git a/services/frontend/.env.example b/services/frontend/.env.example new file mode 100644 index 00000000..60b16412 --- /dev/null +++ b/services/frontend/.env.example @@ -0,0 +1,16 @@ +# Public URL of the frontend +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Internal service URLs (server-side only, BFF pattern) +AUTH_SERVICE_URL=http://localhost:4000 +CHAT_API_URL=http://localhost:8000 + +# NextAuth.js v5 — generate with: openssl rand -base64 32 +NEXTAUTH_SECRET=change-me-in-production +AUTH_TRUST_HOST=true + +# OAuth providers +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/services/frontend/.gitignore b/services/frontend/.gitignore new file mode 100644 index 00000000..6e4e5379 --- /dev/null +++ b/services/frontend/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.next/ +out/ +.env +.env.local +.env*.local +*.log +.DS_Store +.vercel +next-env.d.ts.bak +coverage/ +.turbo/ +tsconfig.tsbuildinfo diff --git a/services/frontend/Dockerfile b/services/frontend/Dockerfile index 14c93d73..42f851c4 100644 --- a/services/frontend/Dockerfile +++ b/services/frontend/Dockerfile @@ -1,9 +1,36 @@ -FROM python:3.12-slim +# syntax=docker/dockerfile:1.6 +# Multi-stage Dockerfile for samosaChaat Next.js frontend. +# ---------- deps ---------- +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat WORKDIR /app +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi -COPY index.html /app/index.html +# ---------- builder ---------- +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build +# ---------- runner ---------- +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs EXPOSE 3000 -CMD ["python", "-m", "http.server", "3000", "--bind", "0.0.0.0"] +CMD ["node", "server.js"] diff --git a/services/frontend/README.md b/services/frontend/README.md new file mode 100644 index 00000000..9da0bb92 --- /dev/null +++ b/services/frontend/README.md @@ -0,0 +1,70 @@ +# samosaChaat — Frontend Service + +Next.js 14 (App Router) application that replaces the legacy `nanochat/ui.html`. It blends Sarvam.ai's clean split-screen layout with samosaChaat's warm desi personality — samosa and chai illustrations, lemon-mirchi toran, gold + cream palette. + +## Pages + +- `/` Landing — Devanagari + Great Vibes calligraphy, animated samosa (float), chai kettle (wobble + steam), toran (pendulum), ambient doodles, CTA. +- `/login` Sign-in — left: architectural/mandala motif with saffron-cream gradient; right: Google + GitHub OAuth, disabled email input. +- `/chat` Chat — collapsible 260px sidebar (grouped history, model selector, user avatar, logout), main area with empty state + suggestion chips, markdown rendering with code-copy, auto-expanding textarea, slash commands (`/temperature`, `/topk`, `/clear`, `/help`), SSE streaming with steam typing indicator. + +## Tech + +- Next.js 14 (App Router, standalone output) +- Tailwind CSS (theme tokens carried from `ui.html`) +- Zustand (persisted conversations/settings) +- NextAuth.js v5 (Google + GitHub) +- Framer Motion (hero transitions) +- Lucide React (icons) +- react-markdown + rehype-highlight (assistant rendering) + +## Environment + +Copy `.env.example` → `.env.local` and fill in: + +| Var | Purpose | +| --- | --- | +| `NEXT_PUBLIC_APP_URL` | Public URL (defaults to `http://localhost:3000`) | +| `AUTH_SERVICE_URL` | Auth microservice (BFF only; reserved for future) | +| `CHAT_API_URL` | Upstream chat service. **If unset the frontend serves mock echo responses** — perfect for local dev. | +| `NEXTAUTH_SECRET` | `openssl rand -base64 32` | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google OAuth (optional in dev) | +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | GitHub OAuth (optional in dev) | + +Middleware protects `/chat/*` — unauthenticated users are redirected to `/login?callbackUrl=/chat`. + +## Run locally + +```bash +cd services/frontend +npm install +cp .env.example .env.local # then edit +npm run dev +# → http://localhost:3000 +``` + +Without any OAuth keys the `/login` buttons will show the NextAuth error page; the rest of the UI works via the mocked API routes. + +## API routes (BFF pattern) + +All client calls hit Next.js routes — the frontend never talks to the chat backend directly. + +- `GET /api/health` — service info + upstream config flags +- `GET /api/conversations` — mocked conversation list +- `POST /api/chat/stream` — proxies to `CHAT_API_URL/chat/completions` if set, otherwise streams a mock echo. Emits SSE: `data: {"token": "...", "gpu": 0}\n\n` and terminates with `data: {"done": true}\n\n`. + +## Docker + +```bash +docker build -t samosachaat-frontend . +docker run --rm -p 3000:3000 \ + -e NEXTAUTH_SECRET=... \ + -e CHAT_API_URL=http://host.docker.internal:8000 \ + samosachaat-frontend +``` + +The image is based on `node:20-alpine`, uses Next.js `output: standalone`, and runs as a non-root user. + +## Porting notes + +All SVG assets (samosa, chai kettle, toran, steam, doodles, logo) are componentized under `components/svg/`. CSS keyframes (`pendulum`, `float`, `wobble`, `steamFloat`, `steamType`) moved into `tailwind.config.ts` — reference via the `animate-*` utilities. The original single-file UI lives at `nanochat/ui.html` for reference. diff --git a/services/frontend/app/api/auth/[...nextauth]/route.ts b/services/frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..0a98352f --- /dev/null +++ b/services/frontend/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth'; + +export const { GET, POST } = handlers; diff --git a/services/frontend/app/api/chat/stream/route.ts b/services/frontend/app/api/chat/stream/route.ts new file mode 100644 index 00000000..494f6141 --- /dev/null +++ b/services/frontend/app/api/chat/stream/route.ts @@ -0,0 +1,95 @@ +import { NextRequest } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface StreamBody { + messages: Array<{ role: string; content: string }>; + model?: string; + temperature?: number; + topK?: number; +} + +const encoder = new TextEncoder(); + +function sseEvent(data: Record) { + return encoder.encode(`data: ${JSON.stringify(data)}\n\n`); +} + +async function proxyUpstream(body: StreamBody, upstreamUrl: string) { + const upstream = await fetch(upstreamUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: body.messages, + temperature: body.temperature ?? 0.8, + top_k: body.topK ?? 50, + max_tokens: 512, + model: body.model, + }), + }); + + if (!upstream.ok || !upstream.body) { + throw new Error(`upstream HTTP ${upstream.status}`); + } + + return new Response(upstream.body, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} + +function mockEcho(body: StreamBody): Response { + const last = body.messages[body.messages.length - 1]?.content ?? ''; + const greetings = [ + 'Namaste! ', + "Here's what I can offer for that question: ", + "Let's think about it together. ", + ]; + const greeting = greetings[Math.floor(Math.random() * greetings.length)]; + const echo = last.trim() ? `You asked: "${last.trim()}".` : 'I am listening.'; + const full = `${greeting}${echo}\n\nThis is a mock response from the samosaChaat frontend — once the chat service is wired, real streaming tokens will land here.`; + + const stream = new ReadableStream({ + async start(controller) { + const words = full.split(/(\s+)/); + for (const w of words) { + controller.enqueue(sseEvent({ token: w, gpu: 0 })); + await new Promise((r) => setTimeout(r, 25)); + } + controller.enqueue(sseEvent({ done: true })); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} + +export async function POST(req: NextRequest) { + let body: StreamBody; + try { + body = (await req.json()) as StreamBody; + } catch { + return new Response('Invalid JSON', { status: 400 }); + } + + const upstream = process.env.CHAT_API_URL; + if (upstream) { + try { + return await proxyUpstream(body, `${upstream.replace(/\/$/, '')}/chat/completions`); + } catch (err) { + console.warn('[chat/stream] upstream failed, falling back to mock:', err); + } + } + + return mockEcho(body); +} diff --git a/services/frontend/app/api/conversations/route.ts b/services/frontend/app/api/conversations/route.ts new file mode 100644 index 00000000..9e4b60d7 --- /dev/null +++ b/services/frontend/app/api/conversations/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +const day = 1000 * 60 * 60 * 24; +const now = () => Date.now(); + +export async function GET() { + return NextResponse.json({ + conversations: [ + { + id: 'mock-1', + title: 'Why is samosa triangular?', + updatedAt: now() - 1000 * 60 * 30, + }, + { + id: 'mock-2', + title: 'Chai masala recipe', + updatedAt: now() - day, + }, + { + id: 'mock-3', + title: 'Explain transformers simply', + updatedAt: now() - day * 4, + }, + { + id: 'mock-4', + title: 'Monsoon pakora tips', + updatedAt: now() - day * 15, + }, + ], + }); +} diff --git a/services/frontend/app/api/health/route.ts b/services/frontend/app/api/health/route.ts new file mode 100644 index 00000000..c7c4d7d3 --- /dev/null +++ b/services/frontend/app/api/health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + status: 'ok', + service: 'samosachaat-frontend', + chatApiConfigured: Boolean(process.env.CHAT_API_URL), + authServiceConfigured: Boolean(process.env.AUTH_SERVICE_URL), + }); +} diff --git a/services/frontend/app/chat/page.tsx b/services/frontend/app/chat/page.tsx new file mode 100644 index 00000000..6e0e1a19 --- /dev/null +++ b/services/frontend/app/chat/page.tsx @@ -0,0 +1,13 @@ +import Sidebar from '@/components/chat/Sidebar'; +import ChatWindow from '@/components/chat/ChatWindow'; + +export const metadata = { title: 'Chat — samosaChaat' }; + +export default function ChatPage() { + return ( +
+ + +
+ ); +} diff --git a/services/frontend/app/globals.css b/services/frontend/app/globals.css new file mode 100644 index 00000000..23df16dc --- /dev/null +++ b/services/frontend/app/globals.css @@ -0,0 +1,50 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light; + --gold: #e8a838; + --brown: #8b4d0a; + --cream: #fff8e7; + --green-chutney: #2d8a4e; + --red-chutney: #c0392b; + --warm-grey: #d4c4a0; + --light-cream: #fffdf5; +} + +html, body { height: 100%; } + +body { + @apply font-sans bg-white text-gray-900; + min-height: 100dvh; + overflow-x: hidden; +} + +/* Markdown prose tweaks inside message bubbles */ +.markdown-body > *:first-child { margin-top: 0 !important; } +.markdown-body > *:last-child { margin-bottom: 0 !important; } +.markdown-body pre { + @apply bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto my-2 text-sm; +} +.markdown-body code:not(pre code) { + @apply px-1 py-0.5 rounded bg-cream-light border border-cream-border text-brown text-[0.9em]; +} +.markdown-body p { @apply my-2 leading-relaxed; } +.markdown-body ul { @apply list-disc pl-6 my-2; } +.markdown-body ol { @apply list-decimal pl-6 my-2; } +.markdown-body h1 { @apply text-xl font-bold mt-4 mb-2; } +.markdown-body h2 { @apply text-lg font-bold mt-3 mb-2; } +.markdown-body h3 { @apply text-base font-bold mt-2 mb-1; } +.markdown-body blockquote { + @apply border-l-4 border-cream-border pl-4 italic text-brown-light my-2; +} +.markdown-body a { @apply text-chutney-green underline hover:text-gold; } + +/* Scrollbar tuning */ +.nice-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; } +.nice-scrollbar::-webkit-scrollbar-thumb { background: #e0d5c0; border-radius: 3px; } +.nice-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--warm-grey); } + +/* Highlight.js minimal tweaks */ +.hljs { background: transparent; } diff --git a/services/frontend/app/layout.tsx b/services/frontend/app/layout.tsx new file mode 100644 index 00000000..65a085c4 --- /dev/null +++ b/services/frontend/app/layout.tsx @@ -0,0 +1,54 @@ +import type { Metadata, Viewport } from 'next'; +import { Baloo_2, Great_Vibes, Caveat, Inter } from 'next/font/google'; +import SessionBoundary from '@/components/SessionBoundary'; +import './globals.css'; + +const baloo = Baloo_2({ + subsets: ['latin', 'devanagari'], + weight: ['400', '600', '700', '800'], + variable: '--font-baloo', + display: 'swap', +}); + +const vibes = Great_Vibes({ + subsets: ['latin'], + weight: ['400'], + variable: '--font-vibes', + display: 'swap', +}); + +const caveat = Caveat({ + subsets: ['latin'], + weight: ['400', '600', '700'], + variable: '--font-caveat', + display: 'swap', +}); + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', + display: 'swap', +}); + +export const metadata: Metadata = { + title: 'समोसाचाट — samosaChaat', + description: 'Crafted with care. For India, from India. A warm, desi-flavored chat experience powered by nanochat.', + icons: { icon: '/logo.svg' }, +}; + +export const viewport: Viewport = { + themeColor: '#fff8e7', + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/services/frontend/app/login/page.tsx b/services/frontend/app/login/page.tsx new file mode 100644 index 00000000..326255c5 --- /dev/null +++ b/services/frontend/app/login/page.tsx @@ -0,0 +1,78 @@ +import Link from 'next/link'; +import { Suspense } from 'react'; +import { redirect } from 'next/navigation'; +import { auth } from '@/auth'; +import MandalaArt from '@/components/login/MandalaArt'; +import OAuthButtons from '@/components/login/OAuthButtons'; +import SamosaLogo from '@/components/svg/SamosaLogo'; + +export const metadata = { title: 'Sign in — samosaChaat' }; + +export default async function LoginPage() { + const session = await auth(); + if (session?.user) redirect('/chat'); + return ( +
+
+ +
+ +
+
+ + + samosaChaat + + +

Login to your account

+

+ Welcome back. Pick up the conversation where you left it. +

+ +
+ +
+ + + +
+ + + +
+ +

+ Don't have an account?{' '} + + Create one + +

+ +

+ By continuing, you agree to our Terms and acknowledge our Privacy Policy. +

+
+
+
+ ); +} diff --git a/services/frontend/app/page.tsx b/services/frontend/app/page.tsx new file mode 100644 index 00000000..1a727dc0 --- /dev/null +++ b/services/frontend/app/page.tsx @@ -0,0 +1,19 @@ +import LandingNav from '@/components/LandingNav'; +import LandingFooter from '@/components/LandingFooter'; +import Hero from '@/components/landing/Hero'; +import { SamosaIllustration, KettleIllustration } from '@/components/landing/Illustrations'; +import Doodles from '@/components/svg/Doodles'; + +export default function LandingPage() { + return ( +
+ + + + + +
+ +
+ ); +} diff --git a/services/frontend/auth.ts b/services/frontend/auth.ts new file mode 100644 index 00000000..b9d98dbc --- /dev/null +++ b/services/frontend/auth.ts @@ -0,0 +1,36 @@ +import NextAuth from 'next-auth'; +import Google from 'next-auth/providers/google'; +import GitHub from 'next-auth/providers/github'; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + trustHost: true, + secret: process.env.NEXTAUTH_SECRET, + providers: [ + Google({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }), + GitHub({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + }), + ], + pages: { + signIn: '/login', + }, + session: { strategy: 'jwt' }, + callbacks: { + async jwt({ token, account, profile }) { + if (account) { + token.provider = account.provider; + } + return token; + }, + async session({ session, token }) { + if (session.user && token.sub) { + (session.user as { id?: string }).id = token.sub; + } + return session; + }, + }, +}); diff --git a/services/frontend/components/LandingFooter.tsx b/services/frontend/components/LandingFooter.tsx new file mode 100644 index 00000000..9912626c --- /dev/null +++ b/services/frontend/components/LandingFooter.tsx @@ -0,0 +1,20 @@ +export default function LandingFooter() { + return ( + + ); +} diff --git a/services/frontend/components/LandingNav.tsx b/services/frontend/components/LandingNav.tsx new file mode 100644 index 00000000..701f2a9c --- /dev/null +++ b/services/frontend/components/LandingNav.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link'; +import ToranSvg from './svg/ToranSvg'; + +export default function LandingNav() { + return ( + + ); +} diff --git a/services/frontend/components/SessionBoundary.tsx b/services/frontend/components/SessionBoundary.tsx new file mode 100644 index 00000000..742bb706 --- /dev/null +++ b/services/frontend/components/SessionBoundary.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; +import type { ReactNode } from 'react'; + +export default function SessionBoundary({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/services/frontend/components/chat/ChatInput.tsx b/services/frontend/components/chat/ChatInput.tsx new file mode 100644 index 00000000..20db4628 --- /dev/null +++ b/services/frontend/components/chat/ChatInput.tsx @@ -0,0 +1,85 @@ +'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(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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (canSend) onSubmit(); + } + }; + + return ( +
+
+
+