45 Commits

Author SHA1 Message Date
Hermes Agent
26c7ad4d7b Add Playwright E2E test suite (25 tests across 4 specs)
- playwright.config.ts: headless CI setup with JSON/HTML reporters
- e2e/global-setup.ts: app reachability check before tests
- e2e/auth.spec.ts: 6 auth tests (sign-in, session, protected routes)
- e2e/compare.spec.ts: 8 API tests (validation, SSE flow, DB persistence)
- e2e/comparisons.spec.ts: 6 CRUD tests (list, detail, 404, view counts)
- e2e/user.spec.ts: 5 user tests (stats, pagination, session binding)
- e2e/helpers.ts: shared SSE parsing, auth, URL resolution utilities
- e2e/README.md: setup and run instructions
- AGENTS.md: updated with LLM provider notes and testing docs
- .gitignore: added playwright report/result directories
- package.json: added @playwright/test and test:e2e scripts
2026-04-27 20:48:05 -07:00
Christopher Mayor
8f64ccd2f6 fix: pass original request headers to auth.api.getSession 2026-04-27 12:05:50 -07:00
Christopher Mayor
419d96aedc fix: disable drizzle query cache (NoopCache) to fix queryWithCache bug 2026-04-27 12:01:42 -07:00
Christopher Mayor
561e7b546e debug: add postgres.js debug logging 2026-04-27 11:57:23 -07:00
Christopher Mayor
d686d1bd4f fix: use plain Headers for auth session lookup in compare route 2026-04-27 11:43:42 -07:00
Christopher Mayor
3cb771a1cd debug: add error catching to compare getSession 2026-04-27 11:39:06 -07:00
Christopher Mayor
b44277506a fix: add withTimezone to session and verification expiresAt timestamps 2026-04-27 11:35:02 -07:00
Christopher Mayor
33d68502fb fix #12: explicit model name mapping in drizzle adapter schema 2026-04-27 11:29:12 -07:00
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
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
66a2d647bb feat: add Dockerfile and docker-compose.yml for containerized deployment 2026-04-24 14:34:35 -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
2c2fd3547c feat: add auth middleware protecting /compare and /profile routes 2026-04-24 14:34:13 -07:00
Christopher Mayor
3568e2f008 feat: update Better Auth config with schema and session expiry 2026-04-24 14:33:54 -07:00
Christopher Mayor
71ef567d0d feat: enhance OpenAI provider with web research context 2026-04-24 14:33:50 -07:00
Christopher Mayor
d8ff5f4bb1 feat: add users and sessions tables for Better Auth 2026-04-24 14:33:37 -07:00
Christopher Mayor
3a448a5063 feat: add Tavily search provider 2026-04-24 14:33:20 -07:00
48 changed files with 3483 additions and 302 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

10
.gitignore vendored
View File

@@ -12,6 +12,10 @@
# testing # testing
/coverage /coverage
/playwright-report/
/playwright-results.json
/test-results/
/playwright/.cache/
# next.js # next.js
/.next/ /.next/
@@ -32,6 +36,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel
@@ -39,3 +44,8 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Worktrees (tracked via branches)
comparaison-backend/
comparaison-llm/
comparaison-ui/

View File

@@ -3,3 +3,59 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
# Comparaison — Project Agent Rules
## LLM Provider
- **Base URL**: `https://nano-gpt.com/api/v1` (not `api.nano-gpt.com`)
- **API Key**: Use `NANOGPT_API_KEY` env var from the host's Docker `.env`
- **Model**: `minimax/minimax-m2.7` (supports `response_format: {type: json_object}`)
- **Schema tolerance**: The LLM output may deviate from the requested schema. The provider code (`src/lib/llm/providers/openai.ts`) includes `normalizeResult()` to handle common deviations (overallScore as object, sources as strings, dimensions as array of objects). Do NOT assume the LLM returns the exact schema.
## Development
```bash
# Local dev
npm run dev
# Build Docker image
docker compose build
# Run container
docker compose up -d
# Run E2E tests
npm install
npm run test:e2e:install
npm run test:e2e
```
## Deployment
Deployed to `ubuntu:/srv/compose/comparaison/` via `docker compose`.
Traefik routes `comparaison.local.tophermayor.com` → container port 3000.
## Environment Variables
Required in `.env`:
- `DATABASE_URL` — PostgreSQL connection string
- `BETTER_AUTH_SECRET` — Session secret (min 32 chars)
- `BETTER_AUTH_URL` — Auth callback URL
- `LLM_API_KEY` — NanoGPT API key (Hermes `NANOGPT_API_KEY` works)
- `LLM_BASE_URL``https://nano-gpt.com/api/v1`
- `LLM_MODEL``minimax/minimax-m2.7`
## Key Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/api/compare` | POST | Yes | Start SSE comparison stream |
| `/api/comparisons/[slug]` | GET | Yes | Get comparison by slug |
| `/api/user/comparisons` | GET | Yes | List user's comparisons |
| `/api/user/stats` | GET | Yes | User statistics |
| `/api/auth/sign-in/email` | POST | No | Email/password sign-in |
## Testing
E2E tests live in `e2e/` and use Playwright. See `e2e/README.md` for setup.

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

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 ```bash
npm run dev npm run dev
# or # App runs at http://localhost:3000
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
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. ### Production Setup
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
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

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
app:
build: .
container_name: comparaison
restart: unless-stopped
environment:
- 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", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -36,17 +36,34 @@
"name": "weight", "name": "weight",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"default": 1 "default": 1
}, },
"order": { "order": {
"name": "order", "name": "order",
"type": "integer", "type": "integer",
"primaryKey": false, "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": { "foreignKeys": {
"comparison_dimensions_comparison_id_comparisons_id_fk": { "comparison_dimensions_comparison_id_comparisons_id_fk": {
"name": "comparison_dimensions_comparison_id_comparisons_id_fk", "name": "comparison_dimensions_comparison_id_comparisons_id_fk",
@@ -130,10 +147,27 @@
"name": "order", "name": "order",
"type": "integer", "type": "integer",
"primaryKey": false, "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": { "foreignKeys": {
"comparison_items_comparison_id_comparisons_id_fk": { "comparison_items_comparison_id_comparisons_id_fk": {
"name": "comparison_items_comparison_id_comparisons_id_fk", "name": "comparison_items_comparison_id_comparisons_id_fk",
@@ -169,7 +203,7 @@
"name": "user_id", "name": "user_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": true
}, },
"title": { "title": {
"name": "title", "name": "title",
@@ -181,17 +215,18 @@
"name": "query", "name": "query",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": true
}, },
"slug": { "slug": {
"name": "slug", "name": "slug",
"type": "varchar(255)", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"status": { "status": {
"name": "status", "name": "status",
"type": "text", "type": "comparison_status",
"typeSchema": "public",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"default": "'researching'" "default": "'researching'"
@@ -218,26 +253,26 @@
"name": "is_public", "name": "is_public",
"type": "boolean", "type": "boolean",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"default": false "default": true
}, },
"view_count": { "view_count": {
"name": "view_count", "name": "view_count",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": true,
"default": 0 "default": 0
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp with time zone",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"default": "now()" "default": "now()"
}, },
"updated_at": { "updated_at": {
"name": "updated_at", "name": "updated_at",
"type": "timestamp", "type": "timestamp with time zone",
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"default": "now()" "default": "now()"
@@ -258,9 +293,53 @@
"concurrently": false, "concurrently": false,
"method": "btree", "method": "btree",
"with": {} "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": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {
"comparisons_slug_unique": { "comparisons_slug_unique": {
@@ -274,9 +353,84 @@
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "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": {}, "schemas": {},
"sequences": {}, "sequences": {},
"roles": {}, "roles": {},

View File

@@ -5,8 +5,29 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1777066133958, "when": 1777066297133,
"tag": "0000_opposite_doomsday", "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 "breakpoints": true
} }
] ]

76
e2e/README.md Normal file
View File

@@ -0,0 +1,76 @@
# E2E Tests
Playwright-based end-to-end tests covering auth, the compare API, comparisons CRUD, and user stats.
## Setup
```bash
# Install dependencies (includes Playwright)
npm install
# Install Chromium browser for Playwright
npm run test:e2e:install
# Copy and fill in test credentials
cp .env.test.example .env.test.local
# Edit .env.test.local with your test user credentials
```
## Running Tests
```bash
# Run all tests (headless)
npm run test:e2e
# Run with headed browser (visible)
npm run test:e2e:headed
# Run with Playwright UI (step-through debugger)
npm run test:e2e:ui
# View the HTML report from last run
npm run test:e2e:report
```
## Prerequisites
The Comparaison Docker container must be running on the target host:
```bash
ssh bear@192.168.50.61 'cd /srv/compose/comparaison && docker compose up -d'
```
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `E2E_HOST` | `192.168.50.61` | Host running the Docker container |
| `E2E_BASE_URL` | `http://localhost:3000` | App URL for browser navigation |
| `E2E_TARGET_HOST` | `192.168.50.61` | Host for API calls via Traefik |
| `E2E_TEST_EMAIL` | `admin@admin.com` | Test user email |
| `E2E_TEST_PASSWORD` | `adminpass` | Test user password |
## Test Structure
```
e2e/
global-setup.ts # Verifies app is reachable before tests
global-teardown.ts # Cleanup after tests
helpers.ts # Shared utilities (auth, SSE parsing, URL resolution)
auth.spec.ts # Authentication flows (6 tests)
compare.spec.ts # Compare API including SSE and DB persistence (8 tests)
comparisons.spec.ts # List and detail views (6 tests)
user.spec.ts # User stats and account (5 tests)
```
## CI Integration
In CI (GitHub Actions, Gitea Actions), set environment variables and run:
```bash
npm install
npm run test:e2e:install
npm run test:e2e
```
The JSON results file (`playwright-results.json`) can be uploaded as a CI artifact.

72
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,72 @@
import { test, expect, type Page } from "@playwright/test";
import { resolveUrl, getSessionCookie, testUser } from "./helpers";
/**
* E2E tests for authentication flows.
* Covers: sign-in, sign-out, protected routes, and session persistence.
*/
test.describe("Auth", () => {
// ─── Helpers ───────────────────────────────────────────────────────────────
async function signInViaUI(page: Page, email = testUser.email, password = testUser.password) {
await page.goto(resolveUrl("/"));
await page.getByRole("link", { name: /sign in/i }).click();
await page.getByPlaceholder("you@example.com").fill(email);
await page.getByPlaceholder("your password").fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("**/");
}
// ─── Tests ────────────────────────────────────────────────────────────────
test("1. Homepage loads without auth", async ({ page }) => {
const res = await page.request.get(resolveUrl("/"));
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(400);
});
test("2. Sign in via API returns session cookie", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/auth/sign-in/email"), {
data: { email: testUser.email, password: testUser.password },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(200);
const setCookie = res.headers()["set-cookie"] ?? "";
expect(setCookie).toContain("session_token=");
});
test("3. Sign in with wrong credentials returns 401", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/auth/sign-in/email"), {
data: { email: "bad@example.com", password: "wrongpass" },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(401);
});
test("4. Protected API route requires auth", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["A", "B"] },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error).toMatch(/auth/i);
});
test("5. Authenticated session persists across requests", async ({ page }) => {
const cookie = await getSessionCookie(page);
// Should be able to hit protected endpoints with the cookie
const res = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
});
test("6. UI sign-in flow works", async ({ page }) => {
await signInViaUI(page);
// After sign-in, user should see their name or a dashboard link
const bodyText = await page.content();
expect(bodyText.toLowerCase()).not.toContain("sign in");
});
});

161
e2e/compare.spec.ts Normal file
View File

@@ -0,0 +1,161 @@
import { test, expect } from "@playwright/test";
import { resolveUrl, getSessionCookie, parseSSEStream, lastSSEOfType } from "./helpers";
/**
* E2E tests for the /api/compare endpoint.
* Covers: request validation, full SSE flow, success/failure states, and DB persistence.
*/
test.describe("Compare API", () => {
let cookie: string;
test.beforeEach(async ({ page }) => {
cookie = await getSessionCookie(page);
});
// ─── Input Validation ────────────────────────────────────────────────────
test("1. Rejects fewer than 2 items", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["React"] },
headers: { "Content-Type": "application/json", Cookie: cookie },
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/2 items|at least/i);
});
test("2. Rejects more than 10 items", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: Array.from({ length: 11 }, (_, i) => `Item${i}`) },
headers: { "Content-Type": "application/json", Cookie: cookie },
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/10|maximum/i);
});
test("3. Rejects empty item names", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["React", " ", "Vue"] },
headers: { "Content-Type": "application/json", Cookie: cookie },
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/empty/i);
});
test("4. Rejects unauthenticated requests", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["React", "Vue"] },
headers: { "Content-Type": "application/json" },
});
expect(res.status()).toBe(401);
});
// ─── Happy Path ──────────────────────────────────────────────────────────
test("5. Compare returns SSE stream with progress events", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "Python vs JavaScript", items: ["Python", "JavaScript"] },
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain("text/event-stream");
const body = await res.body();
if (!body) { test.skip(); return; }
const bodyStr = Buffer.from(body).toString("utf-8");
const events = await parseSSEStream(bodyStr);
// Should have at least one progress event
const progressEvents = events.filter((e) => e.status !== undefined);
expect(progressEvents.length).toBeGreaterThan(0);
});
test("6. Full compare flow completes successfully", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "React vs Vue", items: ["React", "Vue"] },
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
timeout: 120_000,
});
expect(res.status()).toBe(200);
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
// Must have a final status
const finalProgress = lastSSEOfType(events, "progress");
expect(finalProgress).toBeDefined();
expect(["completed", "failed"]).toContain(finalProgress!.status);
if (finalProgress!.status === "completed") {
const doneEvent = lastSSEOfType(events, "done") as Record<string, unknown> | undefined;
expect(doneEvent).toBeDefined();
const doneData = doneEvent!.done as Record<string, unknown>;
expect(doneData).toHaveProperty("id");
expect(doneData).toHaveProperty("slug");
expect((doneData.data as Record<string, unknown>)).toHaveProperty("summary");
expect((doneData.data as Record<string, unknown>)).toHaveProperty("items");
}
});
test("7. Comparison is persisted to DB after completion", async ({ page }) => {
// Run a comparison
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "Rust vs Go", items: ["Rust", "Go"] },
headers: { "Content-Type": "application/json", Cookie: cookie, Accept: "text/event-stream" },
timeout: 120_000,
});
expect(res.status()).toBe(200);
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
const doneEvent = lastSSEOfType(events, "done") as Record<string, unknown> | undefined;
if (!doneEvent) { test.skip(); return; }
const { slug } = doneEvent.done as { slug: string };
expect(slug).toBeTruthy();
// Fetch it back
const getRes = await page.request.get(resolveUrl(`/api/comparisons/${slug}`), {
headers: { Cookie: cookie },
});
expect(getRes.status()).toBe(200);
const comparison = await getRes.json();
expect(comparison.slug).toBe(slug);
expect(comparison.status).toBe("completed");
});
test("8. Compare with custom dimensions", async ({ page }) => {
const res = await page.request.post(resolveUrl("/api/compare"), {
data: {
query: "React vs Angular",
items: ["React", "Angular"],
dimensions: ["Performance", "Learning Curve", "Ecosystem"],
},
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
timeout: 120_000,
});
expect(res.status()).toBe(200);
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
const finalProgress = lastSSEOfType(events, "progress");
expect(finalProgress).toBeDefined();
expect(["completed", "failed"]).toContain(finalProgress!.status);
});
});

95
e2e/comparisons.spec.ts Normal file
View File

@@ -0,0 +1,95 @@
import { test, expect } from "@playwright/test";
import { resolveUrl, getSessionCookie, parseSSEStream, lastSSEOfType } from "./helpers";
/**
* E2E tests for comparison list and detail views.
* Covers: /api/comparisons/[slug], /api/user/comparisons
*/
test.describe("Comparisons", () => {
let cookie: string;
let testSlug: string;
test.beforeAll(async ({ page }) => {
cookie = await getSessionCookie(page);
// Create a comparison to ensure we have data
const res = await page.request.post(resolveUrl("/api/compare"), {
data: { query: "Cats vs Dogs", items: ["Cats", "Dogs"] },
headers: { "Content-Type": "application/json", Cookie: cookie, Accept: "text/event-stream" },
timeout: 120_000,
});
if (res.ok()) {
const body = Buffer.from((await res.body()) ?? []).toString("utf-8");
const events = await parseSSEStream(body);
const done = lastSSEOfType(events, "done") as Record<string, unknown> | undefined;
if (done) {
testSlug = (done.done as Record<string, unknown>).slug as string;
}
}
});
test("1. User comparisons list returns 200 for authenticated user", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/comparisons"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});
test("2. User comparisons list requires auth", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/comparisons"));
expect(res.status()).toBe(401);
});
test("3. Get comparison by slug returns 200 with data", async ({ page }) => {
if (!testSlug) { test.skip(); return; }
const res = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("slug", testSlug);
expect(data).toHaveProperty("status");
expect(["completed", "failed", "researching"]).toContain(data.status);
});
test("4. Get comparison by non-existent slug returns 404", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/comparisons/this-slug-does-not-exist-xyz123"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(404);
});
test("5. Comparison slug increments view count", async ({ page }) => {
if (!testSlug) { test.skip(); return; }
const res1 = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
const viewsBefore = (await res1.json()).viewCount ?? 0;
const res2 = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
const viewsAfter = (await res2.json()).viewCount ?? 0;
expect(viewsAfter).toBeGreaterThanOrEqual(viewsBefore);
});
test("6. Comparison response includes all required fields", async ({ page }) => {
if (!testSlug) { test.skip(); return; }
const res = await page.request.get(resolveUrl(`/api/comparisons/${testSlug}`), {
headers: { Cookie: cookie },
});
const data = await res.json();
expect(data).toHaveProperty("id");
expect(data).toHaveProperty("title");
expect(data).toHaveProperty("slug");
expect(data).toHaveProperty("status");
expect(data).toHaveProperty("query");
});
});

32
e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { FullConfig } from "@playwright/test";
import { baseURL } from "./playwright.config";
/**
* Global setup — runs once before all tests.
* Verifies the app is reachable before running E2E suite.
*/
export default async function globalSetup(_config: FullConfig) {
const url = baseURL.replace("localhost", process.env.E2E_HOST || "192.168.50.61");
console.log(`[E2E Setup] Checking app at: ${url}`);
let attempts = 0;
while (attempts < 10) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (res.ok || res.status === 401) {
// 401 means the server is up (auth is working), 200 means homepage
console.log(`[E2E Setup] App reachable (HTTP ${res.status})`);
return;
}
} catch {
// ignore and retry
}
attempts++;
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(
`[E2E Setup] App not reachable at ${url} after ${attempts} attempts. ` +
"Ensure the Docker container is running: `docker compose up -d`"
);
}

9
e2e/global-teardown.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { FullConfig } from "@playwright/test";
/**
* Global teardown — runs once after all tests finish.
* No-op placeholder for cleanup hooks.
*/
export default async function globalTeardown(_config: FullConfig) {
// Add cleanup logic here if needed (e.g., stop test containers)
}

53
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,53 @@
import { type Page, type Request, type Response, expect } from "@playwright/test";
import { baseURL, targetHost, testUser } from "../playwright.config";
/** Resolve URL relative to the target host (bypasses localhost resolution). */
export function resolveUrl(path: string): string {
const base = baseURL.replace("localhost", targetHost);
return `${base}${path.startsWith("/") ? path : "/" + path}`;
}
/** Sign in as the test user via the API and return the session cookie. */
export async function getSessionCookie(page: Page): Promise<string> {
const res = await page.request.post(resolveUrl("/api/auth/sign-in/email"), {
data: { email: testUser.email, password: testUser.password },
headers: { "Content-Type": "application/json" },
});
if (!res.ok()) {
throw new Error(`Sign-in failed: ${res.status()} ${await res.text()}`);
}
const setCookie = res.headers()["set-cookie"] ?? "";
const match = setCookie.match(/session_token=([^;]+)/);
if (!match) throw new Error("No session_token cookie in sign-in response");
return `session_token=${match[1]}`;
}
/** Parse SSE stream from a fetch response body. */
export async function parseSSEStream(body: string): Promise<Record<string, unknown>[]> {
const events: Record<string, unknown>[] = [];
for (const line of body.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("event:")) continue;
if (trimmed.startsWith("data:")) {
const dataStr = trimmed.slice(5).trim();
try {
events.push(JSON.parse(dataStr));
} catch {
// skip malformed
}
}
}
return events;
}
/** Extract the last SSE event of a given type. */
export function lastSSEOfType(
events: Record<string, unknown>[],
type: "progress" | "done"
): Record<string, unknown> | undefined {
return events.reverse().find((e) => e[type] !== undefined);
}
// Re-export common helpers
export { expect, resolveUrl as url, testUser };
export type { Page, Request, Response };

77
e2e/user.spec.ts Normal file
View File

@@ -0,0 +1,77 @@
import { test, expect } from "@playwright/test";
import { resolveUrl, getSessionCookie } from "./helpers";
/**
* E2E tests for user endpoints: stats, profile, and account management.
*/
test.describe("User API", () => {
let cookie: string;
test.beforeEach(async ({ page }) => {
cookie = await getSessionCookie(page);
});
test("1. User stats returns 200 for authenticated user", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("totalComparisons");
expect(data).toHaveProperty("totalViews");
expect(typeof data.totalComparisons).toBe("number");
expect(typeof data.totalViews).toBe("number");
});
test("2. User stats requires auth", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/stats"));
expect(res.status()).toBe(401);
});
test("3. User stats increments after new comparison", async ({ page }) => {
const beforeRes = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
const before = await beforeRes.json();
const countBefore = before.totalComparisons ?? 0;
// Create a new comparison
const createRes = await page.request.post(resolveUrl("/api/compare"), {
data: { items: ["TypeScript", "JavaScript"] },
headers: {
"Content-Type": "application/json",
Cookie: cookie,
Accept: "text/event-stream",
},
timeout: 120_000,
});
if (createRes.status() === 200) {
// Wait briefly for DB write
await new Promise((r) => setTimeout(r, 2000));
const afterRes = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
const after = await afterRes.json();
expect(after.totalComparisons).toBeGreaterThanOrEqual(countBefore);
}
});
test("4. User comparisons list pagination works", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/comparisons?page=1&limit=5"), {
headers: { Cookie: cookie },
});
expect(res.status()).toBe(200);
});
test("5. Auth session is bound to correct user", async ({ page }) => {
const res = await page.request.get(resolveUrl("/api/user/stats"), {
headers: { Cookie: cookie },
});
const data = await res.json();
// Stats should be non-negative integers
expect(data.totalComparisons).toBeGreaterThanOrEqual(0);
expect(data.totalViews).toBeGreaterThanOrEqual(0);
});
});

View File

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

View File

@@ -6,7 +6,12 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report",
"test:e2e:install": "playwright install chromium"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",
@@ -27,6 +32,7 @@
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",

41
playwright.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.E2E_BASE_URL || "http://localhost:3000";
const targetHost = process.env.E2E_TARGET_HOST || "192.168.50.61";
const testUser = {
email: process.env.E2E_TEST_EMAIL || "admin@admin.com",
password: process.env.E2E_TEST_PASSWORD || "adminpass",
};
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
["json", { outputFile: "playwright-results.json" }],
],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
globalSetup: "./e2e/global-setup.ts",
globalTeardown: "./e2e/global-teardown.ts",
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
timeout: 120_000,
expect: {
timeout: 10_000,
},
});
export { baseURL, targetHost, testUser };

View File

@@ -1,97 +1,94 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useCallback } from "react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Skeleton } from "@/components/ui/skeleton"
import { Search, Eye, BarChart3, Filter, X, Loader2 } from "lucide-react" import { Search, Eye, Filter, X, Loader2, RefreshCw } from "lucide-react"
import Link from "next/link" import Link from "next/link"
const allComparisons = [ interface Comparison {
{ id: string
id: "1", title: string
title: "React vs Vue vs Svelte", summary: string
description: "Frontend framework comparison for modern web development", slug: string
items: ["React", "Vue", "Svelte"], tags: string[]
tags: ["Tech", "JavaScript"], items: string[]
author: "Alex Johnson", viewCount: number
overallScore: 8.5, createdAt: string
views: 1247, }
},
{
id: "2",
title: "GPT-4 vs Claude vs Gemini",
description: "Comparing top AI language models for reasoning tasks",
items: ["GPT-4", "Claude 3", "Gemini Pro"],
tags: ["AI", "Products"],
author: "Sarah Chen",
overallScore: 8.8,
views: 3891,
},
{
id: "3",
title: "Notion vs Obsidian vs Roam",
description: "Knowledge management tools for productivity",
items: ["Notion", "Obsidian", "Roam Research"],
tags: ["Productivity", "Tools"],
author: "Mike Peters",
overallScore: 7.5,
views: 892,
},
{
id: "4",
title: "AWS vs GCP vs Azure",
description: "Cloud platform comparison for enterprise infrastructure",
items: ["AWS", "Google Cloud", "Microsoft Azure"],
tags: ["Tech", "Cloud"],
author: "Emma Wilson",
overallScore: 9.0,
views: 2156,
},
{
id: "5",
title: "iPhone 15 Pro vs Samsung S24 Ultra",
description: "Flagship smartphone comparison with camera and performance benchmarks",
items: ["iPhone 15 Pro", "Samsung S24 Ultra"],
tags: ["Products", "Mobile"],
author: "James Lee",
overallScore: 8.2,
views: 3421,
},
{
id: "6",
title: "Python vs Rust vs Go",
description: "Systems programming languages compared for performance and productivity",
items: ["Python", "Rust", "Go"],
tags: ["Tech", "Programming"],
author: "Anna Kim",
overallScore: 8.4,
views: 1873,
},
]
const categories = ["All", "Tech", "Products", "AI", "Cloud", "Productivity"] interface ComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
export default function ExplorePage() { export default function ExplorePage() {
const [searchQuery, setSearchQuery] = useState("") const [comparisons, setComparisons] = useState<Comparison[]>([])
const [selectedCategory, setSelectedCategory] = useState("All") const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false) 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 filteredComparisons = allComparisons.filter((comparison) => { const limit = 20
const matchesSearch =
searchQuery === "" || const fetchComparisons = useCallback(async (pageNum: number, search: string, append = false) => {
comparison.title.toLowerCase().includes(searchQuery.toLowerCase()) || setLoading(true)
comparison.items.some((item) => setError(null)
item.toLowerCase().includes(searchQuery.toLowerCase()) 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 = const matchesCategory =
selectedCategory === "All" || selectedCategory === "All" ||
comparison.tags.some((tag) => tag.toLowerCase() === selectedCategory.toLowerCase()) comparison.tags.some((tag) => tag.toLowerCase() === selectedCategory.toLowerCase())
return matchesSearch && matchesCategory return matchesCategory
}) })
const loadMore = () => {
fetchComparisons(page + 1, debouncedSearch, true)
}
const hasMore = comparisons.length < total
return ( return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6"> <div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
@@ -125,14 +122,44 @@ export default function ExplorePage() {
</div> </div>
</div> </div>
{loading ? ( {loading && comparisons.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Loader2 className="size-8 animate-spin text-primary" /> {[...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> </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 ? ( ) : filteredComparisons.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filteredComparisons.map((comparison) => ( {filteredComparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}> <Link key={comparison.id} href={`/compare/${comparison.slug}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md"> <Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -141,7 +168,7 @@ export default function ExplorePage() {
</CardTitle> </CardTitle>
</div> </div>
<CardDescription className="text-sm line-clamp-2"> <CardDescription className="text-sm line-clamp-2">
{comparison.description} {comparison.summary}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -153,15 +180,6 @@ export default function ExplorePage() {
))} ))}
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Avatar className="size-6">
<AvatarFallback className="text-[10px]">
{comparison.author.split(" ").map((n) => n[0]).join("")}
</AvatarFallback>
</Avatar>
<span className="text-xs">{comparison.author}</span>
</div>
<div className="flex items-center justify-between pt-2 border-t"> <div className="flex items-center justify-between pt-2 border-t">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")} {comparison.items.join(" vs ")}
@@ -169,10 +187,7 @@ export default function ExplorePage() {
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1 text-muted-foreground"> <span className="flex items-center gap-1 text-muted-foreground">
<Eye className="size-3.5" /> <Eye className="size-3.5" />
{comparison.views.toLocaleString()} {comparison.viewCount.toLocaleString()}
</span>
<span className="font-semibold text-foreground bg-primary/10 px-2 py-0.5 rounded">
{comparison.overallScore}/10
</span> </span>
</div> </div>
</div> </div>
@@ -206,11 +221,19 @@ export default function ExplorePage() {
</Card> </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"> <div className="flex justify-center">
<Button variant="outline" className="gap-2"> <Button variant="outline" onClick={loadMore} className="gap-2">
Load More Load More
</Button> </Button>
</div> </div>
)}
</div> </div>
) )
} }

View File

@@ -1,66 +1,58 @@
"use client" "use client"
import { useState, useEffect, useCallback } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { BarChart3, Eye, Calendar, Plus, ArrowRight } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton"
import { BarChart3, Eye, Calendar, Plus, ArrowRight, RefreshCw, LogIn } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useSession } from "@/lib/auth-client"
const mockUser = { interface Comparison {
name: "Alex Johnson", id: string
email: "alex@example.com", title: string
avatar: "/placeholder-avatar.png", slug: string
items: string[]
tags: string[]
viewCount: number
overallScore: number
createdAt: string
} }
const mockComparisons = [ interface UserComparisonsResponse {
{ comparisons: Comparison[]
id: "1", total: number
title: "React vs Vue vs Svelte", page: number
items: ["React", "Vue", "Svelte"], }
tags: ["Tech", "JavaScript"],
overallScore: 8.5,
views: 1247,
createdAt: "2024-01-15",
},
{
id: "2",
title: "GPT-4 vs Claude vs Gemini",
items: ["GPT-4", "Claude 3", "Gemini Pro"],
tags: ["AI", "Products"],
overallScore: 8.8,
views: 3891,
createdAt: "2024-01-10",
},
{
id: "3",
title: "Notion vs Obsidian vs Roam",
items: ["Notion", "Obsidian", "Roam Research"],
tags: ["Productivity"],
overallScore: 7.5,
views: 892,
createdAt: "2024-01-05",
},
]
const stats = [ interface UserStats {
{ label: "Total Comparisons", value: 12, icon: BarChart3 }, totalComparisons: number
{ label: "Total Views", value: "8.2K", icon: Eye }, totalViews: number
] }
export default function ProfilePage() { 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 ( return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8"> <div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<Avatar className="size-20"> <Avatar className="size-20">
<AvatarImage src={mockUser.avatar} /> <AvatarImage src={user.avatar} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold"> <AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold">
{mockUser.name.split(" ").map((n) => n[0]).join("")} {user.name.split(" ").map((n) => n[0]).join("")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="space-y-1.5"> <div className="space-y-1.5">
<h1 className="text-2xl font-bold">{mockUser.name}</h1> <h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-muted-foreground">{mockUser.email}</p> <p className="text-muted-foreground">{user.email}</p>
</div> </div>
</div> </div>
@@ -91,9 +83,9 @@ export default function ProfilePage() {
</Link> </Link>
</div> </div>
{mockComparisons.length > 0 ? ( {comparisons.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{mockComparisons.map((comparison) => ( {comparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}> <Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md"> <Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -118,7 +110,7 @@ export default function ProfilePage() {
<div className="flex items-center gap-3 text-sm text-muted-foreground"> <div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Eye className="size-3.5" /> <Eye className="size-3.5" />
{comparison.views.toLocaleString()} {comparison.viewCount.toLocaleString()}
</span> </span>
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{comparison.overallScore}/10 {comparison.overallScore}/10

View File

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

View File

@@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema"; import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { auth } from "@/lib/auth";
function serializeSSE(event: string, data: unknown): string { function serializeSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -18,14 +19,37 @@ function slugify(text: string): string {
.slice(0, 200); .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) { 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[] } = const body: { query?: string; items?: string[]; dimensions?: string[] } =
await request.json(); await request.json();
const { query, items, dimensions } = body; const { query, items, dimensions } = body;
if (!items || items.length < 2) { if (!items || items.length < 2) {
return Response.json( 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 } { status: 400 }
); );
} }
@@ -36,8 +60,9 @@ export async function POST(request: Request) {
await db.insert(comparisons).values({ await db.insert(comparisons).values({
id, id,
userId: session.user.id,
title, title,
query: query || null, query: query ?? title,
slug, slug,
status: "researching", 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") { if (progress.stage === "researching") {
itemsCompleted++; itemsCompleted++;
controller.enqueue( controller.enqueue(
@@ -102,7 +141,17 @@ export async function POST(request: Request) {
if (progress.stage === "complete") { if (progress.stage === "complete") {
const result = progress.result; 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, title,
query: query || "", query: query || "",
status: "completed", status: "completed",
@@ -177,7 +226,7 @@ export async function POST(request: Request) {
encoder.encode( encoder.encode(
serializeSSE("progress", { serializeSSE("progress", {
status: "failed", status: "failed",
message: progress.error, message: `Comparison failed: ${progress.error}`,
itemsCompleted, itemsCompleted,
totalItems: items.length, totalItems: items.length,
currentStep: "Failed", currentStep: "Failed",
@@ -192,11 +241,16 @@ export async function POST(request: Request) {
.set({ status: "failed", updatedAt: new Date() }) .set({ status: "failed", updatedAt: new Date() })
.where(eq(comparisons.id, id)); .where(eq(comparisons.id, id));
const message =
error instanceof Error
? error.message
: "An unexpected error occurred during research";
controller.enqueue( controller.enqueue(
encoder.encode( encoder.encode(
serializeSSE("progress", { serializeSSE("progress", {
status: "failed", status: "failed",
message: error instanceof Error ? error.message : "Unknown error", message: `Comparison failed: ${message}`,
itemsCompleted, itemsCompleted,
totalItems: items.length, totalItems: items.length,
currentStep: "Failed", 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,8 +1,18 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db"; import { db } from "./db";
import { users, sessions, accounts, verifications } from "./db/schema";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }), database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: users,
session: sessions,
account: accounts,
verification: verifications,
},
}),
emailAndPassword: { enabled: true }, emailAndPassword: { enabled: true },
session: { expiresIn: 60 * 60 * 24 * 7 },
}); });

View File

@@ -1,8 +1,9 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import { NoopCache } from "drizzle-orm/cache/core/cache";
import * as schema from "./schema"; import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!; const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString); const client = postgres(connectionString);
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema, cache: new NoopCache() });

View File

@@ -1,40 +1,105 @@
import { import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable, pgTable,
text, text,
timestamp, timestamp,
jsonb,
integer,
boolean,
varchar,
index,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false),
image: text("image"),
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", { withTimezone: true }).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")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at", { withTimezone: true }).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( export const comparisons = pgTable(
"comparisons", "comparisons",
{ {
id: text("id").primaryKey(), id: text("id")
userId: text("user_id"), .primaryKey()
title: text("title").notNull(), .$defaultFn(() => createId()),
query: text("query"), userId: text("user_id")
slug: varchar("slug", { length: 255 }).notNull().unique(),
status: text("status", {
enum: ["researching", "completed", "failed"],
})
.notNull() .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"), summary: text("summary"),
overallData: jsonb("overall_data"), overallData: jsonb("overall_data"),
tags: text("tags").array(), tags: text("tags").array(),
isPublic: boolean("is_public").default(false), isPublic: boolean("is_public").notNull().default(true),
viewCount: integer("view_count").default(0), viewCount: integer("view_count").notNull().default(0),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), 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", { export const comparisonItems = pgTable(
id: text("id").primaryKey(), "comparison_items",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
comparisonId: text("comparison_id") comparisonId: text("comparison_id")
.notNull() .notNull()
.references(() => comparisons.id, { onDelete: "cascade" }), .references(() => comparisons.id, { onDelete: "cascade" }),
@@ -45,22 +110,24 @@ export const comparisonItems = pgTable("comparison_items", {
scores: jsonb("scores"), scores: jsonb("scores"),
pros: text("pros").array(), pros: text("pros").array(),
cons: text("cons").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", { export const comparisonDimensions = pgTable(
id: text("id").primaryKey(), "comparison_dimensions",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
comparisonId: text("comparison_id") comparisonId: text("comparison_id")
.notNull() .notNull()
.references(() => comparisons.id, { onDelete: "cascade" }), .references(() => comparisons.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
description: text("description"), description: text("description"),
weight: integer("weight").default(1), weight: integer("weight").notNull().default(1),
order: integer("order").notNull(), order: integer("order").notNull().default(0),
}); },
(table) => [index("comparison_dimensions_comparison_id_idx").on(table.comparisonId)],
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;

View File

@@ -3,7 +3,9 @@ import type {
ComparisonResult, ComparisonResult,
ResearchProgress, ResearchProgress,
} from "./types"; } 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 { export type {
ComparisonRequest, ComparisonRequest,
@@ -24,6 +26,28 @@ export async function* runResearch(
return; 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++) { for (let i = 0; i < request.items.length; i++) {
yield { yield {
stage: "researching", stage: "researching",
@@ -31,14 +55,15 @@ export async function* runResearch(
progress: Math.round(((i + 0.5) / request.items.length) * 80), progress: Math.round(((i + 0.5) / request.items.length) * 80),
}; };
} }
}
yield { yield {
stage: "synthesizing", stage: "synthesizing",
message: "Synthesizing research into structured comparison...", message: `Synthesizing research into structured comparison using ${provider.name}...`,
}; };
try { try {
const result = await generateComparison(request); const result = await provider.synthesize(request, searchResults);
yield { stage: "complete", result }; yield { stage: "complete", result };
} catch (error) { } catch (error) {
yield { 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, DimensionResult,
ItemResearch, ItemResearch,
} from "../types"; } from "../types";
import type { SearchResult } from "./tavily";
const client = new OpenAI({ let _client: OpenAI | null = null;
apiKey: process.env.OPENAI_API_KEY,
}); 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. 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++) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try { try {
const response = await client.chat.completions.create({ const response = await getClient().chat.completions.create({
model: "gpt-4o-mini", model: "gpt-4o-mini",
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { 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}` `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 = export type ResearchProgress =
| { stage: "parsing"; message: string } | { stage: "parsing"; message: string }
| { stage: "searching"; item: string; results: number }
| { stage: "researching"; item: string; progress: number } | { stage: "researching"; item: string; progress: number }
| { stage: "synthesizing"; message: string } | { stage: "synthesizing"; message: string }
| { stage: "complete"; result: ComparisonResult } | { stage: "complete"; result: ComparisonResult }

View File

@@ -1,20 +1,29 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
const publicPaths = ["/sign-in", "/sign-up", "/api/auth"]; const publicPaths = ["/", "/explore", "/sign-in", "/sign-up", "/api/auth"];
const protectedPaths = ["/compare", "/profile"];
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
return NextResponse.next();
}
const isPublic = publicPaths.some( const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith(path + "/"), (path) => pathname === path || pathname.startsWith(path + "/"),
); );
if (isPublic) { const isProtected = protectedPaths.some(
return NextResponse.next(); (path) => pathname === path || pathname.startsWith(path + "/"),
} );
if (pathname.startsWith("/_next") || pathname.startsWith("/favicon")) { if (isPublic && !isProtected) {
return NextResponse.next(); return NextResponse.next();
} }
@@ -22,7 +31,7 @@ export async function middleware(request: NextRequest) {
headers: request.headers, headers: request.headers,
}); });
if (!session) { if (!session && isProtected) {
const signInUrl = new URL("/sign-in", request.url); const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname); signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl); return NextResponse.redirect(signInUrl);