Compare commits
10 Commits
feat/backe
...
aac0e2f5b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac0e2f5b1 | ||
|
|
5bde4e3aa6 | ||
|
|
6832fbdebb | ||
|
|
43f011e519 | ||
|
|
a273f29e07 | ||
|
|
2f4239a83b | ||
|
|
e13b1ea2d5 | ||
|
|
637f1540cf | ||
|
|
71ef567d0d | ||
|
|
3a448a5063 |
152
README.md
152
README.md
@@ -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
|
```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.
|
## 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.
|
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
|
||||||
- [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!
|
## 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
|
||||||
|
|||||||
204
docs/api-reference.md
Normal file
204
docs/api-reference.md
Normal 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
129
docs/architecture.md
Normal 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
86
docs/development.md
Normal 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
163
docs/specs.md
Normal 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
343
docs/ui-ux-flow.md
Normal 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
|
||||||
216
src/app/(main)/explore/page.tsx
Normal file
216
src/app/(main)/explore/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,50 +1,174 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
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({
|
export default function MainLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<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">
|
<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">
|
<div className="container flex h-14 items-center px-4 mx-auto gap-4">
|
||||||
<Link href="/" className="flex items-center gap-2 font-semibold mr-8">
|
<Link href="/" className="flex items-center gap-2 font-semibold shrink-0">
|
||||||
<Sparkles className="size-5 text-primary" />
|
<Sparkles className="size-5 text-primary" />
|
||||||
<span className="text-lg">ComparAIson</span>
|
<span className="text-lg hidden sm:inline">ComparAIson</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex-1 max-w-md">
|
<div className="flex-1 max-w-md mx-auto hidden md:block">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search comparisons..."
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<Link href="/compare">
|
<Link href="/compare" className="hidden sm:block">
|
||||||
<button className="h-8 px-3 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/80 transition-colors">
|
<Button size="sm" className="gap-2">
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
New Comparison
|
New Comparison
|
||||||
</button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground">
|
|
||||||
U
|
<DropdownMenu>
|
||||||
</div>
|
<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>
|
||||||
</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>
|
</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">
|
<div className="container mx-auto px-4 text-center text-xs text-muted-foreground">
|
||||||
ComparAIson — AI-powered deep research comparisons
|
ComparAIson — AI-powered deep research comparisons
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
157
src/app/(main)/profile/page.tsx
Normal file
157
src/app/(main)/profile/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ 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 body: { query?: string; items?: string[]; dimensions?: string[] } =
|
const body: { query?: string; items?: string[]; dimensions?: string[] } =
|
||||||
await request.json();
|
await request.json();
|
||||||
@@ -25,7 +29,21 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,6 +88,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 +134,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 +219,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 +234,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",
|
||||||
|
|||||||
237
src/app/page.tsx
237
src/app/page.tsx
@@ -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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex flex-col min-h-screen">
|
||||||
<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">
|
<section className="flex-1 flex flex-col items-center justify-center py-20 px-4 bg-gradient-to-b from-background to-muted/30">
|
||||||
<Image
|
<div className="max-w-3xl mx-auto text-center space-y-8">
|
||||||
className="dark:invert"
|
<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">
|
||||||
src="/next.svg"
|
<Sparkles className="size-4" />
|
||||||
alt="Next.js logo"
|
AI-Powered Research
|
||||||
width={100}
|
</div>
|
||||||
height={20}
|
|
||||||
priority
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
||||||
/>
|
Compare Anything with{" "}
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<span className="text-primary">AI-Powered</span> Deep Research
|
||||||
<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.
|
|
||||||
</h1>
|
</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{" "}
|
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
<a
|
Stop spending hours researching. Let AI do the work for you with comprehensive,
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
multi-dimensional comparisons on anything you can think of.
|
||||||
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>
|
</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>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
</section>
|
||||||
<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]"
|
<section className="py-16 px-4 bg-muted/20">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="max-w-5xl mx-auto">
|
||||||
target="_blank"
|
<h2 className="text-2xl font-bold text-center mb-10">Example Comparisons</h2>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Image
|
{exampleComparisons.map((example) => (
|
||||||
className="dark:invert"
|
<Link key={example.title} href="/compare" className="group">
|
||||||
src="/vercel.svg"
|
<Card className="h-full transition-all group-hover:border-primary group-hover:shadow-md">
|
||||||
alt="Vercel logomark"
|
<CardHeader className="pb-3">
|
||||||
width={16}
|
<div className="flex items-center gap-2 mb-2">
|
||||||
height={16}
|
<span className="text-lg font-bold">{example.title}</span>
|
||||||
/>
|
</div>
|
||||||
Deploy Now
|
<CardDescription className="text-sm line-clamp-2">
|
||||||
</a>
|
{example.description}
|
||||||
<a
|
</CardDescription>
|
||||||
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]"
|
</CardHeader>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<CardContent className="space-y-3">
|
||||||
target="_blank"
|
<div className="flex flex-wrap gap-1.5">
|
||||||
rel="noopener noreferrer"
|
{example.tags.map((tag) => (
|
||||||
>
|
<span
|
||||||
Documentation
|
key={tag}
|
||||||
</a>
|
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>
|
</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'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>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -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,21 +26,44 @@ export async function* runResearch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < request.items.length; i++) {
|
const provider = getActiveProvider();
|
||||||
yield {
|
const searchResults: Record<string, SearchResult[]> = {};
|
||||||
stage: "researching",
|
|
||||||
item: request.items[i],
|
if (provider.hasSearch) {
|
||||||
progress: Math.round(((i + 0.5) / request.items.length) * 80),
|
for (let i = 0; i < request.items.length; i++) {
|
||||||
};
|
const item = request.items[i];
|
||||||
|
const results = await searchItem(item, request.query);
|
||||||
|
searchResults[item] = results;
|
||||||
|
|
||||||
|
yield {
|
||||||
|
stage: "searching",
|
||||||
|
item,
|
||||||
|
results: results.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
yield {
|
||||||
|
stage: "researching",
|
||||||
|
item,
|
||||||
|
progress: Math.round(((i + 1) / request.items.length) * 50),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < request.items.length; i++) {
|
||||||
|
yield {
|
||||||
|
stage: "researching",
|
||||||
|
item: request.items[i],
|
||||||
|
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 {
|
||||||
|
|||||||
63
src/lib/llm/providers/index.ts
Normal file
63
src/lib/llm/providers/index.ts
Normal 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";
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
DimensionResult,
|
DimensionResult,
|
||||||
ItemResearch,
|
ItemResearch,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import type { SearchResult } from "./tavily";
|
||||||
|
|
||||||
let _client: OpenAI | null = null;
|
let _client: OpenAI | null = null;
|
||||||
|
|
||||||
@@ -145,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 client.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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
117
src/lib/llm/providers/perplexity.ts
Normal file
117
src/lib/llm/providers/perplexity.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/lib/llm/providers/tavily.ts
Normal file
69
src/lib/llm/providers/tavily.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user