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

484 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}