scaffold: Next.js 15 + Drizzle + Better Auth + OpenAI + Recharts base
This commit is contained in:
25
components.json
Normal file
25
components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
74
docs/plans/IMPLEMENTATION_PLAN.md
Normal file
74
docs/plans/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# ComparAIson — Implementation Plan
|
||||
|
||||
**Goal:** Web app where users query an LLM to perform deep research comparing 2+ items, displayed with interactive visualizations, saved as blog-style posts on user profiles.
|
||||
|
||||
**Architecture:** Next.js 15 full-stack (App Router), Server Actions, SSE streaming for research progress. PostgreSQL for persistence. Better Auth for users. Multi-provider LLM research engine (Tavily → Perplexity → OpenAI).
|
||||
|
||||
**Tech Stack:** Next.js 15, TypeScript, Tailwind CSS, shadcn/ui, Recharts, Drizzle ORM, PostgreSQL 16, Better Auth, Docker Compose + Traefik
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Project Scaffold (COMPLETE)
|
||||
- [x] Next.js 15 init with TypeScript + Tailwind
|
||||
- [x] Install deps: drizzle-orm, postgres, better-auth, recharts, openai, drizzle-kit
|
||||
|
||||
## Phase 2: Database + Auth
|
||||
- [ ] 2.1 Define Drizzle schema (users, comparisons, comparison_items, comparison_dimensions)
|
||||
- [ ] 2.2 Setup Better Auth (server config + client + catch-all route)
|
||||
- [ ] 2.3 Auth UI pages (sign-in, sign-up forms with shadcn)
|
||||
|
||||
## Phase 3: LLM Research Engine
|
||||
- [ ] 3.1 Research engine types + orchestration (src/lib/llm/)
|
||||
- [ ] 3.2 OpenAI provider (structured JSON output for comparison data)
|
||||
- [ ] 3.3 SSE streaming endpoint for research progress
|
||||
- [ ] 3.4 Server Action: createComparison (kicks off research pipeline)
|
||||
|
||||
## Phase 4: Frontend — Comparison UI
|
||||
- [ ] 4.1 shadcn/ui init + core components
|
||||
- [ ] 4.2 Comparison input page (tag-style item entry, start research)
|
||||
- [ ] 4.3 Radar chart visualization (Recharts)
|
||||
- [ ] 4.4 Comparison table (feature matrix, color-coded)
|
||||
- [ ] 4.5 Score cards + pros/cons lists
|
||||
- [ ] 4.6 Grouped bar chart
|
||||
- [ ] 4.7 Comparison results page (assembles all viz components)
|
||||
|
||||
## Phase 5: User Profiles + Blog
|
||||
- [ ] 5.1 User profile page (avatar, comparison history grid)
|
||||
- [ ] 5.2 Explore/feed page (public comparisons, search, filter)
|
||||
- [ ] 5.3 Layout + navigation (header, sidebar, footer)
|
||||
|
||||
## Phase 6: Deploy
|
||||
- [ ] 6.1 Docker Compose (Next.js standalone + PostgreSQL)
|
||||
- [ ] 6.2 Traefik route + domain config
|
||||
- [ ] 6.3 Environment secrets
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### users (managed by Better Auth)
|
||||
- id, name, email, emailVerified, image, createdAt, updatedAt
|
||||
|
||||
### comparisons
|
||||
- id, userId, title, query, slug, status (researching|completed|failed)
|
||||
- summary, overallData (JSONB), tags (text[]), isPublic, viewCount
|
||||
- createdAt, updatedAt
|
||||
|
||||
### comparison_items
|
||||
- id, comparisonId, name, description, imageUrl
|
||||
- researchData (JSONB), scores (JSONB), pros (text[]), cons (text[]), order
|
||||
|
||||
### comparison_dimensions
|
||||
- id, comparisonId, name, description, weight, order
|
||||
|
||||
## JSONB Schema for comparison_data
|
||||
```json
|
||||
{
|
||||
"overall_score": 8.5,
|
||||
"dimensions": {
|
||||
"price": { "score": 9, "summary": "...", "details": "..." },
|
||||
"performance": { "score": 8, "summary": "...", "benchmark_data": [...] },
|
||||
"ease_of_use": { "score": 9, "summary": "...", "pros": [...], "cons": [...] }
|
||||
}
|
||||
}
|
||||
```
|
||||
8
drizzle.config.ts
Normal file
8
drizzle.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: { url: process.env.DATABASE_URL! },
|
||||
});
|
||||
42
drizzle/0000_opposite_doomsday.sql
Normal file
42
drizzle/0000_opposite_doomsday.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE "comparison_dimensions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"comparison_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"weight" integer DEFAULT 1,
|
||||
"order" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comparison_items" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"comparison_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"image_url" text,
|
||||
"research_data" jsonb,
|
||||
"scores" jsonb,
|
||||
"pros" text[],
|
||||
"cons" text[],
|
||||
"order" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comparisons" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text,
|
||||
"title" text NOT NULL,
|
||||
"query" text,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"status" text DEFAULT 'researching' NOT NULL,
|
||||
"summary" text,
|
||||
"overall_data" jsonb,
|
||||
"tags" text[],
|
||||
"is_public" boolean DEFAULT false,
|
||||
"view_count" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "comparisons_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "comparison_dimensions" ADD CONSTRAINT "comparison_dimensions_comparison_id_comparisons_id_fk" FOREIGN KEY ("comparison_id") REFERENCES "public"."comparisons"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comparison_items" ADD CONSTRAINT "comparison_items_comparison_id_comparisons_id_fk" FOREIGN KEY ("comparison_id") REFERENCES "public"."comparisons"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "comparisons_user_id_idx" ON "comparisons" USING btree ("user_id");
|
||||
290
drizzle/meta/0000_snapshot.json
Normal file
290
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,290 @@
|
||||
{
|
||||
"id": "6cc67b11-8016-409b-9de9-8966593c97b0",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.comparison_dimensions": {
|
||||
"name": "comparison_dimensions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"comparison_id": {
|
||||
"name": "comparison_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"comparison_dimensions_comparison_id_comparisons_id_fk": {
|
||||
"name": "comparison_dimensions_comparison_id_comparisons_id_fk",
|
||||
"tableFrom": "comparison_dimensions",
|
||||
"tableTo": "comparisons",
|
||||
"columnsFrom": [
|
||||
"comparison_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.comparison_items": {
|
||||
"name": "comparison_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"comparison_id": {
|
||||
"name": "comparison_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"research_data": {
|
||||
"name": "research_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scores": {
|
||||
"name": "scores",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pros": {
|
||||
"name": "pros",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cons": {
|
||||
"name": "cons",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"comparison_items_comparison_id_comparisons_id_fk": {
|
||||
"name": "comparison_items_comparison_id_comparisons_id_fk",
|
||||
"tableFrom": "comparison_items",
|
||||
"tableTo": "comparisons",
|
||||
"columnsFrom": [
|
||||
"comparison_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.comparisons": {
|
||||
"name": "comparisons",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"query": {
|
||||
"name": "query",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'researching'"
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"overall_data": {
|
||||
"name": "overall_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
},
|
||||
"view_count": {
|
||||
"name": "view_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"comparisons_user_id_idx": {
|
||||
"name": "comparisons_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"comparisons_slug_unique": {
|
||||
"name": "comparisons_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1777066133958,
|
||||
"tag": "0000_opposite_doomsday",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
5807
package-lock.json
generated
5807
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -9,15 +9,29 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@paralleldrive/cuid2": "^3.3.0",
|
||||
"better-auth": "^1.6.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"openai": "^6.34.0",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.4.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
81
src/app/(auth)/sign-in/page.tsx
Normal file
81
src/app/(auth)/sign-in/page.tsx
Normal 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't have an account?{" "}
|
||||
<Link href="/sign-up" className="text-primary underline">
|
||||
Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/app/(auth)/sign-up/page.tsx
Normal file
94
src/app/(auth)/sign-up/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/app/(main)/compare/[slug]/page.tsx
Normal file
41
src/app/(main)/compare/[slug]/page.tsx
Normal 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} />
|
||||
}
|
||||
151
src/app/(main)/compare/[slug]/results-client.tsx
Normal file
151
src/app/(main)/compare/[slug]/results-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
src/app/(main)/compare/page.tsx
Normal file
186
src/app/(main)/compare/page.tsx
Normal 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
50
src/app/(main)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
src/app/actions/comparison.ts
Normal file
114
src/app/actions/comparison.ts
Normal 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);
|
||||
}
|
||||
4
src/app/api/auth/[...all]/route.ts
Normal file
4
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
219
src/app/api/compare/route.ts
Normal file
219
src/app/api/compare/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
69
src/components/comparison/bar-chart.tsx
Normal file
69
src/components/comparison/bar-chart.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
CartesianAxis,
|
||||
} from "recharts"
|
||||
import type { ResearchResult } from "@/lib/types"
|
||||
import { getItemColor } from "@/lib/types"
|
||||
|
||||
interface ComparisonBarChartProps {
|
||||
items: ResearchResult[]
|
||||
dimensions: string[]
|
||||
}
|
||||
|
||||
export function ComparisonBarChart({ items, dimensions }: ComparisonBarChartProps) {
|
||||
const data = dimensions.map((dim) => {
|
||||
const entry: Record<string, string | number> = { dimension: dim }
|
||||
for (const item of items) {
|
||||
entry[item.name] = item.dimensions[dim]?.score ?? 0
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||
<CartesianAxis stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="dimension"
|
||||
tick={{ fill: "var(--foreground)", fontSize: 12 }}
|
||||
axisLine={{ stroke: "var(--border)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 10]}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
color: "var(--popover-foreground)",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
{items.map((item, index) => (
|
||||
<Bar
|
||||
key={item.name}
|
||||
dataKey={item.name}
|
||||
fill={getItemColor(index)}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
152
src/components/comparison/comparison-table.tsx
Normal file
152
src/components/comparison/comparison-table.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { ResearchResult } from "@/lib/types"
|
||||
import { getItemColor } from "@/lib/types"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
|
||||
interface ComparisonTableProps {
|
||||
items: ResearchResult[]
|
||||
dimensions: string[]
|
||||
}
|
||||
|
||||
function getScoreColor(score: number, best: number, worst: number): string {
|
||||
if (score === best) return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
if (score === worst) return "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300"
|
||||
const ratio = best === worst ? 0.5 : (score - worst) / (best - worst)
|
||||
if (ratio >= 0.6) return "bg-yellow-50 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
|
||||
return "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
|
||||
}
|
||||
|
||||
export function ComparisonTable({ items, dimensions }: ComparisonTableProps) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleRow = (dim: string) => {
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(dim)) next.delete(dim)
|
||||
else next.add(dim)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground w-40">
|
||||
Dimension
|
||||
</th>
|
||||
{items.map((item, i) => (
|
||||
<th
|
||||
key={item.name}
|
||||
className="text-center py-3 px-4 font-medium min-w-[140px]"
|
||||
style={{ color: getItemColor(i) }}
|
||||
>
|
||||
{item.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dimensions.map((dim) => {
|
||||
const scores = items.map((item) => item.dimensions[dim]?.score ?? 0)
|
||||
const best = Math.max(...scores)
|
||||
const worst = Math.min(...scores)
|
||||
const isExpanded = expandedRows.has(dim)
|
||||
|
||||
return (
|
||||
<tr key={dim} className="border-b last:border-b-0">
|
||||
<td className="py-3 px-4">
|
||||
<button
|
||||
onClick={() => toggleRow(dim)}
|
||||
className="flex items-center gap-1.5 font-medium hover:text-primary transition-colors text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
{dim}
|
||||
</button>
|
||||
</td>
|
||||
{items.map((item) => {
|
||||
const score = item.dimensions[dim]?.score ?? 0
|
||||
return (
|
||||
<td key={item.name} className="text-center py-3 px-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center size-8 rounded-full text-xs font-semibold ${getScoreColor(score, best, worst)}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<tr className="border-t-2 bg-muted/30 font-semibold">
|
||||
<td className="py-3 px-4">Overall</td>
|
||||
{items.map((item, i) => {
|
||||
const scores = items.map((it) => it.overallScore)
|
||||
const best = Math.max(...scores)
|
||||
const worst = Math.min(...scores)
|
||||
return (
|
||||
<td key={item.name} className="text-center py-3 px-4">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center size-10 rounded-full text-sm font-bold ${getScoreColor(item.overallScore, best, worst)}`}
|
||||
>
|
||||
{item.overallScore.toFixed(1)}
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{Array.from(expandedRows).map((dim) => {
|
||||
const dimData = items.map((item) => ({
|
||||
name: item.name,
|
||||
...item.dimensions[dim],
|
||||
}))
|
||||
return (
|
||||
<div key={`expanded-${dim}`} className="border-t bg-muted/20 p-4">
|
||||
<h4 className="font-medium mb-3 text-sm">{dim} — Details</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{dimData.map((d) => (
|
||||
<div key={d.name} className="rounded-lg border bg-card p-3 text-sm">
|
||||
<p className="font-medium mb-1">{d.name}</p>
|
||||
<p className="text-muted-foreground text-xs mb-2">{d.summary}</p>
|
||||
{d.pros && d.pros.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<span className="text-xs font-medium text-emerald-600">Pros:</span>
|
||||
<ul className="text-xs text-muted-foreground ml-3 list-disc">
|
||||
{d.pros.map((p, i) => (
|
||||
<li key={i}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{d.cons && d.cons.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-red-600">Cons:</span>
|
||||
<ul className="text-xs text-muted-foreground ml-3 list-disc">
|
||||
{d.cons.map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
src/components/comparison/pros-cons-card.tsx
Normal file
76
src/components/comparison/pros-cons-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import type { ResearchResult } from "@/lib/types"
|
||||
import { getItemColor } from "@/lib/types"
|
||||
import { useState } from "react"
|
||||
|
||||
interface ProsConsCardProps {
|
||||
item: ResearchResult
|
||||
index: number
|
||||
}
|
||||
|
||||
export function ProsConsCard({ item, index }: ProsConsCardProps) {
|
||||
const color = getItemColor(index)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const displayPros = expanded ? item.pros : item.pros.slice(0, 5)
|
||||
const displayCons = expanded ? item.cons : item.cons.slice(0, 5)
|
||||
const hasMore = item.pros.length > 5 || item.cons.length > 5
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div
|
||||
className="absolute top-0 left-0 bottom-0 w-1 rounded-l-xl"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<CardHeader className="pb-2 pl-5">
|
||||
<CardTitle className="text-base">{item.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-emerald-600 dark:text-emerald-400 mb-2">
|
||||
Pros
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{displayPros.map((pro, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="mt-1.5 size-1.5 rounded-full bg-emerald-500 shrink-0" />
|
||||
<span>{pro}</span>
|
||||
</li>
|
||||
))}
|
||||
{item.pros.length === 0 && (
|
||||
<li className="text-xs text-muted-foreground">No pros listed</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">
|
||||
Cons
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{displayCons.map((con, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="mt-1.5 size-1.5 rounded-full bg-red-500 shrink-0" />
|
||||
<span>{con}</span>
|
||||
</li>
|
||||
))}
|
||||
{item.cons.length === 0 && (
|
||||
<li className="text-xs text-muted-foreground">No cons listed</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? "Show less" : `Show all (${item.pros.length + item.cons.length} items)`}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
68
src/components/comparison/radar-chart.tsx
Normal file
68
src/components/comparison/radar-chart.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Radar,
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts"
|
||||
import type { ResearchResult } from "@/lib/types"
|
||||
import { getItemColor } from "@/lib/types"
|
||||
|
||||
interface ComparisonRadarChartProps {
|
||||
items: ResearchResult[]
|
||||
dimensions: string[]
|
||||
}
|
||||
|
||||
export function ComparisonRadarChart({ items, dimensions }: ComparisonRadarChartProps) {
|
||||
const data = dimensions.map((dim) => {
|
||||
const entry: Record<string, string | number> = { dimension: dim }
|
||||
for (const item of items) {
|
||||
entry[item.name] = item.dimensions[dim]?.score ?? 0
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={data} cx="50%" cy="50%" outerRadius="75%">
|
||||
<PolarGrid stroke="var(--border)" />
|
||||
<PolarAngleAxis
|
||||
dataKey="dimension"
|
||||
tick={{ fill: "var(--foreground)", fontSize: 12 }}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[0, 10]}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 10 }}
|
||||
/>
|
||||
{items.map((item, index) => (
|
||||
<Radar
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
dataKey={item.name}
|
||||
stroke={getItemColor(index)}
|
||||
fill={getItemColor(index)}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
color: "var(--popover-foreground)",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
src/components/comparison/score-card.tsx
Normal file
93
src/components/comparison/score-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import type { ResearchResult } from "@/lib/types"
|
||||
import { getItemColor } from "@/lib/types"
|
||||
|
||||
interface ScoreCardProps {
|
||||
item: ResearchResult
|
||||
index: number
|
||||
dimensions: string[]
|
||||
}
|
||||
|
||||
function ScoreRing({ score, color, size = 80 }: { score: number; color: string; size?: number }) {
|
||||
const radius = (size - 8) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const progress = (score / 10) * circumference
|
||||
const center = size / 2
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--muted)"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="4"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - progress}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScoreCard({ item, index, dimensions }: ScoreCardProps) {
|
||||
const color = getItemColor(index)
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden">
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{item.name}</CardTitle>
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{item.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<ScoreRing score={item.overallScore} color={color} />
|
||||
<span className="absolute text-lg font-bold" style={{ color }}>
|
||||
{item.overallScore.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
{dimensions.slice(0, 4).map((dim) => {
|
||||
const dimScore = item.dimensions[dim]?.score ?? 0
|
||||
return (
|
||||
<div key={dim} className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-24 truncate">{dim}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-700 ease-out"
|
||||
style={{
|
||||
width: `${dimScore * 10}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.7 + (dimScore / 10) * 0.3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium w-6 text-right">{dimScore}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
109
src/components/ui/avatar.tsx
Normal file
109
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
268
src/components/ui/dropdown-menu.tsx
Normal file
268
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
82
src/components/ui/tabs.tsx
Normal file
82
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
157
src/hooks/use-comparison-stream.ts
Normal file
157
src/hooks/use-comparison-stream.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { ResearchProgress } from "@/lib/types";
|
||||
import type { ComparisonData } from "@/lib/types";
|
||||
|
||||
interface UseComparisonStreamReturn {
|
||||
progress: ResearchProgress;
|
||||
result: ComparisonData | null;
|
||||
error: string | null;
|
||||
isStreaming: boolean;
|
||||
startResearch: (
|
||||
query: string,
|
||||
items: string[],
|
||||
dimensions?: string[]
|
||||
) => void;
|
||||
cancel: () => void;
|
||||
comparisonId: string | null;
|
||||
comparisonSlug: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_PROGRESS: ResearchProgress = {
|
||||
status: "idle",
|
||||
message: "",
|
||||
itemsCompleted: 0,
|
||||
totalItems: 0,
|
||||
currentStep: "",
|
||||
};
|
||||
|
||||
export function useComparisonStream(): UseComparisonStreamReturn {
|
||||
const [progress, setProgress] =
|
||||
useState<ResearchProgress>(INITIAL_PROGRESS);
|
||||
const [result, setResult] = useState<ComparisonData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [comparisonId, setComparisonId] = useState<string | null>(null);
|
||||
const [comparisonSlug, setComparisonSlug] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
setProgress(INITIAL_PROGRESS);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const startResearch = useCallback(
|
||||
async (query: string, items: string[], dimensions?: string[]) => {
|
||||
abortControllerRef.current?.abort();
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setProgress({
|
||||
status: "researching",
|
||||
message: "Starting research...",
|
||||
itemsCompleted: 0,
|
||||
totalItems: items.length,
|
||||
currentStep: "Initializing",
|
||||
});
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setComparisonId(null);
|
||||
setComparisonSlug(null);
|
||||
setIsStreaming(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/compare", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, items, dimensions }),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Failed to start comparison");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let currentEvent = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ") && currentEvent) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (currentEvent === "progress") {
|
||||
setProgress(data);
|
||||
if (data.status === "failed") {
|
||||
setError(data.message);
|
||||
}
|
||||
} else if (currentEvent === "done") {
|
||||
setComparisonId(data.id);
|
||||
setComparisonSlug(data.slug);
|
||||
if (data.data) {
|
||||
setResult(data.data);
|
||||
}
|
||||
} else if (currentEvent === "error") {
|
||||
setError(data.error);
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
status: "failed",
|
||||
message: data.error,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// skip malformed JSON
|
||||
}
|
||||
currentEvent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown error";
|
||||
setError(message);
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
status: "failed",
|
||||
message,
|
||||
}));
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
progress,
|
||||
result,
|
||||
error,
|
||||
isStreaming,
|
||||
startResearch,
|
||||
cancel,
|
||||
comparisonId,
|
||||
comparisonSlug,
|
||||
};
|
||||
}
|
||||
4
src/lib/auth-client.ts
Normal file
4
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||
8
src/lib/auth.ts
Normal file
8
src/lib/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "./db";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
8
src/lib/db/index.ts
Normal file
8
src/lib/db/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
66
src/lib/db/schema.ts
Normal file
66
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
integer,
|
||||
boolean,
|
||||
varchar,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const comparisons = pgTable(
|
||||
"comparisons",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id"),
|
||||
title: text("title").notNull(),
|
||||
query: text("query"),
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
status: text("status", {
|
||||
enum: ["researching", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("researching"),
|
||||
summary: text("summary"),
|
||||
overallData: jsonb("overall_data"),
|
||||
tags: text("tags").array(),
|
||||
isPublic: boolean("is_public").default(false),
|
||||
viewCount: integer("view_count").default(0),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("comparisons_user_id_idx").on(table.userId)]
|
||||
);
|
||||
|
||||
export const comparisonItems = pgTable("comparison_items", {
|
||||
id: text("id").primaryKey(),
|
||||
comparisonId: text("comparison_id")
|
||||
.notNull()
|
||||
.references(() => comparisons.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
imageUrl: text("image_url"),
|
||||
researchData: jsonb("research_data"),
|
||||
scores: jsonb("scores"),
|
||||
pros: text("pros").array(),
|
||||
cons: text("cons").array(),
|
||||
order: integer("order").notNull(),
|
||||
});
|
||||
|
||||
export const comparisonDimensions = pgTable("comparison_dimensions", {
|
||||
id: text("id").primaryKey(),
|
||||
comparisonId: text("comparison_id")
|
||||
.notNull()
|
||||
.references(() => comparisons.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
weight: integer("weight").default(1),
|
||||
order: integer("order").notNull(),
|
||||
});
|
||||
|
||||
export type Comparison = typeof comparisons.$inferSelect;
|
||||
export type NewComparison = typeof comparisons.$inferInsert;
|
||||
export type ComparisonItem = typeof comparisonItems.$inferSelect;
|
||||
export type NewComparisonItem = typeof comparisonItems.$inferInsert;
|
||||
export type ComparisonDimension = typeof comparisonDimensions.$inferSelect;
|
||||
65
src/lib/llm/index.ts
Normal file
65
src/lib/llm/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
ComparisonRequest,
|
||||
ComparisonResult,
|
||||
ResearchProgress,
|
||||
} from "./types";
|
||||
import { generateComparison } from "./providers/openai";
|
||||
|
||||
export type {
|
||||
ComparisonRequest,
|
||||
ComparisonResult,
|
||||
ResearchProgress,
|
||||
} from "./types";
|
||||
|
||||
export async function* runResearch(
|
||||
request: ComparisonRequest
|
||||
): AsyncGenerator<ResearchProgress> {
|
||||
yield { stage: "parsing", message: "Analyzing comparison request..." };
|
||||
|
||||
if (!request.items || request.items.length < 2) {
|
||||
yield {
|
||||
stage: "error",
|
||||
error: "At least 2 items are required for comparison",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < request.items.length; i++) {
|
||||
yield {
|
||||
stage: "researching",
|
||||
item: request.items[i],
|
||||
progress: Math.round(((i + 0.5) / request.items.length) * 80),
|
||||
};
|
||||
}
|
||||
|
||||
yield {
|
||||
stage: "synthesizing",
|
||||
message: "Synthesizing research into structured comparison...",
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await generateComparison(request);
|
||||
yield { stage: "complete", result };
|
||||
} catch (error) {
|
||||
yield {
|
||||
stage: "error",
|
||||
error:
|
||||
error instanceof Error ? error.message : "Research failed unexpectedly",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeResearch(
|
||||
request: ComparisonRequest
|
||||
): Promise<ComparisonResult> {
|
||||
for await (const progress of runResearch(request)) {
|
||||
if (progress.stage === "complete") {
|
||||
return progress.result;
|
||||
}
|
||||
if (progress.stage === "error") {
|
||||
throw new Error(progress.error);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Research completed without producing a result");
|
||||
}
|
||||
142
src/lib/llm/providers/openai.ts
Normal file
142
src/lib/llm/providers/openai.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import OpenAI from "openai";
|
||||
import type {
|
||||
ComparisonRequest,
|
||||
ComparisonResult,
|
||||
DimensionResult,
|
||||
ItemResearch,
|
||||
} from "../types";
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
const SYSTEM_PROMPT = `You are an expert research analyst. Your job is to compare items across multiple dimensions and produce structured, insightful comparison data.
|
||||
|
||||
When given a list of items to compare:
|
||||
1. Identify 5-8 relevant comparison dimensions (e.g., price, performance, ease of use, ecosystem, community support, scalability, documentation, etc.)
|
||||
2. Research each item across each dimension
|
||||
3. Score each item 1-10 per dimension (10 = best)
|
||||
4. Generate pros and cons lists for each item
|
||||
5. Write a concise summary comparison
|
||||
6. Provide a clear recommendation
|
||||
|
||||
You MUST respond with valid JSON matching this exact structure:
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "item name",
|
||||
"description": "brief overview of this item in context of the comparison",
|
||||
"overallScore": 7.5,
|
||||
"dimensions": {
|
||||
"Dimension Name": {
|
||||
"score": 8,
|
||||
"summary": "brief assessment",
|
||||
"details": "detailed analysis with specific data points",
|
||||
"pros": ["pro 1", "pro 2"],
|
||||
"cons": ["con 1", "con 2"]
|
||||
}
|
||||
},
|
||||
"pros": ["overall pro 1", "overall pro 2"],
|
||||
"cons": ["overall con 1", "overall con 2"],
|
||||
"sources": [
|
||||
{ "title": "source title", "url": "https://...", "snippet": "relevant excerpt" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": ["Dimension 1", "Dimension 2", ...],
|
||||
"summary": "overall comparison summary highlighting key differences and trade-offs",
|
||||
"recommendation": "clear recommendation with reasoning"
|
||||
}
|
||||
|
||||
Important:
|
||||
- Be factual and specific. Include real data where possible.
|
||||
- Scores should be on a 1-10 scale where 10 is best.
|
||||
- Provide at least 2-3 pros and cons per item.
|
||||
- The top-level pros/cons for each item should be the most significant overall points.
|
||||
- Dimension-level pros/cons should be specific to that dimension.
|
||||
- Sources should be realistic URLs to relevant documentation or resources.
|
||||
- The recommendation should consider different use cases when appropriate.`;
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function validateComparisonResult(data: unknown): data is ComparisonResult {
|
||||
if (!data || typeof data !== "object") return false;
|
||||
const result = data as Record<string, unknown>;
|
||||
if (!Array.isArray(result.items)) return false;
|
||||
if (!Array.isArray(result.dimensions)) return false;
|
||||
if (typeof result.summary !== "string") return false;
|
||||
if (typeof result.recommendation !== "string") return false;
|
||||
|
||||
for (const item of result.items as ItemResearch[]) {
|
||||
if (typeof item.name !== "string") return false;
|
||||
if (typeof item.description !== "string") return false;
|
||||
if (typeof item.overallScore !== "number") return false;
|
||||
if (!Array.isArray(item.pros)) return false;
|
||||
if (!Array.isArray(item.cons)) return false;
|
||||
if (typeof item.dimensions !== "object") return false;
|
||||
|
||||
for (const dim of Object.values(item.dimensions) as DimensionResult[]) {
|
||||
if (typeof dim.score !== "number") return false;
|
||||
if (typeof dim.summary !== "string") return false;
|
||||
if (typeof dim.details !== "string") return false;
|
||||
if (!Array.isArray(dim.pros)) return false;
|
||||
if (!Array.isArray(dim.cons)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function generateComparison(
|
||||
request: ComparisonRequest
|
||||
): Promise<ComparisonResult> {
|
||||
const userPrompt = `Compare the following items: ${request.items.join(", ")}
|
||||
${request.query ? `Focus: ${request.query}` : ""}
|
||||
${request.dimensions?.length ? `Specific dimensions to include: ${request.dimensions.join(", ")}` : ""}
|
||||
|
||||
Provide a comprehensive comparison with scores, pros/cons, and a recommendation.`;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await client.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("Empty response from OpenAI");
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
|
||||
if (!validateComparisonResult(parsed)) {
|
||||
throw new Error("Invalid comparison result structure from OpenAI");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await sleep(RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to generate comparison after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
||||
);
|
||||
}
|
||||
37
src/lib/llm/types.ts
Normal file
37
src/lib/llm/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface ComparisonRequest {
|
||||
query: string;
|
||||
items: string[];
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export interface DimensionResult {
|
||||
score: number;
|
||||
summary: string;
|
||||
details: string;
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
}
|
||||
|
||||
export interface ItemResearch {
|
||||
name: string;
|
||||
description: string;
|
||||
overallScore: number;
|
||||
dimensions: Record<string, DimensionResult>;
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
sources: { title: string; url: string; snippet: string }[];
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
items: ItemResearch[];
|
||||
dimensions: string[];
|
||||
summary: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export type ResearchProgress =
|
||||
| { stage: "parsing"; message: string }
|
||||
| { stage: "researching"; item: string; progress: number }
|
||||
| { stage: "synthesizing"; message: string }
|
||||
| { stage: "complete"; result: ComparisonResult }
|
||||
| { stage: "error"; error: string };
|
||||
65
src/lib/types.ts
Normal file
65
src/lib/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface DimensionScore {
|
||||
score: number
|
||||
summary: string
|
||||
details: string
|
||||
pros?: string[]
|
||||
cons?: string[]
|
||||
benchmarkData?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export interface ResearchResult {
|
||||
name: string
|
||||
description: string
|
||||
imageUrl?: string
|
||||
overallScore: number
|
||||
dimensions: Record<string, DimensionScore>
|
||||
pros: string[]
|
||||
cons: string[]
|
||||
}
|
||||
|
||||
export interface ComparisonData {
|
||||
id: string
|
||||
userId: string
|
||||
title: string
|
||||
query: string
|
||||
slug: string
|
||||
status: "researching" | "completed" | "failed"
|
||||
summary: string
|
||||
items: ResearchResult[]
|
||||
dimensions: string[]
|
||||
tags: string[]
|
||||
isPublic: boolean
|
||||
viewCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: "status" | "item" | "dimension" | "complete" | "error"
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export interface ResearchProgress {
|
||||
status: "idle" | "researching" | "completed" | "failed"
|
||||
message: string
|
||||
itemsCompleted: number
|
||||
totalItems: number
|
||||
currentStep: string
|
||||
}
|
||||
|
||||
export const ITEM_COLORS = [
|
||||
"#3b82f6",
|
||||
"#14b8a6",
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#f43f5e",
|
||||
"#eab308",
|
||||
"#06b6d4",
|
||||
"#ec4899",
|
||||
"#84cc16",
|
||||
"#6366f1",
|
||||
]
|
||||
|
||||
export function getItemColor(index: number): string {
|
||||
return ITEM_COLORS[index % ITEM_COLORS.length]
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
36
src/middleware.ts
Normal file
36
src/middleware.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
const publicPaths = ["/sign-in", "/sign-up", "/api/auth"];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const isPublic = publicPaths.some(
|
||||
(path) => pathname === path || pathname.startsWith(path + "/"),
|
||||
);
|
||||
|
||||
if (isPublic) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/_next") || pathname.startsWith("/favicon")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
const signInUrl = new URL("/sign-in", request.url);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user