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

483
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,483 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion"
import { Restaurant } from "@/app/types"
function priceLabel(range: number) {
return "€".repeat(range)
}
function AdminContent() {
const searchParams = useSearchParams()
const router = useRouter()
const preselectedId = searchParams.get("restaurant")
const [restaurants, setRestaurants] = useState<Restaurant[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<"add-restaurant" | "add-visit" | "manage">(
preselectedId ? "add-visit" : "add-restaurant"
)
const [feedback, setFeedback] = useState<string | null>(null)
// Add restaurant form
const [rForm, setRForm] = useState({
name: "",
city: "",
country: "",
address: "",
lat: "",
lng: "",
cuisine: "",
price_range: "2",
})
// Add visit form
const [vForm, setVForm] = useState({
restaurantId: preselectedId || "",
date: new Date().toISOString().slice(0, 10),
dishes: "",
rating: "8",
notes: "",
price_paid: "",
})
useEffect(() => {
fetch("/api/restaurants")
.then((r) => r.json())
.then((data) => {
setRestaurants(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
function showFeedback(msg: string) {
setFeedback(msg)
setTimeout(() => setFeedback(null), 3000)
}
async function handleAddRestaurant(e: React.FormEvent) {
e.preventDefault()
const body = {
name: rForm.name,
city: rForm.city,
country: rForm.country,
address: rForm.address,
lat: parseFloat(rForm.lat),
lng: parseFloat(rForm.lng),
cuisine: rForm.cuisine.split(",").map((c) => c.trim()).filter(Boolean),
price_range: parseInt(rForm.price_range) as 1 | 2 | 3 | 4,
}
const res = await fetch("/api/restaurants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (res.ok) {
const newR = await res.json()
setRestaurants((prev) => [...prev, newR])
setRForm({ name: "", city: "", country: "", address: "", lat: "", lng: "", cuisine: "", price_range: "2" })
showFeedback("Restaurant added!")
} else {
showFeedback("Error adding restaurant")
}
}
async function handleAddVisit(e: React.FormEvent) {
e.preventDefault()
if (!vForm.restaurantId) return
const body = {
date: new Date(vForm.date).toISOString(),
dishes: vForm.dishes.split("\n").map((d) => d.trim()).filter(Boolean),
rating: parseInt(vForm.rating),
notes: vForm.notes,
...(vForm.price_paid ? { price_paid: parseFloat(vForm.price_paid) } : {}),
}
const res = await fetch(`/api/restaurants/${vForm.restaurantId}/visits`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (res.ok) {
setVForm((prev) => ({ ...prev, dishes: "", notes: "", price_paid: "" }))
showFeedback("Visit added!")
// Refresh
fetch("/api/restaurants")
.then((r) => r.json())
.then(setRestaurants)
} else {
showFeedback("Error adding visit")
}
}
async function handleDeleteVisit(restaurantId: string, visitId: string) {
if (!confirm("Delete this visit?")) return
const res = await fetch(`/api/restaurants/${restaurantId}/visits/${visitId}`, {
method: "DELETE",
})
if (res.ok) {
setRestaurants((prev) =>
prev.map((r) =>
r.id === restaurantId
? { ...r, visits: r.visits.filter((v) => v.id !== visitId) }
: r
)
)
showFeedback("Visit deleted")
}
}
async function handleDeleteRestaurant(restaurantId: string) {
if (!confirm("Delete this restaurant and all its visits?")) return
const res = await fetch(`/api/restaurants/${restaurantId}`, { method: "DELETE" })
if (res.ok) {
setRestaurants((prev) => prev.filter((r) => r.id !== restaurantId))
showFeedback("Restaurant deleted")
}
}
const inputClass =
"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"
const labelClass = "block text-xs font-semibold uppercase tracking-wider text-white/40 mb-1.5"
const tabs = [
{ id: "add-restaurant", label: "New Place" },
{ id: "add-visit", label: "Add Visit" },
{ id: "manage", label: "Manage" },
] as const
return (
<div className="max-w-2xl mx-auto px-5 md:px-8 py-8 md:py-12">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="mb-8"
>
<h1 className="text-4xl font-black tracking-tight">admin</h1>
<p className="text-white/30 text-sm mt-1">manage your food journal</p>
</motion.div>
{/* Feedback toast */}
<AnimatePresence>
{feedback && (
<motion.div
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="fixed top-20 left-1/2 -translate-x-1/2 z-50 bg-[#f59e0b] text-black font-semibold text-sm px-5 py-2.5 rounded-xl shadow-xl"
>
{feedback}
</motion.div>
)}
</AnimatePresence>
{/* Tabs */}
<div className="flex gap-1 bg-white/[0.04] rounded-xl p-1 mb-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 py-2 rounded-lg text-sm font-semibold transition-all ${
activeTab === tab.id
? "bg-[#f59e0b] text-black"
: "text-white/40 hover:text-white/70"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Add Restaurant */}
{activeTab === "add-restaurant" && (
<motion.form
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onSubmit={handleAddRestaurant}
className="space-y-5"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label className={labelClass}>Name</label>
<input
className={inputClass}
placeholder="Restaurant name"
value={rForm.name}
onChange={(e) => setRForm({ ...rForm, name: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>City</label>
<input
className={inputClass}
placeholder="City"
value={rForm.city}
onChange={(e) => setRForm({ ...rForm, city: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Country</label>
<input
className={inputClass}
placeholder="Country"
value={rForm.country}
onChange={(e) => setRForm({ ...rForm, country: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Price Range</label>
<select
className={inputClass}
value={rForm.price_range}
onChange={(e) => setRForm({ ...rForm, price_range: e.target.value })}
>
<option value="1"> Budget</option>
<option value="2"> Mid</option>
<option value="3"> Upscale</option>
<option value="4"> Fine Dining</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Address</label>
<input
className={inputClass}
placeholder="Full address"
value={rForm.address}
onChange={(e) => setRForm({ ...rForm, address: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-5">
<div>
<label className={labelClass}>Latitude</label>
<input
className={inputClass}
placeholder="48.8566"
type="number"
step="any"
value={rForm.lat}
onChange={(e) => setRForm({ ...rForm, lat: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Longitude</label>
<input
className={inputClass}
placeholder="2.3522"
type="number"
step="any"
value={rForm.lng}
onChange={(e) => setRForm({ ...rForm, lng: e.target.value })}
required
/>
</div>
</div>
<div>
<label className={labelClass}>Cuisine (comma-separated)</label>
<input
className={inputClass}
placeholder="Italian, Pizza, Wine Bar"
value={rForm.cuisine}
onChange={(e) => setRForm({ ...rForm, cuisine: e.target.value })}
required
/>
</div>
<button
type="submit"
className="w-full bg-[#f59e0b] text-black font-bold py-3.5 rounded-xl hover:bg-[#d97706] transition-colors"
>
Add Restaurant
</button>
</motion.form>
)}
{/* Add Visit */}
{activeTab === "add-visit" && (
<motion.form
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onSubmit={handleAddVisit}
className="space-y-5"
>
<div>
<label className={labelClass}>Restaurant</label>
<select
className={inputClass}
value={vForm.restaurantId}
onChange={(e) => setVForm({ ...vForm, restaurantId: e.target.value })}
required
>
<option value="">Select a restaurant...</option>
{restaurants.map((r) => (
<option key={r.id} value={r.id}>
{r.name} {r.city}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-5">
<div>
<label className={labelClass}>Date</label>
<input
className={inputClass}
type="date"
value={vForm.date}
onChange={(e) => setVForm({ ...vForm, date: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Rating (110)</label>
<input
className={inputClass}
type="number"
min="1"
max="10"
value={vForm.rating}
onChange={(e) => setVForm({ ...vForm, rating: e.target.value })}
required
/>
</div>
</div>
<div>
<label className={labelClass}>Dishes (one per line)</label>
<textarea
className={`${inputClass} resize-none h-24`}
placeholder={"Duck confit\nTruffle pasta\nCrème brûlée"}
value={vForm.dishes}
onChange={(e) => setVForm({ ...vForm, dishes: e.target.value })}
required
/>
</div>
<div>
<label className={labelClass}>Notes</label>
<textarea
className={`${inputClass} resize-none h-24`}
placeholder="How was it? What stood out?"
value={vForm.notes}
onChange={(e) => setVForm({ ...vForm, notes: e.target.value })}
/>
</div>
<div>
<label className={labelClass}>Price paid (, optional)</label>
<input
className={inputClass}
type="number"
step="0.01"
placeholder="85"
value={vForm.price_paid}
onChange={(e) => setVForm({ ...vForm, price_paid: e.target.value })}
/>
</div>
<button
type="submit"
className="w-full bg-[#f59e0b] text-black font-bold py-3.5 rounded-xl hover:bg-[#d97706] transition-colors"
>
Add Visit
</button>
</motion.form>
)}
{/* Manage */}
{activeTab === "manage" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-4"
>
{loading ? (
<div className="flex justify-center py-12">
<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>
) : (
restaurants.map((r) => (
<div
key={r.id}
className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5"
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-bold text-[#f5f5f5]">{r.name}</h3>
<p className="text-sm text-white/40">
{r.city}, {r.country} · {priceLabel(r.price_range)}
</p>
</div>
<button
onClick={() => handleDeleteRestaurant(r.id)}
className="text-xs text-red-400/60 hover:text-red-400 transition-colors shrink-0"
>
Delete
</button>
</div>
{r.visits.length > 0 && (
<div className="mt-4 space-y-2">
<p className="text-xs text-white/30 uppercase tracking-wider font-semibold">
Visits
</p>
{r.visits.map((v) => (
<div
key={v.id}
className="flex items-center justify-between gap-4 py-2 border-t border-white/[0.04]"
>
<div>
<span className="text-xs text-white/50">
{new Date(v.date).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
<span className="text-[#f59e0b] text-xs font-bold ml-3">
{v.rating}/10
</span>
</div>
<button
onClick={() => handleDeleteVisit(r.id, v.id)}
className="text-[10px] text-red-400/50 hover:text-red-400 transition-colors"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
))
)}
</motion.div>
)}
</div>
)
}
export default function AdminPage() {
return (
<Suspense>
<AdminContent />
</Suspense>
)
}

View File

@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server'
import { readRestaurants, writeRestaurants } from '@/app/lib/restaurants'
import { Restaurant } from '@/app/types'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const restaurants = readRestaurants()
const restaurant = restaurants.find((r) => r.id === id)
if (!restaurant) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(restaurant)
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json() as Partial<Restaurant>
const restaurants = readRestaurants()
const index = restaurants.findIndex((r) => r.id === id)
if (index === -1) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
restaurants[index] = { ...restaurants[index], ...body, id }
writeRestaurants(restaurants)
return NextResponse.json(restaurants[index])
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const restaurants = readRestaurants()
const filtered = restaurants.filter((r) => r.id !== id)
if (filtered.length === restaurants.length) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
writeRestaurants(filtered)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { readRestaurants, writeRestaurants } from '@/app/lib/restaurants'
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string; visitId: string }> }
) {
const { id, visitId } = await params
const restaurants = readRestaurants()
const index = restaurants.findIndex((r) => r.id === id)
if (index === -1) {
return NextResponse.json({ error: 'Restaurant not found' }, { status: 404 })
}
const visitIndex = restaurants[index].visits.findIndex((v) => v.id === visitId)
if (visitIndex === -1) {
return NextResponse.json({ error: 'Visit not found' }, { status: 404 })
}
restaurants[index].visits.splice(visitIndex, 1)
writeRestaurants(restaurants)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { readRestaurants, writeRestaurants } from '@/app/lib/restaurants'
import { Visit } from '@/app/types'
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json() as Omit<Visit, 'id'>
const restaurants = readRestaurants()
const index = restaurants.findIndex((r) => r.id === id)
if (index === -1) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const newVisit: Visit = {
...body,
id: crypto.randomUUID(),
}
restaurants[index].visits.push(newVisit)
writeRestaurants(restaurants)
return NextResponse.json(newVisit, { status: 201 })
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { readRestaurants, writeRestaurants } from '@/app/lib/restaurants'
import { Restaurant } from '@/app/types'
export async function GET() {
const restaurants = readRestaurants()
return NextResponse.json(restaurants)
}
export async function POST(request: Request) {
const body = await request.json() as Omit<Restaurant, 'id' | 'visits'>
const restaurants = readRestaurants()
const newRestaurant: Restaurant = {
...body,
id: crypto.randomUUID(),
visits: [],
}
restaurants.push(newRestaurant)
writeRestaurants(restaurants)
return NextResponse.json(newRestaurant, { status: 201 })
}

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>
)
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

72
app/globals.css Normal file
View File

@@ -0,0 +1,72 @@
@import "tailwindcss";
:root {
--background: #0d0d0d;
--foreground: #f5f5f5;
--accent: #f59e0b;
--accent-dim: #d97706;
--surface: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.08);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background-color: #0d0d0d;
color: #f5f5f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: #0d0d0d;
}
::-webkit-scrollbar-thumb {
background: rgba(245, 158, 11, 0.3);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(245, 158, 11, 0.6);
}
/* Leaflet dark overrides */
.leaflet-container {
background: #0d0d0d;
}
.leaflet-popup-content-wrapper {
background: #1a1a1a;
color: #f5f5f5;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
}
.leaflet-popup-tip {
background: #1a1a1a;
}
.leaflet-popup-close-button {
color: #f5f5f5 !important;
}

39
app/layout.tsx Normal file
View File

@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Nav from "@/app/components/Nav";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "food.",
description: "A personal food journal",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full`}
>
<body className="min-h-full flex flex-col bg-[#0d0d0d] text-[#f5f5f5]">
<Nav />
<main className="flex-1 pb-20 md:pb-0">
{children}
</main>
</body>
</html>
);
}

18
app/lib/restaurants.ts Normal file
View File

@@ -0,0 +1,18 @@
import fs from 'fs'
import path from 'path'
import { Restaurant } from '@/app/types'
const DATA_FILE = path.join(process.cwd(), 'data', 'restaurants.json')
export function readRestaurants(): Restaurant[] {
try {
const raw = fs.readFileSync(DATA_FILE, 'utf-8')
return JSON.parse(raw) as Restaurant[]
} catch {
return []
}
}
export function writeRestaurants(restaurants: Restaurant[]): void {
fs.writeFileSync(DATA_FILE, JSON.stringify(restaurants, null, 2), 'utf-8')
}

127
app/map/page.tsx Normal file
View File

@@ -0,0 +1,127 @@
"use client"
import dynamic from "next/dynamic"
import { useState, useEffect } from "react"
import { motion } from "framer-motion"
import Link from "next/link"
import { Restaurant } from "@/app/types"
const MapView = dynamic(() => import("@/app/components/MapView"), { ssr: false })
function avgRating(restaurant: Restaurant) {
if (restaurant.visits.length === 0) return 0
return (
restaurant.visits.reduce((s, v) => s + v.rating, 0) / restaurant.visits.length
)
}
export default function MapPage() {
const [restaurants, setRestaurants] = useState<Restaurant[]>([])
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<string | null>(null)
useEffect(() => {
fetch("/api/restaurants")
.then((r) => r.json())
.then((data) => {
setRestaurants(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
return (
<div className="flex flex-col md:flex-row h-[calc(100vh-65px)]">
{/* Map */}
<div className="flex-1 relative">
{loading ? (
<div className="flex items-center justify-center h-full bg-[#0d0d0d]">
<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>
) : (
<MapView
restaurants={restaurants}
selectedId={selected}
onSelect={setSelected}
/>
)}
</div>
{/* Desktop sidebar */}
<aside className="hidden md:flex w-80 flex-col border-l border-white/[0.06] bg-[#0d0d0d] overflow-y-auto">
<div className="p-5 border-b border-white/[0.06]">
<h2 className="text-lg font-black tracking-tight">
{restaurants.length} places
</h2>
</div>
<div className="flex-1 p-3 space-y-2">
{restaurants.map((r) => (
<button
key={r.id}
onClick={() => setSelected(r.id === selected ? null : r.id)}
className={`w-full text-left rounded-xl p-3 transition-all border ${
selected === r.id
? "bg-[#f59e0b]/10 border-[#f59e0b]/30"
: "bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.05]"
}`}
>
<p className="font-semibold text-sm text-[#f5f5f5]">{r.name}</p>
<p className="text-xs text-white/40 mt-0.5">
{r.city} · {r.cuisine[0]}
</p>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[#f59e0b] text-xs font-bold">
{avgRating(r).toFixed(1)}
</span>
<span className="text-white/25 text-xs">
{r.visits.length} {r.visits.length === 1 ? "visit" : "visits"}
</span>
</div>
</button>
))}
</div>
</aside>
{/* Mobile bottom sheet */}
<motion.div
className="md:hidden fixed bottom-16 left-0 right-0 bg-[#111]/95 backdrop-blur-xl border-t border-white/[0.06] max-h-64 overflow-y-auto z-40"
initial={{ y: 200 }}
animate={{ y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
>
<div className="p-3 grid grid-cols-2 gap-2">
{restaurants.map((r) => (
<button
key={r.id}
onClick={() => setSelected(r.id === selected ? null : r.id)}
className={`text-left rounded-xl p-3 transition-all border ${
selected === r.id
? "bg-[#f59e0b]/10 border-[#f59e0b]/30"
: "bg-white/[0.03] border-white/[0.06]"
}`}
>
<p className="font-semibold text-xs text-[#f5f5f5] truncate">{r.name}</p>
<p className="text-[10px] text-white/40">{r.city}</p>
<span className="text-[#f59e0b] text-xs font-bold">
{avgRating(r).toFixed(1)}
</span>
</button>
))}
</div>
</motion.div>
{selected && (
<Link
href={`/restaurant/${selected}`}
className="hidden md:block absolute bottom-6 right-6 bg-[#f59e0b] text-black font-bold text-sm px-4 py-2 rounded-xl hover:bg-[#d97706] transition-colors z-50"
>
View details
</Link>
)}
</div>
)
}

198
app/page.tsx Normal file
View 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>
)
}

View File

@@ -0,0 +1,240 @@
"use client"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
import { motion } from "framer-motion"
import Link from "next/link"
import { Restaurant, Visit } from "@/app/types"
function priceLabel(range: number) {
return "€".repeat(range)
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
}
function RatingBar({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-3">
<div className="flex-1 h-1.5 bg-white/[0.08] rounded-full overflow-hidden">
<motion.div
className="h-full bg-[#f59e0b] rounded-full"
initial={{ width: 0 }}
animate={{ width: `${(rating / 10) * 100}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
</div>
<span className="text-[#f59e0b] font-black text-xl leading-none w-8 text-right">
{rating}
</span>
</div>
)
}
function VisitCard({ visit, index }: { visit: Visit; index: number }) {
return (
<motion.div
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
className="relative pl-6"
>
{/* Timeline line */}
<div className="absolute left-0 top-0 bottom-0 w-px bg-white/[0.08]" />
{/* Timeline dot */}
<div className="absolute left-[-4px] top-3 w-2 h-2 rounded-full bg-[#f59e0b]" />
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5 mb-4">
<div className="flex items-start justify-between gap-4 mb-4">
<p className="text-sm text-white/40">{formatDate(visit.date)}</p>
{visit.price_paid && (
<span className="text-xs text-white/30 shrink-0">
{visit.price_paid}
</span>
)}
</div>
<RatingBar rating={visit.rating} />
<div className="mt-4 space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">
Dishes
</p>
<ul className="space-y-1">
{visit.dishes.map((dish, i) => (
<li key={i} className="text-sm text-[#f5f5f5] flex items-start gap-2">
<span className="text-[#f59e0b] mt-1 shrink-0">·</span>
{dish}
</li>
))}
</ul>
</div>
{visit.notes && (
<div className="mt-4 pt-4 border-t border-white/[0.06]">
<p className="text-sm text-white/60 leading-relaxed italic">
&ldquo;{visit.notes}&rdquo;
</p>
</div>
)}
</div>
</motion.div>
)
}
export default function RestaurantPage() {
const params = useParams()
const router = useRouter()
const id = params.id as string
const [restaurant, setRestaurant] = useState<Restaurant | null>(null)
const [loading, setLoading] = useState(true)
const [notFound, setNotFound] = useState(false)
useEffect(() => {
fetch(`/api/restaurants/${id}`)
.then((r) => {
if (!r.ok) {
setNotFound(true)
setLoading(false)
return null
}
return r.json()
})
.then((data) => {
if (data) {
setRestaurant(data)
setLoading(false)
}
})
.catch(() => {
setNotFound(true)
setLoading(false)
})
}, [id])
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<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>
)
}
if (notFound || !restaurant) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<p className="text-white/40">Restaurant not found</p>
<Link href="/" className="text-[#f59e0b] text-sm font-medium hover:underline">
Back home
</Link>
</div>
)
}
const avgRating =
restaurant.visits.length > 0
? restaurant.visits.reduce((s, v) => s + v.rating, 0) / restaurant.visits.length
: 0
const sortedVisits = [...restaurant.visits].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
return (
<div className="max-w-2xl mx-auto px-5 md:px-8 py-8 md:py-12">
{/* Back */}
<motion.div
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<button
onClick={() => router.back()}
className="text-sm text-white/40 hover:text-white/70 transition-colors mb-8 flex items-center gap-2"
>
back
</button>
</motion.div>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-[#f5f5f5] leading-none">
{restaurant.name}
</h1>
<div className="shrink-0 text-right">
<div className="text-[#f59e0b] font-black text-3xl leading-none">
{avgRating.toFixed(1)}
</div>
<div className="text-white/25 text-xs mt-1">avg</div>
</div>
</div>
<p className="text-white/40 text-base mt-2">
{restaurant.address} · {restaurant.city}, {restaurant.country}
</p>
<div className="flex flex-wrap items-center gap-3 mt-4">
{restaurant.cuisine.map((tag) => (
<span
key={tag}
className="text-xs font-semibold uppercase tracking-wider text-[#f59e0b]/70 bg-[#f59e0b]/10 rounded-full px-3 py-1"
>
{tag}
</span>
))}
<span className="text-white/30 text-sm font-medium">
{priceLabel(restaurant.price_range)}
</span>
<span className="text-white/25 text-sm">
{restaurant.visits.length} {restaurant.visits.length === 1 ? "visit" : "visits"}
</span>
</div>
</motion.div>
{/* Divider */}
<div className="my-8 border-t border-white/[0.06]" />
{/* Visits timeline */}
<div>
<h2 className="text-lg font-bold mb-6 text-white/70">Visits</h2>
<div className="space-y-0">
{sortedVisits.map((visit, i) => (
<VisitCard key={visit.id} visit={visit} index={i} />
))}
</div>
</div>
{/* Admin link */}
<div className="mt-12 pt-8 border-t border-white/[0.06] flex gap-4">
<Link
href={`/admin?restaurant=${restaurant.id}`}
className="text-sm text-white/30 hover:text-[#f59e0b] transition-colors"
>
+ add visit
</Link>
<Link
href="/admin"
className="text-sm text-white/30 hover:text-[#f59e0b] transition-colors"
>
manage
</Link>
</div>
</div>
)
}

21
app/types/index.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface Visit {
id: string
date: string // ISO
dishes: string[]
rating: number // 1-10
notes: string
price_paid?: number // optional, in EUR
}
export interface Restaurant {
id: string
name: string
city: string
country: string
address: string
lat: number
lng: number
cuisine: string[]
price_range: 1 | 2 | 3 | 4
visits: Visit[]
}