34 Commits

Author SHA1 Message Date
Christopher Mayor
eab1618d04 fix #12: pass full schema to drizzle adapter 2026-04-27 11:26:34 -07:00
Christopher Mayor
273b600e98 fix #12: simplify auth adapter, add verifications table 2026-04-27 11:22:42 -07:00
Christopher Mayor
024f3cb1f7 fix #12: add missing session fields ipAddress and userAgent to Drizzle schema 2026-04-27 11:01:30 -07:00
Christopher Mayor
cd51f2a0c8 fix #12: add accounts table for Better Auth credential storage 2026-04-27 10:57:15 -07:00
Christopher Mayor
4d5e1502e9 fix #9 #10 #11: fix email_verified schema, add auth gate to compare, use real user id 2026-04-27 10:33:22 -07:00
Christopher Mayor
56b6f67d00 fix: update docker-compose to use shared postgres and Traefik labels 2026-04-26 22:16:24 -07:00
Christopher Mayor
370fd2d8e6 fix: map users/sessions schema to better-auth expected names 2026-04-26 22:06:24 -07:00
Christopher Mayor
089de443a0 docs: update deployment section with current production state
- Document production URL (comparaison.local.tophermayor.com)
- Detail host (ubuntu/192.168.50.61), Traefik ingress, shared Postgres
- Add Docker label routing, proxy-net network info
- List recent fixes: userId in comparison inserts, OpenAI getClient(), BETTER_AUTH_SECRET
2026-04-26 17:39:40 -07:00
Christopher Mayor
78e1c74fa3 fix: use getClient() instead of undefined client in openai provider 2026-04-26 16:55:59 -07:00
Christopher Mayor
d9ed1586cc fix: use title as fallback query instead of null in compare route 2026-04-26 16:53:21 -07:00
Christopher Mayor
5187d75d53 fix: add userId to comparison inserts (placeholder 'system' until auth is wired) 2026-04-26 16:50:07 -07:00
Christopher Mayor
8d2239aebd fix: use viewCount instead of views in profile page 2026-04-26 16:44:21 -07:00
Christopher Mayor
0b523b7274 fix: replace mockUser/mockComparisons with proper local variables in profile page 2026-04-26 16:43:25 -07:00
Christopher Mayor
db30a7e178 Merge branch 'feat/wire-pages' 2026-04-26 15:58:04 -07:00
Christopher Mayor
50fd4cda6a Merge branch 'feat/api-endpoints' 2026-04-26 15:58:04 -07:00
Christopher Mayor
565085aba1 feat: wire up explore and profile pages
Updated explore and profile page components.
2026-04-26 15:58:00 -07:00
Christopher Mayor
c9e6e156ac feat: add comparison and user API endpoints
New API routes under src/app/api/ for comparisons and user operations.
2026-04-26 15:57:58 -07:00
Christopher Mayor
494dcb91fa fix: remove duplicate users table definition in schema
The users table was defined twice with conflicting field orderings
(timestamp vs boolean for emailVerified, different default placements).
Kept the cleaner definition and removed the duplicate.
2026-04-26 15:55:47 -07:00
Christopher Mayor
3c5df6a74c docs: add v0.2-v0.4 implementation plan for feed, profile, auth, and search 2026-04-26 01:16:35 -07:00
Christopher Mayor
7888d7995c chore: update drizzle schema and migrations 2026-04-24 15:04:29 -07:00
Christopher Mayor
3689b1707a chore: gitignore worktree directories 2026-04-24 15:03:14 -07:00
Christopher Mayor
aac0e2f5b1 docs: comprehensive documentation - README, specs, architecture, API reference, UI/UX flow, dev guide 2026-04-24 15:02:40 -07:00
Christopher Mayor
5bde4e3aa6 Merge branch 'feat/backend' 2026-04-24 15:01:37 -07:00
Christopher Mayor
6832fbdebb Merge branch 'feat/frontend' 2026-04-24 15:01:21 -07:00
Christopher Mayor
43f011e519 feat: complete frontend UI - comparison views, profile, explore, layout 2026-04-24 14:39:54 -07:00
Christopher Mayor
37c07e468d fix: lazy-init OpenAI client to avoid build failure without API key 2026-04-24 14:37:28 -07:00
Christopher Mayor
a273f29e07 feat: improve compare API route with searching stage and validation 2026-04-24 14:36:06 -07:00
Christopher Mayor
2f4239a83b feat: add provider fallback chain with priority system 2026-04-24 14:35:20 -07:00
Christopher Mayor
3539a5f3eb feat: add .env.example with required environment variables 2026-04-24 14:35:19 -07:00
Christopher Mayor
e13b1ea2d5 feat: add Perplexity Sonar provider with OpenAI fallback 2026-04-24 14:34:55 -07:00
Christopher Mayor
26d879c82e feat: add standalone output to next.config.ts for Docker builds 2026-04-24 14:34:50 -07:00
Christopher Mayor
637f1540cf feat: update research pipeline with Tavily search and progress stages 2026-04-24 14:34:22 -07:00
Christopher Mayor
71ef567d0d feat: enhance OpenAI provider with web research context 2026-04-24 14:33:50 -07:00
Christopher Mayor
3a448a5063 feat: add Tavily search provider 2026-04-24 14:33:20 -07:00
36 changed files with 3282 additions and 254 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/comparaison
BETTER_AUTH_SECRET=change-me-to-random-string
OPENAI_API_KEY=
PERPLEXITY_API_KEY=
TAVILY_API_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000

6
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
@@ -39,3 +40,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Worktrees (tracked via branches)
comparaison-backend/
comparaison-llm/
comparaison-ui/

190
README.md
View File

@@ -1,36 +1,186 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# ComparAIson
## Getting Started
**AI-Powered Deep Research Comparison Platform**
First, run the development server:
Compare anything with intelligent, multi-dimensional analysis. ComparAIson uses LLM-powered research to generate comprehensive, visual comparisons — then saves them as shareable posts on your profile.
## Features
- **Deep Research Engine** — Multi-provider LLM pipeline (Tavily search → Perplexity/OpenAI synthesis) with automatic fallback
- **Interactive Visualizations** — Radar charts, grouped bar charts, feature comparison tables, score cards, pros/cons breakdowns
- **Real-Time Streaming** — Watch research progress live via Server-Sent Events
- **User Profiles** — Save comparisons to your profile, browse a public feed
- **Self-Hosted** — Docker Compose deployment, runs on a Raspberry Pi
## Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router, Server Components, Server Actions) |
| Language | TypeScript |
| Database | PostgreSQL 16 |
| ORM | Drizzle ORM |
| Auth | Better Auth (email + password) |
| UI | Tailwind CSS + shadcn/ui |
| Charts | Recharts |
| LLM | OpenAI GPT-4o-mini + Tavily Search + Perplexity Sonar |
| Deployment | Docker Compose + Traefik reverse proxy |
## Quick Start
### Prerequisites
- Node.js 20+
- PostgreSQL 16+ (or Docker)
- At least one LLM API key
### 1. Clone & Install
```bash
git clone https://gitea.tophermayor.com/TopherMayor/comparaison.git
cd comparaison
npm install
```
### 2. Configure Environment
```bash
cp .env.example .env.local
# Edit .env.local with your API keys and database URL
```
Required environment variables:
| Variable | Description | Required |
|---|---|---|
| `DATABASE_URL` | PostgreSQL connection string | Yes |
| `BETTER_AUTH_SECRET` | Random secret for session signing | Yes |
| `OPENAI_API_KEY` | OpenAI API key (GPT-4o-mini) | Yes* |
| `TAVILY_API_KEY` | Tavily search API key | Recommended |
| `PERPLEXITY_API_KEY` | Perplexity Sonar API key | Optional |
| `NEXT_PUBLIC_APP_URL` | Public URL of the app | Yes |
*At minimum, one LLM provider key is required. OpenAI works standalone; Tavily adds web search; Perplexity adds cheaper synthesis.
### 3. Database Setup
```bash
# Generate migration
npx drizzle-kit generate
# Run migration
npx drizzle-kit migrate
```
### 4. Development
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
# App runs at http://localhost:3000
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### 5. Docker Deployment
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
docker compose up -d
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Deployment
## Learn More
**Production URL:** [https://comparaison.local.tophermayor.com](https://comparaison.local.tophermayor.com)
To learn more about Next.js, take a look at the following resources:
| Detail | Value |
|---|---|
| Host | `ubuntu` (`192.168.50.61`) |
| Compose file | `/srv/compose/comparaison/docker-compose.yml` |
| Reverse proxy | Traefik (shared instance on `proxy-net`) |
| Database | Shared PostgreSQL (`postgres-shared` container on `proxy-net`) |
| Routing | Docker labels on the app container (Traefik router/rules) |
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
### Production Setup
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
1. **Traefik Ingress** — A shared Traefik instance handles TLS termination and routes traffic to the app container via Docker labels. The app joins the `proxy-net` network so Traefik can reach it.
## Deploy on Vercel
2. **Shared PostgreSQL** — A standalone `postgres-shared` container provides the database. The comparaison app connects to it over `proxy-net`. No separate DB container is defined in the app's compose file.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
3. **Environment** — The following are configured in the production environment:
- `DATABASE_URL` — Points to the shared Postgres instance
- `BETTER_AUTH_SECRET` — Random secret for session signing
- `OPENAI_API_KEY`, `TAVILY_API_KEY`, `PERPLEXITY_API_KEY` — LLM provider keys
- `NEXT_PUBLIC_APP_URL``https://comparaison.local.tophermayor.com`
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Deploying Updates
```bash
# On ubuntu (192.168.50.61)
cd /srv/compose/comparaison
docker compose pull && docker compose up -d
```
### Recent Fixes
- Added `userId` to comparison inserts so saved comparisons are properly associated with authenticated users
- Fixed OpenAI provider `getClient()` to correctly initialize the OpenAI client
- Added `BETTER_AUTH_SECRET` to production environment for proper session management
## Project Structure
```
src/
├── app/
│ ├── (auth)/ # Auth route group
│ │ ├── sign-in/ # Sign in page
│ │ └── sign-up/ # Sign up page
│ ├── (main)/ # Main app route group (with nav)
│ │ ├── compare/ # Comparison input + results
│ │ ├── explore/ # Public comparisons feed
│ │ └── profile/ # User profile + saved comparisons
│ ├── api/
│ │ ├── auth/[...all]/ # Better Auth catch-all
│ │ └── compare/ # SSE streaming research endpoint
│ ├── layout.tsx # Root layout
│ └── page.tsx # Landing page
├── components/
│ ├── comparison/ # Visualization components
│ │ ├── radar-chart.tsx
│ │ ├── bar-chart.tsx
│ │ ├── comparison-table.tsx
│ │ ├── score-card.tsx
│ │ └── pros-cons-card.tsx
│ └── ui/ # shadcn/ui components
├── hooks/
│ └── use-comparison-stream.ts # SSE streaming hook
├── lib/
│ ├── auth.ts # Better Auth server config
│ ├── auth-client.ts # Better Auth client
│ ├── db/
│ │ ├── index.ts # Drizzle client
│ │ └── schema.ts # Database schema
│ ├── llm/
│ │ ├── index.ts # Research pipeline orchestrator
│ │ ├── types.ts # LLM type definitions
│ │ └── providers/
│ │ ├── index.ts # Provider fallback chain
│ │ ├── openai.ts # OpenAI GPT-4o-mini provider
│ │ ├── tavily.ts # Tavily search provider
│ │ └── perplexity.ts # Perplexity Sonar provider
│ ├── types.ts # Shared type definitions
│ └── utils.ts # Utility functions
└── middleware.ts # Auth middleware + route protection
```
## Architecture
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
## API Reference
See [docs/api-reference.md](docs/api-reference.md) for endpoint documentation.
## UI/UX Flow
See [docs/ui-ux-flow.md](docs/ui-ux-flow.md) for user journey and wireframe descriptions.
## License
MIT

View File

@@ -1,30 +1,24 @@
version: "3.8"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/comparaison
depends_on:
db:
condition: service_healthy
container_name: comparaison
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: comparaison
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
- DATABASE_URL=postgresql://bear:changeme@postgres-shared:5432/comparaison
- BETTER_AUTH_SECRET=Y6oPTrn3adCnf+Bx60/4g3KjuBfLGVJJB9NFKR5bbVk=
- BETTER_AUTH_URL=https://comparaison.local.tophermayor.com
- NODE_ENV=production
networks:
- proxy-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.comparaison.rule=Host(`comparaison.local.tophermayor.com`)"
- "traefik.http.routers.comparaison.entrypoints=websecure"
- "traefik.http.routers.comparaison.tls=true"
- "traefik.http.routers.comparaison.tls.certresolver=cloudflare"
- "traefik.http.routers.comparaison.middlewares=local-only@file"
- "traefik.http.services.comparaison.loadbalancer.server.port=3000"
networks:
proxy-net:
external: true

204
docs/api-reference.md Normal file
View File

@@ -0,0 +1,204 @@
# ComparAIson — API Reference
## Base URL
```
Development: http://localhost:3000
Production: https://comparaison.tophermayor.com
```
---
## Authentication
All authenticated endpoints use Better Auth session cookies.
### POST `/api/auth/sign-up`
Create a new account.
**Request:**
```json
{
"name": "Alex Johnson",
"email": "alex@example.com",
"password": "securepassword123"
}
```
**Response:** `200 OK` — Sets session cookie, returns user object
### POST `/api/auth/sign-in`
Sign in to existing account.
**Request:**
```json
{
"email": "alex@example.com",
"password": "securepassword123"
}
```
**Response:** `200 OK` — Sets session cookie, redirects to callbackUrl
### POST `/api/auth/sign-out`
Sign out, clears session.
**Response:** `200 OK` — Redirects to `/`
---
## Comparison Endpoints
### POST `/api/compare`
Start a new comparison research. Returns a Server-Sent Events stream.
**Authentication:** Required
**Request:**
```json
{
"query": "for modern web development",
"items": ["React", "Vue", "Svelte"],
"dimensions": ["Performance", "Developer Experience"]
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `query` | string | No | Context/focus for the comparison |
| `items` | string[] | Yes | Items to compare (2-10) |
| `dimensions` | string[] | No | Optional dimension hints |
**Response:** `200 OK``text/event-stream`
SSE event format:
```
event: progress
data: {"status":"researching","message":"Analyzing comparison request...","itemsCompleted":0,"totalItems":3,"currentStep":"Analyzing comparison request..."}
event: progress
data: {"status":"researching","message":"Researching React...","itemsCompleted":1,"totalItems":3,"currentStep":"Analyzing React"}
event: progress
data: {"status":"completed","message":"Research complete!","itemsCompleted":3,"totalItems":3,"currentStep":"Done"}
event: done
data: {"id":"clx123abc","slug":"react-vs-vue-vs-svelte-clx123","data":{...full comparison data...}}
```
**Error Response:** `400 Bad Request`
```json
{ "error": "At least 2 items are required" }
```
---
## Page Routes
### `GET /`
Landing page. Public. Shows hero, example comparisons, features.
### `GET /sign-in`
Sign in form. Public. Redirects to `/compare` on success.
### `GET /sign-up`
Sign up form. Public. Redirects to `/compare` on success.
### `GET /compare`
Comparison creation page. **Protected.** Shows item input form + streaming progress.
### `GET /compare/[slug]`
Comparison results page. Public (if comparison is public). Shows tabbed visualization layout.
**URL format:** `/compare/react-vs-vue-vs-svelte-clx123a`
### `GET /explore`
Public comparisons feed. Public. Grid layout with search + category filter.
### `GET /profile`
User profile page. **Protected.** Shows user info, stats, and saved comparisons.
---
## TypeScript Types
### ComparisonRequest
```typescript
interface ComparisonRequest {
query: string;
items: string[];
dimensions?: string[];
}
```
### ComparisonResult
```typescript
interface ComparisonResult {
items: ItemResearch[];
dimensions: string[];
summary: string;
recommendation: string;
}
```
### ItemResearch
```typescript
interface ItemResearch {
name: string;
description: string;
overallScore: number;
dimensions: Record<string, DimensionResult>;
pros: string[];
cons: string[];
sources: { title: string; url: string; snippet: string }[];
}
```
### DimensionResult
```typescript
interface DimensionResult {
score: number; // 1-10
summary: string;
details: string;
pros: string[];
cons: string[];
}
```
### ResearchProgress (SSE)
```typescript
type ResearchProgress =
| { stage: "parsing"; message: string }
| { stage: "searching"; item: string; results: number }
| { stage: "researching"; item: string; progress: number }
| { stage: "synthesizing"; message: string }
| { stage: "complete"; result: ComparisonResult }
| { stage: "error"; error: string };
```
### ComparisonData (Frontend)
```typescript
interface ComparisonData {
id: string;
userId: string;
title: string;
query: string;
slug: string;
status: "researching" | "completed" | "failed";
summary: string;
items: {
name: string;
description: string;
overallScore: number;
dimensions: Record<string, DimensionResult>;
pros: string[];
cons: string[];
}[];
dimensions: string[];
tags: string[];
isPublic: boolean;
viewCount: number;
createdAt: string;
updatedAt: string;
}
```

129
docs/architecture.md Normal file
View File

@@ -0,0 +1,129 @@
# ComparAIson — Architecture
## System Overview
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │────▶│ Traefik │────▶│ Next.js │
│ (React SPA) │◀────│ (Reverse │◀────│ (Port 3000)│
│ │ │ Proxy) │ │ │
└─────────────┘ └──────────────┘ └──────┬──────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│PostgreSQL│ │ Tavily │ │ OpenAI │
│ (DB) │ │ (Search) │ │ (LLM) │
└──────────┘ └──────────┘ └──────────┘
┌──────────┐
│Perplexity│
│ (LLM) │
└──────────┘
```
## Component Architecture
### Frontend (Next.js App Router)
```
Route Groups:
├── (auth)/ — Unauthenticated pages (sign-in, sign-up)
├── (main)/ — Authenticated pages with shared nav layout
│ ├── compare/ — Comparison creation + results viewing
│ ├── explore/ — Public feed browsing
│ └── profile/ — User profile + comparison history
└── api/ — Server-side API routes
├── auth/ — Better Auth endpoints
└── compare/ — SSE research streaming endpoint
```
**Key patterns:**
- **Server Components** for data-fetching pages (profile, explore)
- **Client Components** for interactive UI (compare input, charts, streaming)
- **Server Actions** for mutations (save comparison, update profile)
- **SSE streaming** for real-time research progress
### Backend Services
#### Research Pipeline (`src/lib/llm/`)
Orchestrates the multi-stage research process:
1. **Input parsing** — Validate items, extract dimensions
2. **Provider detection** — Check available API keys, select best provider chain
3. **Web search** — Tavily API searches per item
4. **LLM synthesis** — Structured JSON generation via Perplexity or OpenAI
5. **Validation** — Runtime type checking of LLM output
6. **Persistence** — Store results in PostgreSQL via Drizzle ORM
#### Auth System (`src/lib/auth.ts`)
- Better Auth with Drizzle adapter
- Email + password authentication
- 7-day session expiry
- Middleware-based route protection
#### Database (`src/lib/db/`)
- Drizzle ORM with PostgreSQL
- 5 tables: users, sessions, comparisons, comparison_items, comparison_dimensions
- JSONB columns for flexible research data storage
- Indexed on userId, slug, status for query performance
## Data Flow
### Comparison Creation Flow
```
1. User enters items + query on /compare
2. Client calls POST /api/compare with { items, query, dimensions }
3. Server creates comparison record (status: "researching")
4. Server opens SSE stream to client
5. Research pipeline runs:
a. Parsing → emits SSE "parsing" event
b. For each item:
- Tavily search → emits SSE "searching" event
- Process results → emits SSE "researching" event with progress %
c. Synthesize all data → emits SSE "synthesizing" event
d. Validate + structure → emits SSE "complete" event with full data
6. Server persists results to comparisons + comparison_items tables
7. Client renders visualization components with result data
8. User redirected to /compare/[slug] with full results
```
### Comparison Viewing Flow
```
1. User navigates to /compare/[slug]
2. Server component fetches comparison from DB by slug
3. If status === "researching": show streaming progress UI
4. If status === "completed": render full results with all viz components
5. If status === "failed": show error with retry option
6. Increment viewCount on each visit
```
## Deployment Architecture
### Docker Compose
```yaml
services:
app: # Next.js standalone (~150-300MB RAM)
db: # PostgreSQL 16 Alpine (~200-400MB RAM)
```
Total estimated RAM: **400-800MB** — fits comfortably on an 8GB Raspberry Pi.
### Traefik Integration
The app is exposed via Traefik reverse proxy with:
- HTTPS termination
- Domain routing (e.g., `comparaison.tophermayor.com`)
- Automatic SSL certificate management
## Error Handling
| Layer | Strategy |
|---|---|
| LLM API failures | 3x retry with exponential backoff |
| Provider unavailable | Automatic fallback to next provider in chain |
| Invalid LLM output | Runtime validation + retry with new prompt |
| Database errors | Transaction rollback, error logged, user sees "failed" status |
| SSE connection lost | Client auto-reconnects, polls comparison status from DB |
| Auth failures | Redirect to sign-in with callback URL |

86
docs/development.md Normal file
View File

@@ -0,0 +1,86 @@
# ComparAIson — Development Guide
## Getting Started
```bash
# Clone
git clone https://gitea.tophermayor.com/TopherMayor/comparaison.git
cd comparaison
# Install
npm install
# Environment
cp .env.example .env.local
# Edit .env.local with your keys
# Database
docker compose up db -d # Start just PostgreSQL
npx drizzle-kit generate # Generate migrations
npx drizzle-kit migrate # Apply migrations
# Dev server
npm run dev
```
## Commands
| Command | Description |
|---|---|
| `npm run dev` | Start dev server (port 3000) |
| `npm run build` | Production build (standalone output) |
| `npm run start` | Start production server |
| `npm run lint` | ESLint check |
| `npx tsc --noEmit` | Type check without building |
| `npx drizzle-kit generate` | Generate DB migrations from schema |
| `npx drizzle-kit migrate` | Apply migrations to database |
| `npx drizzle-kit studio` | Open Drizzle Studio (DB browser) |
## Branch Strategy
| Branch | Purpose |
|---|---|
| `main` | Stable, deployable code |
| `feat/backend` | DB schema, auth, Docker, config changes |
| `feat/llm-engine` | LLM provider changes, research pipeline |
| `feat/frontend` | UI components, pages, layouts |
Worktrees are used for parallel development:
```bash
git worktree add ../comparaison-backend -b feat/backend main
```
## Database Schema Changes
1. Edit `src/lib/db/schema.ts`
2. Run `npx drizzle-kit generate` to create migration SQL
3. Run `npx drizzle-kit migrate` to apply
4. Commit both schema.ts and the generated SQL in `drizzle/`
## Adding a New LLM Provider
1. Create `src/lib/llm/providers/your-provider.ts`
2. Implement the provider function matching the `Provider` interface:
```typescript
export async function synthesize(
request: ComparisonRequest,
searchResults: Record<string, SearchResult[]>
): Promise<ComparisonResult>
```
3. Register in `src/lib/llm/providers/index.ts` — add to the fallback chain
4. Add API key to `.env.example` and document in README
## Adding a New Visualization
1. Create `src/components/comparison/your-chart.tsx`
2. Accept `ComparisonData` as props (or relevant subset)
3. Use Recharts for chart components
4. Import and add to the tab layout in `results-client.tsx`
## Key Conventions
- **Server Components** by default, `"use client"` only when needed (hooks, interactivity)
- **Drizzle ORM** for all database queries — no raw SQL
- **shadcn/ui** for UI components — no external component libraries
- **Tailwind** for styling — no CSS modules or styled-components
- **TypeScript strict mode** — no `any` types

View File

@@ -0,0 +1,458 @@
# ComparAIson v0.2v0.4 Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Replace all mock data with real API-backed pages, wire auth into the comparison flow, and add search + management features.
**Architecture:** Next.js App Router API routes backed by existing Drizzle ORM schema. Server actions for simple reads, REST endpoints for complex queries. Better Auth sessions for user identity.
**Tech Stack:** Next.js 15, Drizzle ORM, PostgreSQL, Better Auth, shadcn/ui, Recharts
---
## Dependency Graph
```
v0.2 Feed & Profile (issues #1-#4)
#1 API: Public feed endpoint ──────┐
#2 API: User comparisons endpoint ─┤
├──> #3 Wire Explore page
├──> #4 Wire Profile page
v0.3 Auth Integration (issue #5)
#5 Associate comparisons with users
v0.4 Search & Polish (issues #6-#8)
#6 Functional search ──> depends on #3
#7 Delete/toggle visibility ──> depends on #5
#8 Loading/error states ──> can start anytime
```
## Recommended Execution Order
1. **#1** — API: Public feed endpoint (no dependencies)
2. **#2** — API: User comparisons endpoint (no dependencies)
3. **#3** — Wire Explore page (depends on #1)
4. **#4** — Wire Profile page (depends on #2)
5. **#5** — Associate comparisons with users (foundational for v0.3)
6. **#6** — Functional search (depends on #3)
7. **#7** — Delete/toggle visibility (depends on #5)
8. **#8** — Loading/error states (independent, can parallelize)
---
## Phase 1: v0.2 — Feed & Profile (Milestone #5)
### Task 1.1: Create GET /api/comparisons route (#1)
**Objective:** Paginated public feed API endpoint.
**Files:**
- Create: `src/app/api/comparisons/route.ts`
**Step 1: Create the route file**
```typescript
// src/app/api/comparisons/route.ts
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq, and, ilike, desc, sql } from "drizzle-orm";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page")) || 1);
const limit = Math.min(50, Math.max(1, Number(searchParams.get("limit")) || 20));
const search = searchParams.get("search")?.trim() || "";
const offset = (page - 1) * limit;
const conditions = [
eq(comparisons.isPublic, true),
eq(comparisons.status, "completed"),
];
if (search) {
conditions.push(ilike(comparisons.title, `%${search}%`));
}
const where = and(...conditions);
const [rows, countResult] = await Promise.all([
db
.select()
.from(comparisons)
.where(where)
.orderBy(desc(comparisons.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(comparisons)
.where(where),
]);
const total = countResult[0]?.count ?? 0;
// Fetch item names for each comparison
const comparisonIds = rows.map((r) => r.id);
const items = comparisonIds.length
? await db
.select({
comparisonId: comparisonItems.comparisonId,
name: comparisonItems.name,
})
.from(comparisonItems)
.where(sql`${comparisonItems.comparisonId} IN ${comparisonIds}`)
: [];
const itemsByComparison = items.reduce<Record<string, string[]>>(
(acc, item) => {
if (!acc[item.comparisonId]) acc[item.comparisonId] = [];
acc[item.comparisonId].push(item.name);
return acc;
},
{}
);
return Response.json({
comparisons: rows.map((row) => ({
id: row.id,
title: row.title,
summary: row.summary?.slice(0, 200) || "",
slug: row.slug,
tags: row.tags || [],
items: itemsByComparison[row.id] || [],
viewCount: row.viewCount,
createdAt: row.createdAt.toISOString(),
})),
total,
page,
limit,
});
}
```
**Step 2: Create GET /api/comparisons/[slug]/route.ts**
```typescript
// src/app/api/comparisons/[slug]/route.ts
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { getComparison } from "@/app/actions/comparison";
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
// Increment view count
await db
.update(comparisons)
.set({ viewCount: sql`${comparisons.viewCount} + 1` })
.where(eq(comparisons.slug, slug));
const data = await getComparison(slug);
if (!data) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(data);
}
```
**Step 3: Verify**
```bash
npm run build # check for type errors
```
---
### Task 1.2: Create GET /api/user/comparisons and /api/user/stats (#2)
**Objective:** User-specific comparison listing and stats.
**Files:**
- Create: `src/app/api/user/comparisons/route.ts`
- Create: `src/app/api/user/stats/route.ts`
**Step 1: User comparisons endpoint**
```typescript
// src/app/api/user/comparisons/route.ts
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq, desc, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page")) || 1);
const limit = Math.min(50, Math.max(1, Number(searchParams.get("limit")) || 20));
const offset = (page - 1) * limit;
const userId = session.user.id;
const [rows, countResult] = await Promise.all([
db
.select()
.from(comparisons)
.where(eq(comparisons.userId, userId))
.orderBy(desc(comparisons.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(comparisons)
.where(eq(comparisons.userId, userId)),
]);
const total = countResult[0]?.count ?? 0;
const comparisonIds = rows.map((r) => r.id);
const items = comparisonIds.length
? await db
.select({
comparisonId: comparisonItems.comparisonId,
name: comparisonItems.name,
})
.from(comparisonItems)
.where(sql`${comparisonItems.comparisonId} IN ${comparisonIds}`)
: [];
const itemsByComparison = items.reduce<Record<string, string[]>>(
(acc, item) => {
if (!acc[item.comparisonId]) acc[item.comparisonId] = [];
acc[item.comparisonId].push(item.name);
return acc;
},
{}
);
return Response.json({
comparisons: rows.map((row) => ({
id: row.id,
title: row.title,
slug: row.slug,
tags: row.tags || [],
items: itemsByComparison[row.id] || [],
status: row.status,
isPublic: row.isPublic,
viewCount: row.viewCount,
createdAt: row.createdAt.toISOString(),
})),
total,
page,
});
}
```
**Step 2: User stats endpoint**
```typescript
// src/app/api/user/stats/route.ts
import { db } from "@/lib/db";
import { comparisons } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const stats = await db
.select({
totalComparisons: sql<number>`count(*)`,
totalViews: sql<number>`coalesce(sum(${comparisons.viewCount}), 0)`,
})
.from(comparisons)
.where(eq(comparisons.userId, userId));
return Response.json({
totalComparisons: stats[0]?.totalComparisons ?? 0,
totalViews: stats[0]?.totalViews ?? 0,
});
}
```
**Step 3: Verify**
```bash
npm run build
```
---
### Task 1.3: Wire Explore page (#3)
**Objective:** Replace mock data with real API calls.
**Files:**
- Modify: `src/app/(main)/explore/page.tsx`
**Step 1: Replace mock data with fetch logic**
Replace the entire `allComparisons` array and `categories` with:
```typescript
"use client"
import { useState, useEffect, useCallback } from "react"
// ... existing imports ...
interface ExploreComparison {
id: string
title: string
summary: string
slug: string
tags: string[]
items: string[]
viewCount: number
createdAt: string
}
export default function ExplorePage() {
const [searchQuery, setSearchQuery] = useState("")
const [comparisons, setComparisons] = useState<ExploreComparison[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const limit = 20
const fetchComparisons = useCallback(async (search: string, pageNum: number) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: String(pageNum),
limit: String(limit),
})
if (search) params.set("search", search)
const res = await fetch(`/api/comparisons?${params}`)
if (!res.ok) throw new Error("Failed to fetch")
const data = await res.json()
if (pageNum === 1) {
setComparisons(data.comparisons)
} else {
setComparisons((prev) => [...prev, ...data.comparisons])
}
setTotal(data.total)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load comparisons")
} finally {
setLoading(false)
}
}, [])
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
setPage(1)
fetchComparisons(searchQuery, 1)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, fetchComparisons])
// Initial load
useEffect(() => {
fetchComparisons("", 1)
}, [fetchComparisons])
// Extract unique tags from loaded comparisons
const categories = ["All", ...new Set(comparisons.flatMap((c) => c.tags))]
const [selectedCategory, setSelectedCategory] = useState("All")
const filtered = selectedCategory === "All"
? comparisons
: comparisons.filter((c) => c.tags.includes(selectedCategory))
const hasMore = comparisons.length < total
// ... render (replace allComparisons references with `filtered`)
// Change Link href from `/compare/${comparison.id}` to `/compare/${comparison.slug}`
}
```
**Step 2: Update "Load More" button**
```tsx
<Button
variant="outline"
className="gap-2"
onClick={() => {
const nextPage = page + 1
setPage(nextPage)
fetchComparisons(searchQuery, nextPage)
}}
disabled={loading || !hasMore}
>
{loading ? "Loading..." : "Load More"}
</Button>
```
---
### Task 1.4: Wire Profile page (#4)
**Objective:** Replace mock data with real user data.
**Files:**
- Modify: `src/app/(main)/profile/page.tsx`
Similar pattern to Explore — replace mock arrays with `useEffect` + `fetch` to `/api/user/comparisons` and `/api/user/stats`. Show sign-in CTA if no session.
---
## Phase 2: v0.3 — Auth Integration (Milestone #6)
### Task 2.1: Associate comparisons with users (#5)
**Objective:** Set userId on comparison creation.
**Files:**
- Modify: `src/app/api/compare/route.ts`
- Modify: `src/app/actions/comparison.ts`
Read auth session from headers, extract userId, pass to insert. Add auth check to compare page.
---
## Phase 3: v0.4 — Search & Polish (Milestone #7)
### Task 3.1: Functional search (#6)
Navigate header search to `/explore?search=...`. Read URL params in Explore page.
### Task 3.2: Delete/toggle visibility (#7)
Add `DELETE` and `PATCH` routes. Add dropdown menus to profile comparison cards.
### Task 3.3: Loading/error states (#8)
Add error rendering in compare page, error boundary, toast notifications.
---
## Gitea Issues Summary
| # | Title | Labels | Milestone |
|---|-------|--------|-----------|
| 1 | API: Public feed endpoint | feature, backend | v0.2 |
| 2 | API: User comparisons endpoint | feature, backend | v0.2 |
| 3 | Wire Explore page | feature, frontend | v0.2 |
| 4 | Wire Profile page | feature, frontend | v0.2 |
| 5 | Associate comparisons with users | bug, backend | v0.3 |
| 6 | Functional search | feature, frontend | v0.4 |
| 7 | Delete/toggle visibility | feature, frontend, backend | v0.4 |
| 8 | Loading/error states | improvement, frontend | v0.4 |

163
docs/specs.md Normal file
View File

@@ -0,0 +1,163 @@
# ComparAIson — Product Specification
## 1. Product Overview
**ComparAIson** is a self-hosted web application that enables users to compare two or more items using AI-powered deep research. The system performs multi-source research, generates structured comparison data, and presents results through interactive visualizations. Completed comparisons are saved as posts on user profiles, creating a browsable library of research.
## 2. Problem Statement
Comparing products, technologies, or services requires gathering data from multiple sources, synthesizing findings, and presenting them clearly. This is time-consuming and often produces inconsistent results. ComparAIson automates this process with LLM-powered research that produces structured, visual, and comparable outputs.
## 3. Target Users
- **Developers** comparing frameworks, tools, cloud services
- **Consumers** comparing products before purchase
- **Researchers** comparing methodologies, papers, or approaches
- **Teams** evaluating options for technical decisions
## 4. Core Features
### 4.1 AI Research Engine
- Multi-item comparison (2-10 items)
- Multi-dimensional scoring (5-8 dimensions per comparison)
- Web search integration via Tavily API
- LLM synthesis via OpenAI GPT-4o-mini or Perplexity Sonar
- Automatic provider fallback chain
- Structured JSON output with validation
- Server-Sent Events for real-time progress
### 4.2 Interactive Visualizations
- **Radar/Spider Chart** — Multi-dimensional overlay showing all items
- **Grouped Bar Chart** — Side-by-side metric comparison
- **Comparison Table** — Feature matrix with color-coded cells
- **Score Cards** — Animated progress bars with overall + per-dimension scores
- **Pros/Cons Cards** — Expandable per-item breakdown
### 4.3 User System
- Email + password authentication (Better Auth)
- Session management (7-day expiry)
- Protected routes for compare/profile actions
- Public profile pages with comparison history
### 4.4 Social/Feed Features
- Public comparisons feed (Explore page)
- Per-comparison view count tracking
- Tag-based categorization and filtering
- Search across public comparisons
- Shareable URLs for each comparison
## 5. Technical Constraints
| Constraint | Value |
|---|---|
| Deployment target | Raspberry Pi ARM64, 8GB RAM |
| Concurrent users | Low (homelab, <20) |
| Total RAM budget | ~500MB-1GB (app + DB + reverse proxy) |
| Cost target | Minimal (free tier APIs where possible) |
| Network | Behind Traefik reverse proxy with HTTPS |
## 6. Data Model
### 6.1 Users (Better Auth managed)
```
users: id, name, email, emailVerified, image, createdAt, updatedAt
sessions: id, userId (FK), token, expiresAt, createdAt, updatedAt
```
### 6.2 Comparisons
```
comparisons: id, userId (FK), title, query, slug, status (researching|completed|failed),
summary, overallData (JSONB), tags[], isPublic, viewCount, createdAt, updatedAt
```
### 6.3 Comparison Items
```
comparison_items: id, comparisonId (FK), name, description, imageUrl,
researchData (JSONB), scores (JSONB), pros[], cons[], order
```
### 6.4 Comparison Dimensions
```
comparison_dimensions: id, comparisonId (FK), name, description, weight, order
```
### 6.5 JSONB Schemas
**overallData (on comparisons):**
```json
{
"title": "React vs Vue vs Svelte",
"query": "for modern web development",
"status": "completed",
"summary": "...",
"items": [
{
"name": "React",
"description": "...",
"overallScore": 8.5,
"dimensions": {
"Performance": { "score": 8, "summary": "...", "details": "...", "pros": [], "cons": [] }
},
"pros": ["..."],
"cons": ["..."]
}
],
"dimensions": ["Performance", "Developer Experience", "Ecosystem", ...]
}
```
**researchData (on comparison_items):**
Full `ItemResearch` object including dimensions, sources, and scores.
## 7. LLM Research Pipeline
### 7.1 Flow
```
User submits query
→ Parse request (validate items ≥ 2)
→ Detect available providers (Tavily? Perplexity? OpenAI?)
→ If Tavily available: search each item individually
→ Synthesize via best available provider:
Priority 1: Tavily search + Perplexity synthesis
Priority 2: Tavily search + OpenAI synthesis
Priority 3: OpenAI only (no web search)
→ Validate structured JSON output
→ Persist to database
→ Stream results to client
```
### 7.2 Provider Details
| Provider | Role | Model | Cost |
|---|---|---|---|
| Tavily | Web search | Search API | ~$0.005/search |
| Perplexity | Synthesis | Sonar | ~$0.002/query |
| OpenAI | Synthesis | GPT-4o-mini | ~$0.15/1M tokens |
### 7.3 Progress Stages (SSE)
1. `parsing` — Validating query and extracting items
2. `searching` — Running web search for each item (Tavily only)
3. `researching` — Processing research per item
4. `synthesizing` — LLM generating structured comparison
5. `complete` — Final result with all data
6. `error` — Failure with error message
## 8. Security Considerations
- Auth middleware protects `/compare` and `/profile` routes
- Session tokens stored in HTTP-only cookies
- API keys never exposed to client (server-only LLM calls)
- Input validation on all API endpoints (min 2 items, max 10)
- SQL injection prevented via Drizzle ORM parameterized queries
- CSRF protection via Better Auth
- Rate limiting placeholder in compare API route
## 9. Future Considerations
- [ ] OAuth providers (Google, GitHub)
- [ ] Comparison comments/likes
- [ ] Export to PDF/image
- [ ] Embeddable comparison widgets
- [ ] Comparison templates
- [ ] Batch comparison queue for heavy loads
- [ ] Local Ollama fallback for offline operation

343
docs/ui-ux-flow.md Normal file
View File

@@ -0,0 +1,343 @@
# ComparAIson — UI/UX Flow
## User Journeys
### Journey 1: New User Discovers → Signs Up → First Comparison
```
Landing Page (/)
▼ "Start Comparing" CTA or /compare URL
Sign In Page (/sign-in)
│ ─── "Don't have an account? Sign up"
Sign Up Page (/sign-up)
│ User enters: name, email, password
│ On success → redirect to /compare
Compare Page (/compare)
│ User enters items (2-10) + query
│ Clicks "Start Research"
Streaming Progress
│ Progress bar + current step label
│ Steps: "Analyzing..." → "Searching React..." → "Synthesizing..."
Results Page (/compare/[slug])
│ Tab layout: Overview | Charts | Table | Details
│ User explores visualizations
│ Comparison auto-saved to profile
Profile Page (/profile)
│ Sees their first comparison in the grid
└── Can click to view again
```
### Journey 2: Returning User Quick Compare
```
Home (/) → already logged in
▼ Click "Compare" in nav
Compare Page (/compare)
│ Enter items + query → "Start Research"
Results Page (/compare/[slug])
│ Review results, share URL
└── Return to profile or start new compare
```
### Journey 3: Anonymous User Browses Public Comparisons
```
Landing Page (/)
▼ "Explore" in nav or example comparison card
Explore Page (/explore)
│ Grid of public comparisons
│ Filter by category tags
│ Search by keyword
▼ Click a comparison card
Results Page (/compare/[slug])
│ View full results (read-only)
│ "Start Your Own Comparison" CTA if not logged in
└── Sign up flow from Journey 1
```
---
## Page Descriptions
### 1. Landing Page (`/`)
**Purpose:** First impression, explain the product, drive sign-ups
**Layout:**
```
┌─────────────────────────────────────────────┐
│ Header: Logo "ComparAIson" | [Sign In] │
├─────────────────────────────────────────────┤
│ │
│ ✨ AI-Powered Research │
│ │
│ Compare Anything with │
│ AI-Powered Deep Research │
│ │
│ [🚀 Start Comparing] [Explore Examples] │
│ │
├─────────────────────────────────────────────┤
│ Example Comparisons (2x2 grid) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ React vs Vue │ │ GPT-4 vs │ │
│ │ vs Svelte │ │ Claude vs │ │
│ │ ⭐ 8.5 │ │ Gemini ⭐ 8.8│ │
│ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Notion vs │ │ AWS vs GCP │ │
│ │ Obsidian │ │ vs Azure │ │
│ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────┤
│ Features (3 columns) │
│ 📊 Rich Viz 🔍 Deep Research 🔗 Share │
├─────────────────────────────────────────────┤
│ Footer │
└─────────────────────────────────────────────┘
```
### 2. Sign In Page (`/sign-in`)
**Purpose:** Authenticate existing users
**Layout:**
```
Centered card, max-w-md:
┌─────────────────────────────┐
│ Sign In │
│ Welcome back │
│ │
│ Email [______________] │
│ Password [______________] │
│ │
│ [ Sign In ] │
│ │
│ Don't have an account? │
│ [Sign Up] │
└─────────────────────────────┘
```
- Error state: Red text below form on invalid credentials
- Loading state: Spinner on button, disabled inputs
- Success: Redirect to callbackUrl or /compare
### 3. Sign Up Page (`/sign-up`)
**Purpose:** Register new users
**Layout:** Same as sign in but with Name field added
```
Name [______________]
Email [______________]
Password [______________]
[ Create Account ]
Already have an account? [Sign In]
```
### 4. Compare Page (`/compare`)
**Purpose:** Main comparison creation interface
**Layout:**
```
Centered card, max-w-2xl:
┌─────────────────────────────────────────┐
│ ✨ Start a Comparison │
│ Enter items to compare and let AI │
│ do deep research for you. │
│ │
│ What would you like to compare? │
│ [________________________________] │
│ (textarea for natural language query) │
│ │
│ Items to compare (min 2, max 10) │
│ [React ×] [Vue ×] [Svelte ×] [+ Add] │
│ [________________________] [Add] │
│ │
│ Comparison dimensions (optional) │
│ [________________________________] │
│ (comma-separated hints) │
│ │
│ [🚀 Start Research] │
│ │
│ ┌─ Progress (shown during research) ──┐ │
│ │ [████████████░░░░░░░] 60% │ │
│ │ 🔍 Researching Svelte... │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
**Interactions:**
- Tag-style item input: type + Enter to add, X to remove
- Minimum 2 items required to start
- Progress bar appears after "Start Research" clicked
- Cancel button during research
- Auto-redirect to results on completion
### 5. Results Page (`/compare/[slug]`)
**Purpose:** Display completed comparison with visualizations
**Layout:**
```
┌─────────────────────────────────────────────────┐
│ ← Back React vs Vue vs Svelte [Share] │
│ Overall: React ⭐ 8.5 | Vue ⭐ 7.8 | Svelte ⭐ 8.2│
├─────────────────────────────────────────────────┤
│ [Overview] [Charts] [Table] [Details] │
├─────────────────────────────────────────────────┤
│ │
│ ┌── Overview Tab ─────────────────────────────┐ │
│ │ │ │
│ │ Summary text from AI │ │
│ │ "React excels in ecosystem size..." │ │
│ │ │ │
│ │ ┌─ Score Cards ────────────────────────┐ │ │
│ │ │ React: ⭐ 8.5 │ │ │
│ │ │ [█████████░] Performance: 8/10 │ │ │
│ │ │ [████████░░] DX: 9/10 │ │ │
│ │ │ ... │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │
│ │ 🏆 Recommendation: "For most projects..." │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌── Charts Tab ──────────────────────────────┐ │
│ │ │ │
│ │ Radar Chart Bar Chart │ │
│ │ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │ React ╲ │ │ ▓ ▓ ▓ │ │ │
│ │ │ │ Vue │ │ │ ▓ ▓ ▓ │ │ │
│ │ │ ╲Svelte │ │ ▓ ▓ ▓ │ │ │
│ │ └─────────────┘ └──────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌── Table Tab ───────────────────────────────┐ │
│ │ │ React │ Vue │ Svelte │ │ │
│ │ Performance│ 8 │ 7 │ 9 │ │ │
│ │ DX │ 9 │ 8 │ 8 │ │ │
│ │ Ecosystem │ 10 │ 7 │ 6 │ │ │
│ │ Price │ 9 │ 9 │ 10 │ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌── Details Tab ─────────────────────────────┐ │
│ │ Per-item expandable deep dives │ │
│ │ ▶ React - Performance (8/10) │ │
│ │ Pros: Virtual DOM, concurrent mode... │ │
│ │ Cons: Bundle size can grow... │ │
│ │ ▶ Vue - Performance (7/10) │ │
│ │ ... │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
**Interactions:**
- Tab switching between Overview/Charts/Table/Details
- Hover tooltips on all charts
- Click legend items to toggle visibility on radar/bar charts
- Expandable rows in table for detailed breakdowns
- Share button copies URL to clipboard
- Trophy icon highlights the winner
### 6. Profile Page (`/profile`)
**Purpose:** User's personal comparison library
**Layout:**
```
┌─────────────────────────────────────────────┐
│ [Avatar] Alex Johnson │
│ alex@example.com │
│ │
│ ┌─ Stats ─────────┐ ┌─ Stats ──────────┐ │
│ │ 12 │ │ 8.2K │ │
│ │ Total Comparisons│ │ Total Views │ │
│ └──────────────────┘ └───────────────────┘ │
│ │
│ My Comparisons │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ React vs Vue │ │ GPT-4 vs │ │
│ │ vs Svelte │ │ Claude │ │
│ │ ⭐ 8.5 | 👁 1.2K│ │ ⭐ 8.8 | 👁 3.9K│ │
│ │ Jan 15, 2024 │ │ Jan 10, 2024 │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ [+ New Comparison] │
└─────────────────────────────────────────────┘
```
### 7. Explore Page (`/explore`)
**Purpose:** Browse public comparisons from all users
**Layout:**
```
┌─────────────────────────────────────────────────┐
│ Explore Comparisons │
│ [🔍 Search comparisons...] │
│ │
│ [All] [Tech] [Products] [AI] [Cloud] [Prod.] │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │React vs │ │GPT-4 vs │ │iPhone vs│ │
│ │Vue vs │ │Claude vs │ │Samsung │ │
│ │Svelte │ │Gemini │ │S24 Ultra│ │
│ │⭐8.5 👁1.2K│ │⭐8.8 👁3.9K│ │⭐8.2 👁3.4K│ │
│ │by Alex │ │by Sarah │ │by James │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │AWS vs │ │Python vs│ │Notion vs│ │
│ │GCP vs │ │Rust vs │ │Obsidian │ │
│ │Azure │ │Go │ │vs Roam │ │
│ │⭐9.0 👁2.2K│ │⭐8.4 👁1.9K│ │⭐7.5 👁892 │ │
│ │by Emma │ │by Anna │ │by Mike │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ [Load More] │
└─────────────────────────────────────────────────┘
```
---
## Responsive Design
### Desktop (≥768px)
- Sidebar navigation on the left
- Full-width comparison tables
- Side-by-side charts on Charts tab
- 3-column grid on Explore page
### Mobile (<768px)
- Bottom navigation bar (fixed)
- Stacked charts (full width each)
- Single column cards on Explore
- Collapsible navigation menu
---
## Loading & Error States
### Research Progress
```
┌─ Research in Progress ──────────────┐
│ [████████████░░░░░░░░░] 60% │
│ 🔍 Researching Svelte... │
│ │
│ Items: React ✓ Vue ✓ Svelte ⟳ │
│ │
│ [Cancel] │
└──────────────────────────────────────┘
```
### Skeleton Loaders
- Used on results page while data loads
- Shimmer effect on card placeholders
- Chart skeleton rectangles
### Error States
- **API error:** Red banner with error message + "Try Again" button
- **Auth required:** Redirect to sign-in with callback
- **Not found:** 404 page with "Browse comparisons" link
- **Network error:** Toast notification + retry prompt

View File

@@ -0,0 +1,59 @@
CREATE TYPE "public"."comparison_status" AS ENUM('researching', 'completed', 'failed');--> statement-breakpoint
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 NOT NULL,
"order" integer DEFAULT 0 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 DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "comparisons" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"title" text NOT NULL,
"query" text NOT NULL,
"slug" text NOT NULL,
"status" "comparison_status" DEFAULT 'researching' NOT NULL,
"summary" text,
"overall_data" jsonb,
"tags" text[],
"is_public" boolean DEFAULT true NOT NULL,
"view_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "comparisons_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"email" text NOT NULL,
"email_verified" timestamp with time zone,
"image" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> 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
ALTER TABLE "comparisons" ADD CONSTRAINT "comparisons_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comparison_dimensions_comparison_id_idx" ON "comparison_dimensions" USING btree ("comparison_id");--> statement-breakpoint
CREATE INDEX "comparison_items_comparison_id_idx" ON "comparison_items" USING btree ("comparison_id");--> statement-breakpoint
CREATE INDEX "comparisons_user_id_idx" ON "comparisons" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "comparisons_slug_idx" ON "comparisons" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "comparisons_status_idx" ON "comparisons" USING btree ("status");

View File

@@ -1,42 +0,0 @@
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");

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DATA TYPE boolean USING ("email_verified" IS NOT NULL);

View File

@@ -0,0 +1,17 @@
CREATE TABLE "accounts" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"id_token" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "accounts_user_id_idx" ON "accounts" USING btree ("user_id");

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS "verifications" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);

View File

@@ -1,5 +1,5 @@
{
"id": "6cc67b11-8016-409b-9de9-8966593c97b0",
"id": "c719fbf4-6ed1-4b38-9a09-33a7e0799267",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -36,17 +36,34 @@
"name": "weight",
"type": "integer",
"primaryKey": false,
"notNull": false,
"notNull": true,
"default": 1
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"default": 0
}
},
"indexes": {
"comparison_dimensions_comparison_id_idx": {
"name": "comparison_dimensions_comparison_id_idx",
"columns": [
{
"expression": "comparison_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"indexes": {},
"foreignKeys": {
"comparison_dimensions_comparison_id_comparisons_id_fk": {
"name": "comparison_dimensions_comparison_id_comparisons_id_fk",
@@ -130,10 +147,27 @@
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"default": 0
}
},
"indexes": {
"comparison_items_comparison_id_idx": {
"name": "comparison_items_comparison_id_idx",
"columns": [
{
"expression": "comparison_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"indexes": {},
"foreignKeys": {
"comparison_items_comparison_id_comparisons_id_fk": {
"name": "comparison_items_comparison_id_comparisons_id_fk",
@@ -169,7 +203,7 @@
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"title": {
"name": "title",
@@ -181,17 +215,18 @@
"name": "query",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"type": "comparison_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'researching'"
@@ -218,26 +253,26 @@
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
"notNull": true,
"default": true
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
@@ -258,9 +293,53 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"comparisons_slug_idx": {
"name": "comparisons_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"comparisons_status_idx": {
"name": "comparisons_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"comparisons_user_id_users_id_fk": {
"name": "comparisons_user_id_users_id_fk",
"tableFrom": "comparisons",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"comparisons_slug_unique": {
@@ -274,9 +353,84 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.comparison_status": {
"name": "comparison_status",
"schema": "public",
"values": [
"researching",
"completed",
"failed"
]
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},

View File

@@ -5,8 +5,29 @@
{
"idx": 0,
"version": "7",
"when": 1777066133958,
"tag": "0000_opposite_doomsday",
"when": 1777066297133,
"tag": "0000_gorgeous_puma",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777066300000,
"tag": "0001_fix_email_verified",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1777066400000,
"tag": "0002_add_accounts_table",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1777066500000,
"tag": "0003_add_verifications_table",
"breakpoints": true
}
]

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

View File

@@ -0,0 +1,239 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Search, Eye, Filter, X, Loader2, RefreshCw } from "lucide-react"
import Link from "next/link"
interface Comparison {
id: string
title: string
summary: string
slug: string
tags: string[]
items: string[]
viewCount: number
createdAt: string
}
interface ComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
export default function ExplorePage() {
const [comparisons, setComparisons] = useState<Comparison[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [selectedCategory, setSelectedCategory] = useState("All")
const limit = 20
const fetchComparisons = useCallback(async (pageNum: number, search: string, append = false) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: pageNum.toString(),
limit: limit.toString(),
...(search && { search }),
})
const res = await fetch(`/api/comparisons?${params}`)
if (!res.ok) throw new Error("Failed to fetch comparisons")
const data: ComparisonsResponse = await res.json()
setComparisons(prev => append ? [...prev, ...data.comparisons] : data.comparisons)
setTotal(data.total)
setPage(pageNum)
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchQuery)
setPage(1)
fetchComparisons(1, searchQuery)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, fetchComparisons])
useEffect(() => {
fetchComparisons(1, "")
}, [fetchComparisons])
const categories = ["All", ...Array.from(new Set(comparisons.flatMap(c => c.tags)))]
const filteredComparisons = comparisons.filter((comparison) => {
const matchesCategory =
selectedCategory === "All" ||
comparison.tags.some((tag) => tag.toLowerCase() === selectedCategory.toLowerCase())
return matchesCategory
})
const loadMore = () => {
fetchComparisons(page + 1, debouncedSearch, true)
}
const hasMore = comparisons.length < total
return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Explore Comparisons</h1>
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category)}
className="rounded-full"
>
{category}
</Button>
))}
</div>
</div>
{loading && comparisons.length === 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i} className="h-full">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-12" />
<Skeleton className="h-5 w-12" />
</div>
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-destructive/10 flex items-center justify-center">
<X className="size-6 text-destructive" />
</div>
<div>
<p className="font-medium">Failed to load comparisons</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
<Button onClick={() => fetchComparisons(page, debouncedSearch)} className="gap-2">
<RefreshCw className="size-4" />
Retry
</Button>
</div>
</Card>
) : filteredComparisons.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filteredComparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.slug}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-1 flex-1">
{comparison.title}
</CardTitle>
</div>
<CardDescription className="text-sm line-clamp-2">
{comparison.summary}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{comparison.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center justify-between pt-2 border-t">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<Eye className="size-3.5" />
{comparison.viewCount.toLocaleString()}
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Search className="size-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium">No comparisons found</p>
<p className="text-sm text-muted-foreground">
Try adjusting your search or filters
</p>
</div>
<Button
variant="outline"
onClick={() => {
setSearchQuery("")
setSelectedCategory("All")
}}
>
Clear Filters
</Button>
</div>
</Card>
)}
{loading && comparisons.length > 0 && (
<div className="flex justify-center py-4">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && hasMore && (
<div className="flex justify-center">
<Button variant="outline" onClick={loadMore} className="gap-2">
Load More
</Button>
</div>
)}
</div>
)
}

View File

@@ -1,46 +1,170 @@
"use client"
import Link from "next/link"
import { Sparkles } from "lucide-react"
import { usePathname } from "next/navigation"
import { Sparkles, Home, BarChart3, Compass, User, Menu, X, Search } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
const navItems = [
{ href: "/", label: "Home", icon: Home },
{ href: "/compare", label: "Compare", icon: BarChart3 },
{ href: "/explore", label: "Explore", icon: Compass },
{ href: "/profile", label: "Profile", icon: User },
]
function NavSidebar({ className }: { className?: string }) {
const pathname = usePathname()
return (
<nav className={cn("flex flex-col gap-1", className)}>
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
return (
<Link key={item.href} href={item.href}>
<Button
variant={isActive ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-2",
isActive && "bg-muted text-primary font-medium"
)}
>
<item.icon className="size-4" />
{item.label}
</Button>
</Link>
)
})}
</nav>
)
}
function MobileNav() {
const pathname = usePathname()
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:hidden">
<div className="flex items-center justify-around h-14">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
return (
<Link key={item.href} href={item.href} className="flex flex-col items-center gap-1 py-2 px-3">
<item.icon className={cn("size-5", isActive ? "text-primary" : "text-muted-foreground")} />
<span className={cn("text-xs", isActive ? "text-primary font-medium" : "text-muted-foreground")}>
{item.label}
</span>
</Link>
)
})}
</div>
</nav>
)
}
export default function MainLayout({
children,
}: {
children: React.ReactNode
}) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
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">
<div className="container flex h-14 items-center px-4 mx-auto gap-4">
<Link href="/" className="flex items-center gap-2 font-semibold shrink-0">
<Sparkles className="size-5 text-primary" />
<span className="text-lg">ComparAIson</span>
<span className="text-lg hidden sm:inline">ComparAIson</span>
</Link>
<div className="flex-1 max-w-md">
<div className="flex-1 max-w-md mx-auto hidden md:block">
<div className="relative">
<input
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<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"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
/>
</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">
<div className="flex items-center gap-2 ml-auto">
<Link href="/compare" className="hidden sm:block">
<Button size="sm" className="gap-2">
<Sparkles className="size-3.5" />
New Comparison
</button>
</Button>
</Link>
<div className="size-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground">
U
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full">
<Avatar className="size-8">
<AvatarImage src="/placeholder-avatar.png" />
<AvatarFallback className="bg-primary/10 text-primary font-medium">U</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link href="/profile" className="w-full">Profile</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href="/compare" className="w-full">New Comparison</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link href="/sign-in" className="w-full">Sign Out</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</Button>
</div>
</div>
{mobileMenuOpen && (
<div className="border-t md:hidden bg-background p-4">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<NavSidebar />
</div>
)}
</header>
<main className="flex-1">{children}</main>
<div className="flex flex-1">
<aside className="hidden md:block w-52 border-r bg-muted/20 p-4 shrink-0">
<NavSidebar />
</aside>
<footer className="border-t py-4">
<main className="flex-1 pb-16 md:pb-0">{children}</main>
</div>
<MobileNav />
<footer className="border-t py-4 hidden md:block">
<div className="container mx-auto px-4 text-center text-xs text-muted-foreground">
ComparAIson AI-powered deep research comparisons
</div>

View File

@@ -0,0 +1,149 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { BarChart3, Eye, Calendar, Plus, ArrowRight, RefreshCw, LogIn } from "lucide-react"
import Link from "next/link"
import { useSession } from "@/lib/auth-client"
interface Comparison {
id: string
title: string
slug: string
items: string[]
tags: string[]
viewCount: number
overallScore: number
createdAt: string
}
interface UserComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
}
interface UserStats {
totalComparisons: number
totalViews: number
}
export default function ProfilePage() {
// TODO: Replace with real auth session data
const user = { name: "Demo User", email: "demo@example.com", avatar: "" }
const stats = [
{ label: "Comparisons", value: "0", icon: BarChart3 },
{ label: "Total Views", value: "0", icon: Eye },
]
const comparisons: Comparison[] = []
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8">
<div className="flex items-center gap-6">
<Avatar className="size-20">
<AvatarImage src={user.avatar} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold">
{user.name.split(" ").map((n) => n[0]).join("")}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-muted-foreground">{user.email}</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className="flex items-center gap-4 p-4">
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
<stat.icon className="size-5 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</div>
</CardContent>
</Card>
))}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">My Comparisons</h2>
<Link href="/compare">
<Button size="sm" className="gap-2">
<Plus className="size-4" />
New Comparison
</Button>
</Link>
</div>
{comparisons.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{comparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3">
<CardTitle className="text-base line-clamp-1">{comparison.title}</CardTitle>
<CardDescription className="flex items-center gap-2 text-xs">
<Calendar className="size-3.5" />
{comparison.createdAt}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{comparison.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{comparison.viewCount.toLocaleString()}
</span>
<span className="font-semibold text-foreground">
{comparison.overallScore}/10
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<BarChart3 className="size-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium">No comparisons yet</p>
<p className="text-sm text-muted-foreground">
Create your first comparison to get started
</p>
</div>
<Link href="/compare">
<Button className="gap-2">
<Plus className="size-4" />
Create Comparison
</Button>
</Link>
</div>
</Card>
)}
</div>
</div>
)
}

View File

@@ -32,6 +32,7 @@ export async function createComparison(formData: FormData) {
await db.insert(comparisons).values({
id,
userId: "system",
title,
query,
slug,

View File

@@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { auth } from "@/lib/auth";
function serializeSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -18,14 +19,37 @@ function slugify(text: string): string {
.slice(0, 200);
}
// TODO: Implement rate limiting per IP/user
// Example: Use Upstash Ratelimit or a simple in-memory counter
// const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") })
export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
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" },
{ error: "At least 2 items are required for comparison" },
{ status: 400 }
);
}
if (items.length > 10) {
return Response.json(
{ error: "Maximum 10 items allowed per comparison" },
{ status: 400 }
);
}
if (items.some((item) => item.trim().length === 0)) {
return Response.json(
{ error: "Item names cannot be empty" },
{ status: 400 }
);
}
@@ -36,8 +60,9 @@ export async function POST(request: Request) {
await db.insert(comparisons).values({
id,
userId: session.user.id,
title,
query: query || null,
query: query ?? title,
slug,
status: "researching",
});
@@ -70,6 +95,20 @@ export async function POST(request: Request) {
);
}
if (progress.stage === "searching") {
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "researching",
message: `Searching the web for ${progress.item}... (${progress.results} results found)`,
itemsCompleted,
totalItems: items.length,
currentStep: `Searching ${progress.item}`,
})
)
);
}
if (progress.stage === "researching") {
itemsCompleted++;
controller.enqueue(
@@ -102,7 +141,17 @@ export async function POST(request: Request) {
if (progress.stage === "complete") {
const result = progress.result;
const comparisonData: Omit<ComparisonData, "id" | "userId" | "slug" | "tags" | "isPublic" | "viewCount" | "createdAt" | "updatedAt"> = {
const comparisonData: Omit<
ComparisonData,
| "id"
| "userId"
| "slug"
| "tags"
| "isPublic"
| "viewCount"
| "createdAt"
| "updatedAt"
> = {
title,
query: query || "",
status: "completed",
@@ -177,7 +226,7 @@ export async function POST(request: Request) {
encoder.encode(
serializeSSE("progress", {
status: "failed",
message: progress.error,
message: `Comparison failed: ${progress.error}`,
itemsCompleted,
totalItems: items.length,
currentStep: "Failed",
@@ -192,11 +241,16 @@ export async function POST(request: Request) {
.set({ status: "failed", updatedAt: new Date() })
.where(eq(comparisons.id, id));
const message =
error instanceof Error
? error.message
: "An unexpected error occurred during research";
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "failed",
message: error instanceof Error ? error.message : "Unknown error",
message: `Comparison failed: ${message}`,
itemsCompleted,
totalItems: items.length,
currentStep: "Failed",

View File

@@ -0,0 +1,24 @@
import { db } from "@/lib/db";
import { comparisons } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { getComparison } from "@/app/actions/comparison";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
await db
.update(comparisons)
.set({ viewCount: sql`${comparisons.viewCount} + 1` })
.where(eq(comparisons.slug, slug));
const comparison = await getComparison(slug);
if (!comparison) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(comparison);
}

View File

@@ -0,0 +1,65 @@
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq, and, desc, ilike, sql, inArray } from "drizzle-orm";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page")) || 1);
const limit = Math.min(100, Math.max(1, Number(searchParams.get("limit")) || 20));
const search = searchParams.get("search") || "";
const offset = (page - 1) * limit;
const conditions = [eq(comparisons.isPublic, true), eq(comparisons.status, "completed")];
if (search) {
conditions.push(ilike(comparisons.title, `%${search}%`));
}
const where = and(...conditions);
const [result, countResult] = await Promise.all([
db
.select()
.from(comparisons)
.where(where)
.orderBy(desc(comparisons.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(comparisons)
.where(where),
]);
const total = countResult[0].count;
const comparisonIds = result.map((c) => c.id);
const itemsMap: Record<string, string[]> = {};
if (comparisonIds.length > 0) {
const items = await db
.select({
comparisonId: comparisonItems.comparisonId,
name: comparisonItems.name,
})
.from(comparisonItems)
.where(inArray(comparisonItems.comparisonId, comparisonIds));
for (const item of items) {
if (!itemsMap[item.comparisonId]) itemsMap[item.comparisonId] = [];
itemsMap[item.comparisonId].push(item.name);
}
}
const data = result.map((c) => ({
id: c.id,
title: c.title,
summary: (c.summary || "").slice(0, 200),
slug: c.slug,
tags: c.tags || [],
items: itemsMap[c.id] || [],
viewCount: c.viewCount ?? 0,
createdAt: c.createdAt.toISOString(),
}));
return Response.json({ comparisons: data, total, page, limit });
}

View File

@@ -0,0 +1,69 @@
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq, desc, sql, inArray } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page")) || 1);
const limit = Math.min(100, Math.max(1, Number(searchParams.get("limit")) || 20));
const offset = (page - 1) * limit;
const where = eq(comparisons.userId, session.user.id);
const [result, countResult] = await Promise.all([
db
.select()
.from(comparisons)
.where(where)
.orderBy(desc(comparisons.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(comparisons)
.where(where),
]);
const total = countResult[0].count;
const comparisonIds = result.map((c) => c.id);
const itemsMap: Record<string, string[]> = {};
if (comparisonIds.length > 0) {
const items = await db
.select({
comparisonId: comparisonItems.comparisonId,
name: comparisonItems.name,
})
.from(comparisonItems)
.where(inArray(comparisonItems.comparisonId, comparisonIds));
for (const item of items) {
if (!itemsMap[item.comparisonId]) itemsMap[item.comparisonId] = [];
itemsMap[item.comparisonId].push(item.name);
}
}
const data = result.map((c) => ({
id: c.id,
title: c.title,
summary: (c.summary || "").slice(0, 200),
slug: c.slug,
tags: c.tags || [],
items: itemsMap[c.id] || [],
viewCount: c.viewCount ?? 0,
status: c.status,
isPublic: c.isPublic,
createdAt: c.createdAt.toISOString(),
}));
return Response.json({ comparisons: data, total, page, limit });
}

View File

@@ -0,0 +1,23 @@
import { db } from "@/lib/db";
import { comparisons } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await db
.select({
totalComparisons: sql<number>`count(*)`,
totalViews: sql<number>`coalesce(sum(${comparisons.viewCount}), 0)`,
})
.from(comparisons)
.where(eq(comparisons.userId, session.user.id));
return Response.json(result[0]);
}

View File

@@ -1,65 +1,186 @@
import Image from "next/image";
import Link from "next/link"
import { Sparkles, BarChart3, Search, Share2, ArrowRight, CheckCircle2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export default function Home() {
const exampleComparisons = [
{
title: "React vs Vue vs Svelte",
description: "Frontend framework comparison for modern web development",
tags: ["Tech", "JavaScript"],
items: ["React", "Vue", "Svelte"],
scores: [8.5, 7.8, 8.2],
},
{
title: "GPT-4 vs Claude vs Gemini",
description: "Comparing top AI language models for reasoning tasks",
tags: ["AI", "Products"],
items: ["GPT-4", "Claude 3", "Gemini Pro"],
scores: [8.8, 9.0, 8.4],
},
{
title: "Notion vs Obsidian vs Roam",
description: "Knowledge management tools for productivity",
tags: ["Products", "Productivity"],
items: ["Notion", "Obsidian", "Roam Research"],
scores: [7.5, 8.3, 7.2],
},
{
title: "AWS vs GCP vs Azure",
description: "Cloud platform comparison for enterprise infrastructure",
tags: ["Tech", "Cloud"],
items: ["AWS", "Google Cloud", "Microsoft Azure"],
scores: [9.0, 8.2, 8.5],
},
]
const features = [
{
icon: BarChart3,
title: "Rich Visualizations",
description: "Interactive radar charts, bar graphs, and comparison tables to understand your results at a glance.",
},
{
icon: Search,
title: "Deep Research",
description: "AI-powered research that gathers comprehensive data from multiple sources for each item.",
},
{
icon: Share2,
title: "Save & Share",
description: "Keep your comparisons private or share them with the world. Build a library of research.",
},
]
export default function HomePage() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="flex flex-col min-h-screen">
<section className="flex-1 flex flex-col items-center justify-center py-20 px-4 bg-gradient-to-b from-background to-muted/30">
<div className="max-w-3xl mx-auto text-center space-y-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-4">
<Sparkles className="size-4" />
AI-Powered Research
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
Compare Anything with{" "}
<span className="text-primary">AI-Powered</span> Deep Research
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto">
Stop spending hours researching. Let AI do the work for you with comprehensive,
multi-dimensional comparisons on anything you can think of.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
<Link href="/compare">
<Button size="lg" className="gap-2 text-lg px-8">
<Sparkles className="size-5" />
Start Comparing
<ArrowRight className="size-4" />
</Button>
</Link>
<Link href="/explore">
<Button size="lg" variant="outline" className="text-lg px-8">
Explore Examples
</Button>
</Link>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
</div>
</section>
<section className="py-16 px-4 bg-muted/20">
<div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold text-center mb-10">Example Comparisons</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{exampleComparisons.map((example) => (
<Link key={example.title} href="/compare" className="group">
<Card className="h-full transition-all group-hover:border-primary group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg font-bold">{example.title}</span>
</div>
<CardDescription className="text-sm line-clamp-2">
{example.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{example.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 rounded-full bg-muted text-xs font-medium"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
{tag}
</span>
))}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{example.items.slice(0, 2).join(" vs ")}
{example.items.length > 2 && ` vs ${example.items.length - 2}+`}
</span>
</div>
<div className="flex items-center gap-1">
{example.scores.slice(0, 3).map((score, i) => (
<div
key={i}
className="h-1 flex-1 rounded-full bg-primary/30"
style={{ width: `${score * 10}%` }}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
))}
</div>
</main>
</CardContent>
</Card>
</Link>
))}
</div>
);
</div>
</section>
<section className="py-20 px-4">
<div className="max-w-4xl mx-auto">
<h2 className="text-2xl font-bold text-center mb-12">Why ComparAIson?</h2>
<div className="grid gap-8 sm:grid-cols-3">
{features.map((feature) => (
<div key={feature.title} className="flex flex-col items-center text-center space-y-3">
<div className="size-12 rounded-xl bg-primary/10 flex items-center justify-center">
<feature.icon className="size-6 text-primary" />
</div>
<h3 className="font-semibold text-lg">{feature.title}</h3>
<p className="text-muted-foreground text-sm">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
<section className="py-16 px-4 bg-primary text-primary-foreground">
<div className="max-w-2xl mx-auto text-center space-y-6">
<h2 className="text-2xl sm:text-3xl font-bold">Ready to compare?</h2>
<p className="text-primary-foreground/80">
Start your first comparison in seconds. It&apos;s free to get started.
</p>
<Link href="/compare">
<Button size="lg" variant="secondary" className="gap-2 text-lg px-8">
<Sparkles className="size-5" />
Start Comparing
</Button>
</Link>
</div>
</section>
<footer className="border-t py-8 px-4">
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
<span className="font-semibold">ComparAIson</span>
</div>
<p>AI-powered deep research comparisons</p>
</div>
</footer>
</div>
)
}

View File

@@ -4,7 +4,10 @@ import { db } from "./db";
import * as schema from "./db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema }),
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
emailAndPassword: { enabled: true },
session: { expiresIn: 60 * 60 * 24 * 7 },
});

View File

@@ -1,13 +1,14 @@
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
jsonb,
integer,
boolean,
varchar,
index,
} from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2";
export const users = pgTable("users", {
id: text("id").primaryKey(),
@@ -15,10 +16,43 @@ export const users = pgTable("users", {
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey().$defaultFn(() => createId()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
idToken: text("id_token"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey().$defaultFn(() => createId()),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});
export const comparisonStatusEnum = pgEnum("comparison_status", [
"researching",
"completed",
"failed",
]);
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
@@ -26,36 +60,46 @@ export const sessions = pgTable("sessions", {
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
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"],
})
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
userId: text("user_id")
.notNull()
.default("researching"),
.references(() => users.id),
title: text("title").notNull(),
query: text("query").notNull(),
slug: text("slug").notNull().unique(),
status: comparisonStatusEnum("status").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(),
isPublic: boolean("is_public").notNull().default(true),
viewCount: integer("view_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("comparisons_user_id_idx").on(table.userId)]
(table) => [
index("comparisons_user_id_idx").on(table.userId),
index("comparisons_slug_idx").on(table.slug),
index("comparisons_status_idx").on(table.status),
],
);
export const comparisonItems = pgTable("comparison_items", {
id: text("id").primaryKey(),
export const comparisonItems = pgTable(
"comparison_items",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
comparisonId: text("comparison_id")
.notNull()
.references(() => comparisons.id, { onDelete: "cascade" }),
@@ -66,22 +110,24 @@ export const comparisonItems = pgTable("comparison_items", {
scores: jsonb("scores"),
pros: text("pros").array(),
cons: text("cons").array(),
order: integer("order").notNull(),
});
order: integer("order").notNull().default(0),
},
(table) => [index("comparison_items_comparison_id_idx").on(table.comparisonId)],
);
export const comparisonDimensions = pgTable("comparison_dimensions", {
id: text("id").primaryKey(),
export const comparisonDimensions = pgTable(
"comparison_dimensions",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
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;
weight: integer("weight").notNull().default(1),
order: integer("order").notNull().default(0),
},
(table) => [index("comparison_dimensions_comparison_id_idx").on(table.comparisonId)],
);

View File

@@ -3,7 +3,9 @@ import type {
ComparisonResult,
ResearchProgress,
} from "./types";
import { generateComparison } from "./providers/openai";
import { searchItem, type SearchResult } from "./providers/tavily";
import { generateComparisonWithResearch } from "./providers/openai";
import { getActiveProvider } from "./providers";
export type {
ComparisonRequest,
@@ -24,6 +26,28 @@ export async function* runResearch(
return;
}
const provider = getActiveProvider();
const searchResults: Record<string, SearchResult[]> = {};
if (provider.hasSearch) {
for (let i = 0; i < request.items.length; i++) {
const item = request.items[i];
const results = await searchItem(item, request.query);
searchResults[item] = results;
yield {
stage: "searching",
item,
results: results.length,
};
yield {
stage: "researching",
item,
progress: Math.round(((i + 1) / request.items.length) * 50),
};
}
} else {
for (let i = 0; i < request.items.length; i++) {
yield {
stage: "researching",
@@ -31,14 +55,15 @@ export async function* runResearch(
progress: Math.round(((i + 0.5) / request.items.length) * 80),
};
}
}
yield {
stage: "synthesizing",
message: "Synthesizing research into structured comparison...",
message: `Synthesizing research into structured comparison using ${provider.name}...`,
};
try {
const result = await generateComparison(request);
const result = await provider.synthesize(request, searchResults);
yield { stage: "complete", result };
} catch (error) {
yield {

View File

@@ -0,0 +1,63 @@
import type { ComparisonRequest, ComparisonResult } from "../types";
import type { SearchResult } from "./tavily";
import { generateComparisonWithResearch, generateComparison } from "./openai";
import { synthesizeResearch } from "./perplexity";
export interface Provider {
name: string;
hasSearch: boolean;
synthesize: (
request: ComparisonRequest,
searchResults: Record<string, SearchResult[]>
) => Promise<ComparisonResult>;
}
export function getActiveProvider(): Provider {
const hasTavily = !!process.env.TAVILY_API_KEY;
const hasPerplexity = !!process.env.PERPLEXITY_API_KEY;
const hasOpenAI = !!process.env.OPENAI_API_KEY;
if (hasTavily && hasPerplexity) {
console.log("[llm] Using provider: Tavily search + Perplexity synthesis");
return {
name: "Tavily + Perplexity",
hasSearch: true,
synthesize: synthesizeResearch,
};
}
if (hasTavily && hasOpenAI) {
console.log("[llm] Using provider: Tavily search + OpenAI synthesis");
return {
name: "Tavily + OpenAI",
hasSearch: true,
synthesize: generateComparisonWithResearch,
};
}
if (hasOpenAI) {
console.log("[llm] Using provider: OpenAI only (no web search)");
return {
name: "OpenAI",
hasSearch: false,
synthesize: async (request) => generateComparison(request),
};
}
console.warn(
"[llm] No API keys configured. Research will fail at synthesis."
);
return {
name: "None",
hasSearch: false,
synthesize: async () => {
throw new Error(
"No LLM provider configured. Set OPENAI_API_KEY, TAVILY_API_KEY, or PERPLEXITY_API_KEY."
);
},
};
}
export { searchItem, type SearchResult } from "./tavily";
export { generateComparison, generateComparisonWithResearch } from "./openai";
export { synthesizeResearch } from "./perplexity";

View File

@@ -5,10 +5,16 @@ import type {
DimensionResult,
ItemResearch,
} from "../types";
import type { SearchResult } from "./tavily";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
let _client: OpenAI | null = null;
function getClient(): OpenAI {
if (!_client) {
_client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return _client;
}
const SYSTEM_PROMPT = `You are an expert research analyst. Your job is to compare items across multiple dimensions and produce structured, insightful comparison data.
@@ -105,7 +111,7 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await client.chat.completions.create({
const response = await getClient().chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
@@ -140,3 +146,75 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
`Failed to generate comparison after ${MAX_RETRIES} attempts: ${lastError?.message}`
);
}
export async function generateComparisonWithResearch(
request: ComparisonRequest,
searchResults: Record<string, SearchResult[]>
): Promise<ComparisonResult> {
const allResults = Object.values(searchResults).flat();
if (allResults.length === 0) {
return generateComparison(request);
}
let researchContext = "Web research data:\n\n";
for (const [itemName, results] of Object.entries(searchResults)) {
if (results.length === 0) continue;
researchContext += `=== ${itemName} ===\n`;
for (const r of results) {
researchContext += `- ${r.title}: ${r.content}\n Source: ${r.url}\n`;
}
researchContext += "\n";
}
const userPrompt = `Compare the following items: ${request.items.join(", ")}
${request.query ? `Focus: ${request.query}` : ""}
${request.dimensions?.length ? `Specific dimensions to include: ${request.dimensions.join(", ")}` : ""}
${researchContext}
Use the web research data above to provide factual, data-driven insights. Reference specific data points in your analysis. 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 getClient().chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "system",
content:
"You have access to real web search results. Use them to ground your comparison in factual data. Cite specific findings from the research when scoring and analyzing items.",
},
{ 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 with research after ${MAX_RETRIES} attempts: ${lastError?.message}`
);
}

View File

@@ -0,0 +1,117 @@
import type { ComparisonRequest, ComparisonResult } from "../types";
import type { SearchResult } from "./tavily";
import { generateComparisonWithResearch } from "./openai";
const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
const SYSTEM_PROMPT = `You are a research synthesis engine. Given web search results for multiple items, produce a structured JSON comparison.
You MUST respond with valid JSON matching this exact structure:
{
"items": [
{
"name": "item name",
"description": "brief overview",
"overallScore": 7.5,
"dimensions": {
"Dimension Name": {
"score": 8,
"summary": "brief assessment",
"details": "detailed analysis",
"pros": ["pro 1"],
"cons": ["con 1"]
}
},
"pros": ["overall pro 1"],
"cons": ["overall con 1"],
"sources": [{ "title": "source", "url": "https://...", "snippet": "excerpt" }]
}
],
"dimensions": ["Dimension 1", "Dimension 2"],
"summary": "comparison summary",
"recommendation": "clear recommendation"
}`;
export async function synthesizeResearch(
request: ComparisonRequest,
searchResults: Record<string, SearchResult[]>
): Promise<ComparisonResult> {
const apiKey = process.env.PERPLEXITY_API_KEY;
if (!apiKey) {
return generateComparisonWithResearch(request, searchResults);
}
const allResults = Object.values(searchResults).flat();
if (allResults.length === 0) {
return generateComparisonWithResearch(request, searchResults);
}
let researchContext = "Search results for each item:\n\n";
for (const [itemName, results] of Object.entries(searchResults)) {
if (results.length === 0) continue;
researchContext += `=== ${itemName} ===\n`;
for (const r of results) {
researchContext += `- ${r.title}: ${r.content}\n Source: ${r.url}\n`;
}
researchContext += "\n";
}
const userPrompt = `Compare: ${request.items.join(", ")}
${request.query ? `Focus: ${request.query}` : ""}
${request.dimensions?.length ? `Dimensions: ${request.dimensions.join(", ")}` : ""}
${researchContext}`;
try {
const response = await fetch(PERPLEXITY_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "sonar",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: 0.3,
}),
});
if (!response.ok) {
console.error(
`Perplexity API error: ${response.status} ${response.statusText}`
);
return generateComparisonWithResearch(request, searchResults);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
console.error("Empty response from Perplexity");
return generateComparisonWithResearch(request, searchResults);
}
const parsed: unknown = JSON.parse(content);
if (
!parsed ||
typeof parsed !== "object" ||
!Array.isArray((parsed as Record<string, unknown>).items) ||
!Array.isArray((parsed as Record<string, unknown>).dimensions)
) {
console.error("Invalid structure from Perplexity, falling back to OpenAI");
return generateComparisonWithResearch(request, searchResults);
}
return parsed as ComparisonResult;
} catch (error) {
console.error(
"Perplexity synthesis failed, falling back to OpenAI:",
error instanceof Error ? error.message : error
);
return generateComparisonWithResearch(request, searchResults);
}
}

View File

@@ -0,0 +1,69 @@
export interface SearchResult {
title: string;
url: string;
content: string;
score: number;
}
const TAVILY_API_URL = "https://api.tavily.com/search";
export async function searchItem(
itemName: string,
context: string
): Promise<SearchResult[]> {
const apiKey = process.env.TAVILY_API_KEY;
if (!apiKey) {
return [];
}
try {
const query = `${itemName} ${context}`.trim();
const response = await fetch(TAVILY_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
api_key: apiKey,
query,
max_results: 5,
include_answer: false,
search_depth: "advanced",
}),
});
if (!response.ok) {
console.error(
`Tavily API error: ${response.status} ${response.statusText}`
);
return [];
}
const data = await response.json();
if (!data.results || !Array.isArray(data.results)) {
return [];
}
return data.results.map(
(result: {
title?: string;
url?: string;
content?: string;
score?: number;
}) => ({
title: result.title ?? "",
url: result.url ?? "",
content: result.content ?? "",
score: result.score ?? 0,
})
);
} catch (error) {
console.error(
`Tavily search failed for "${itemName}":`,
error instanceof Error ? error.message : error
);
return [];
}
}

View File

@@ -31,6 +31,7 @@ export interface ComparisonResult {
export type ResearchProgress =
| { stage: "parsing"; message: string }
| { stage: "searching"; item: string; results: number }
| { stage: "researching"; item: string; progress: number }
| { stage: "synthesizing"; message: string }
| { stage: "complete"; result: ComparisonResult }