mirror of
https://github.com/runyanjake/cooking.git
synced 2026-03-26 01:43:17 -07:00
Initial commit
This commit is contained in:
parent
1ce494ed87
commit
8545112d56
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx create-next-app@latest:*)",
|
||||||
|
"Bash(node --version:*)",
|
||||||
|
"Bash(npm --version:*)",
|
||||||
|
"Bash(where:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm run build:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
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*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
58
app/about/page.tsx
Normal file
58
app/about/page.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'About - Cooking',
|
||||||
|
description: 'Learn more about our recipe collection',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
|
About
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
Welcome to our content-first recipe site
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Our Mission
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
We believe cooking should be accessible, enjoyable, and creative. Our goal is to provide
|
||||||
|
high-quality recipes with clear instructions and beautiful photography to inspire home cooks
|
||||||
|
everywhere.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 pt-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Content Structure
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Each recipe is organized in its own folder with:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
|
||||||
|
<li>MDX file with recipe content and instructions</li>
|
||||||
|
<li>metadata.json for tags and organization</li>
|
||||||
|
<li>Assets folder for images and other media</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 pt-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Technology
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Built with modern web technologies including Next.js, React, TypeScript, and Tailwind CSS
|
||||||
|
to ensure fast performance and excellent SEO.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/globals.css
Normal file
27
app/globals.css
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/layout.tsx
Normal file
37
app/layout.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Cooking - Recipe Collection",
|
||||||
|
description: "A content-first recipe site with delicious recipes and cooking tips",
|
||||||
|
keywords: ["recipes", "cooking", "food", "kitchen"],
|
||||||
|
authors: [{ name: "Cooking" }],
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "en_US",
|
||||||
|
url: "https://cooking.example.com",
|
||||||
|
siteName: "Cooking",
|
||||||
|
title: "Cooking - Recipe Collection",
|
||||||
|
description: "A content-first recipe site with delicious recipes and cooking tips",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="flex flex-col min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/page.tsx
Normal file
54
app/page.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
<section className="text-center space-y-6">
|
||||||
|
<h1 className="text-5xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Welcome to Cooking
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Discover delicious recipes, cooking tips, and culinary inspiration for every occasion.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/recipes"
|
||||||
|
className="px-6 py-3 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-200 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Browse Recipes
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="px-6 py-3 border border-gray-900 dark:border-white text-gray-900 dark:text-white rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid md:grid-cols-3 gap-8 pt-12">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="text-4xl">🍳</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Easy Recipes</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Step-by-step instructions for home cooks of all skill levels
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="text-4xl">📸</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Beautiful Photos</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
High-quality images to inspire your cooking journey
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="text-4xl">🏷️</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Organized</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Find recipes by tags, ingredients, and dietary preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
app/recipes/[category]/[slug]/page.tsx
Normal file
132
app/recipes/[category]/[slug]/page.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { getRecipeByCategoryAndSlug, getAllRecipePaths } from '@/lib/recipes';
|
||||||
|
|
||||||
|
interface RecipePageProps {
|
||||||
|
params: Promise<{
|
||||||
|
category: string;
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const paths = getAllRecipePaths();
|
||||||
|
return paths.map((path) => ({
|
||||||
|
category: path.category,
|
||||||
|
slug: path.slug,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: RecipePageProps): Promise<Metadata> {
|
||||||
|
const { category, slug } = await params;
|
||||||
|
const recipe = getRecipeByCategoryAndSlug(category, slug);
|
||||||
|
|
||||||
|
if (!recipe) {
|
||||||
|
return {
|
||||||
|
title: 'Recipe Not Found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${recipe.title} - Cooking`,
|
||||||
|
description: recipe.description,
|
||||||
|
keywords: recipe.tags,
|
||||||
|
openGraph: {
|
||||||
|
title: recipe.title,
|
||||||
|
description: recipe.description,
|
||||||
|
type: 'article',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RecipePage({ params }: RecipePageProps) {
|
||||||
|
const { category, slug } = await params;
|
||||||
|
const recipe = getRecipeByCategoryAndSlug(category, slug);
|
||||||
|
|
||||||
|
if (!recipe) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="max-w-4xl mx-auto">
|
||||||
|
<header className="mb-8 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span className="capitalize">{recipe.category}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<time dateTime={recipe.date}>
|
||||||
|
Published {new Date(recipe.date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
{recipe.lastUpdated !== recipe.date && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<time dateTime={recipe.lastUpdated}>
|
||||||
|
Updated {new Date(recipe.lastUpdated).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{recipe.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||||
|
{recipe.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Prep:</span>
|
||||||
|
<span>{recipe.prepTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Cook:</span>
|
||||||
|
<span>{recipe.cookTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Total:</span>
|
||||||
|
<span>{recipe.totalTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Servings:</span>
|
||||||
|
<span>{recipe.servings}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Difficulty:</span>
|
||||||
|
<span className="capitalize">{recipe.difficulty}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{recipe.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-3 py-1 text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900 p-8 rounded-lg">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Recipe content will be rendered here from MDX
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs mt-4 overflow-auto">
|
||||||
|
{recipe.content.substring(0, 500)}...
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/recipes/page.tsx
Normal file
29
app/recipes/page.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { getAllRecipes, getAllCategories, getAllTags } from '@/lib/recipes';
|
||||||
|
import RecipesClient from '@/components/RecipesClient';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Recipes - Cooking',
|
||||||
|
description: 'Browse our collection of delicious recipes',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecipesPage() {
|
||||||
|
const recipes = getAllRecipes();
|
||||||
|
const categories = getAllCategories();
|
||||||
|
const tags = getAllTags();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Recipes
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
Explore our collection of {recipes.length} delicious recipes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecipesClient recipes={recipes} categories={categories} tags={tags} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
components/Footer.tsx
Normal file
26
components/Footer.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-gray-200 dark:border-gray-800 mt-auto">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex justify-center items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>© {new Date().getFullYear()} PWS</span>
|
||||||
|
<span className="text-gray-300 dark:text-gray-700">•</span>
|
||||||
|
<Link href="/about" className="hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-300 dark:text-gray-700">•</span>
|
||||||
|
<a
|
||||||
|
href="https://github.com/runyanjake/cooking"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/Header.tsx
Normal file
39
components/Header.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-2xl font-bold text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cooking
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex space-x-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/recipes"
|
||||||
|
className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Recipes
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
components/RecipeCard.tsx
Normal file
59
components/RecipeCard.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { RecipeMetadata } from '@/lib/recipes';
|
||||||
|
|
||||||
|
interface RecipeCardProps {
|
||||||
|
recipe: RecipeMetadata & { folderPath: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeCard({ recipe }: RecipeCardProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/recipes/${recipe.category}/${recipe.slug}`}
|
||||||
|
className="group block bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="aspect-video bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-4xl">
|
||||||
|
📷
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||||
|
{recipe.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||||
|
{recipe.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
⏱️ {recipe.totalTime} min
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
👥 {recipe.servings} servings
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{recipe.difficulty === 'easy' && '⭐'}
|
||||||
|
{recipe.difficulty === 'medium' && '⭐⭐'}
|
||||||
|
{recipe.difficulty === 'hard' && '⭐⭐⭐'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{recipe.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{recipe.tags.length > 3 && (
|
||||||
|
<span className="px-2 py-1 text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
+{recipe.tags.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
components/RecipesClient.tsx
Normal file
85
components/RecipesClient.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import RecipesSidebar, { FilterState } from './RecipesSidebar';
|
||||||
|
import RecipeCard from './RecipeCard';
|
||||||
|
import type { Recipe } from '@/lib/recipes';
|
||||||
|
|
||||||
|
interface RecipesClientProps {
|
||||||
|
recipes: Recipe[];
|
||||||
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipesClient({ recipes, categories, tags }: RecipesClientProps) {
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
search: '',
|
||||||
|
category: '',
|
||||||
|
selectedTags: [],
|
||||||
|
});
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const filteredRecipes = useMemo(() => {
|
||||||
|
return recipes.filter((recipe) => {
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
const matchesSearch =
|
||||||
|
recipe.title.toLowerCase().includes(searchLower) ||
|
||||||
|
recipe.description.toLowerCase().includes(searchLower) ||
|
||||||
|
recipe.tags.some((tag) => tag.toLowerCase().includes(searchLower));
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.category && recipe.category !== filters.category) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.selectedTags.length > 0) {
|
||||||
|
const hasAllTags = filters.selectedTags.every((tag) => recipe.tags.includes(tag));
|
||||||
|
if (!hasAllTags) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [recipes, filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<RecipesSidebar
|
||||||
|
categories={categories}
|
||||||
|
tags={tags}
|
||||||
|
onFilterChange={setFilters}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{filteredRecipes.length === recipes.length
|
||||||
|
? `Showing all ${recipes.length} recipes`
|
||||||
|
: `Showing ${filteredRecipes.length} of ${recipes.length} recipes`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredRecipes.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
{filteredRecipes.map((recipe) => (
|
||||||
|
<RecipeCard key={recipe.slug} recipe={recipe} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">🔍</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
No recipes found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Try adjusting your filters or search terms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/RecipesSidebar.tsx
Normal file
138
components/RecipesSidebar.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface RecipesSidebarProps {
|
||||||
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
onFilterChange: (filters: FilterState) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
search: string;
|
||||||
|
category: string;
|
||||||
|
selectedTags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipesSidebar({ categories, tags, onFilterChange, isOpen, onToggle }: RecipesSidebarProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [showAllTags, setShowAllTags] = useState(false);
|
||||||
|
|
||||||
|
useMemo(() => {
|
||||||
|
onFilterChange({ search, category, selectedTags });
|
||||||
|
}, [search, category, selectedTags, onFilterChange]);
|
||||||
|
|
||||||
|
const handleTagToggle = (tag: string) => {
|
||||||
|
setSelectedTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setSearch('');
|
||||||
|
setCategory('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = search || category || selectedTags.length > 0;
|
||||||
|
const displayedTags = showAllTags ? tags : tags.slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="lg:hidden w-full mb-3 px-3 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isOpen ? '✕ Hide Filters' : '⚙️ Show Filters'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`space-y-3 ${!isOpen ? 'hidden lg:block' : ''}`}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="search" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search recipes..."
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="category" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTags([])}
|
||||||
|
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 max-h-48 overflow-y-auto">
|
||||||
|
{displayedTags.map((tag) => (
|
||||||
|
<label
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1.5 py-0.5 px-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTags.includes(tag)}
|
||||||
|
onChange={() => handleTagToggle(tag)}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-700 dark:text-gray-300">{tag}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tags.length > 10 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllTags(!showAllTags)}
|
||||||
|
className="text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1"
|
||||||
|
>
|
||||||
|
{showAllTags ? 'Show less' : `Show ${tags.length - 10} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="w-full px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Clear All Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
data/README.md
Normal file
140
data/README.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# Data Directory
|
||||||
|
|
||||||
|
This directory contains all content and reference data for the cooking website.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── README.md # This file
|
||||||
|
├── authors.json # Author profiles and information
|
||||||
|
├── taxonomy.json # Categories, tags, and classification system
|
||||||
|
└── recipes/ # Recipe content
|
||||||
|
├── README.md
|
||||||
|
├── examples/
|
||||||
|
├── appetizers/
|
||||||
|
├── mains/
|
||||||
|
├── desserts/
|
||||||
|
├── sides/
|
||||||
|
├── beverages/
|
||||||
|
└── breads/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
### authors.json
|
||||||
|
|
||||||
|
Contains author profiles with biographical information, social links, and specialties.
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"id": "pws",
|
||||||
|
"name": "PWS",
|
||||||
|
"fullName": "Paul Wilson Smith",
|
||||||
|
"bio": "Short biography",
|
||||||
|
"email": "email@example.com",
|
||||||
|
"website": "https://...",
|
||||||
|
"avatar": "/images/authors/pws.jpg",
|
||||||
|
"social": {
|
||||||
|
"github": "username"
|
||||||
|
},
|
||||||
|
"joinDate": "2026-01-01",
|
||||||
|
"specialties": ["Italian cuisine", "Baking"],
|
||||||
|
"favoriteIngredient": "Garlic"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in recipes:**
|
||||||
|
- Reference the author by their `id` in the recipe frontmatter
|
||||||
|
- Example: `author: "pws"`
|
||||||
|
- The site will look up full author details from this file
|
||||||
|
|
||||||
|
### taxonomy.json
|
||||||
|
|
||||||
|
Defines the complete taxonomy system for organizing recipes.
|
||||||
|
|
||||||
|
**Contains:**
|
||||||
|
1. **Categories** - Main recipe classifications (appetizers, mains, desserts, etc.)
|
||||||
|
2. **Tags** - Organized by type:
|
||||||
|
- `cuisine` - Italian, Mexican, Asian, etc.
|
||||||
|
- `protein` - Chicken, beef, vegetarian, etc.
|
||||||
|
- `dietary` - Gluten-free, vegan, keto, etc.
|
||||||
|
- `meal-type` - Breakfast, lunch, dinner, etc.
|
||||||
|
- `occasion` - Weeknight, holiday, party, etc.
|
||||||
|
- `cooking-method` - Baking, grilling, slow-cooker, etc.
|
||||||
|
- `speed` - Quick-meals, one-pot, make-ahead, etc.
|
||||||
|
- `flavor-profile` - Spicy, sweet, savory, etc.
|
||||||
|
- `special` - Chocolate, pasta, soup, etc.
|
||||||
|
3. **Difficulty** - Easy, medium, hard with descriptions
|
||||||
|
|
||||||
|
**Usage in recipes:**
|
||||||
|
- Use tag IDs from this file in recipe frontmatter
|
||||||
|
- Example: `tags: ["italian", "chicken", "comfort-food"]`
|
||||||
|
- Example: `category: "mains"`
|
||||||
|
- Example: `difficulty: "medium"`
|
||||||
|
- The site can validate tags against this taxonomy
|
||||||
|
|
||||||
|
## Benefits of Reference Files
|
||||||
|
|
||||||
|
### Consistency
|
||||||
|
- Ensures uniform spelling and naming across all recipes
|
||||||
|
- Prevents duplicate tags with slight variations
|
||||||
|
- Maintains standardized author information
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- Can validate recipe frontmatter against these schemas
|
||||||
|
- Catch typos and invalid values during build
|
||||||
|
- Provide helpful error messages
|
||||||
|
|
||||||
|
### UI Generation
|
||||||
|
- Generate filter dropdowns from taxonomy
|
||||||
|
- Display tag descriptions and icons
|
||||||
|
- Show author bios and links automatically
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
- Easy to add new authors, tags, or categories
|
||||||
|
- Central place to update descriptions and metadata
|
||||||
|
- Supports multiple authors and contributors
|
||||||
|
|
||||||
|
### SEO & Discovery
|
||||||
|
- Structured data for search engines
|
||||||
|
- Consistent taxonomy improves findability
|
||||||
|
- Tag descriptions enhance metadata
|
||||||
|
|
||||||
|
## Adding New Entries
|
||||||
|
|
||||||
|
### Adding an Author
|
||||||
|
|
||||||
|
1. Open `data/authors.json`
|
||||||
|
2. Add a new author object to the `authors` array
|
||||||
|
3. Ensure the `id` is unique and lowercase-hyphenated
|
||||||
|
4. Add avatar image to `/public/images/authors/`
|
||||||
|
5. Reference the author ID in recipe frontmatter
|
||||||
|
|
||||||
|
### Adding a Tag
|
||||||
|
|
||||||
|
1. Open `data/taxonomy.json`
|
||||||
|
2. Find the appropriate tag category (cuisine, protein, etc.)
|
||||||
|
3. Add a new tag object with `id`, `name`, and `description`
|
||||||
|
4. Use the tag ID in recipe frontmatter
|
||||||
|
|
||||||
|
### Adding a Category
|
||||||
|
|
||||||
|
1. Open `data/taxonomy.json`
|
||||||
|
2. Add a new category to the `categories` array
|
||||||
|
3. Create the corresponding folder in `data/recipes/`
|
||||||
|
4. Use the category ID in recipe frontmatter
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Always reference tags/categories/authors by their `id`
|
||||||
|
- Keep IDs lowercase with hyphens (kebab-case)
|
||||||
|
- Write clear, helpful descriptions
|
||||||
|
- Add emojis to categories for visual interest
|
||||||
|
- Update both reference files and recipes together
|
||||||
|
- Validate changes before committing
|
||||||
19
data/authors.json
Normal file
19
data/authors.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"id": "pws",
|
||||||
|
"name": "PWS",
|
||||||
|
"fullName": "Paul Wilson Smith",
|
||||||
|
"bio": "Home cook and food enthusiast sharing tested recipes and cooking techniques.",
|
||||||
|
"email": "pws@example.com",
|
||||||
|
"website": "https://github.com/runyanjake/cooking",
|
||||||
|
"avatar": "/images/authors/pws.jpg",
|
||||||
|
"social": {
|
||||||
|
"github": "runyanjake"
|
||||||
|
},
|
||||||
|
"joinDate": "2026-01-01",
|
||||||
|
"specialties": ["Italian cuisine", "Baking", "Comfort food"],
|
||||||
|
"favoriteIngredient": "Garlic"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
206
data/recipes/README.md
Normal file
206
data/recipes/README.md
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# Recipe Content Structure
|
||||||
|
|
||||||
|
This directory contains all recipe content for the cooking website.
|
||||||
|
|
||||||
|
## Folder Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
data/recipes/
|
||||||
|
├── appetizers/
|
||||||
|
├── mains/
|
||||||
|
├── desserts/
|
||||||
|
├── sides/
|
||||||
|
├── beverages/
|
||||||
|
└── breads/
|
||||||
|
```
|
||||||
|
|
||||||
|
Each recipe follows this structure:
|
||||||
|
```
|
||||||
|
category/
|
||||||
|
└── YYYY.MM.DD-recipe-slug/
|
||||||
|
├── YYYY.MM.DD-recipe-slug.mdx
|
||||||
|
└── assets/
|
||||||
|
├── hero.jpg
|
||||||
|
├── step1.jpg
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recipe Format
|
||||||
|
|
||||||
|
### MDX File Structure
|
||||||
|
|
||||||
|
The `.mdx` file contains all recipe metadata and content in one place.
|
||||||
|
|
||||||
|
#### Frontmatter (Required)
|
||||||
|
|
||||||
|
All recipe metadata is stored in YAML frontmatter at the top of the MDX file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "Recipe Title"
|
||||||
|
slug: "recipe-slug"
|
||||||
|
date: "YYYY-MM-DD"
|
||||||
|
lastUpdated: "YYYY-MM-DD"
|
||||||
|
category: "mains"
|
||||||
|
tags: ["tag1", "tag2", "tag3"]
|
||||||
|
dietary: ["vegetarian", "gluten-free"]
|
||||||
|
cookTime: 45
|
||||||
|
prepTime: 20
|
||||||
|
totalTime: 65
|
||||||
|
difficulty: "easy"
|
||||||
|
servings: 4
|
||||||
|
author: "Author Name"
|
||||||
|
description: "Short description for SEO and previews"
|
||||||
|
featured: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontmatter Fields:**
|
||||||
|
- `title` - Display title of the recipe
|
||||||
|
- `slug` - URL-friendly identifier
|
||||||
|
- `date` - Publication date (YYYY-MM-DD)
|
||||||
|
- `lastUpdated` - Last modification date (YYYY-MM-DD)
|
||||||
|
- `category` - Main category ID (references `../taxonomy.json`)
|
||||||
|
- `tags` - Array of tag IDs (references `../taxonomy.json`)
|
||||||
|
- `dietary` - Array of dietary tag IDs (references `../taxonomy.json`)
|
||||||
|
- `cookTime` - Active cooking time in minutes
|
||||||
|
- `prepTime` - Preparation time in minutes
|
||||||
|
- `totalTime` - Total time in minutes
|
||||||
|
- `difficulty` - Difficulty ID: easy, medium, or hard (references `../taxonomy.json`)
|
||||||
|
- `servings` - Number of servings
|
||||||
|
- `author` - Author ID (references `../authors.json`)
|
||||||
|
- `description` - Brief description for SEO and cards
|
||||||
|
- `featured` - Boolean for homepage featuring
|
||||||
|
|
||||||
|
**Note:** Use IDs from the reference files (`data/authors.json` and `data/taxonomy.json`) to ensure consistency and enable validation.
|
||||||
|
|
||||||
|
#### Content Sections
|
||||||
|
|
||||||
|
The following `## ` (h2) sections are parsed into tabs in the UI:
|
||||||
|
|
||||||
|
1. **## Photos** - Recipe images with captions
|
||||||
|
2. **## Ingredients** - Lists of ingredients (can use h3 subsections)
|
||||||
|
3. **## Instructions** - Step-by-step cooking instructions
|
||||||
|
4. **## Notes** - Tips, variations, storage info (optional)
|
||||||
|
5. **## References** - Sources, inspirations, credits (optional)
|
||||||
|
|
||||||
|
#### Example MDX Structure
|
||||||
|
|
||||||
|
```mdx
|
||||||
|
---
|
||||||
|
title: "Recipe Name"
|
||||||
|
slug: "recipe-name"
|
||||||
|
date: "2026-02-08"
|
||||||
|
lastUpdated: "2026-02-08"
|
||||||
|
category: "mains"
|
||||||
|
tags: ["italian", "chicken"]
|
||||||
|
dietary: ["gluten-free-option"]
|
||||||
|
cookTime: 45
|
||||||
|
prepTime: 20
|
||||||
|
totalTime: 65
|
||||||
|
difficulty: "medium"
|
||||||
|
servings: 4
|
||||||
|
author: "PWS"
|
||||||
|
description: "Short description for SEO and previews"
|
||||||
|
featured: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recipe Name
|
||||||
|
|
||||||
|
Introduction paragraph about the recipe.
|
||||||
|
|
||||||
|
## Photos
|
||||||
|
|
||||||
|

|
||||||
|
*Caption describing the image*
|
||||||
|
|
||||||
|

|
||||||
|
*Another helpful image*
|
||||||
|
|
||||||
|
## Ingredients
|
||||||
|
|
||||||
|
### For the Main Component
|
||||||
|
- 2 cups ingredient one
|
||||||
|
- 1 tablespoon ingredient two
|
||||||
|
- Salt and pepper to taste
|
||||||
|
|
||||||
|
### For the Sauce
|
||||||
|
- 1 cup sauce base
|
||||||
|
- Seasonings
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Preparation
|
||||||
|
1. **Step name**: Detailed instruction with technique.
|
||||||
|
2. **Another step**: More details here.
|
||||||
|
|
||||||
|
### Cooking
|
||||||
|
3. **Heat and cook**: Continue with numbered steps.
|
||||||
|
4. **Finish**: Final steps.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Tips for Success
|
||||||
|
- Helpful tip one
|
||||||
|
- Helpful tip two
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
- How to store leftovers
|
||||||
|
- Freezing instructions
|
||||||
|
|
||||||
|
### Variations
|
||||||
|
- How to adapt the recipe
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Source credits
|
||||||
|
- Inspiration mentions
|
||||||
|
- Cookbook references
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content Guidelines
|
||||||
|
|
||||||
|
### Writing Style
|
||||||
|
- Use clear, conversational language
|
||||||
|
- Include helpful tips and context
|
||||||
|
- Explain techniques for beginners
|
||||||
|
- Add timing and temperature details
|
||||||
|
|
||||||
|
### Photography
|
||||||
|
- Include hero shot (main finished dish)
|
||||||
|
- Add process shots for complex steps
|
||||||
|
- Use descriptive alt text for accessibility
|
||||||
|
- Optimize images (web-friendly sizes)
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
Choose from these categories:
|
||||||
|
- **Cuisine**: italian, mexican, asian, american, mediterranean, etc.
|
||||||
|
- **Protein**: chicken, beef, pork, seafood, vegetarian, vegan
|
||||||
|
- **Meal Type**: breakfast, lunch, dinner, snack, appetizer
|
||||||
|
- **Occasion**: weeknight, holiday, party, comfort-food
|
||||||
|
- **Dietary**: gluten-free, dairy-free, low-carb, keto, paleo
|
||||||
|
- **Cooking Method**: baking, grilling, slow-cooker, instant-pot
|
||||||
|
- **Speed**: quick-meals, one-pot, make-ahead, no-cook
|
||||||
|
|
||||||
|
### Difficulty Levels
|
||||||
|
- **easy**: Beginner-friendly, simple techniques, common ingredients
|
||||||
|
- **medium**: Some cooking experience needed, multiple steps
|
||||||
|
- **hard**: Advanced techniques, precise timing, specialty equipment
|
||||||
|
|
||||||
|
## Adding New Recipes
|
||||||
|
|
||||||
|
1. Create folder: `data/recipes/[category]/YYYY.MM.DD-recipe-name/`
|
||||||
|
2. Create `YYYY.MM.DD-recipe-name.mdx` with frontmatter and content
|
||||||
|
3. Add images to `assets/` folder
|
||||||
|
4. Build locally to verify rendering
|
||||||
|
5. Commit and push
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Use consistent date formatting (YYYY.MM.DD)
|
||||||
|
- Keep slugs URL-friendly (lowercase, hyphens)
|
||||||
|
- Optimize images before adding (compress, resize)
|
||||||
|
- Test recipes before publishing
|
||||||
|
- Include metric and imperial measurements when possible
|
||||||
|
- Credit sources and inspirations
|
||||||
|
- Update featured flag sparingly (limit to 3-5 recipes)
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
title: "Perfect Chocolate Chip Cookies"
|
||||||
|
slug: "chocolate-chip-cookies"
|
||||||
|
date: "2026-02-08"
|
||||||
|
lastUpdated: "2026-02-08"
|
||||||
|
category: "desserts"
|
||||||
|
tags: ["cookies", "chocolate", "baking", "dessert", "american"]
|
||||||
|
dietary: ["vegetarian"]
|
||||||
|
cookTime: 12
|
||||||
|
prepTime: 15
|
||||||
|
totalTime: 27
|
||||||
|
difficulty: "easy"
|
||||||
|
servings: 24
|
||||||
|
author: "pws"
|
||||||
|
description: "Classic chewy chocolate chip cookies with crispy edges and gooey centers, loaded with chocolate chips."
|
||||||
|
featured: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Perfect Chocolate Chip Cookies
|
||||||
|
|
||||||
|
The ultimate chocolate chip cookie recipe that delivers crispy edges, chewy centers, and loads of melty chocolate chips in every bite. This recipe has been tested and perfected to create bakery-style cookies at home.
|
||||||
|
|
||||||
|
## Photos
|
||||||
|
|
||||||
|

|
||||||
|
*A stack of golden-brown cookies showing the perfect texture*
|
||||||
|
|
||||||
|

|
||||||
|
*Gooey chocolate chips and perfect chewy texture*
|
||||||
|
|
||||||
|

|
||||||
|
*Fresh from the oven with melted chocolate*
|
||||||
|
|
||||||
|

|
||||||
|
*The perfect cookie and milk pairing*
|
||||||
|
|
||||||
|
## Ingredients
|
||||||
|
|
||||||
|
### Dry Ingredients
|
||||||
|
- 2 1/4 cups (280g) all-purpose flour
|
||||||
|
- 1 teaspoon baking soda
|
||||||
|
- 1 teaspoon fine sea salt
|
||||||
|
|
||||||
|
### Wet Ingredients
|
||||||
|
- 1 cup (2 sticks / 226g) unsalted butter, softened to room temperature
|
||||||
|
- 3/4 cup (150g) granulated sugar
|
||||||
|
- 3/4 cup (165g) packed light brown sugar
|
||||||
|
- 2 large eggs, room temperature
|
||||||
|
- 2 teaspoons pure vanilla extract
|
||||||
|
|
||||||
|
### Mix-ins
|
||||||
|
- 2 cups (340g) semi-sweet chocolate chips
|
||||||
|
- 1 cup (170g) milk chocolate chips (optional, for extra chocolate)
|
||||||
|
- Flaky sea salt for topping (optional)
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Prepare
|
||||||
|
1. **Preheat oven**: Set to 375°F (190°C). Line two baking sheets with parchment paper.
|
||||||
|
2. **Combine dry ingredients**: In a medium bowl, whisk together flour, baking soda, and salt. Set aside.
|
||||||
|
|
||||||
|
### Make the Dough
|
||||||
|
3. **Cream butter and sugars**: In a large bowl or stand mixer, beat softened butter with both sugars on medium speed for 2-3 minutes until light and fluffy.
|
||||||
|
4. **Add eggs and vanilla**: Beat in eggs one at a time, then add vanilla extract. Mix until well combined, scraping down the sides of the bowl.
|
||||||
|
5. **Add dry ingredients**: With mixer on low speed, gradually add the flour mixture. Mix just until no flour streaks remain (don't overmix).
|
||||||
|
6. **Fold in chocolate**: Use a spatula or wooden spoon to fold in chocolate chips until evenly distributed.
|
||||||
|
|
||||||
|
### Chill (Important!)
|
||||||
|
7. **Chill the dough**: Cover bowl with plastic wrap and refrigerate for at least 30 minutes (or up to 72 hours for even better flavor). Cold dough = thicker cookies!
|
||||||
|
|
||||||
|
### Bake
|
||||||
|
8. **Scoop dough**: Use a cookie scoop or tablespoon to form balls (about 2 tablespoons each). Place 2 inches apart on prepared baking sheets.
|
||||||
|
9. **Add salt**: If using, sprinkle a tiny pinch of flaky sea salt on top of each dough ball.
|
||||||
|
10. **Bake**: Bake for 10-12 minutes until edges are golden brown but centers still look slightly underdone.
|
||||||
|
11. **Cool**: Let cookies cool on the baking sheet for 5 minutes (they'll continue to set), then transfer to a wire rack.
|
||||||
|
|
||||||
|
### Serve
|
||||||
|
12. **Enjoy**: Serve warm or at room temperature. Best enjoyed with cold milk!
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Tips for Success
|
||||||
|
- **Room temperature ingredients**: Softened butter and eggs create the best texture
|
||||||
|
- **Don't skip chilling**: Cold dough prevents spreading and creates thicker cookies
|
||||||
|
- **Underbake slightly**: Cookies will look underdone but will set as they cool
|
||||||
|
- **Use parchment paper**: Prevents sticking and promotes even browning
|
||||||
|
- **Measure flour correctly**: Spoon and level flour, don't pack it
|
||||||
|
|
||||||
|
### Storage & Freezing
|
||||||
|
- **Room temperature**: Store in an airtight container for up to 5 days
|
||||||
|
- **Refresh**: Warm in a 300°F oven for 3-4 minutes to restore chewiness
|
||||||
|
- **Freeze dough**: Scoop dough balls and freeze for up to 3 months. Bake from frozen, adding 1-2 minutes
|
||||||
|
- **Freeze baked cookies**: Freeze baked cookies for up to 2 months
|
||||||
|
|
||||||
|
### Variations
|
||||||
|
- **Brown butter cookies**: Brown the butter for a nutty, caramel flavor
|
||||||
|
- **Thick and bakery-style**: Increase flour to 2 1/2 cups and chill overnight
|
||||||
|
- **Double chocolate**: Add 1/4 cup cocoa powder to dry ingredients
|
||||||
|
- **Mix-ins**: Try nuts, toffee bits, or different chocolate varieties
|
||||||
|
- **Giant cookies**: Use 1/4 cup dough per cookie, bake 14-16 minutes
|
||||||
|
|
||||||
|
### Science Behind the Recipe
|
||||||
|
- **Brown sugar**: Creates chewiness and moisture
|
||||||
|
- **Granulated sugar**: Creates crispy edges
|
||||||
|
- **Baking soda**: Promotes spreading and browning
|
||||||
|
- **Chilling**: Allows flour to hydrate and flavors to develop
|
||||||
|
- **Underbaking**: Keeps centers soft and gooey
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Based on the classic Nestlé Toll House recipe
|
||||||
|
- Chilling technique from *The Food Lab* by J. Kenji López-Alt
|
||||||
|
- Brown butter variation inspired by *BraveTart* by Stella Parks
|
||||||
|
- Tested with feedback from family and friends over multiple batches
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
# Assets folder for chocolate chip cookies
|
||||||
|
# Add images here:
|
||||||
|
# - stack.jpg
|
||||||
|
# - cross-section.jpg
|
||||||
|
# - cooling-rack.jpg
|
||||||
|
# - hero.jpg
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
title: "Classic Chicken Parmesan"
|
||||||
|
slug: "chicken-parmesan"
|
||||||
|
date: "2026-02-05"
|
||||||
|
lastUpdated: "2026-02-05"
|
||||||
|
category: "mains"
|
||||||
|
tags: ["italian", "chicken", "comfort-food", "family-friendly"]
|
||||||
|
dietary: ["gluten-free-option"]
|
||||||
|
cookTime: 45
|
||||||
|
prepTime: 20
|
||||||
|
totalTime: 65
|
||||||
|
difficulty: "medium"
|
||||||
|
servings: 4
|
||||||
|
author: "pws"
|
||||||
|
description: "A classic Italian-American dish featuring crispy breaded chicken topped with marinara sauce and melted cheese."
|
||||||
|
featured: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Classic Chicken Parmesan
|
||||||
|
|
||||||
|
A beloved Italian-American comfort food that combines crispy breaded chicken cutlets with rich marinara sauce and gooey melted cheese. Perfect for a family dinner or special occasion.
|
||||||
|
|
||||||
|
## Photos
|
||||||
|
|
||||||
|

|
||||||
|
*Golden-brown chicken topped with bubbling cheese*
|
||||||
|
|
||||||
|

|
||||||
|
*Perfectly crispy coating with melted mozzarella*
|
||||||
|
|
||||||
|

|
||||||
|
*Served alongside spaghetti with fresh basil*
|
||||||
|
|
||||||
|
## Ingredients
|
||||||
|
|
||||||
|
### For the Chicken
|
||||||
|
- 4 boneless, skinless chicken breasts (about 6-8 oz each)
|
||||||
|
- 1 cup all-purpose flour
|
||||||
|
- 2 large eggs, beaten
|
||||||
|
- 2 cups Italian-style breadcrumbs
|
||||||
|
- 1 cup grated Parmesan cheese (divided)
|
||||||
|
- 1 teaspoon garlic powder
|
||||||
|
- 1 teaspoon dried oregano
|
||||||
|
- Salt and freshly ground black pepper to taste
|
||||||
|
- 1/2 cup olive oil (for frying)
|
||||||
|
|
||||||
|
### For the Sauce & Topping
|
||||||
|
- 2 cups marinara sauce (homemade or quality store-bought)
|
||||||
|
- 1 1/2 cups shredded mozzarella cheese
|
||||||
|
- 1/4 cup fresh basil leaves, torn
|
||||||
|
- Extra Parmesan for serving
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Prep the Chicken
|
||||||
|
1. **Pound the chicken**: Place chicken breasts between two sheets of plastic wrap and pound to an even 1/2-inch thickness using a meat mallet.
|
||||||
|
2. **Season**: Season both sides generously with salt and pepper.
|
||||||
|
|
||||||
|
### Set Up Breading Station
|
||||||
|
3. **Prepare three shallow dishes**:
|
||||||
|
- Dish 1: All-purpose flour
|
||||||
|
- Dish 2: Beaten eggs with 1 tablespoon water
|
||||||
|
- Dish 3: Breadcrumbs mixed with 1/2 cup Parmesan, garlic powder, and oregano
|
||||||
|
|
||||||
|
### Bread and Fry
|
||||||
|
4. **Coat the chicken**: Dredge each breast in flour (shake off excess), dip in egg mixture, then press firmly into breadcrumb mixture, coating both sides thoroughly.
|
||||||
|
5. **Heat oil**: In a large skillet, heat olive oil over medium-high heat until shimmering.
|
||||||
|
6. **Fry**: Cook chicken for 4-5 minutes per side until golden brown and cooked through (internal temp 165°F). Work in batches if needed. Transfer to a paper towel-lined plate.
|
||||||
|
|
||||||
|
### Assemble and Bake
|
||||||
|
7. **Preheat oven**: Set to 400°F (200°C) or turn on broiler.
|
||||||
|
8. **Assemble**: Place fried chicken in a baking dish. Spoon marinara sauce over each piece, then top with mozzarella and remaining Parmesan.
|
||||||
|
9. **Bake**: Bake for 10-12 minutes (or broil for 3-4 minutes) until cheese is melted and bubbly.
|
||||||
|
10. **Garnish and serve**: Top with fresh torn basil and serve immediately with pasta or a side salad.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Tips for Success
|
||||||
|
- **Even thickness**: Pounding the chicken ensures even cooking
|
||||||
|
- **Don't overcrowd**: Fry in batches to maintain oil temperature
|
||||||
|
- **Quality ingredients**: Use good marinara sauce for best flavor
|
||||||
|
- **Make ahead**: Bread chicken up to 4 hours ahead and refrigerate
|
||||||
|
- **Gluten-free option**: Substitute with gluten-free flour and breadcrumbs
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
- **Refrigerate**: Store leftovers in an airtight container for up to 3 days
|
||||||
|
- **Reheat**: Warm in a 350°F oven for 15-20 minutes to maintain crispiness
|
||||||
|
- **Freeze**: Freeze breaded (uncooked) chicken for up to 2 months
|
||||||
|
|
||||||
|
### Variations
|
||||||
|
- **Baked version**: Skip frying and bake breaded chicken at 425°F for 20-25 minutes
|
||||||
|
- **Spicy**: Add red pepper flakes to the breadcrumb mixture
|
||||||
|
- **Extra crispy**: Use panko breadcrumbs instead of Italian breadcrumbs
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Adapted from traditional Italian-American recipes
|
||||||
|
- Inspired by *The Silver Spoon* Italian cookbook
|
||||||
|
- Breading technique from classic French *cotoletta* method
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
# Assets folder for chicken parmesan
|
||||||
|
# Add images here:
|
||||||
|
# - hero.jpg
|
||||||
|
# - close-up.jpg
|
||||||
|
# - plated.jpg
|
||||||
187
data/taxonomy.json
Normal file
187
data/taxonomy.json
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "appetizers",
|
||||||
|
"name": "Appetizers",
|
||||||
|
"slug": "appetizers",
|
||||||
|
"description": "Small dishes to start your meal",
|
||||||
|
"icon": "🥖"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mains",
|
||||||
|
"name": "Main Dishes",
|
||||||
|
"slug": "mains",
|
||||||
|
"description": "Hearty entrees and main courses",
|
||||||
|
"icon": "🍽️"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "desserts",
|
||||||
|
"name": "Desserts",
|
||||||
|
"slug": "desserts",
|
||||||
|
"description": "Sweet treats and baked goods",
|
||||||
|
"icon": "🍰"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sides",
|
||||||
|
"name": "Side Dishes",
|
||||||
|
"slug": "sides",
|
||||||
|
"description": "Complementary dishes and accompaniments",
|
||||||
|
"icon": "🥗"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "beverages",
|
||||||
|
"name": "Beverages",
|
||||||
|
"slug": "beverages",
|
||||||
|
"description": "Drinks, smoothies, and cocktails",
|
||||||
|
"icon": "🥤"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "breads",
|
||||||
|
"name": "Breads & Baking",
|
||||||
|
"slug": "breads",
|
||||||
|
"description": "Homemade breads, rolls, and baked goods",
|
||||||
|
"icon": "🍞"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": {
|
||||||
|
"cuisine": {
|
||||||
|
"name": "Cuisine",
|
||||||
|
"description": "Regional and cultural cooking styles",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "italian", "name": "Italian", "description": "Italian cuisine and cooking traditions" },
|
||||||
|
{ "id": "mexican", "name": "Mexican", "description": "Mexican and Latin American dishes" },
|
||||||
|
{ "id": "asian", "name": "Asian", "description": "Asian cuisine including Chinese, Japanese, Thai, etc." },
|
||||||
|
{ "id": "american", "name": "American", "description": "Classic American cooking" },
|
||||||
|
{ "id": "mediterranean", "name": "Mediterranean", "description": "Mediterranean diet and cuisine" },
|
||||||
|
{ "id": "french", "name": "French", "description": "French culinary techniques and dishes" },
|
||||||
|
{ "id": "indian", "name": "Indian", "description": "Indian spices and cooking methods" },
|
||||||
|
{ "id": "middle-eastern", "name": "Middle Eastern", "description": "Middle Eastern flavors and dishes" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"protein": {
|
||||||
|
"name": "Protein",
|
||||||
|
"description": "Main protein sources",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "chicken", "name": "Chicken", "description": "Chicken-based recipes" },
|
||||||
|
{ "id": "beef", "name": "Beef", "description": "Beef and red meat dishes" },
|
||||||
|
{ "id": "pork", "name": "Pork", "description": "Pork and ham recipes" },
|
||||||
|
{ "id": "seafood", "name": "Seafood", "description": "Fish and shellfish dishes" },
|
||||||
|
{ "id": "lamb", "name": "Lamb", "description": "Lamb-based recipes" },
|
||||||
|
{ "id": "vegetarian", "name": "Vegetarian", "description": "No meat, may include dairy and eggs" },
|
||||||
|
{ "id": "vegan", "name": "Vegan", "description": "Plant-based, no animal products" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dietary": {
|
||||||
|
"name": "Dietary",
|
||||||
|
"description": "Special dietary considerations",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "gluten-free", "name": "Gluten-Free", "description": "No gluten-containing ingredients" },
|
||||||
|
{ "id": "gluten-free-option", "name": "Gluten-Free Option", "description": "Can be made gluten-free with substitutions" },
|
||||||
|
{ "id": "dairy-free", "name": "Dairy-Free", "description": "No dairy products" },
|
||||||
|
{ "id": "low-carb", "name": "Low-Carb", "description": "Reduced carbohydrate content" },
|
||||||
|
{ "id": "keto", "name": "Keto", "description": "Ketogenic diet friendly" },
|
||||||
|
{ "id": "paleo", "name": "Paleo", "description": "Paleo diet compliant" },
|
||||||
|
{ "id": "whole30", "name": "Whole30", "description": "Whole30 program approved" },
|
||||||
|
{ "id": "nut-free", "name": "Nut-Free", "description": "No nuts or nut products" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"meal-type": {
|
||||||
|
"name": "Meal Type",
|
||||||
|
"description": "When to serve this dish",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "breakfast", "name": "Breakfast", "description": "Morning meals and brunch" },
|
||||||
|
{ "id": "lunch", "name": "Lunch", "description": "Midday meals" },
|
||||||
|
{ "id": "dinner", "name": "Dinner", "description": "Evening meals" },
|
||||||
|
{ "id": "snack", "name": "Snack", "description": "Small bites between meals" },
|
||||||
|
{ "id": "brunch", "name": "Brunch", "description": "Late morning combination meal" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"occasion": {
|
||||||
|
"name": "Occasion",
|
||||||
|
"description": "Special events and settings",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "weeknight", "name": "Weeknight", "description": "Quick meals for busy evenings" },
|
||||||
|
{ "id": "weekend", "name": "Weekend", "description": "Recipes with more time investment" },
|
||||||
|
{ "id": "holiday", "name": "Holiday", "description": "Special holiday dishes" },
|
||||||
|
{ "id": "party", "name": "Party", "description": "Entertaining and gatherings" },
|
||||||
|
{ "id": "comfort-food", "name": "Comfort Food", "description": "Cozy, satisfying dishes" },
|
||||||
|
{ "id": "family-friendly", "name": "Family-Friendly", "description": "Kid-approved recipes" },
|
||||||
|
{ "id": "date-night", "name": "Date Night", "description": "Romantic or impressive dishes" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cooking-method": {
|
||||||
|
"name": "Cooking Method",
|
||||||
|
"description": "Primary cooking technique",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "baking", "name": "Baking", "description": "Oven-baked dishes" },
|
||||||
|
{ "id": "grilling", "name": "Grilling", "description": "Outdoor or indoor grilling" },
|
||||||
|
{ "id": "slow-cooker", "name": "Slow Cooker", "description": "Crock-pot and slow cooker meals" },
|
||||||
|
{ "id": "instant-pot", "name": "Instant Pot", "description": "Pressure cooker recipes" },
|
||||||
|
{ "id": "stovetop", "name": "Stovetop", "description": "Cooked on the stove" },
|
||||||
|
{ "id": "no-cook", "name": "No-Cook", "description": "No cooking required" },
|
||||||
|
{ "id": "air-fryer", "name": "Air Fryer", "description": "Air fryer recipes" },
|
||||||
|
{ "id": "sous-vide", "name": "Sous Vide", "description": "Precision cooking method" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"name": "Speed & Convenience",
|
||||||
|
"description": "Time and effort indicators",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "quick-meals", "name": "Quick Meals", "description": "30 minutes or less" },
|
||||||
|
{ "id": "one-pot", "name": "One-Pot", "description": "Minimal cleanup required" },
|
||||||
|
{ "id": "make-ahead", "name": "Make-Ahead", "description": "Can be prepared in advance" },
|
||||||
|
{ "id": "meal-prep", "name": "Meal Prep", "description": "Great for batch cooking" },
|
||||||
|
{ "id": "leftovers", "name": "Leftovers", "description": "Tastes great reheated" },
|
||||||
|
{ "id": "freezer-friendly", "name": "Freezer-Friendly", "description": "Can be frozen" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"flavor-profile": {
|
||||||
|
"name": "Flavor Profile",
|
||||||
|
"description": "Dominant tastes and characteristics",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "spicy", "name": "Spicy", "description": "Hot and spicy flavors" },
|
||||||
|
{ "id": "sweet", "name": "Sweet", "description": "Sweet or dessert-like" },
|
||||||
|
{ "id": "savory", "name": "Savory", "description": "Rich savory flavors" },
|
||||||
|
{ "id": "tangy", "name": "Tangy", "description": "Acidic or citrus notes" },
|
||||||
|
{ "id": "rich", "name": "Rich", "description": "Indulgent and decadent" },
|
||||||
|
{ "id": "light", "name": "Light", "description": "Fresh and light dishes" },
|
||||||
|
{ "id": "smoky", "name": "Smoky", "description": "Smoky flavors" },
|
||||||
|
{ "id": "umami", "name": "Umami", "description": "Deep savory umami taste" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"special": {
|
||||||
|
"name": "Special Features",
|
||||||
|
"description": "Notable characteristics",
|
||||||
|
"tags": [
|
||||||
|
{ "id": "chocolate", "name": "Chocolate", "description": "Contains chocolate" },
|
||||||
|
{ "id": "cookies", "name": "Cookies", "description": "Cookie recipes" },
|
||||||
|
{ "id": "pasta", "name": "Pasta", "description": "Pasta dishes" },
|
||||||
|
{ "id": "soup", "name": "Soup", "description": "Soups and stews" },
|
||||||
|
{ "id": "salad", "name": "Salad", "description": "Salads and fresh dishes" },
|
||||||
|
{ "id": "sandwich", "name": "Sandwich", "description": "Sandwiches and wraps" },
|
||||||
|
{ "id": "pizza", "name": "Pizza", "description": "Pizza recipes" },
|
||||||
|
{ "id": "bbq", "name": "BBQ", "description": "Barbecue style" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"difficulty": [
|
||||||
|
{
|
||||||
|
"id": "easy",
|
||||||
|
"name": "Easy",
|
||||||
|
"description": "Beginner-friendly, simple techniques, common ingredients",
|
||||||
|
"icon": "⭐"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "medium",
|
||||||
|
"name": "Medium",
|
||||||
|
"description": "Some cooking experience needed, multiple steps",
|
||||||
|
"icon": "⭐⭐"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hard",
|
||||||
|
"name": "Hard",
|
||||||
|
"description": "Advanced techniques, precise timing, specialty equipment",
|
||||||
|
"icon": "⭐⭐⭐"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
164
lib/recipes.ts
Normal file
164
lib/recipes.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import matter from 'gray-matter';
|
||||||
|
|
||||||
|
const recipesDirectory = path.join(process.cwd(), 'data/recipes');
|
||||||
|
|
||||||
|
// Cache to avoid repeated file system walks
|
||||||
|
let recipesCache: Recipe[] | null = null;
|
||||||
|
|
||||||
|
export interface RecipeMetadata {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
date: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
dietary: string[];
|
||||||
|
cookTime: number;
|
||||||
|
prepTime: number;
|
||||||
|
totalTime: number;
|
||||||
|
difficulty: string;
|
||||||
|
servings: number;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
featured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recipe extends RecipeMetadata {
|
||||||
|
filePath: string;
|
||||||
|
folderPath: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMDXFiles(dir: string, fileList: string[] = []): string[] {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
findMDXFiles(filePath, fileList);
|
||||||
|
} else if (file.endsWith('.mdx')) {
|
||||||
|
fileList.push(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrPopulateRecipes(): Recipe[] {
|
||||||
|
if (recipesCache !== null) {
|
||||||
|
return recipesCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdxFiles = findMDXFiles(recipesDirectory);
|
||||||
|
|
||||||
|
const recipes = mdxFiles.map((filePath) => {
|
||||||
|
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data, content } = matter(fileContents);
|
||||||
|
|
||||||
|
const folderPath = path.dirname(filePath);
|
||||||
|
const relativePath = path.relative(recipesDirectory, folderPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(data as RecipeMetadata),
|
||||||
|
filePath,
|
||||||
|
folderPath: relativePath,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedRecipes = recipes.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
recipesCache = sortedRecipes;
|
||||||
|
return sortedRecipes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllRecipes(): Recipe[] {
|
||||||
|
return getOrPopulateRecipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipesByCategory(category: string): Recipe[] {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
return allRecipes.filter((recipe) => recipe.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipesByTag(tag: string): Recipe[] {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
return allRecipes.filter((recipe) => recipe.tags.includes(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeaturedRecipes(): Recipe[] {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
return allRecipes.filter((recipe) => recipe.featured);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllCategories(): string[] {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
const categories = new Set(allRecipes.map((recipe) => recipe.category));
|
||||||
|
return Array.from(categories).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllTags(): string[] {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
const tags = new Set(allRecipes.flatMap((recipe) => recipe.tags));
|
||||||
|
return Array.from(tags).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipeBySlug(slug: string): Recipe | undefined {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
return allRecipes.find((recipe) => recipe.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipeByCategoryAndSlug(category: string, slug: string): Recipe | undefined {
|
||||||
|
const categoryPath = path.join(recipesDirectory, category);
|
||||||
|
|
||||||
|
if (!fs.existsSync(categoryPath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdxFiles = findMDXFiles(categoryPath);
|
||||||
|
|
||||||
|
for (const filePath of mdxFiles) {
|
||||||
|
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const { data, content } = matter(fileContents);
|
||||||
|
const metadata = data as RecipeMetadata;
|
||||||
|
|
||||||
|
if (metadata.slug === slug && metadata.category === category) {
|
||||||
|
const folderPath = path.dirname(filePath);
|
||||||
|
const relativePath = path.relative(recipesDirectory, folderPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
filePath,
|
||||||
|
folderPath: relativePath,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchRecipes(query: string): Recipe[] {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
return allRecipes.filter((recipe) => {
|
||||||
|
return (
|
||||||
|
recipe.title.toLowerCase().includes(lowerQuery) ||
|
||||||
|
recipe.description.toLowerCase().includes(lowerQuery) ||
|
||||||
|
recipe.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllRecipePaths(): Array<{ category: string; slug: string }> {
|
||||||
|
const allRecipes = getOrPopulateRecipes();
|
||||||
|
return allRecipes.map((recipe) => ({
|
||||||
|
category: recipe.category,
|
||||||
|
slug: recipe.slug,
|
||||||
|
}));
|
||||||
|
}
|
||||||
17
next.config.ts
Normal file
17
next.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import createMDX from '@next/mdx';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const withMDX = createMDX({
|
||||||
|
extension: /\.mdx?$/,
|
||||||
|
options: {
|
||||||
|
remarkPlugins: [],
|
||||||
|
rehypePlugins: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withMDX(nextConfig);
|
||||||
8025
package-lock.json
generated
Normal file
8025
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": "cooking",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdx-js/loader": "^3.1.1",
|
||||||
|
"@mdx-js/react": "^3.1.1",
|
||||||
|
"@next/mdx": "^16.1.6",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"next": "^15.1.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "^15.1.6",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
postcss.config.mjs
Normal file
9
postcss.config.mjs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
18
tailwind.config.ts
Normal file
18
tailwind.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "var(--background)",
|
||||||
|
foreground: "var(--foreground)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user