Initial food blog app

This commit is contained in:
Andy
2026-03-21 11:57:08 +00:00
commit b83762bfc3
33 changed files with 8621 additions and 0 deletions

125
app/components/MapView.tsx Normal file
View File

@@ -0,0 +1,125 @@
"use client"
import { useEffect, useRef } from "react"
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"
import L from "leaflet"
import "leaflet/dist/leaflet.css"
import { Restaurant } from "@/app/types"
import Link from "next/link"
// Custom amber marker
function createAmberIcon(isSelected: boolean) {
const size = isSelected ? 40 : 32
const svg = `
<svg width="${size}" height="${size}" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="14" r="10" fill="${isSelected ? "#f59e0b" : "#d97706"}" stroke="${isSelected ? "#fff" : "#f59e0b"}" stroke-width="2"/>
<path d="M16 28 L10 20 Q16 22 22 20 Z" fill="${isSelected ? "#f59e0b" : "#d97706"}"/>
<circle cx="16" cy="14" r="4" fill="${isSelected ? "#000" : "#0d0d0d"}"/>
</svg>
`
return L.divIcon({
html: svg,
className: "",
iconSize: [size, size],
iconAnchor: [size / 2, size],
popupAnchor: [0, -size],
})
}
function avgRating(r: Restaurant) {
if (!r.visits.length) return 0
return r.visits.reduce((s, v) => s + v.rating, 0) / r.visits.length
}
function FlyToSelected({
restaurants,
selectedId,
}: {
restaurants: Restaurant[]
selectedId: string | null
}) {
const map = useMap()
useEffect(() => {
if (!selectedId) return
const r = restaurants.find((x) => x.id === selectedId)
if (r) {
map.flyTo([r.lat, r.lng], 13, { duration: 1 })
}
}, [selectedId, restaurants, map])
return null
}
interface Props {
restaurants: Restaurant[]
selectedId: string | null
onSelect: (id: string | null) => void
}
export default function MapView({ restaurants, selectedId, onSelect }: Props) {
const center: [number, number] =
restaurants.length > 0
? [restaurants[0].lat, restaurants[0].lng]
: [51.5, 10]
return (
<MapContainer
center={center}
zoom={5}
style={{ height: "100%", width: "100%", background: "#0d0d0d" }}
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
subdomains="abcd"
maxZoom={20}
/>
<FlyToSelected restaurants={restaurants} selectedId={selectedId} />
{restaurants.map((r) => (
<Marker
key={r.id}
position={[r.lat, r.lng]}
icon={createAmberIcon(selectedId === r.id)}
eventHandlers={{
click: () => onSelect(r.id === selectedId ? null : r.id),
}}
>
<Popup>
<div className="min-w-[180px]">
<p className="font-bold text-[#f5f5f5] text-base leading-tight">{r.name}</p>
<p className="text-white/50 text-xs mt-1">
{r.city}, {r.country}
</p>
<div className="flex flex-wrap gap-1 mt-2">
{r.cuisine.map((c) => (
<span
key={c}
className="text-[9px] uppercase tracking-wider text-[#f59e0b] bg-[#f59e0b]/15 rounded px-1.5 py-0.5 font-semibold"
>
{c}
</span>
))}
</div>
<div className="flex items-center gap-3 mt-2">
<span className="text-[#f59e0b] font-black text-lg">
{avgRating(r).toFixed(1)}
</span>
<span className="text-white/40 text-xs">
{r.visits.length} {r.visits.length === 1 ? "visit" : "visits"}
</span>
</div>
<Link
href={`/restaurant/${r.id}`}
className="mt-3 block text-center text-xs font-bold text-black bg-[#f59e0b] rounded-lg py-1.5 hover:bg-[#d97706] transition-colors"
>
View details
</Link>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)
}

71
app/components/Nav.tsx Normal file
View File

@@ -0,0 +1,71 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { motion } from "framer-motion"
const navItems = [
{ href: "/", label: "Home", icon: "◈" },
{ href: "/map", label: "Map", icon: "◉" },
{ href: "/admin", label: "Add", icon: "+" },
]
export default function Nav() {
const pathname = usePathname()
return (
<>
{/* Desktop top nav */}
<nav className="hidden md:flex items-center justify-between px-8 py-5 border-b border-white/5 sticky top-0 z-50 bg-[#0d0d0d]/80 backdrop-blur-xl">
<Link href="/" className="text-2xl font-black tracking-tighter text-[#f5f5f5] hover:text-[#f59e0b] transition-colors">
food.
</Link>
<div className="flex items-center gap-8">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`text-sm font-medium transition-colors hover:text-[#f59e0b] ${
pathname === item.href ? "text-[#f59e0b]" : "text-white/60"
}`}
>
{item.label}
</Link>
))}
</div>
</nav>
{/* Mobile bottom nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-white/5 bg-[#0d0d0d]/90 backdrop-blur-xl">
<div className="flex items-center justify-around py-3">
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
className="flex flex-col items-center gap-1 px-6 py-1"
>
<motion.div
whileTap={{ scale: 0.85 }}
className={`text-xl transition-colors ${
isActive ? "text-[#f59e0b]" : "text-white/40"
}`}
>
{item.icon}
</motion.div>
<span
className={`text-[10px] font-medium tracking-wider uppercase transition-colors ${
isActive ? "text-[#f59e0b]" : "text-white/30"
}`}
>
{item.label}
</span>
</Link>
)
})}
</div>
</nav>
</>
)
}

View File

@@ -0,0 +1,97 @@
"use client"
import Link from "next/link"
import { motion } from "framer-motion"
import { Restaurant, Visit } from "@/app/types"
function priceLabel(range: number) {
return "€".repeat(range)
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})
}
interface Props {
restaurant: Restaurant
latestVisit: Visit
index: number
}
export default function RestaurantCard({ restaurant, latestVisit, index }: Props) {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.08, ease: [0.25, 0.1, 0.25, 1] }}
>
<Link href={`/restaurant/${restaurant.id}`}>
<div className="group relative rounded-2xl border border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/[0.12] transition-all duration-300 p-5 cursor-pointer overflow-hidden">
{/* Amber glow on hover */}
<div className="absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
style={{ background: "radial-gradient(circle at 50% 0%, rgba(245,158,11,0.06) 0%, transparent 70%)" }}
/>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-[#f5f5f5] group-hover:text-[#f59e0b] transition-colors truncate">
{restaurant.name}
</h3>
<p className="text-sm text-white/40 mt-0.5">
{restaurant.city}, {restaurant.country}
</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[#f59e0b] font-black text-lg leading-none">
{latestVisit.rating}
</span>
<span className="text-white/30 text-xs">/10</span>
</div>
<span className="text-white/30 text-xs font-medium tracking-wide">
{priceLabel(restaurant.price_range)}
</span>
</div>
</div>
<div className="mt-4">
{latestVisit.dishes[0] && (
<p className="text-sm text-white/60 italic truncate">
&ldquo;{latestVisit.dishes[0]}&rdquo;
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between">
<div className="flex flex-wrap gap-1.5">
{restaurant.cuisine.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-[10px] font-semibold uppercase tracking-wider text-[#f59e0b]/70 bg-[#f59e0b]/10 rounded-full px-2.5 py-0.5"
>
{tag}
</span>
))}
</div>
<span className="text-xs text-white/25 shrink-0">
{formatDate(latestVisit.date)}
</span>
</div>
{restaurant.visits.length > 1 && (
<div className="mt-3 pt-3 border-t border-white/[0.05]">
<p className="text-xs text-white/30">
{restaurant.visits.length} visits
</p>
</div>
)}
</div>
</Link>
</motion.div>
)
}