13 Commits

Author SHA1 Message Date
Christopher Mayor
7888d7995c chore: update drizzle schema and migrations 2026-04-24 15:04:29 -07:00
Christopher Mayor
3689b1707a chore: gitignore worktree directories 2026-04-24 15:03:14 -07:00
Christopher Mayor
aac0e2f5b1 docs: comprehensive documentation - README, specs, architecture, API reference, UI/UX flow, dev guide 2026-04-24 15:02:40 -07:00
Christopher Mayor
5bde4e3aa6 Merge branch 'feat/backend' 2026-04-24 15:01:37 -07:00
Christopher Mayor
6832fbdebb Merge branch 'feat/frontend' 2026-04-24 15:01:21 -07:00
Christopher Mayor
43f011e519 feat: complete frontend UI - comparison views, profile, explore, layout 2026-04-24 14:39:54 -07:00
Christopher Mayor
37c07e468d fix: lazy-init OpenAI client to avoid build failure without API key 2026-04-24 14:37:28 -07:00
Christopher Mayor
3539a5f3eb feat: add .env.example with required environment variables 2026-04-24 14:35:19 -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
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
d8ff5f4bb1 feat: add users and sessions tables for Better Auth 2026-04-24 14:33:37 -07:00
23 changed files with 2170 additions and 217 deletions

6
.env.example Normal file
View File

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

6
.gitignore vendored
View File

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

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"]

152
README.md
View File

@@ -1,36 +1,148 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# ComparAIson
## Getting Started
**AI-Powered Deep Research Comparison Platform**
First, run the development server:
Compare anything with intelligent, multi-dimensional analysis. ComparAIson uses LLM-powered research to generate comprehensive, visual comparisons — then saves them as shareable posts on your profile.
## Features
- **Deep Research Engine** — Multi-provider LLM pipeline (Tavily search → Perplexity/OpenAI synthesis) with automatic fallback
- **Interactive Visualizations** — Radar charts, grouped bar charts, feature comparison tables, score cards, pros/cons breakdowns
- **Real-Time Streaming** — Watch research progress live via Server-Sent Events
- **User Profiles** — Save comparisons to your profile, browse a public feed
- **Self-Hosted** — Docker Compose deployment, runs on a Raspberry Pi
## Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router, Server Components, Server Actions) |
| Language | TypeScript |
| Database | PostgreSQL 16 |
| ORM | Drizzle ORM |
| Auth | Better Auth (email + password) |
| UI | Tailwind CSS + shadcn/ui |
| Charts | Recharts |
| LLM | OpenAI GPT-4o-mini + Tavily Search + Perplexity Sonar |
| Deployment | Docker Compose + Traefik reverse proxy |
## Quick Start
### Prerequisites
- Node.js 20+
- PostgreSQL 16+ (or Docker)
- At least one LLM API key
### 1. Clone & Install
```bash
git clone https://gitea.tophermayor.com/TopherMayor/comparaison.git
cd comparaison
npm install
```
### 2. Configure Environment
```bash
cp .env.example .env.local
# Edit .env.local with your API keys and database URL
```
Required environment variables:
| Variable | Description | Required |
|---|---|---|
| `DATABASE_URL` | PostgreSQL connection string | Yes |
| `BETTER_AUTH_SECRET` | Random secret for session signing | Yes |
| `OPENAI_API_KEY` | OpenAI API key (GPT-4o-mini) | Yes* |
| `TAVILY_API_KEY` | Tavily search API key | Recommended |
| `PERPLEXITY_API_KEY` | Perplexity Sonar API key | Optional |
| `NEXT_PUBLIC_APP_URL` | Public URL of the app | Yes |
*At minimum, one LLM provider key is required. OpenAI works standalone; Tavily adds web search; Perplexity adds cheaper synthesis.
### 3. Database Setup
```bash
# Generate migration
npx drizzle-kit generate
# Run migration
npx drizzle-kit migrate
```
### 4. Development
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
# App runs at http://localhost:3000
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### 5. Docker Deployment
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
docker compose up -d
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Project Structure
## Learn More
```
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
```
To learn more about Next.js, take a look at the following resources:
## Architecture
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## API Reference
## Deploy on Vercel
See [docs/api-reference.md](docs/api-reference.md) for endpoint documentation.
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.
## UI/UX Flow
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
See [docs/ui-ux-flow.md](docs/ui-ux-flow.md) for user journey and wireframe descriptions.
## License
MIT

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: "3.8"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/comparaison
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: comparaison
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:

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

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

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

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1777066133958,
"tag": "0000_opposite_doomsday",
"when": 1777066297133,
"tag": "0000_gorgeous_puma",
"breakpoints": true
}
]

View File

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

View File

@@ -0,0 +1,216 @@
"use client"
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Search, Eye, BarChart3, Filter, X, Loader2 } from "lucide-react"
import Link from "next/link"
const allComparisons = [
{
id: "1",
title: "React vs Vue vs Svelte",
description: "Frontend framework comparison for modern web development",
items: ["React", "Vue", "Svelte"],
tags: ["Tech", "JavaScript"],
author: "Alex Johnson",
overallScore: 8.5,
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"]
export default function ExplorePage() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedCategory, setSelectedCategory] = useState("All")
const [loading, setLoading] = useState(false)
const filteredComparisons = allComparisons.filter((comparison) => {
const matchesSearch =
searchQuery === "" ||
comparison.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
comparison.items.some((item) =>
item.toLowerCase().includes(searchQuery.toLowerCase())
)
const matchesCategory =
selectedCategory === "All" ||
comparison.tags.some((tag) => tag.toLowerCase() === selectedCategory.toLowerCase())
return matchesSearch && matchesCategory
})
return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Explore Comparisons</h1>
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category)}
className="rounded-full"
>
{category}
</Button>
))}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
) : filteredComparisons.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filteredComparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-1 flex-1">
{comparison.title}
</CardTitle>
</div>
<CardDescription className="text-sm line-clamp-2">
{comparison.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{comparison.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center 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">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<Eye className="size-3.5" />
{comparison.views.toLocaleString()}
</span>
<span className="font-semibold text-foreground bg-primary/10 px-2 py-0.5 rounded">
{comparison.overallScore}/10
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Search className="size-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium">No comparisons found</p>
<p className="text-sm text-muted-foreground">
Try adjusting your search or filters
</p>
</div>
<Button
variant="outline"
onClick={() => {
setSearchQuery("")
setSelectedCategory("All")
}}
>
Clear Filters
</Button>
</div>
</Card>
)}
<div className="flex justify-center">
<Button variant="outline" className="gap-2">
Load More
</Button>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,157 @@
"use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { BarChart3, Eye, Calendar, Plus, ArrowRight } from "lucide-react"
import Link from "next/link"
const mockUser = {
name: "Alex Johnson",
email: "alex@example.com",
avatar: "/placeholder-avatar.png",
}
const mockComparisons = [
{
id: "1",
title: "React vs Vue vs Svelte",
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 = [
{ label: "Total Comparisons", value: 12, icon: BarChart3 },
{ label: "Total Views", value: "8.2K", icon: Eye },
]
export default function ProfilePage() {
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8">
<div className="flex items-center gap-6">
<Avatar className="size-20">
<AvatarImage src={mockUser.avatar} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold">
{mockUser.name.split(" ").map((n) => n[0]).join("")}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h1 className="text-2xl font-bold">{mockUser.name}</h1>
<p className="text-muted-foreground">{mockUser.email}</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className="flex items-center gap-4 p-4">
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
<stat.icon className="size-5 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</div>
</CardContent>
</Card>
))}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">My Comparisons</h2>
<Link href="/compare">
<Button size="sm" className="gap-2">
<Plus className="size-4" />
New Comparison
</Button>
</Link>
</div>
{mockComparisons.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{mockComparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3">
<CardTitle className="text-base line-clamp-1">{comparison.title}</CardTitle>
<CardDescription className="flex items-center gap-2 text-xs">
<Calendar className="size-3.5" />
{comparison.createdAt}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{comparison.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{comparison.views.toLocaleString()}
</span>
<span className="font-semibold text-foreground">
{comparison.overallScore}/10
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<BarChart3 className="size-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium">No comparisons yet</p>
<p className="text-sm text-muted-foreground">
Create your first comparison to get started
</p>
</div>
<Link href="/compare">
<Button className="gap-2">
<Plus className="size-4" />
Create Comparison
</Button>
</Link>
</div>
</Card>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -1,66 +1,114 @@
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
jsonb,
integer,
boolean,
varchar,
index,
} from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: timestamp("email_verified", { withTimezone: true }),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const comparisonStatusEnum = pgEnum("comparison_status", [
"researching",
"completed",
"failed",
]);
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").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
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").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const comparisons = pgTable(
"comparisons",
{
id: text("id").primaryKey(),
userId: text("user_id"),
title: text("title").notNull(),
query: text("query"),
slug: varchar("slug", { length: 255 }).notNull().unique(),
status: text("status", {
enum: ["researching", "completed", "failed"],
})
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
userId: text("user_id")
.notNull()
.default("researching"),
.references(() => users.id),
title: text("title").notNull(),
query: text("query").notNull(),
slug: text("slug").notNull().unique(),
status: comparisonStatusEnum("status").notNull().default("researching"),
summary: text("summary"),
overallData: jsonb("overall_data"),
tags: text("tags").array(),
isPublic: boolean("is_public").default(false),
viewCount: integer("view_count").default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
isPublic: boolean("is_public").notNull().default(true),
viewCount: integer("view_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("comparisons_user_id_idx").on(table.userId)]
(table) => [
index("comparisons_user_id_idx").on(table.userId),
index("comparisons_slug_idx").on(table.slug),
index("comparisons_status_idx").on(table.status),
],
);
export const comparisonItems = pgTable("comparison_items", {
id: text("id").primaryKey(),
comparisonId: text("comparison_id")
.notNull()
.references(() => comparisons.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
imageUrl: text("image_url"),
researchData: jsonb("research_data"),
scores: jsonb("scores"),
pros: text("pros").array(),
cons: text("cons").array(),
order: integer("order").notNull(),
});
export const comparisonItems = pgTable(
"comparison_items",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
comparisonId: text("comparison_id")
.notNull()
.references(() => comparisons.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
imageUrl: text("image_url"),
researchData: jsonb("research_data"),
scores: jsonb("scores"),
pros: text("pros").array(),
cons: text("cons").array(),
order: integer("order").notNull().default(0),
},
(table) => [index("comparison_items_comparison_id_idx").on(table.comparisonId)],
);
export const comparisonDimensions = pgTable("comparison_dimensions", {
id: text("id").primaryKey(),
comparisonId: text("comparison_id")
.notNull()
.references(() => comparisons.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
weight: integer("weight").default(1),
order: integer("order").notNull(),
});
export type Comparison = typeof comparisons.$inferSelect;
export type NewComparison = typeof comparisons.$inferInsert;
export type ComparisonItem = typeof comparisonItems.$inferSelect;
export type NewComparisonItem = typeof comparisonItems.$inferInsert;
export type ComparisonDimension = typeof comparisonDimensions.$inferSelect;
export const comparisonDimensions = pgTable(
"comparison_dimensions",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
comparisonId: text("comparison_id")
.notNull()
.references(() => comparisons.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
weight: integer("weight").notNull().default(1),
order: integer("order").notNull().default(0),
},
(table) => [index("comparison_dimensions_comparison_id_idx").on(table.comparisonId)],
);

View File

@@ -7,9 +7,14 @@ import type {
} from "../types";
import type { SearchResult } from "./tavily";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
let _client: OpenAI | null = null;
function getClient(): OpenAI {
if (!_client) {
_client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return _client;
}
const SYSTEM_PROMPT = `You are an expert research analyst. Your job is to compare items across multiple dimensions and produce structured, insightful comparison data.
@@ -106,7 +111,7 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await client.chat.completions.create({
const response = await getClient().chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },

View File

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