13 Commits

Author SHA1 Message Date
Christopher Mayor
54879f3ab5 fix: force-dynamic on explore page to prevent static prerender crash with useSearchParams 2026-04-28 08:39:13 -07:00
Christopher Mayor
776f121eae fix: merge comparisons [id] and [slug] routes to resolve ambiguous route error 2026-04-28 08:31:01 -07:00
Christopher Mayor
1c6e36cc6f feat: implement issues #5-#8 — error states, header search, delete/toggle visibility, auth-aware UI 2026-04-28 08:21:00 -07:00
Christopher Mayor
e1d97178a1 fix: force-dynamic on profile page to prevent static prerender crash 2026-04-28 08:13:50 -07:00
Christopher Mayor
3f3932082c fix: update package-lock.json for playwright dependency 2026-04-28 07:19:41 -07:00
Christopher Mayor
e0cbba6dc5 fix: type safety for profile page, exclude e2e from tsconfig 2026-04-28 07:15:59 -07:00
Christopher Mayor
50b9be2f1c fix #4: wire Profile page to real API data (user comparisons, stats, auth session) 2026-04-28 06:56:02 -07:00
Christopher Mayor
a2dabd527f feat: support OpenRouter/custom LLM providers via env vars
Add LLM_API_KEY, LLM_BASE_URL, LLM_MODEL env vars so any
OpenAI-compatible API (OpenRouter, etc.) can be used as the LLM
backend without code changes.
2026-04-28 06:56:02 -07:00
Christopher Mayor
cfe50af1af fix #12: middleware __Secure- cookie prefix check
Middleware only checked for better-auth.session_token but HTTPS uses
__Secure-better-auth.session_token, causing all protected routes to
redirect to sign-in even when authenticated.
2026-04-28 06:56:02 -07:00
Christopher Mayor
2e138a8364 fix #12: extract session token before dot (Better Auth signed cookie)
Better Auth cookie format is 'token.signature' but DB only stores the
token portion. Split on '.' to extract the actual session token.
2026-04-28 06:56:02 -07:00
Christopher Mayor
d8eb0eef8e fix #12: handle __Secure- cookie prefix in all auth bypass code
Better Auth sets cookies with __Secure- prefix when served over HTTPS.
Updated cookie parsing in compare, user/comparisons, and user/stats
routes to check for both __Secure-better-auth.session_token and
better-auth.session_token.
2026-04-28 06:56:02 -07:00
Christopher Mayor
371755c241 fix #12: remove all auth.api.getSession() calls
- middleware.ts: cookie-presence check only (Edge Runtime can't use DB),
  skip auth for API routes entirely
- compare/route.ts: manual session token parsing + db.select() queries
- user/comparisons/route.ts: same manual auth bypass
- user/stats/route.ts: same manual auth bypass

Root cause: Drizzle 0.45.2 queryWithCache bug triggers when
auth.api.getSession() is called from non-route-handler contexts.
Bypass entirely with direct db.select() on sessions/users tables.
2026-04-28 06:56:02 -07:00
Christopher Mayor
fe5153c4e5 fix #12: bypass auth.api.getSession() Drizzle queryWithCache bug
Manually parse session token from cookie and query sessions/users
tables via db.select() (regular query builder) instead of using
auth.api.getSession() which triggers Drizzle 0.45.2 queryWithCache
internal error when called from non-route-handler async context.
2026-04-28 06:56:02 -07:00
16 changed files with 670 additions and 89 deletions

64
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
@@ -2994,6 +2995,22 @@
"cuid2": "bin/cuid2.js"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -9446,6 +9463,53 @@
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View File

@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { Share2, ArrowLeft, Loader2, Trophy } from "lucide-react"
import { Share2, ArrowLeft, Loader2, Trophy, AlertCircle } from "lucide-react"
import Link from "next/link"
interface ComparisonResultsClientProps {
@@ -32,6 +32,33 @@ export function ComparisonResultsClient({ initialData }: ComparisonResultsClient
const winner = [...data.items].sort((a, b) => b.overallScore - a.overallScore)[0]
if (data.status === "failed") {
return (
<div className="max-w-2xl mx-auto p-4 space-y-6">
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/30 p-6 space-y-4">
<div className="flex items-start gap-3">
<AlertCircle className="size-6 text-red-600 shrink-0 mt-0.5" />
<div className="space-y-2">
<h2 className="text-lg font-semibold text-red-800 dark:text-red-200">
Comparison Failed
</h2>
<p className="text-sm text-red-700 dark:text-red-300">
This comparison could not be completed. This may be due to a processing error or
invalid input.
</p>
</div>
</div>
<Link href="/compare">
<Button variant="outline" className="gap-2">
<ArrowLeft className="size-4" />
Try Again
</Button>
</Link>
</div>
</div>
)
}
if (isResearching) {
return (
<div className="max-w-4xl mx-auto p-4 space-y-6">

View File

@@ -1,23 +1,36 @@
"use client"
import { useState, useCallback, KeyboardEvent } from "react"
import { useState, useCallback, useEffect, KeyboardEvent } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useComparisonStream } from "@/hooks/use-comparison-stream"
import { X, Plus, Loader2, Sparkles } from "lucide-react"
import { X, Plus, Loader2, Sparkles, AlertCircle } from "lucide-react"
import Link from "next/link"
export default function ComparePage() {
const [query, setQuery] = useState("")
const [items, setItems] = useState<string[]>([])
const [itemInput, setItemInput] = useState("")
const [dimensionHints, setDimensionHints] = useState("")
const { progress, startResearch, cancel } = useComparisonStream()
const { progress, result, error, comparisonSlug, startResearch, cancel } = useComparisonStream()
const router = useRouter()
const isResearching = progress.status === "researching"
// Auto-navigate to comparison results when stream completes
useEffect(() => {
if (result && comparisonSlug && !isResearching) {
const timer = setTimeout(() => {
router.push(`/compare/${comparisonSlug}`)
}, 800)
return () => clearTimeout(timer)
}
}, [result, comparisonSlug, isResearching, router])
const addItem = useCallback(() => {
const trimmed = itemInput.trim()
if (trimmed && !items.includes(trimmed) && items.length < 10) {
@@ -132,6 +145,28 @@ export default function ComparePage() {
/>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/30 p-4 space-y-2">
<div className="flex items-start gap-2">
<AlertCircle className="size-5 text-red-600 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
Something went wrong
</p>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
{(error.includes("Authentication") || error.includes("401")) && (
<Link
href="/sign-in"
className="inline-flex items-center text-sm font-medium text-red-700 underline underline-offset-2 hover:text-red-900 dark:text-red-300 dark:hover:text-red-100"
>
Sign in to continue
</Link>
)}
</div>
</div>
</div>
)}
{isResearching && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
export default function ExploreLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -28,13 +29,16 @@ interface ComparisonsResponse {
}
export default function ExplorePage() {
const searchParams = useSearchParams()
const initialSearch = searchParams.get("search") ?? ""
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 [searchQuery, setSearchQuery] = useState(initialSearch)
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch)
const [selectedCategory, setSelectedCategory] = useState("All")
const limit = 20
@@ -71,8 +75,8 @@ export default function ExplorePage() {
}, [searchQuery, fetchComparisons])
useEffect(() => {
fetchComparisons(1, "")
}, [fetchComparisons])
fetchComparisons(1, initialSearch)
}, [fetchComparisons, initialSearch])
const categories = ["All", ...Array.from(new Set(comparisons.flatMap(c => c.tags)))]

View File

@@ -1,7 +1,7 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { usePathname, useRouter } 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"
@@ -72,6 +72,13 @@ export default function MainLayout({
}) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const router = useRouter()
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && searchQuery.trim()) {
router.push(`/explore?search=${encodeURIComponent(searchQuery.trim())}`)
}
}
return (
<div className="min-h-screen flex flex-col">
@@ -90,6 +97,7 @@ export default function MainLayout({
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="pl-9 h-9"
/>
</div>
@@ -146,6 +154,7 @@ export default function MainLayout({
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="pl-9"
/>
</div>

View File

@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -6,7 +6,13 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { BarChart3, Eye, Calendar, Plus, ArrowRight, RefreshCw, LogIn } from "lucide-react"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu"
import { BarChart3, Eye, Calendar, Plus, RefreshCw, LogIn, MoreVertical, Trash2, Globe, Lock } from "lucide-react"
import Link from "next/link"
import { useSession } from "@/lib/auth-client"
@@ -17,7 +23,8 @@ interface Comparison {
items: string[]
tags: string[]
viewCount: number
overallScore: number
status: string
isPublic: boolean
createdAt: string
}
@@ -25,6 +32,7 @@ interface UserComparisonsResponse {
comparisons: Comparison[]
total: number
page: number
limit: number
}
interface UserStats {
@@ -33,21 +41,133 @@ interface UserStats {
}
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 { data: session, isPending: sessionLoading } = useSession()
const [comparisons, setComparisons] = useState<Comparison[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [stats, setStats] = useState<UserStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const limit = 20
const fetchComparisons = useCallback(async (pageNum: number) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: pageNum.toString(),
limit: limit.toString(),
})
const res = await fetch(`/api/user/comparisons?${params}`)
if (res.status === 401) {
setError("Not authenticated")
return
}
if (!res.ok) throw new Error("Failed to fetch comparisons")
const data: UserComparisonsResponse = await res.json()
setComparisons(data.comparisons)
setTotal(data.total)
setPage(pageNum)
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong")
} finally {
setLoading(false)
}
}, [])
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/user/stats")
if (res.ok) {
const data: UserStats = await res.json()
setStats(data)
}
} catch {
// Stats are non-critical, don't block the page
}
}, [])
useEffect(() => {
if (!sessionLoading && session?.user) {
fetchComparisons(1)
fetchStats()
} else if (!sessionLoading && !session?.user) {
setLoading(false)
}
}, [sessionLoading, session, fetchComparisons, fetchStats])
const handleToggleVisibility = async (comparison: Comparison) => {
try {
const res = await fetch(`/api/comparisons/${comparison.slug}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isPublic: !comparison.isPublic }),
})
if (res.ok) {
fetchComparisons(page)
fetchStats()
}
} catch {
// Silently fail — user can retry
}
}
const handleDelete = async (comparison: Comparison) => {
if (!window.confirm(`Delete "${comparison.title}"? This cannot be undone.`)) return
try {
const res = await fetch(`/api/comparisons/${comparison.slug}`, {
method: "DELETE",
})
if (res.ok) {
fetchComparisons(page)
fetchStats()
}
} catch {
// Silently fail — user can retry
}
}
// Not authenticated — show sign-in prompt
if (!sessionLoading && !session?.user) {
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6">
<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">
<LogIn className="size-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Sign in to view your profile</p>
<p className="text-sm text-muted-foreground">
View your comparisons and stats
</p>
</div>
<Link href="/sign-in">
<Button className="gap-2">
<LogIn className="size-4" />
Sign In
</Button>
</Link>
</div>
</Card>
</div>
)
}
const user = session!.user!
const statsCards = [
{ label: "Comparisons", value: stats?.totalComparisons ?? 0, icon: BarChart3 },
{ label: "Total Views", value: stats?.totalViews ?? 0, icon: Eye },
]
const comparisons: Comparison[] = []
return (
<div className="max-w-4xl mx-auto p-4 sm:p-6 space-y-8">
<div className="flex items-center gap-6">
<Avatar className="size-20">
<AvatarImage src={user.avatar} />
<AvatarImage src={user.image ?? undefined} />
<AvatarFallback className="text-2xl bg-primary/10 text-primary font-semibold">
{user.name.split(" ").map((n) => n[0]).join("")}
{user.name?.split(" ").map((n) => n[0]).join("") ?? "?"}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
@@ -56,15 +176,18 @@ export default function ProfilePage() {
</div>
</div>
{/* Stats cards */}
<div className="grid gap-4 sm:grid-cols-2">
{stats.map((stat) => (
{statsCards.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-2xl font-bold">
{loading ? <Skeleton className="h-7 w-16" /> : stat.value.toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</div>
</CardContent>
@@ -72,6 +195,7 @@ export default function ProfilePage() {
))}
</div>
{/* User comparisons */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">My Comparisons</h2>
@@ -83,43 +207,121 @@ export default function ProfilePage() {
</Link>
</div>
{comparisons.length > 0 ? (
{loading ? (
<div className="grid gap-4 sm:grid-cols-2">
{[...Array(4)].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-1/2" />
</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">
<BarChart3 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(1); fetchStats() }} className="gap-2">
<RefreshCw className="size-4" />
Retry
</Button>
</div>
</Card>
) : comparisons.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{comparisons.map((comparison) => (
<Link key={comparison.id} href={`/compare/${comparison.id}`}>
<Card className="h-full transition-all hover:border-primary hover:shadow-md">
<CardHeader className="pb-3">
<CardTitle className="text-base line-clamp-1">{comparison.title}</CardTitle>
<CardDescription className="flex items-center gap-2 text-xs">
<Calendar className="size-3.5" />
{comparison.createdAt}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{comparison.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{comparison.viewCount.toLocaleString()}
</span>
<span className="font-semibold text-foreground">
{comparison.overallScore}/10
</span>
<Card key={comparison.id} className="h-full group transition-all hover:border-primary hover:shadow-md">
<div className="flex flex-col h-full">
<Link href={`/compare/${comparison.slug}`} className="flex-1">
<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" />
{new Date(comparison.createdAt).toLocaleDateString()}
</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>
</CardContent>
</Card>
</Link>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{comparison.items.join(" vs ")}
</span>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{comparison.viewCount.toLocaleString()}
</span>
{!comparison.isPublic ? (
<Badge variant="outline" className="text-xs">Private</Badge>
) : (
<Badge variant="outline" className="text-xs">Public</Badge>
)}
</div>
</div>
</CardContent>
</Link>
<div className="flex justify-end px-4 pb-3">
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center justify-center size-8 rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-accent"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<MoreVertical className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleToggleVisibility(comparison)
}}
>
{comparison.isPublic ? (
<>
<Lock className="size-4" />
Make Private
</>
) : (
<>
<Globe className="size-4" />
Make Public
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.stopPropagation()
handleDelete(comparison)
}}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
))}
</div>
) : (
@@ -143,6 +345,19 @@ export default function ProfilePage() {
</div>
</Card>
)}
{/* Pagination */}
{!loading && comparisons.length > 0 && comparisons.length < total && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => fetchComparisons(page + 1)}
className="gap-2"
>
Load More
</Button>
</div>
)}
</div>
</div>
)

View File

@@ -2,10 +2,9 @@ import { runResearch } from "@/lib/llm";
import type { ComparisonRequest } from "@/lib/llm/types";
import type { ComparisonData } from "@/lib/types";
import { db } from "@/lib/db";
import { comparisons, comparisonItems } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { comparisons, comparisonItems, sessions, users } from "@/lib/db/schema";
import { eq, and, gt } 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`;
@@ -24,11 +23,41 @@ 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) {
// Bypass auth.api.getSession() — Drizzle queryWithCache bug (#12)
// Manually parse session token from cookie and query sessions table directly
const cookieHeader = request.headers.get("cookie") ?? "";
const cookieMatch = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("__Secure-better-auth.session_token=") || c.startsWith("better-auth.session_token="));
const token = cookieMatch?.split("=")?.slice(1)?.join("=")?.trim().split(".")[0];
if (!token) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const userId = userRows[0].id;
const body: { query?: string; items?: string[]; dimensions?: string[] } =
await request.json();
const { query, items, dimensions } = body;
@@ -60,7 +89,7 @@ export async function POST(request: Request) {
await db.insert(comparisons).values({
id,
userId: session.user.id,
userId: userId,
title,
query: query ?? title,
slug,

View File

@@ -1,8 +1,45 @@
import { db } from "@/lib/db";
import { comparisons } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { comparisons, sessions, users } from "@/lib/db/schema";
import { eq, sql, and, gt } from "drizzle-orm";
import { headers } from "next/headers";
import { getComparison } from "@/app/actions/comparison";
async function getAuthedUserId(): Promise<string | null> {
const hdrs = await headers();
const cookieHeader = hdrs.get("cookie") ?? "";
const cookieMatch = cookieHeader
.split(";")
.map((c) => c.trim())
.find(
(c) =>
c.startsWith("__Secure-better-auth.session_token=") ||
c.startsWith("better-auth.session_token=")
);
const token = cookieMatch
?.split("=")
?.slice(1)
?.join("=")
?.trim()
.split(".")[0];
if (!token) return null;
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) return null;
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) return null;
return userRows[0].id;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
@@ -22,3 +59,78 @@ export async function GET(
return Response.json(comparison);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const userId = await getAuthedUserId();
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await params;
const existing = await db
.select({ id: comparisons.id, userId: comparisons.userId })
.from(comparisons)
.where(eq(comparisons.slug, slug))
.limit(1);
if (!existing.length) {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (existing[0].userId !== userId) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
await db.delete(comparisons).where(eq(comparisons.id, existing[0].id));
return Response.json({ success: true });
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const userId = await getAuthedUserId();
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await params;
const existing = await db
.select({
id: comparisons.id,
userId: comparisons.userId,
isPublic: comparisons.isPublic,
})
.from(comparisons)
.where(eq(comparisons.slug, slug))
.limit(1);
if (!existing.length) {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (existing[0].userId !== userId) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const newIsPublic =
typeof body.isPublic === "boolean" ? body.isPublic : !existing[0].isPublic;
const [updated] = await db
.update(comparisons)
.set({ isPublic: newIsPublic, updatedAt: new Date() })
.where(eq(comparisons.id, existing[0].id))
.returning();
return Response.json({
id: updated.id,
isPublic: updated.isPublic,
});
}

View File

@@ -1,22 +1,47 @@
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 { comparisons, comparisonItems, sessions, users } from "@/lib/db/schema";
import { eq, desc, sql, inArray, and, gt } from "drizzle-orm";
import { headers } from "next/headers";
export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
// Bypass auth.api.getSession() — Drizzle queryWithCache bug (#12)
const hdrs = await headers();
const cookieHeader = hdrs.get("cookie") ?? "";
const cookieMatch = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("__Secure-better-auth.session_token=") || c.startsWith("better-auth.session_token="));
const token = cookieMatch?.split("=")?.slice(1)?.join("=")?.trim().split(".")[0];
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = userRows[0].id;
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 where = eq(comparisons.userId, userId);
const [result, countResult] = await Promise.all([
db

View File

@@ -1,23 +1,48 @@
import { db } from "@/lib/db";
import { comparisons } from "@/lib/db/schema";
import { eq, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { comparisons, sessions, users } from "@/lib/db/schema";
import { eq, sql, and, gt } from "drizzle-orm";
import { headers } from "next/headers";
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
// Bypass auth.api.getSession() — Drizzle queryWithCache bug (#12)
const hdrs = await headers();
const cookieHeader = hdrs.get("cookie") ?? "";
const cookieMatch = cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("__Secure-better-auth.session_token=") || c.startsWith("better-auth.session_token="));
const token = cookieMatch?.split("=")?.slice(1)?.join("=")?.trim().split(".")[0];
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const sessionRows = await db
.select()
.from(sessions)
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!sessionRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRows = await db
.select()
.from(users)
.where(eq(users.id, sessionRows[0].userId))
.limit(1);
if (!userRows.length) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = userRows[0].id;
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));
.where(eq(comparisons.userId, userId));
return Response.json(result[0]);
}

View File

@@ -13,9 +13,9 @@ export interface Provider {
}
export function getActiveProvider(): Provider {
const hasOpenAI = !!process.env.OPENAI_API_KEY || !!process.env.LLM_API_KEY;
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");

View File

@@ -8,10 +8,16 @@ import type {
import type { SearchResult } from "./tavily";
let _client: OpenAI | null = null;
const MODEL = process.env.LLM_MODEL || "gpt-4o-mini";
function getClient(): OpenAI {
if (!_client) {
_client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const baseURL = process.env.LLM_BASE_URL || undefined;
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY;
if (!apiKey) {
throw new Error("No API key configured. Set OPENAI_API_KEY or LLM_API_KEY.");
}
_client = new OpenAI({ apiKey, baseURL });
}
return _client;
}
@@ -112,7 +118,7 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await getClient().chat.completions.create({
model: "gpt-4o-mini",
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
@@ -179,7 +185,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 getClient().chat.completions.create({
model: "gpt-4o-mini",
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{

View File

@@ -1,9 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
const publicPaths = ["/", "/explore", "/sign-in", "/sign-up", "/api/auth"];
const protectedPaths = ["/compare", "/profile"];
function hasSessionCookie(headers: Headers): boolean {
const cookieHeader = headers.get("cookie") ?? "";
return cookieHeader
.split(";")
.some((c) => {
const trimmed = c.trim();
return trimmed.startsWith("better-auth.session_token=") || trimmed.startsWith("__Secure-better-auth.session_token=");
});
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -15,6 +24,11 @@ export async function middleware(request: NextRequest) {
return NextResponse.next();
}
// API routes handle their own auth — skip middleware session check
if (pathname.startsWith("/api/")) {
return NextResponse.next();
}
const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith(path + "/"),
);
@@ -27,11 +41,9 @@ export async function middleware(request: NextRequest) {
return NextResponse.next();
}
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session && isProtected) {
// Cookie-presence check only — real auth happens in route handlers.
// auth.api.getSession() bypassed due to Drizzle queryWithCache bug (#12).
if (!hasSessionCookie(request.headers) && isProtected) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);

View File

@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
}