Initial food blog app
This commit is contained in:
483
app/admin/page.tsx
Normal file
483
app/admin/page.tsx
Normal 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 (1–10)</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user