Files
food/app/page.tsx
2026-03-21 11:57:08 +00:00

199 lines
6.4 KiB
TypeScript

"use client"
import { useState, useEffect, useMemo } from "react"
import { motion, AnimatePresence } from "framer-motion"
import RestaurantCard from "@/app/components/RestaurantCard"
import { Restaurant, Visit } from "@/app/types"
type RestaurantWithLatest = {
restaurant: Restaurant
latestVisit: Visit
}
const PRICE_LABELS = ["€", "€€", "€€€", "€€€€"]
export default function HomePage() {
const [restaurants, setRestaurants] = useState<Restaurant[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [selectedCuisines, setSelectedCuisines] = useState<string[]>([])
const [selectedPrices, setSelectedPrices] = useState<number[]>([])
useEffect(() => {
fetch("/api/restaurants")
.then((r) => r.json())
.then((data) => {
setRestaurants(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
const allCuisines = useMemo(() => {
const set = new Set<string>()
restaurants.forEach((r) => r.cuisine.forEach((c) => set.add(c)))
return Array.from(set).sort()
}, [restaurants])
const feed = useMemo((): RestaurantWithLatest[] => {
return restaurants
.filter((r) => r.visits.length > 0)
.map((r) => {
const sorted = [...r.visits].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
return { restaurant: r, latestVisit: sorted[0] }
})
.sort(
(a, b) =>
new Date(b.latestVisit.date).getTime() -
new Date(a.latestVisit.date).getTime()
)
}, [restaurants])
const filtered = useMemo(() => {
return feed.filter(({ restaurant }) => {
const q = search.toLowerCase()
if (
q &&
!restaurant.name.toLowerCase().includes(q) &&
!restaurant.city.toLowerCase().includes(q) &&
!restaurant.country.toLowerCase().includes(q)
)
return false
if (
selectedCuisines.length > 0 &&
!selectedCuisines.some((c) => restaurant.cuisine.includes(c))
)
return false
if (
selectedPrices.length > 0 &&
!selectedPrices.includes(restaurant.price_range)
)
return false
return true
})
}, [feed, search, selectedCuisines, selectedPrices])
const toggleCuisine = (c: string) => {
setSelectedCuisines((prev) =>
prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]
)
}
const togglePrice = (p: number) => {
setSelectedPrices((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
)
}
return (
<div className="min-h-screen">
{/* Hero */}
<section className="px-5 md:px-8 pt-16 md:pt-24 pb-12">
<motion.div
initial={{ opacity: 0, y: 32 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
>
<h1 className="text-[clamp(4rem,16vw,10rem)] font-black leading-none tracking-tighter text-[#f5f5f5]">
food<span className="text-[#f59e0b]">.</span>
</h1>
<p className="text-white/40 text-base md:text-lg mt-3 font-light tracking-wide">
a personal food journal
</p>
</motion.div>
</section>
{/* Filters */}
<section className="px-5 md:px-8 mb-8">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
className="space-y-4"
>
<input
type="text"
placeholder="Search restaurants, cities..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-xl px-4 py-3 text-sm text-[#f5f5f5] placeholder-white/25 outline-none focus:border-[#f59e0b]/40 focus:bg-white/[0.06] transition-all"
/>
{allCuisines.length > 0 && (
<div className="flex flex-wrap gap-2">
{allCuisines.map((c) => (
<button
key={c}
onClick={() => toggleCuisine(c)}
className={`text-xs font-semibold uppercase tracking-wider rounded-full px-3 py-1.5 transition-all border ${
selectedCuisines.includes(c)
? "bg-[#f59e0b] text-black border-[#f59e0b]"
: "bg-white/[0.04] text-white/50 border-white/[0.08] hover:border-white/20"
}`}
>
{c}
</button>
))}
</div>
)}
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4].map((p) => (
<button
key={p}
onClick={() => togglePrice(p)}
className={`text-xs font-semibold rounded-full px-3 py-1.5 transition-all border ${
selectedPrices.includes(p)
? "bg-[#f59e0b] text-black border-[#f59e0b]"
: "bg-white/[0.04] text-white/50 border-white/[0.08] hover:border-white/20"
}`}
>
{PRICE_LABELS[p - 1]}
</button>
))}
</div>
</motion.div>
</section>
{/* Feed */}
<section className="px-5 md:px-8 pb-8">
{loading ? (
<div className="flex items-center justify-center py-24">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-6 h-6 border-2 border-[#f59e0b] border-t-transparent rounded-full"
/>
</div>
) : filtered.length === 0 ? (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-white/30 text-center py-24 text-sm"
>
No restaurants found.
</motion.p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-6xl">
<AnimatePresence mode="popLayout">
{filtered.map(({ restaurant, latestVisit }, i) => (
<RestaurantCard
key={restaurant.id}
restaurant={restaurant}
latestVisit={latestVisit}
index={i}
/>
))}
</AnimatePresence>
</div>
)}
</section>
</div>
)
}