scaffold: Next.js 15 + Drizzle + Better Auth + OpenAI + Recharts base

This commit is contained in:
Christopher Mayor
2026-04-24 14:29:47 -07:00
parent 858f7264ce
commit d13780931e
45 changed files with 9036 additions and 121 deletions

View File

@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link";
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
const res = await signIn.email({ email, password });
if (res.error) {
setError(res.error.message ?? "Sign in failed");
setLoading(false);
return;
}
router.push("/");
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your credentials to access your account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="text-primary underline">
Sign Up
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signUp } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link";
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
const res = await signUp.email({ name, email, password });
if (res.error) {
setError(res.error.message ?? "Sign up failed");
setLoading(false);
return;
}
router.push("/");
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign Up</CardTitle>
<CardDescription>Create an account to get started</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading}>
{loading ? "Creating account..." : "Sign Up"}
</Button>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/sign-in" className="text-primary underline">
Sign In
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { notFound } from "next/navigation"
import type { ComparisonData } from "@/lib/types"
import { ComparisonResultsClient } from "./results-client"
interface ComparisonResultsPageProps {
params: Promise<{ slug: string }>
}
async function getComparison(slug: string): Promise<ComparisonData | null> {
try {
const base = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
const res = await fetch(`${base}/api/comparisons/${slug}`, {
cache: "no-store",
})
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
export async function generateMetadata({ params }: ComparisonResultsPageProps) {
const { slug } = await params
const comparison = await getComparison(slug)
if (!comparison) return { title: "Comparison Not Found" }
return {
title: comparison.title,
description: comparison.summary,
}
}
export default async function ComparisonResultsPage({ params }: ComparisonResultsPageProps) {
const { slug } = await params
const comparison = await getComparison(slug)
if (!comparison) {
notFound()
}
return <ComparisonResultsClient initialData={comparison} />
}

View File

@@ -0,0 +1,151 @@
"use client"
import { useState } from "react"
import type { ComparisonData } from "@/lib/types"
import { ComparisonRadarChart } from "@/components/comparison/radar-chart"
import { ComparisonTable } from "@/components/comparison/comparison-table"
import { ComparisonBarChart } from "@/components/comparison/bar-chart"
import { ScoreCard } from "@/components/comparison/score-card"
import { ProsConsCard } from "@/components/comparison/pros-cons-card"
import { useComparisonStream } from "@/hooks/use-comparison-stream"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { Share2, ArrowLeft, Loader2, Trophy } from "lucide-react"
import Link from "next/link"
interface ComparisonResultsClientProps {
initialData: ComparisonData
}
export function ComparisonResultsClient({ initialData }: ComparisonResultsClientProps) {
const [data] = useState<ComparisonData>(initialData)
const { progress } = useComparisonStream()
const isResearching = data.status === "researching" || progress?.status === "researching"
const handleShare = async () => {
const url = window.location.href
await navigator.clipboard.writeText(url)
}
const winner = [...data.items].sort((a, b) => b.overallScore - a.overallScore)[0]
if (isResearching) {
return (
<div className="max-w-4xl mx-auto p-4 space-y-6">
<div className="flex items-center gap-3">
<Loader2 className="size-5 animate-spin text-primary" />
<div>
<h1 className="text-xl font-semibold">{data.title}</h1>
<p className="text-sm text-muted-foreground">
{progress.message || "Research in progress..."}
</p>
</div>
</div>
<div className="space-y-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
)
}
return (
<div className="max-w-6xl mx-auto p-4 space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Link href="/compare">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="size-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight">{data.title}</h1>
</div>
<p className="text-muted-foreground text-sm max-w-2xl">{data.summary}</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{data.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="size-3.5" />
Share
</Button>
</div>
<Separator />
<Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="charts">Charts</TabsTrigger>
<TabsTrigger value="table">Table</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-6">
{winner && (
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/30 p-3">
<Trophy className="size-5 text-emerald-600" />
<span className="text-sm font-medium">
Top pick: <strong>{winner.name}</strong> ({winner.overallScore.toFixed(1)}/10)
</span>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.items.map((item, index) => (
<ScoreCard
key={item.name}
item={item}
index={index}
dimensions={data.dimensions}
/>
))}
</div>
</TabsContent>
<TabsContent value="charts" className="space-y-8 mt-6">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Radar Chart</h3>
<p className="text-sm text-muted-foreground">
Visual comparison across all dimensions
</p>
<div className="rounded-lg border bg-card p-4">
<ComparisonRadarChart items={data.items} dimensions={data.dimensions} />
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Score Comparison</h3>
<p className="text-sm text-muted-foreground">
Side-by-side bar chart of dimension scores
</p>
<div className="rounded-lg border bg-card p-4">
<ComparisonBarChart items={data.items} dimensions={data.dimensions} />
</div>
</div>
</TabsContent>
<TabsContent value="table" className="mt-6">
<ComparisonTable items={data.items} dimensions={data.dimensions} />
</TabsContent>
<TabsContent value="details" className="space-y-6 mt-6">
<div className="grid gap-4 md:grid-cols-2">
{data.items.map((item, index) => (
<ProsConsCard key={item.name} item={item} index={index} />
))}
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,186 @@
"use client"
import { useState, useCallback, KeyboardEvent } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useComparisonStream } from "@/hooks/use-comparison-stream"
import { X, Plus, Loader2, Sparkles } from "lucide-react"
export default function ComparePage() {
const [query, setQuery] = useState("")
const [items, setItems] = useState<string[]>([])
const [itemInput, setItemInput] = useState("")
const [dimensionHints, setDimensionHints] = useState("")
const { progress, startResearch, cancel } = useComparisonStream()
const isResearching = progress.status === "researching"
const addItem = useCallback(() => {
const trimmed = itemInput.trim()
if (trimmed && !items.includes(trimmed) && items.length < 10) {
setItems((prev) => [...prev, trimmed])
setItemInput("")
}
}, [itemInput, items])
const removeItem = useCallback((item: string) => {
setItems((prev) => prev.filter((i) => i !== item))
}, [])
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
addItem()
}
}
const handleStart = () => {
if (items.length < 2 || !query.trim()) return
const dims = dimensionHints
.split(",")
.map((d) => d.trim())
.filter(Boolean)
startResearch(query.trim(), items, dims.length > 0 ? dims : undefined)
}
const progressPercent =
progress.totalItems > 0
? Math.round((progress.itemsCompleted / progress.totalItems) * 100)
: 0
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Sparkles className="size-5 text-primary" />
<CardTitle className="text-2xl">Start a Comparison</CardTitle>
</div>
<p className="text-sm text-muted-foreground">
Enter the items you want to compare and let AI do deep research for you.
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="query">What would you like to compare?</Label>
<textarea
id="query"
className="flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
placeholder="e.g., Compare React vs Vue vs Svelte for building modern web applications"
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={isResearching}
/>
</div>
<div className="space-y-2">
<Label>Items to compare (min 2)</Label>
<div className="flex gap-2">
<Input
placeholder="e.g., React"
value={itemInput}
onChange={(e) => setItemInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isResearching || items.length >= 10}
/>
<Button
onClick={addItem}
disabled={!itemInput.trim() || items.length >= 10 || isResearching}
variant="outline"
size="icon"
>
<Plus className="size-4" />
</Button>
</div>
{items.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{items.map((item) => (
<Badge key={item} variant="secondary" className="gap-1 pr-1">
{item}
<button
onClick={() => removeItem(item)}
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5"
disabled={isResearching}
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
)}
{items.length > 0 && (
<p className="text-xs text-muted-foreground">
{items.length}/10 items added
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="dimensions" className="text-sm">
Dimension hints{" "}
<span className="text-muted-foreground">(optional, comma-separated)</span>
</Label>
<Input
id="dimensions"
placeholder="e.g., performance, ease of use, community, pricing"
value={dimensionHints}
onChange={(e) => setDimensionHints(e.target.value)}
disabled={isResearching}
/>
</div>
{isResearching && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin text-primary" />
<span className="text-sm font-medium">{progress.message}</span>
</div>
{progress.currentStep && (
<p className="text-xs text-muted-foreground">{progress.currentStep}</p>
)}
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{progress.itemsCompleted} / {progress.totalItems} items researched
</span>
<span>{progressPercent}%</span>
</div>
</div>
)}
<div className="flex gap-3">
{isResearching ? (
<Button onClick={cancel} variant="destructive" className="flex-1">
Cancel Research
</Button>
) : (
<Button
onClick={handleStart}
disabled={items.length < 2 || !query.trim()}
className="flex-1"
size="lg"
>
<Sparkles className="size-4" />
Start Research
</Button>
)}
</div>
{items.length < 2 && query.trim() && (
<p className="text-xs text-center text-muted-foreground">
Add at least 2 items to start comparing
</p>
)}
</CardContent>
</Card>
</div>
)
}

50
src/app/(main)/layout.tsx Normal file
View File

@@ -0,0 +1,50 @@
import Link from "next/link"
import { Sparkles } from "lucide-react"
export default function MainLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen flex flex-col">
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center px-4 mx-auto">
<Link href="/" className="flex items-center gap-2 font-semibold mr-8">
<Sparkles className="size-5 text-primary" />
<span className="text-lg">ComparAIson</span>
</Link>
<div className="flex-1 max-w-md">
<div className="relative">
<input
type="search"
placeholder="Search comparisons..."
className="w-full h-8 rounded-lg border border-input bg-muted/50 px-3 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
<div className="flex items-center gap-3 ml-auto">
<Link href="/compare">
<button className="h-8 px-3 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/80 transition-colors">
New Comparison
</button>
</Link>
<div className="size-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground">
U
</div>
</div>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t py-4">
<div className="container mx-auto px-4 text-center text-xs text-muted-foreground">
ComparAIson AI-powered deep research comparisons
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,114 @@
"use server";
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import type { ComparisonData } from "@/lib/types";
import { eq, desc, and } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
export async function createComparison(formData: FormData) {
const query = formData.get("query") as string;
const itemsRaw = formData.get("items") as string;
if (!query || !itemsRaw) {
return { error: "Query and items are required" };
}
const items: string[] = JSON.parse(itemsRaw);
if (!Array.isArray(items) || items.length < 2) {
return { error: "At least 2 items are required" };
}
const id = createId();
const title =
(formData.get("title") as string) ||
`Comparing ${items.join(" vs ")}`;
const slug =
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 200) + `-${id.slice(-6)}`;
await db.insert(comparisons).values({
id,
title,
query,
slug,
status: "researching",
});
return { id, slug };
}
export async function getComparison(
slug: string
): Promise<ComparisonData | null> {
const result = await db
.select()
.from(comparisons)
.where(eq(comparisons.slug, slug))
.limit(1);
if (!result.length) return null;
const comparison = result[0];
const items = await db
.select()
.from(comparisonItems)
.where(eq(comparisonItems.comparisonId, comparison.id))
.orderBy(comparisonItems.order);
return {
id: comparison.id,
userId: comparison.userId || "",
title: comparison.title,
query: comparison.query || "",
slug: comparison.slug,
status: comparison.status as ComparisonData["status"],
summary: comparison.summary || "",
items: items.map((item) => ({
name: item.name,
description: item.description || "",
overallScore:
(item.scores as Record<string, unknown>)?.overallScore as number ?? 0,
dimensions:
(item.scores as Record<string, unknown>)?.dimensions as Record<
string,
{ score: number; summary: string; details: string; pros?: string[]; cons?: string[] }
> ?? {},
pros: item.pros || [],
cons: item.cons || [],
})),
dimensions:
(comparison.overallData as Record<string, unknown>)?.dimensions as string[] ??
[],
tags: comparison.tags || [],
isPublic: comparison.isPublic ?? false,
viewCount: comparison.viewCount ?? 0,
createdAt: comparison.createdAt.toISOString(),
updatedAt: comparison.updatedAt.toISOString(),
};
}
export async function getUserComparisons(userId: string) {
return db
.select()
.from(comparisons)
.where(eq(comparisons.userId, userId))
.orderBy(desc(comparisons.createdAt));
}
export async function getPublicComparisons(limit = 20) {
return db
.select()
.from(comparisons)
.where(
and(
eq(comparisons.isPublic, true),
eq(comparisons.status, "completed")
)
)
.orderBy(desc(comparisons.createdAt))
.limit(limit);
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -0,0 +1,219 @@
import { runResearch } from "@/lib/llm";
import type { ComparisonRequest } from "@/lib/llm/types";
import type { ComparisonData } from "@/lib/types";
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
function serializeSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 200);
}
export async function POST(request: Request) {
const body: { query?: string; items?: string[]; dimensions?: string[] } =
await request.json();
const { query, items, dimensions } = body;
if (!items || items.length < 2) {
return Response.json(
{ error: "At least 2 items are required" },
{ status: 400 }
);
}
const id = createId();
const title = `Comparing ${items.join(" vs ")}`;
const slug = `${slugify(title)}-${id.slice(-6)}`;
await db.insert(comparisons).values({
id,
title,
query: query || null,
slug,
status: "researching",
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const researchRequest: ComparisonRequest = {
query: query || "",
items,
dimensions,
};
let itemsCompleted = 0;
try {
for await (const progress of runResearch(researchRequest)) {
if (progress.stage === "parsing") {
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "researching",
message: progress.message,
itemsCompleted: 0,
totalItems: items.length,
currentStep: progress.message,
})
)
);
}
if (progress.stage === "researching") {
itemsCompleted++;
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "researching",
message: `Researching ${progress.item}...`,
itemsCompleted,
totalItems: items.length,
currentStep: `Analyzing ${progress.item}`,
})
)
);
}
if (progress.stage === "synthesizing") {
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "researching",
message: progress.message,
itemsCompleted: items.length,
totalItems: items.length,
currentStep: "Synthesizing results",
})
)
);
}
if (progress.stage === "complete") {
const result = progress.result;
const comparisonData: Omit<ComparisonData, "id" | "userId" | "slug" | "tags" | "isPublic" | "viewCount" | "createdAt" | "updatedAt"> = {
title,
query: query || "",
status: "completed",
summary: `${result.summary}\n\nRecommendation: ${result.recommendation}`,
items: result.items.map((item) => ({
name: item.name,
description: item.description,
overallScore: item.overallScore,
dimensions: item.dimensions,
pros: item.pros,
cons: item.cons,
})),
dimensions: result.dimensions,
};
await db
.update(comparisons)
.set({
status: "completed",
summary: comparisonData.summary,
overallData:
comparisonData as unknown as Record<string, unknown>,
updatedAt: new Date(),
})
.where(eq(comparisons.id, id));
for (let i = 0; i < result.items.length; i++) {
const item = result.items[i];
await db.insert(comparisonItems).values({
id: createId(),
comparisonId: id,
name: item.name,
description: item.description,
researchData: item as unknown as Record<string, unknown>,
scores:
item.dimensions as unknown as Record<string, unknown>,
pros: item.pros,
cons: item.cons,
order: i,
});
}
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "completed",
message: "Research complete!",
itemsCompleted: items.length,
totalItems: items.length,
currentStep: "Done",
})
)
);
controller.enqueue(
encoder.encode(
serializeSSE("done", { id, slug, data: comparisonData })
)
);
}
if (progress.stage === "error") {
await db
.update(comparisons)
.set({
status: "failed",
updatedAt: new Date(),
})
.where(eq(comparisons.id, id));
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "failed",
message: progress.error,
itemsCompleted,
totalItems: items.length,
currentStep: "Failed",
})
)
);
}
}
} catch (error) {
await db
.update(comparisons)
.set({ status: "failed", updatedAt: new Date() })
.where(eq(comparisons.id, id));
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "failed",
message: error instanceof Error ? error.message : "Unknown error",
itemsCompleted,
totalItems: items.length,
currentStep: "Failed",
})
)
);
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

View File

@@ -1,26 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "ComparAIson",
description: "AI-powered deep research comparisons",
};
export default function RootLayout({