Next.js app — chat interface, auth, settings, PWA manifest

This commit is contained in:
2026-04-26 02:05:09 -04:00
parent 241acf2b52
commit 996c4e19a7
12 changed files with 1161 additions and 122 deletions
+42
View File
@@ -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;
+77 -16
View File
@@ -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; }
+33 -18
View File
@@ -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 (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" suppressHydrationWarning>
<body className={`${sans.variable} ${mono.variable}`}>
{children}
</body>
</html>
);
}
+117
View File
@@ -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 (
<div
style={{
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
background: '#faf9f6',
}}
>
<div
style={{
width: '100%',
maxWidth: '360px',
background: '#f0ede6',
border: '1px solid #dddad2',
borderRadius: '16px',
padding: '32px',
}}
>
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ fontSize: '22px', fontWeight: 500, color: '#1a1a18', marginBottom: '4px' }}>
Aaron AI
</div>
<div style={{ fontSize: '13px', color: '#999990' }}>
personal knowledge assistant
</div>
</div>
<input
type="password"
value={password}
onChange={e => 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 && (
<div style={{ fontSize: '12px', color: '#a32d2d', marginBottom: '16px' }}>
{error}
</div>
)}
<button
onClick={handleLogin}
disabled={loading}
style={{
width: '100%',
background: '#2d5a3d',
color: '#e8f5ed',
border: 'none',
borderRadius: '10px',
padding: '12px',
fontSize: '15px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.5 : 1,
display: 'block',
boxSizing: 'border-box',
}}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</div>
);
}
+103 -55
View File
@@ -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 (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<div
className="flex h-dvh overflow-hidden"
style={{ background: 'var(--bg)' }}
data-theme={settings.theme}
data-font={settings.font_size}
>
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
)}
{/* Sidebar */}
<div
className={`
fixed inset-y-0 left-0 z-30 w-72 transform transition-transform duration-200
md:relative md:translate-x-0 md:w-64 md:flex-shrink-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}
style={{ background: 'var(--sidebar-bg)', borderRight: '1px solid var(--border)' }}
>
<Sidebar />
</div>
{/* Main */}
<div className="flex flex-col flex-1 min-w-0">
{/* Topbar */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="flex items-center gap-3">
{/* Hamburger — mobile only */}
<button
className="md:hidden p-1 rounded"
style={{ color: 'var(--text3)' }}
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle sidebar"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<rect y="3" width="20" height="2" rx="1"/>
<rect y="9" width="20" height="2" rx="1"/>
<rect y="15" width="20" height="2" rx="1"/>
</svg>
</button>
<span style={{ fontSize: '13px', color: 'var(--text3)' }}>
<strong style={{ color: 'var(--text)', fontWeight: 500 }}>Aaron AI</strong>
{' '} personal knowledge assistant
</span>
</div>
<button
onClick={() => useStore.getState().setSettingsOpen(!settingsOpen)}
style={{ color: 'var(--text3)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: '6px', fontSize: '18px' }}
aria-label="Settings"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</button>
</div>
</main>
{/* Messages */}
<MessageList />
{/* Input */}
<MessageInput />
</div>
{/* Settings panel */}
<SettingsPanel />
</div>
);
}