Initial food blog app
This commit is contained in:
125
app/components/MapView.tsx
Normal file
125
app/components/MapView.tsx
Normal 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='© <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
71
app/components/Nav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
97
app/components/RestaurantCard.tsx
Normal file
97
app/components/RestaurantCard.tsx
Normal 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">
|
||||
“{latestVisit.dishes[0]}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user