From 1c6e36cc6fbb3531823ddff66de00cc817b4059f Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Tue, 28 Apr 2026 08:21:00 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20issues=20#5-#8=20=E2=80=94?= =?UTF-8?q?=20error=20states,=20header=20search,=20delete/toggle=20visibil?= =?UTF-8?q?ity,=20auth-aware=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/compare/[slug]/results-client.tsx | 29 +++- src/app/(main)/compare/page.tsx | 41 ++++- src/app/(main)/explore/page.tsx | 12 +- src/app/(main)/layout.tsx | 11 +- src/app/(main)/profile/page.tsx | 149 ++++++++++++++---- src/app/api/comparisons/[id]/route.ts | 111 +++++++++++++ 6 files changed, 310 insertions(+), 43 deletions(-) create mode 100644 src/app/api/comparisons/[id]/route.ts 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, + }); +}