Initial food blog app
This commit is contained in:
198
app/page.tsx
Normal file
198
app/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user