Initial food blog app
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy standalone build
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Copy data directory as default seed — will be overridden by volume mount
|
||||||
|
COPY --from=builder /app/data ./data
|
||||||
|
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
app/api/restaurants/[id]/route.ts
Normal file
53
app/api/restaurants/[id]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
26
app/api/restaurants/[id]/visits/[visitId]/route.ts
Normal file
26
app/api/restaurants/[id]/visits/[visitId]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
27
app/api/restaurants/[id]/visits/route.ts
Normal file
27
app/api/restaurants/[id]/visits/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
24
app/api/restaurants/route.ts
Normal file
24
app/api/restaurants/route.ts
Normal 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
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
72
app/globals.css
Normal file
72
app/globals.css
Normal 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
39
app/layout.tsx
Normal 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
18
app/lib/restaurants.ts
Normal 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
127
app/map/page.tsx
Normal 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
198
app/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
240
app/restaurant/[id]/page.tsx
Normal file
240
app/restaurant/[id]/page.tsx
Normal 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">
|
||||||
|
“{visit.notes}”
|
||||||
|
</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
21
app/types/index.ts
Normal 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[]
|
||||||
|
}
|
||||||
73
data/restaurants.json
Normal file
73
data/restaurants.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "septime-paris",
|
||||||
|
"name": "Septime",
|
||||||
|
"city": "Paris",
|
||||||
|
"country": "France",
|
||||||
|
"address": "80 Rue de Charonne, 75011 Paris",
|
||||||
|
"lat": 48.8528,
|
||||||
|
"lng": 2.3799,
|
||||||
|
"cuisine": ["French", "Contemporary", "Fine Dining"],
|
||||||
|
"price_range": 4,
|
||||||
|
"visits": [
|
||||||
|
{
|
||||||
|
"id": "septime-visit-1",
|
||||||
|
"date": "2025-11-14T19:30:00Z",
|
||||||
|
"dishes": ["Smoked eel with kohlrabi", "Aged duck with wild mushrooms", "Caramel tart"],
|
||||||
|
"rating": 9,
|
||||||
|
"notes": "Exceptional tasting menu. The duck was perfectly aged, served with a reduction that had incredible depth. Service was attentive without being intrusive. The dining room has a beautiful industrial warmth.",
|
||||||
|
"price_paid": 220
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "septime-visit-2",
|
||||||
|
"date": "2026-02-03T20:00:00Z",
|
||||||
|
"dishes": ["Scallop with cauliflower foam", "Lamb with fermented leeks", "Chocolate soufflé"],
|
||||||
|
"rating": 10,
|
||||||
|
"notes": "Even better the second time. The scallop dish was a revelation — so clean and precise. Worth every euro. Booked months in advance but completely justified.",
|
||||||
|
"price_paid": 240
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dry-aged-vilnius",
|
||||||
|
"name": "Dry Aged",
|
||||||
|
"city": "Vilnius",
|
||||||
|
"country": "Lithuania",
|
||||||
|
"address": "Gedimino pr. 9, Vilnius 01103",
|
||||||
|
"lat": 54.6872,
|
||||||
|
"lng": 25.2797,
|
||||||
|
"cuisine": ["Steakhouse", "Grill", "European"],
|
||||||
|
"price_range": 3,
|
||||||
|
"visits": [
|
||||||
|
{
|
||||||
|
"id": "dry-aged-visit-1",
|
||||||
|
"date": "2026-01-20T19:00:00Z",
|
||||||
|
"dishes": ["45-day dry aged ribeye", "Bone marrow butter", "Truffle fries", "Crème brûlée"],
|
||||||
|
"rating": 8,
|
||||||
|
"notes": "The ribeye was phenomenal — proper crust, buttery interior. The bone marrow butter takes it to another level. Vilnius is seriously underrated for food. Great wine list too.",
|
||||||
|
"price_paid": 95
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "baest-copenhagen",
|
||||||
|
"name": "Bæst",
|
||||||
|
"city": "Copenhagen",
|
||||||
|
"country": "Denmark",
|
||||||
|
"address": "Guldbergsgade 29, 2200 Copenhagen",
|
||||||
|
"lat": 55.6897,
|
||||||
|
"lng": 12.5531,
|
||||||
|
"cuisine": ["Pizza", "Italian", "Natural Wine"],
|
||||||
|
"price_range": 2,
|
||||||
|
"visits": [
|
||||||
|
{
|
||||||
|
"id": "baest-visit-1",
|
||||||
|
"date": "2026-03-05T18:30:00Z",
|
||||||
|
"dishes": ["Nduja pizza with smoked mozzarella", "Burrata with heritage tomatoes", "Tiramisu"],
|
||||||
|
"rating": 9,
|
||||||
|
"notes": "Best pizza outside of Naples, full stop. They make their own mozzarella in-house daily — you can taste the difference immediately. The nduja is punchy and perfectly balanced. Casual vibe, great natural wine selection.",
|
||||||
|
"price_paid": 65
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6706
package-lock.json
generated
Normal file
6706
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "food",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"next": "16.2.1",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user