diff --git a/src/app/(main)/compare/[slug]/results-client.tsx b/src/app/(main)/compare/[slug]/results-client.tsx
index 6668126..d23da58 100644
--- a/src/app/(main)/compare/[slug]/results-client.tsx
+++ b/src/app/(main)/compare/[slug]/results-client.tsx
@@ -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 (
+
+
+
+
+
+
+ Comparison Failed
+
+
+ This comparison could not be completed. This may be due to a processing error or
+ invalid input.
+
+
+
+
+
+
+
+
+ )
+ }
+
if (isResearching) {
return (
diff --git a/src/app/(main)/compare/page.tsx b/src/app/(main)/compare/page.tsx
index 5141eba..e1ff8b2 100644
--- a/src/app/(main)/compare/page.tsx
+++ b/src/app/(main)/compare/page.tsx
@@ -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([])
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() {
/>
+ {error && (
+
+
+
+
+
+ Something went wrong
+
+
{error}
+ {(error.includes("Authentication") || error.includes("401")) && (
+
+ Sign in to continue
+
+ )}
+
+
+
+ )}
+
{isResearching && (
diff --git a/src/app/(main)/explore/page.tsx b/src/app/(main)/explore/page.tsx
index 230a54d..91ae98c 100644
--- a/src/app/(main)/explore/page.tsx
+++ b/src/app/(main)/explore/page.tsx
@@ -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
([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(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)))]
diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx
index d99cbb4..27b63a9 100644
--- a/src/app/(main)/layout.tsx
+++ b/src/app/(main)/layout.tsx
@@ -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) => {
+ if (e.key === "Enter" && searchQuery.trim()) {
+ router.push(`/explore?search=${encodeURIComponent(searchQuery.trim())}`)
+ }
+ }
return (
@@ -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"
/>
@@ -146,6 +154,7 @@ export default function MainLayout({
placeholder="Search comparisons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
+ onKeyDown={handleSearchKeyDown}
className="pl-9"
/>
diff --git a/src/app/(main)/profile/page.tsx b/src/app/(main)/profile/page.tsx
index 90e2531..9cd3e78 100644
--- a/src/app/(main)/profile/page.tsx
+++ b/src/app/(main)/profile/page.tsx
@@ -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, 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"
@@ -91,6 +97,37 @@ export default function ProfilePage() {
}
}, [sessionLoading, session, fetchComparisons, fetchStats])
+ const handleToggleVisibility = async (comparison: Comparison) => {
+ try {
+ const res = await fetch(`/api/comparisons/${comparison.id}`, {
+ 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.id}`, {
+ 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 (
@@ -207,40 +244,84 @@ export default function ProfilePage() {
) : comparisons.length > 0 ? (
{comparisons.map((comparison) => (
-
-
-
- {comparison.title}
-
-
- {new Date(comparison.createdAt).toLocaleDateString()}
-
-
-
-
- {comparison.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
-
- {comparison.items.join(" vs ")}
-
-
-
-
- {comparison.viewCount.toLocaleString()}
-
- {!comparison.isPublic && (
-
Draft
- )}
+
+
+
+
+ {comparison.title}
+
+
+ {new Date(comparison.createdAt).toLocaleDateString()}
+
+
+
+
+ {comparison.tags.map((tag) => (
+
+ {tag}
+
+ ))}
-
-
-
-
+
+
+ {comparison.items.join(" vs ")}
+
+
+
+
+ {comparison.viewCount.toLocaleString()}
+
+ {!comparison.isPublic ? (
+ Private
+ ) : (
+ Public
+ )}
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+ {
+ e.stopPropagation()
+ handleToggleVisibility(comparison)
+ }}
+ >
+ {comparison.isPublic ? (
+ <>
+
+ Make Private
+ >
+ ) : (
+ <>
+
+ Make Public
+ >
+ )}
+
+ {
+ e.stopPropagation()
+ handleDelete(comparison)
+ }}
+ >
+
+ Delete
+
+
+
+
+
+
))}
) : (
diff --git a/src/app/api/comparisons/[id]/route.ts b/src/app/api/comparisons/[id]/route.ts
new file mode 100644
index 0000000..a5283b3
--- /dev/null
+++ b/src/app/api/comparisons/[id]/route.ts
@@ -0,0 +1,111 @@
+import { db } from "@/lib/db";
+import { comparisons, sessions, users } from "@/lib/db/schema";
+import { eq, and, gt } from "drizzle-orm";
+import { headers } from "next/headers";
+
+async function getAuthedUserId(): Promise {
+ 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 DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const userId = await getAuthedUserId();
+ if (!userId) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+
+ const existing = await db
+ .select({ userId: comparisons.userId })
+ .from(comparisons)
+ .where(eq(comparisons.id, id))
+ .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, id));
+
+ return Response.json({ success: true });
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const userId = await getAuthedUserId();
+ if (!userId) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+
+ const existing = await db
+ .select({ userId: comparisons.userId, isPublic: comparisons.isPublic })
+ .from(comparisons)
+ .where(eq(comparisons.id, id))
+ .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, id))
+ .returning();
+
+ return Response.json({
+ id: updated.id,
+ isPublic: updated.isPublic,
+ });
+}