From 996c4e19a74109ecd97d7c2347b0b84c1211bfe9 Mon Sep 17 00:00:00 2001 From: Aaron Nelson Date: Sun, 26 Apr 2026 02:05:09 -0400 Subject: [PATCH] =?UTF-8?q?Next.js=20app=20=E2=80=94=20chat=20interface,?= =?UTF-8?q?=20auth,=20settings,=20PWA=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/[...slug]/route.ts | 42 +++++ app/globals.css | 93 +++++++++-- app/layout.tsx | 51 ++++-- app/login/page.tsx | 117 +++++++++++++ app/page.tsx | 158 +++++++++++------- components/MessageInput.tsx | 181 ++++++++++++++++++++ components/MessageList.tsx | 98 +++++++++++ components/SettingsPanel.tsx | 309 +++++++++++++++++++++++++++++++++++ components/Sidebar.tsx | 125 ++++++++++++++ lib/api.ts | 49 ++---- proxy.ts | 29 ++++ public/manifest.json | 31 ++++ 12 files changed, 1161 insertions(+), 122 deletions(-) create mode 100644 app/api/[...slug]/route.ts create mode 100644 app/login/page.tsx create mode 100644 components/MessageInput.tsx create mode 100644 components/MessageList.tsx create mode 100644 components/SettingsPanel.tsx create mode 100644 components/Sidebar.tsx create mode 100644 proxy.ts create mode 100644 public/manifest.json diff --git a/app/api/[...slug]/route.ts b/app/api/[...slug]/route.ts new file mode 100644 index 0000000..a915688 --- /dev/null +++ b/app/api/[...slug]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.API_URL || 'https://ai.aaronnelson.studio'; + +async function handler(request: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params; + const path = '/' + slug.join('/'); + const url = `${API_BASE}${path}${request.nextUrl.search}`; + + const headers = new Headers(); + const contentType = request.headers.get('content-type'); + if (contentType) headers.set('content-type', contentType); + const cookie = request.headers.get('cookie'); + if (cookie) headers.set('cookie', cookie); + + const body = request.method !== 'GET' && request.method !== 'HEAD' + ? await request.blob() + : undefined; + + const response = await fetch(url, { + method: request.method, + headers, + body, + }); + + const responseHeaders = new Headers(); + const setCookie = response.headers.get('set-cookie'); + if (setCookie) responseHeaders.set('set-cookie', setCookie); + responseHeaders.set('content-type', response.headers.get('content-type') || 'application/json'); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); +} + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; +export const OPTIONS = handler; diff --git a/app/globals.css b/app/globals.css index a2dc41e..1a9268d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,87 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --bg: #faf9f6; + --bg2: #f0ede6; + --bg3: #e8e4dc; + --sidebar-bg: #eceae4; + --border: #dddad2; + --border2: #ccc9c0; + --text: #1a1a18; + --text2: #555550; + --text3: #999990; + --accent: #2d5a3d; + --accent-light: #edf5f0; + --accent-border: #c8dece; + --accent-text: #1a3a26; + --user-bg: #e8e4dc; + --font-size: 15px; } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +[data-theme="dark"] { + --bg: #1a1a18; + --bg2: #222220; + --bg3: #2a2a28; + --sidebar-bg: #111110; + --border: #2a2a28; + --border2: #383836; + --text: #e8e8e0; + --text2: #aaa89e; + --text3: #555550; + --accent-light: #1e2e22; + --accent-border: #2a3e2e; + --accent-text: #a8d5b5; + --user-bg: #2a2a28; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} +[data-font="small"] { --font-size: 13px; } +[data-font="medium"] { --font-size: 15px; } +[data-font="large"] { --font-size: 17px; } + +* { box-sizing: border-box; margin: 0; padding: 0; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: var(--bg); + color: var(--text); + font-size: var(--font-size); + font-family: var(--font-sans); } + +/* Markdown styles */ +.prose p { margin-bottom: 0.75em; } +.prose p:last-child { margin-bottom: 0; } +.prose strong { font-weight: 500; color: var(--text); } +.prose em { font-style: italic; } +.prose code { + font-family: var(--font-mono); + font-size: 0.88em; + background: var(--bg3); + padding: 1px 5px; + border-radius: 4px; +} +.prose pre { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + overflow-x: auto; + margin: 0.75em 0; +} +.prose pre code { background: none; padding: 0; } +.prose h1, .prose h2, .prose h3 { + font-weight: 500; + margin: 0.75em 0 0.4em; + color: var(--text); +} +.prose ul, .prose ol { padding-left: 1.4em; margin: 0.5em 0; } +.prose li { margin-bottom: 0.3em; } +.prose hr { border: none; border-top: 1px solid var(--border); margin: 1em 0; } +.prose blockquote { + border-left: 3px solid var(--accent-border); + padding-left: 12px; + color: var(--text2); + margin: 0.5em 0; +} +.prose table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.9em; } +.prose th, .prose td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; } +.prose th { background: var(--bg3); font-weight: 500; } diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..5ea5c50 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,33 +1,48 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata, Viewport } from 'next'; +import { IBM_Plex_Sans, IBM_Plex_Mono } from 'next/font/google'; +import './globals.css'; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], +const sans = IBM_Plex_Sans({ + subsets: ['latin'], + weight: ['400', '500'], + variable: '--font-sans', }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], +const mono = IBM_Plex_Mono({ + subsets: ['latin'], + weight: ['400'], + variable: '--font-mono', }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'Aaron AI', + description: 'Personal knowledge assistant', + manifest: '/manifest.json', + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: 'Aaron AI', + }, +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: '#2d5a3d', }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - - {children} + + + {children} + ); } diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..f88bfe4 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function LoginPage() { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + async function handleLogin() { + if (!password || loading) return; + setLoading(true); + setError(''); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ password }), + }); + if (!res.ok) throw new Error('Invalid password'); + router.push('/'); + router.refresh(); + } catch { + setError('Invalid password'); + setLoading(false); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') handleLogin(); + } + + return ( +
+
+
+
+ Aaron AI +
+
+ personal knowledge assistant +
+
+ + setPassword(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Password" + autoFocus + autoComplete="current-password" + style={{ + width: '100%', + background: '#faf9f6', + border: `1px solid ${error ? '#a32d2d' : '#ccc9c0'}`, + borderRadius: '10px', + padding: '12px 16px', + fontSize: '16px', + color: '#1a1a18', + outline: 'none', + marginBottom: error ? '6px' : '16px', + display: 'block', + boxSizing: 'border-box', + }} + /> + + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..0eb223e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,113 @@ -import Image from "next/image"; +'use client'; + +import { useEffect } from 'react'; +import { useStore } from '@/lib/store'; +import { api } from '@/lib/api'; +import Sidebar from '@/components/Sidebar'; +import MessageList from '@/components/MessageList'; +import MessageInput from '@/components/MessageInput'; +import SettingsPanel from '@/components/SettingsPanel'; export default function Home() { + const { + settings, + setSettings, + setConversations, + setCurrentId, + setMessages, + currentId, + settingsOpen, + sidebarOpen, + setSidebarOpen, + } = useStore(); + + useEffect(() => { + // Load settings from server + api.getSettings().then(setSettings).catch(console.error); + // Load conversations + api.getConversations().then(setConversations).catch(console.error); + }, []); + + useEffect(() => { + // Load messages when conversation changes + if (currentId) { + api.getMessages(currentId).then(setMessages).catch(console.error); + } else { + setMessages([]); + } + }, [currentId]); + return ( -
-
- Next.js logo + {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} /> -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - + +

+ + {/* Main */} +
+ {/* Topbar */} + -
+ + {/* Messages */} + + + {/* Input */} + +
+ + {/* Settings panel */} + ); } diff --git a/components/MessageInput.tsx b/components/MessageInput.tsx new file mode 100644 index 0000000..854eb31 --- /dev/null +++ b/components/MessageInput.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; +import { useStore } from '@/lib/store'; +import { api } from '@/lib/api'; +import type { Message } from '@/lib/api'; + +export default function MessageInput() { + const { currentId, setCurrentId, addMessage, setIsLoading, isLoading, setConversations } = useStore(); + const [text, setText] = useState(''); + const [recording, setRecording] = useState(false); + const textareaRef = useRef(null); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + + useEffect(() => { + textareaRef.current?.focus(); + }, [currentId]); + + function autoResize() { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 160) + 'px'; + } + + async function send() { + const message = text.trim(); + if (!message || isLoading) return; + setText(''); + if (textareaRef.current) textareaRef.current.style.height = 'auto'; + + let convId = currentId; + if (!convId) { + const conv = await api.newConversation(); + convId = conv.id; + setCurrentId(convId); + } + + const userMsg: Message = { + role: 'user', + content: message, + sources: [], + timestamp: new Date().toISOString(), + }; + addMessage(userMsg); + setIsLoading(true); + + try { + const data = await api.sendMessage(message, convId); + setCurrentId(data.conversation_id); + const assistantMsg: Message = { + role: 'assistant', + content: data.response, + sources: data.sources || [], + timestamp: new Date().toISOString(), + }; + addMessage(assistantMsg); + const updated = await api.getConversations(); + setConversations(updated); + } catch (e) { + addMessage({ + role: 'assistant', + content: 'Error — please try again.', + sources: [], + timestamp: new Date().toISOString(), + }); + } finally { + setIsLoading(false); + textareaRef.current?.focus(); + } + } + + async function startRecording() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mr = new MediaRecorder(stream); + chunksRef.current = []; + mr.ondataavailable = e => chunksRef.current.push(e.data); + mr.onstop = async () => { + stream.getTracks().forEach(t => t.stop()); + const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); + try { + const { text: transcript } = await api.transcribe(blob); + setText(prev => prev ? prev + ' ' + transcript : transcript); + textareaRef.current?.focus(); + } catch { + console.error('Transcription failed'); + } + }; + mr.start(); + mediaRecorderRef.current = mr; + setRecording(true); + } catch { + alert('Microphone access denied'); + } + } + + function stopRecording() { + mediaRecorderRef.current?.stop(); + setRecording(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + } + + return ( +
+ {/* Voice button */} + + + {/* Text input */} +
+