Initial commit: DrinkTracker full-stack app
Next.js 14 drink collection tracker with AI-powered search, menu scanning, ratings, wishlist, sharing, and CSV backup/restore. Features: - Auth (credentials + OAuth ready) - Drink collection with ratings and reviews - AI search via Claude/OpenAI with search history - Menu photo scanning with AI extraction - Wishlist / Try Later system - Public sharing via slug URLs - CSV backup and restore (merge/replace modes) - Docker Compose for Postgres + MinIO + dev server Security: docker-compose files use env var interpolation instead of hardcoded secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
164
src/app/(app)/dashboard/page.tsx
Normal file
164
src/app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Beer, Wine, Star, Camera, TrendingUp, Clock } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
const [drinkCount, ratingCount, scanCount, recentRatings] = await Promise.all([
|
||||
prisma.drink.count({ where: { userId: session.user.id } }),
|
||||
prisma.rating.count({ where: { userId: session.user.id } }),
|
||||
prisma.menuScan.count({ where: { userId: session.user.id } }),
|
||||
prisma.rating.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: { drink: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
])
|
||||
|
||||
const avgRating = ratingCount > 0
|
||||
? await prisma.rating.aggregate({
|
||||
where: { userId: session.user.id },
|
||||
_avg: { score: true },
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Dashboard" />
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
Welcome back{session.user.name ? `, ${session.user.name.split(" ")[0]}` : ""}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Here's your drinking diary at a glance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link href="/scan">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex flex-col items-center gap-2 pt-6 pb-4">
|
||||
<Camera className="h-8 w-8 text-primary" />
|
||||
<span className="text-sm font-medium">Scan Menu</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/drinks?action=add">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex flex-col items-center gap-2 pt-6 pb-4">
|
||||
<Beer className="h-8 w-8 text-primary" />
|
||||
<span className="text-sm font-medium">Add Drink</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Wine className="h-4 w-4" />
|
||||
Drinks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{drinkCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Ratings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{ratingCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Avg Rating
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{avgRating?._avg?.score ? avgRating._avg.score.toFixed(1) : "—"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Scans
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{scanCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Recent Ratings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentRatings.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No ratings yet. Start by adding a drink!</p>
|
||||
<Link href="/drinks?action=add">
|
||||
<Button className="mt-3" variant="outline">
|
||||
Add Your First Drink
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRatings.map((rating) => (
|
||||
<Link
|
||||
key={rating.id}
|
||||
href={`/drinks/${rating.drinkId}`}
|
||||
className="flex items-center justify-between py-2 hover:bg-accent/50 -mx-2 px-2 rounded-md transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{rating.drink.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rating.drink.brewery || rating.drink.subType || rating.drink.type}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-primary">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-4 w-4 ${i < rating.score ? "fill-primary" : "fill-none opacity-30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
273
src/app/(app)/drinks/[id]/page.tsx
Normal file
273
src/app/(app)/drinks/[id]/page.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Star, MapPin, Percent, Calendar, ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DrinkDetailActions } from "@/components/drinks/drink-detail-actions"
|
||||
import { AddToWishlistButton } from "@/components/drinks/add-to-wishlist-button"
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
BEER: "bg-amber-500/15 text-amber-700 border-amber-500/25",
|
||||
WINE: "bg-rose-500/15 text-rose-700 border-rose-500/25",
|
||||
COCKTAIL: "bg-purple-500/15 text-purple-700 border-purple-500/25",
|
||||
SPIRIT: "bg-sky-500/15 text-sky-700 border-sky-500/25",
|
||||
OTHER: "bg-slate-500/15 text-slate-700 border-slate-500/25",
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
BEER: "Beer",
|
||||
WINE: "Wine",
|
||||
COCKTAIL: "Cocktail",
|
||||
SPIRIT: "Spirit",
|
||||
OTHER: "Other",
|
||||
}
|
||||
|
||||
export default async function DrinkDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string }
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const drink = await prisma.drink.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
ratings: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!drink) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (drink.userId !== session.user.id) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const scores = drink.ratings.map((r) => r.score)
|
||||
const avgRating =
|
||||
scores.length > 0
|
||||
? scores.reduce((sum, s) => sum + s, 0) / scores.length
|
||||
: null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title={drink.name} />
|
||||
<div className="p-4 md:p-8 space-y-6 max-w-3xl mx-auto">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/drinks"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Collection
|
||||
</Link>
|
||||
|
||||
{/* Main Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl">{drink.name}</CardTitle>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
className={cn(
|
||||
TYPE_COLORS[drink.type] || TYPE_COLORS.OTHER
|
||||
)}
|
||||
>
|
||||
{TYPE_LABELS[drink.type] || drink.type}
|
||||
</Badge>
|
||||
{drink.subType && (
|
||||
<Badge variant="outline">{drink.subType}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DrinkDetailActions drinkId={drink.id} drinkName={drink.name} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Rating summary */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
avgRating && i < Math.round(avgRating)
|
||||
? "fill-primary text-primary"
|
||||
: "fill-none text-muted-foreground/30"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{avgRating
|
||||
? `${avgRating.toFixed(1)} avg from ${scores.length} rating${scores.length !== 1 ? "s" : ""}`
|
||||
: "No ratings yet"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
{drink.brewery && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground font-medium min-w-[80px]">
|
||||
Brewery
|
||||
</span>
|
||||
<span>{drink.brewery}</span>
|
||||
</div>
|
||||
)}
|
||||
{drink.region && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span>{drink.region}</span>
|
||||
</div>
|
||||
)}
|
||||
{drink.abv !== null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Percent className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span>{drink.abv}% ABV</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span>
|
||||
Added{" "}
|
||||
{new Date(drink.createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drink.description && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Description</h4>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{drink.description}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/rate/${drink.id}`}>
|
||||
<Button className="w-full sm:w-auto">
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
Rate This Drink
|
||||
</Button>
|
||||
</Link>
|
||||
<AddToWishlistButton
|
||||
name={drink.name}
|
||||
type={drink.type as "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"}
|
||||
subType={drink.subType}
|
||||
brewery={drink.brewery}
|
||||
abv={drink.abv}
|
||||
description={drink.description}
|
||||
source="collection"
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rating History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Rating History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{drink.ratings.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<p>No ratings yet.</p>
|
||||
<Link href={`/rate/${drink.id}`}>
|
||||
<Button variant="outline" className="mt-3" size="sm">
|
||||
Add Your First Rating
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{drink.ratings.map((rating) => (
|
||||
<div
|
||||
key={rating.id}
|
||||
className="border rounded-lg p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
i < rating.score
|
||||
? "fill-primary text-primary"
|
||||
: "fill-none text-muted-foreground/30"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<span className="text-sm font-medium ml-2">
|
||||
{rating.score}/5
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(rating.createdAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rating.notes && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{rating.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{rating.wouldReorder && (
|
||||
<Badge variant="secondary" className="text-[11px]">
|
||||
Would reorder
|
||||
</Badge>
|
||||
)}
|
||||
{rating.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{rating.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
src/app/(app)/drinks/page.tsx
Normal file
284
src/app/(app)/drinks/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react"
|
||||
import { useSearchParams, useRouter } from "next/navigation"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DrinkFilters } from "@/components/drinks/drink-filters"
|
||||
import { DrinkCard } from "@/components/drinks/drink-card"
|
||||
import { DrinkForm } from "@/components/drinks/drink-form"
|
||||
import { AiDrinkSearch } from "@/components/drinks/ai-drink-search"
|
||||
import { useDrinks, useCreateDrink } from "@/hooks/use-drinks"
|
||||
import { Plus, Wine, Sparkles, PenLine } from "lucide-react"
|
||||
import { ShareButton } from "@/components/sharing/share-button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DrinkCreate } from "@/lib/validators"
|
||||
|
||||
export default function DrinksPage() {
|
||||
return (
|
||||
<Suspense fallback={<DrinksLoading />}>
|
||||
<DrinksContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function DrinksLoading() {
|
||||
return (
|
||||
<div>
|
||||
<Header title="My Drinks" />
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[160px] rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DrinksContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [type, setType] = useState("ALL")
|
||||
const [sort, setSort] = useState("recent")
|
||||
const [page, setPage] = useState(1)
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [addMode, setAddMode] = useState<"ai" | "manual">("ai")
|
||||
|
||||
// Open add dialog if URL has ?action=add
|
||||
useEffect(() => {
|
||||
if (searchParams.get("action") === "add") {
|
||||
setAddDialogOpen(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Debounce search
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(search)
|
||||
setPage(1)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const { data, isLoading, error } = useDrinks({
|
||||
search: debouncedSearch,
|
||||
type,
|
||||
sort,
|
||||
page,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const createDrink = useCreateDrink()
|
||||
|
||||
const handleTypeChange = useCallback((value: string) => {
|
||||
setType(value)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const handleSortChange = useCallback((value: string) => {
|
||||
setSort(value)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
function handleCreate(formData: DrinkCreate) {
|
||||
createDrink.mutate(formData, {
|
||||
onSuccess: (newDrink) => {
|
||||
setAddDialogOpen(false)
|
||||
router.push(`/drinks/${newDrink.id}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleCloseDialog(open: boolean) {
|
||||
setAddDialogOpen(open)
|
||||
// Clear the ?action=add param when closing
|
||||
if (!open && searchParams.get("action") === "add") {
|
||||
router.replace("/drinks")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="My Drinks" />
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">My Collection</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{data?.pagination.total !== undefined
|
||||
? `${data.pagination.total} drink${data.pagination.total !== 1 ? "s" : ""} in your collection`
|
||||
: "Your drink collection"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ShareButton />
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Drink
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrinkFilters
|
||||
search={search}
|
||||
type={type}
|
||||
sort={sort}
|
||||
onSearchChange={setSearch}
|
||||
onTypeChange={handleTypeChange}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[160px] rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-destructive">
|
||||
Failed to load drinks. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
) : data?.drinks.length === 0 ? (
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<Wine className="h-12 w-12 mx-auto text-muted-foreground/50" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">No drinks yet</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{debouncedSearch || type !== "ALL"
|
||||
? "No drinks match your filters. Try adjusting your search."
|
||||
: "Start building your collection by adding your first drink."}
|
||||
</p>
|
||||
</div>
|
||||
{!debouncedSearch && type === "ALL" && (
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Drink
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{data?.drinks.map((drink) => (
|
||||
<DrinkCard key={drink.id} drink={drink} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
Page {page} of {data.pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPage((p) =>
|
||||
Math.min(data.pagination.totalPages, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={page >= data.pagination.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Drink Dialog */}
|
||||
<Dialog open={addDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a New Drink</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search with AI or fill in the details manually.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 p-1 bg-muted rounded-lg">
|
||||
<button
|
||||
onClick={() => setAddMode("ai")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-colors",
|
||||
addMode === "ai"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddMode("manual")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-colors",
|
||||
addMode === "manual"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<PenLine className="h-4 w-4" />
|
||||
Manual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addMode === "ai" ? (
|
||||
<AiDrinkSearch
|
||||
onAdd={async (drink) => {
|
||||
await createDrink.mutateAsync({
|
||||
name: drink.name,
|
||||
type: drink.type,
|
||||
subType: drink.subType,
|
||||
brewery: drink.brewery,
|
||||
abv: drink.abv,
|
||||
description: drink.description,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DrinkForm
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={createDrink.isPending}
|
||||
submitLabel="Add Drink"
|
||||
/>
|
||||
{createDrink.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{createDrink.error.message || "Failed to create drink"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/app/(app)/layout.tsx
Normal file
14
src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Sidebar } from "@/components/layout/sidebar"
|
||||
import { BottomNav } from "@/components/layout/bottom-nav"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="md:pl-64">
|
||||
<main className="pb-20 md:pb-0">{children}</main>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/app/(app)/rate/[drinkId]/page.tsx
Normal file
154
src/app/(app)/rate/[drinkId]/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { RatingForm } from "@/components/ratings/rating-form"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useCreateRating } from "@/hooks/use-ratings"
|
||||
import { ArrowLeft, Wine } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface DrinkInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
subType: string | null
|
||||
brewery: string | null
|
||||
region: string | null
|
||||
abv: number | null
|
||||
imageUrl: string | null
|
||||
}
|
||||
|
||||
export default function RateDrinkPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const drinkId = params.drinkId as string
|
||||
|
||||
const [drink, setDrink] = useState<DrinkInfo | null>(null)
|
||||
const [isLoadingDrink, setIsLoadingDrink] = useState(true)
|
||||
const [loadError, setLoadError] = useState("")
|
||||
|
||||
const createRating = useCreateRating()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDrink() {
|
||||
try {
|
||||
const res = await fetch(`/api/drinks/${drinkId}`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || "Failed to load drink")
|
||||
}
|
||||
const data = await res.json()
|
||||
setDrink(data)
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : "Failed to load drink"
|
||||
)
|
||||
} finally {
|
||||
setIsLoadingDrink(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (drinkId) {
|
||||
fetchDrink()
|
||||
}
|
||||
}, [drinkId])
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
score: number
|
||||
notes?: string
|
||||
wouldReorder: boolean
|
||||
location?: string
|
||||
}) => {
|
||||
await createRating.mutateAsync({
|
||||
drinkId,
|
||||
score: data.score,
|
||||
notes: data.notes,
|
||||
wouldReorder: data.wouldReorder,
|
||||
location: data.location,
|
||||
})
|
||||
router.push(`/drinks/${drinkId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Rate Drink" />
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href={drink ? `/drinks/${drinkId}` : "/drinks"}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to {drink ? drink.name : "drinks"}
|
||||
</Link>
|
||||
|
||||
{/* Drink Info Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
{isLoadingDrink ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="text-destructive">
|
||||
<p className="font-medium">Could not load drink</p>
|
||||
<p className="text-sm">{loadError}</p>
|
||||
</div>
|
||||
) : drink ? (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Wine className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl">{drink.name}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary">{drink.type}</Badge>
|
||||
{drink.subType && (
|
||||
<span>{drink.subType}</span>
|
||||
)}
|
||||
{drink.brewery && (
|
||||
<span className="text-muted-foreground">
|
||||
{drink.brewery}
|
||||
</span>
|
||||
)}
|
||||
{drink.abv != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{drink.abv}% ABV
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Rating Form */}
|
||||
{!isLoadingDrink && !loadError && drink && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Rating</CardTitle>
|
||||
<CardDescription>
|
||||
How would you rate {drink.name}?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RatingForm
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createRating.isPending}
|
||||
submitLabel="Save Rating"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
src/app/(app)/scan/[id]/page.tsx
Normal file
201
src/app/(app)/scan/[id]/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { MenuItemCard } from "@/components/scan/menu-item-card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useScan, useAddDrinkFromScan } from "@/hooks/use-scan"
|
||||
import { Loader2, ArrowLeft, CheckCircle, AlertCircle, Sparkles, Wine } from "lucide-react"
|
||||
|
||||
export default function ScanResultPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const scanId = params.id as string
|
||||
const { data: scan, isLoading, isError } = useScan(scanId)
|
||||
const addDrink = useAddDrinkFromScan()
|
||||
const [addingItemIds, setAddingItemIds] = useState<Set<string>>(new Set())
|
||||
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set())
|
||||
|
||||
async function handleAddFromScan(item: { id: string; name: string; type: string; subType?: string | null; brewery?: string | null; abv?: number | null; description?: string | null }) {
|
||||
if (addingItemIds.has(item.id) || addedItemIds.has(item.id)) return
|
||||
setAddingItemIds((prev) => new Set(prev).add(item.id))
|
||||
try {
|
||||
await addDrink.mutateAsync({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
subType: item.subType || undefined,
|
||||
brewery: item.brewery || undefined,
|
||||
abv: item.abv || undefined,
|
||||
description: item.description || undefined,
|
||||
})
|
||||
setAddedItemIds((prev) => new Set(prev).add(item.id))
|
||||
} finally {
|
||||
setAddingItemIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(item.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Scan Results" />
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !scan) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Scan Results" />
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<p className="text-lg font-medium">Failed to load scan results</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.push("/scan")}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Scan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isProcessing = scan.status === "PROCESSING" || scan.status === "UPLOADING"
|
||||
const isFailed = scan.status === "FAILED"
|
||||
|
||||
const matchedItems = scan.items?.filter((item) => item.matchedDrinkId) || []
|
||||
const recommendedItems = scan.items?.filter((item) => item.aiRecommended && !item.matchedDrinkId) || []
|
||||
const otherItems = scan.items?.filter((item) => !item.matchedDrinkId && !item.aiRecommended) || []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Scan Results" />
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/scan")}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium">Analyzing your menu...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Our AI is extracting drinks and finding recommendations for you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFailed && (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium">Analysis failed</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scan.errorMessage || "Something went wrong. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/scan")}>Try Again</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scan.status === "COMPLETED" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
{scan.items?.length || 0} drinks found
|
||||
</Badge>
|
||||
{matchedItems.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Wine className="h-3 w-3" />
|
||||
{matchedItems.length} you've tried
|
||||
</Badge>
|
||||
)}
|
||||
{recommendedItems.length > 0 && (
|
||||
<Badge className="gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{recommendedItems.length} recommended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drinks you've tried */}
|
||||
{matchedItems.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Wine className="h-5 w-5 text-green-600" />
|
||||
Drinks You've Tried
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{matchedItems.map((item) => (
|
||||
<MenuItemCard
|
||||
key={item.id}
|
||||
{...item}
|
||||
onQuickRate={() => router.push(`/rate/${item.matchedDrinkId}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Recommendations */}
|
||||
{recommendedItems.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
Recommended For You
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{recommendedItems.map((item) => (
|
||||
<MenuItemCard
|
||||
key={item.id}
|
||||
{...item}
|
||||
onAddToDrinks={() => handleAddFromScan(item)}
|
||||
isAddingToDrinks={addingItemIds.has(item.id)}
|
||||
wasAddedToDrinks={addedItemIds.has(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other items */}
|
||||
{otherItems.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Other Menu Items</h2>
|
||||
<div className="space-y-2">
|
||||
{otherItems.map((item) => (
|
||||
<MenuItemCard
|
||||
key={item.id}
|
||||
{...item}
|
||||
onAddToDrinks={() => handleAddFromScan(item)}
|
||||
isAddingToDrinks={addingItemIds.has(item.id)}
|
||||
wasAddedToDrinks={addedItemIds.has(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
src/app/(app)/scan/page.tsx
Normal file
103
src/app/(app)/scan/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { PhotoUpload } from "@/components/scan/photo-upload"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useCreateScan, useScans } from "@/hooks/use-scan"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Clock, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function ScanPage() {
|
||||
const router = useRouter()
|
||||
const createScan = useCreateScan()
|
||||
const { data: scansData, isLoading } = useScans()
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const scan = await createScan.mutateAsync(file)
|
||||
router.push(`/scan/${scan.id}`)
|
||||
} catch (error) {
|
||||
console.error("Scan failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const statusIcon = {
|
||||
UPLOADING: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />,
|
||||
PROCESSING: <Loader2 className="h-4 w-4 animate-spin text-primary" />,
|
||||
COMPLETED: <CheckCircle className="h-4 w-4 text-green-500" />,
|
||||
FAILED: <AlertCircle className="h-4 w-4 text-destructive" />,
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Scan Menu" />
|
||||
<div className="p-4 md:p-8 space-y-6 max-w-2xl mx-auto">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Scan a Menu</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Take a photo of a beer or wine menu to identify drinks and get personalized recommendations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PhotoUpload
|
||||
onUpload={handleUpload}
|
||||
isUploading={createScan.isPending}
|
||||
/>
|
||||
|
||||
{createScan.isError && (
|
||||
<div className="text-sm text-destructive">
|
||||
{createScan.error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Scans */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Recent Scans
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : scansData?.scans?.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
No scans yet. Upload a menu photo to get started!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{scansData?.scans?.map((scan: { id: string; status: string; items?: { length: number }[]; createdAt: string }) => (
|
||||
<Link key={scan.id} href={`/scan/${scan.id}`}>
|
||||
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{statusIcon[scan.status as keyof typeof statusIcon]}
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{scan.items?.length || 0} items found
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(scan.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={scan.status === "COMPLETED" ? "default" : "secondary"}>
|
||||
{scan.status}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
339
src/app/(app)/settings/page.tsx
Normal file
339
src/app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
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 { Separator } from "@/components/ui/separator"
|
||||
import { Key, Trash2, Check, Loader2, Shield, Sliders } from "lucide-react"
|
||||
import { BackupRestore } from "@/components/settings/backup-restore"
|
||||
|
||||
interface ApiKeyInfo {
|
||||
id: string
|
||||
provider: string
|
||||
label?: string
|
||||
maskedKey: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// API Keys
|
||||
const { data: apiKeys = [] } = useQuery<ApiKeyInfo[]>({
|
||||
queryKey: ["api-keys"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/api-keys")
|
||||
if (!res.ok) throw new Error("Failed to fetch API keys")
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
// Preferences
|
||||
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
||||
queryKey: ["preferences"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/preferences")
|
||||
if (!res.ok) throw new Error("Failed to fetch preferences")
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
const savePreferences = useMutation({
|
||||
mutationFn: async (prefs: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/settings/preferences", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(prefs),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to save preferences")
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["preferences"] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Settings" />
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your AI providers and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Keys Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
AI Provider Keys
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add your API keys for AI-powered menu scanning. Keys are encrypted and stored securely.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ApiKeyForm provider="claude" label="Anthropic Claude" existingKey={apiKeys.find(k => k.provider === "claude")} />
|
||||
<Separator />
|
||||
<ApiKeyForm provider="openai" label="OpenAI GPT-4o" existingKey={apiKeys.find(k => k.provider === "openai")} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preferences Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sliders className="h-5 w-5" />
|
||||
Drink Preferences
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Help the AI make better recommendations by telling it what you like
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PreferencesForm
|
||||
preferences={preferences}
|
||||
isLoading={prefsLoading}
|
||||
onSave={(prefs) => savePreferences.mutate(prefs)}
|
||||
isSaving={savePreferences.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Backup & Restore Section */}
|
||||
<BackupRestore />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyForm({
|
||||
provider,
|
||||
label,
|
||||
existingKey,
|
||||
}: {
|
||||
provider: string
|
||||
label: string
|
||||
existingKey?: ApiKeyInfo
|
||||
}) {
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const saveKey = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/settings/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, apiKey }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to save API key")
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
||||
setApiKey("")
|
||||
setIsEditing(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteKey = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/settings/api-keys?provider=${provider}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to delete API key")
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
||||
},
|
||||
})
|
||||
|
||||
if (existingKey && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{label}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-sm bg-muted px-2 py-0.5 rounded">
|
||||
{existingKey.maskedKey}
|
||||
</code>
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteKey.mutate()}
|
||||
disabled={deleteKey.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium">{label}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`Enter your ${label} API key`}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveKey.mutate()}
|
||||
disabled={!apiKey || saveKey.isPending}
|
||||
>
|
||||
{saveKey.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button variant="ghost" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
Your key is encrypted before storage and never exposed in full
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreferencesForm({
|
||||
preferences,
|
||||
isLoading,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: {
|
||||
preferences: Record<string, unknown> | undefined
|
||||
isLoading: boolean
|
||||
onSave: (prefs: Record<string, unknown>) => void
|
||||
isSaving: boolean
|
||||
}) {
|
||||
const [preferredStyles, setPreferredStyles] = useState("")
|
||||
const [avoidedStyles, setAvoidedStyles] = useState("")
|
||||
const [minAbv, setMinAbv] = useState("")
|
||||
const [maxAbv, setMaxAbv] = useState("")
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
if (preferences && !initialized) {
|
||||
const prefs = preferences as { preferredStyles?: string[]; avoidedStyles?: string[]; minAbv?: number; maxAbv?: number }
|
||||
setPreferredStyles(prefs.preferredStyles?.join(", ") || "")
|
||||
setAvoidedStyles(prefs.avoidedStyles?.join(", ") || "")
|
||||
setMinAbv(prefs.minAbv?.toString() || "")
|
||||
setMaxAbv(prefs.maxAbv?.toString() || "")
|
||||
setInitialized(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({
|
||||
preferredStyles: preferredStyles
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
avoidedStyles: avoidedStyles
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
minAbv: minAbv ? parseFloat(minAbv) : null,
|
||||
maxAbv: maxAbv ? parseFloat(maxAbv) : null,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="space-y-3"><p className="text-sm text-muted-foreground">Loading...</p></div>
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="preferred">Preferred Styles</Label>
|
||||
<Input
|
||||
id="preferred"
|
||||
placeholder="e.g., IPA, Stout, Pinot Noir, Malbec"
|
||||
value={preferredStyles}
|
||||
onChange={(e) => setPreferredStyles(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Comma-separated list of styles you enjoy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="avoided">Avoided Styles</Label>
|
||||
<Input
|
||||
id="avoided"
|
||||
placeholder="e.g., Sour, Light Lager, Rosé"
|
||||
value={avoidedStyles}
|
||||
onChange={(e) => setAvoidedStyles(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Comma-separated list of styles you want to avoid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="minAbv">Min ABV %</Label>
|
||||
<Input
|
||||
id="minAbv"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="e.g., 4.0"
|
||||
value={minAbv}
|
||||
onChange={(e) => setMinAbv(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxAbv">Max ABV %</Label>
|
||||
<Input
|
||||
id="maxAbv"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="e.g., 12.0"
|
||||
value={maxAbv}
|
||||
onChange={(e) => setMaxAbv(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Preferences"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
146
src/app/(app)/wishlist/page.tsx
Normal file
146
src/app/(app)/wishlist/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
useWishlist,
|
||||
useRemoveFromWishlist,
|
||||
usePromoteWishlistItem,
|
||||
} from "@/hooks/use-wishlist"
|
||||
import { Bookmark, Trash2, ArrowRight, Wine } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
BEER: "bg-amber-100 text-amber-800",
|
||||
WINE: "bg-purple-100 text-purple-800",
|
||||
COCKTAIL: "bg-blue-100 text-blue-800",
|
||||
SPIRIT: "bg-orange-100 text-orange-800",
|
||||
OTHER: "bg-gray-100 text-gray-800",
|
||||
}
|
||||
|
||||
export default function WishlistPage() {
|
||||
const { data, isLoading, error } = useWishlist()
|
||||
const removeItem = useRemoveFromWishlist()
|
||||
const promoteItem = usePromoteWishlistItem()
|
||||
const [promotingId, setPromotingId] = useState<string | null>(null)
|
||||
|
||||
function handlePromote(id: string) {
|
||||
setPromotingId(id)
|
||||
promoteItem.mutate(id, {
|
||||
onSettled: () => setPromotingId(null),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Try Later" />
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bookmark className="h-6 w-6" />
|
||||
Try Later
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Drinks you want to try. Add them to your collection when you do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-destructive">Failed to load wishlist.</p>
|
||||
</div>
|
||||
) : data?.items.length === 0 ? (
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<Wine className="h-12 w-12 mx-auto text-muted-foreground/50" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Nothing saved yet</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
When you find a drink you want to try later, bookmark it from a
|
||||
menu scan or AI search.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data?.items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="flex items-start justify-between gap-3 p-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("text-xs", typeColors[item.type])}
|
||||
>
|
||||
{item.type}
|
||||
</Badge>
|
||||
{item.subType && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.subType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
{item.brewery && <span>{item.brewery}</span>}
|
||||
{item.abv != null && (
|
||||
<span>
|
||||
{item.brewery ? "·" : ""} {item.abv}% ABV
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
{item.notes && (
|
||||
<p className="text-sm text-muted-foreground/80 mt-1 italic">
|
||||
{item.notes}
|
||||
</p>
|
||||
)}
|
||||
{item.source && (
|
||||
<Badge variant="outline" className="text-xs mt-2">
|
||||
from {item.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handlePromote(item.id)}
|
||||
disabled={promotingId === item.id}
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 mr-1" />
|
||||
Tried it
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => removeItem.mutate(item.id)}
|
||||
disabled={removeItem.isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user