commit b83762bfc39dc2b1d12ef249c09b5dd1b79833ae Author: Andy Date: Sat Mar 21 11:57:08 2026 +0000 Initial food blog app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# 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. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97618dc --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..2d28abd --- /dev/null +++ b/app/admin/page.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState<"add-restaurant" | "add-visit" | "manage">( + preselectedId ? "add-visit" : "add-restaurant" + ) + const [feedback, setFeedback] = useState(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 ( +
+ {/* Header */} + +

admin

+

manage your food journal

+
+ + {/* Feedback toast */} + + {feedback && ( + + {feedback} + + )} + + + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Add Restaurant */} + {activeTab === "add-restaurant" && ( + +
+
+ + setRForm({ ...rForm, name: e.target.value })} + required + /> +
+
+ + setRForm({ ...rForm, city: e.target.value })} + required + /> +
+
+ + setRForm({ ...rForm, country: e.target.value })} + required + /> +
+
+ + +
+
+ +
+ + setRForm({ ...rForm, address: e.target.value })} + required + /> +
+ +
+
+ + setRForm({ ...rForm, lat: e.target.value })} + required + /> +
+
+ + setRForm({ ...rForm, lng: e.target.value })} + required + /> +
+
+ +
+ + setRForm({ ...rForm, cuisine: e.target.value })} + required + /> +
+ + +
+ )} + + {/* Add Visit */} + {activeTab === "add-visit" && ( + +
+ + +
+ +
+
+ + setVForm({ ...vForm, date: e.target.value })} + required + /> +
+
+ + setVForm({ ...vForm, rating: e.target.value })} + required + /> +
+
+ +
+ +