19 Commits

Author SHA1 Message Date
Christopher Mayor
eab1618d04 fix #12: pass full schema to drizzle adapter 2026-04-27 11:26:34 -07:00
Christopher Mayor
273b600e98 fix #12: simplify auth adapter, add verifications table 2026-04-27 11:22:42 -07:00
Christopher Mayor
024f3cb1f7 fix #12: add missing session fields ipAddress and userAgent to Drizzle schema 2026-04-27 11:01:30 -07:00
Christopher Mayor
cd51f2a0c8 fix #12: add accounts table for Better Auth credential storage 2026-04-27 10:57:15 -07:00
Christopher Mayor
4d5e1502e9 fix #9 #10 #11: fix email_verified schema, add auth gate to compare, use real user id 2026-04-27 10:33:22 -07:00
Christopher Mayor
56b6f67d00 fix: update docker-compose to use shared postgres and Traefik labels 2026-04-26 22:16:24 -07:00
Christopher Mayor
370fd2d8e6 fix: map users/sessions schema to better-auth expected names 2026-04-26 22:06:24 -07:00
Christopher Mayor
089de443a0 docs: update deployment section with current production state
- Document production URL (comparaison.local.tophermayor.com)
- Detail host (ubuntu/192.168.50.61), Traefik ingress, shared Postgres
- Add Docker label routing, proxy-net network info
- List recent fixes: userId in comparison inserts, OpenAI getClient(), BETTER_AUTH_SECRET
2026-04-26 17:39:40 -07:00
Christopher Mayor
78e1c74fa3 fix: use getClient() instead of undefined client in openai provider 2026-04-26 16:55:59 -07:00
Christopher Mayor
d9ed1586cc fix: use title as fallback query instead of null in compare route 2026-04-26 16:53:21 -07:00
Christopher Mayor
5187d75d53 fix: add userId to comparison inserts (placeholder 'system' until auth is wired) 2026-04-26 16:50:07 -07:00
Christopher Mayor
8d2239aebd fix: use viewCount instead of views in profile page 2026-04-26 16:44:21 -07:00
Christopher Mayor
0b523b7274 fix: replace mockUser/mockComparisons with proper local variables in profile page 2026-04-26 16:43:25 -07:00
Christopher Mayor
db30a7e178 Merge branch 'feat/wire-pages' 2026-04-26 15:58:04 -07:00
Christopher Mayor
50fd4cda6a Merge branch 'feat/api-endpoints' 2026-04-26 15:58:04 -07:00
Christopher Mayor
565085aba1 feat: wire up explore and profile pages
Updated explore and profile page components.
2026-04-26 15:58:00 -07:00
Christopher Mayor
c9e6e156ac feat: add comparison and user API endpoints
New API routes under src/app/api/ for comparisons and user operations.
2026-04-26 15:57:58 -07:00
Christopher Mayor
494dcb91fa fix: remove duplicate users table definition in schema
The users table was defined twice with conflicting field orderings
(timestamp vs boolean for emailVerified, different default placements).
Kept the cleaner definition and removed the duplicate.
2026-04-26 15:55:47 -07:00
Christopher Mayor
3c5df6a74c docs: add v0.2-v0.4 implementation plan for feed, profile, auth, and search 2026-04-26 01:16:35 -07:00
18 changed files with 950 additions and 187 deletions

View File

@@ -85,6 +85,44 @@ npm run dev
docker compose up -d
```
## Deployment
**Production URL:** [https://comparaison.local.tophermayor.com](https://comparaison.local.tophermayor.com)
| Detail | Value |
|---|---|
| Host | `ubuntu` (`192.168.50.61`) |
| Compose file | `/srv/compose/comparaison/docker-compose.yml` |
| Reverse proxy | Traefik (shared instance on `proxy-net`) |
| Database | Shared PostgreSQL (`postgres-shared` container on `proxy-net`) |
| Routing | Docker labels on the app container (Traefik router/rules) |
### Production Setup
1. **Traefik Ingress** — A shared Traefik instance handles TLS termination and routes traffic to the app container via Docker labels. The app joins the `proxy-net` network so Traefik can reach it.
2. **Shared PostgreSQL** — A standalone `postgres-shared` container provides the database. The comparaison app connects to it over `proxy-net`. No separate DB container is defined in the app's compose file.
3. **Environment** — The following are configured in the production environment:
- `DATABASE_URL` — Points to the shared Postgres instance
- `BETTER_AUTH_SECRET` — Random secret for session signing
- `OPENAI_API_KEY`, `TAVILY_API_KEY`, `PERPLEXITY_API_KEY` — LLM provider keys
- `NEXT_PUBLIC_APP_URL``https://comparaison.local.tophermayor.com`
### Deploying Updates
```bash
# On ubuntu (192.168.50.61)
cd /srv/compose/comparaison
docker compose pull && docker compose up -d
```
### Recent Fixes
- Added `userId` to comparison inserts so saved comparisons are properly associated with authenticated users
- Fixed OpenAI provider `getClient()` to correctly initialize the OpenAI client
- Added `BETTER_AUTH_SECRET` to production environment for proper session management
## Project Structure
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,97 +1,94 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useCallback } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Search, Eye, BarChart3, Filter, X, Loader2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Search, Eye, Filter, X, Loader2, RefreshCw } 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,
},
]
interface Comparison {
id: string
title: string
summary: string
slug: string
tags: string[]
items: string[]
viewCount: number
createdAt: string
}
const categories = ["All", "Tech", "Products", "AI", "Cloud", "Productivity"]
interface ComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
export default function ExplorePage() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedCategory, setSelectedCategory] = useState("All")
const [comparisons, setComparisons] = useState<Comparison[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [selectedCategory, setSelectedCategory] = useState("All")
const filteredComparisons = allComparisons.filter((comparison) => {
const matchesSearch =
searchQuery === "" ||
comparison.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
comparison.items.some((item) =>
item.toLowerCase().includes(searchQuery.toLowerCase())
)
const limit = 20
const fetchComparisons = useCallback(async (pageNum: number, search: string, append = false) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: pageNum.toString(),
limit: limit.toString(),
...(search && { search }),
})
const res = await fetch(`/api/comparisons?${params}`)
if (!res.ok) throw new Error("Failed to fetch comparisons")
const data: ComparisonsResponse = await res.json()
setComparisons(prev => append ? [...prev, ...data.comparisons] : data.comparisons)
setTotal(data.total)
setPage(pageNum)
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchQuery)
setPage(1)
fetchComparisons(1, searchQuery)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, fetchComparisons])
useEffect(() => {
fetchComparisons(1, "")
}, [fetchComparisons])
const categories = ["All", ...Array.from(new Set(comparisons.flatMap(c => c.tags)))]
const filteredComparisons = comparisons.filter((comparison) => {
const matchesCategory =
selectedCategory === "All" ||
comparison.tags.some((tag) => tag.toLowerCase() === selectedCategory.toLowerCase())
return matchesSearch && matchesCategory
return matchesCategory
})
const loadMore = () => {
fetchComparisons(page + 1, debouncedSearch, true)
}
const hasMore = comparisons.length < total
return (
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-6">
<div className="space-y-4">
@@ -125,14 +122,44 @@ export default function ExplorePage() {
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-8 animate-spin text-primary" />
{loading && comparisons.length === 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i} className="h-full">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-12" />
<Skeleton className="h-5 w-12" />
</div>
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<Card className="p-8 text-center">
<div className="flex flex-col items-center gap-4">
<div className="size-12 rounded-full bg-destructive/10 flex items-center justify-center">
<X className="size-6 text-destructive" />
</div>
<div>
<p className="font-medium">Failed to load comparisons</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
<Button onClick={() => fetchComparisons(page, debouncedSearch)} className="gap-2">
<RefreshCw className="size-4" />
Retry
</Button>
</div>
</Card>
) : filteredComparisons.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filteredComparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Link key={comparison.id} href={`/compare/${comparison.slug}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
@@ -141,7 +168,7 @@ export default function ExplorePage() {
</CardTitle>
</div>
<CardDescription className="text-sm line-clamp-2">
{comparison.description}
{comparison.summary}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -153,15 +180,6 @@ export default function ExplorePage() {
))}
</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 ")}
@@ -169,10 +187,7 @@ export default function ExplorePage() {
<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
{comparison.viewCount.toLocaleString()}
</span>
</div>
</div>
@@ -206,11 +221,19 @@ export default function ExplorePage() {
</Card>
)}
{loading && comparisons.length > 0 && (
<div className="flex justify-center py-4">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && hasMore && (
<div className="flex justify-center">
<Button variant="outline" className="gap-2">
<Button variant="outline" onClick={loadMore} className="gap-2">
Load More
</Button>
</div>
)}
</div>
)
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { auth } from "@/lib/auth";
function serializeSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -23,6 +24,11 @@ function slugify(text: string): string {
// const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") })
export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const body: { query?: string; items?: string[]; dimensions?: string[] } =
await request.json();
const { query, items, dimensions } = body;
@@ -54,8 +60,9 @@ export async function POST(request: Request) {
await db.insert(comparisons).values({
id,
userId: session.user.id,
title,
query: query || null,
query: query ?? title,
slug,
status: "researching",
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,37 @@ export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: timestamp("email_verified", { withTimezone: true }),
emailVerified: boolean("email_verified").default(false),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey().$defaultFn(() => createId()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
idToken: text("id_token"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey().$defaultFn(() => createId()),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});
export const comparisonStatusEnum = pgEnum("comparison_status", [
@@ -26,16 +53,6 @@ export const comparisonStatusEnum = pgEnum("comparison_status", [
"failed",
]);
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
@@ -43,8 +60,10 @@ export const sessions = pgTable("sessions", {
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const comparisons = pgTable(

View File

@@ -178,7 +178,7 @@ Use the web research data above to provide factual, data-driven insights. Refere
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await client.chat.completions.create({
const response = await getClient().chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },