mirror of
https://github.com/runyanjake/cooking.git
synced 2026-03-26 01:43:17 -07:00
Add missing
This commit is contained in:
parent
4133d2e342
commit
4bbb8e4cfe
96
components/RecipeLayout.tsx
Normal file
96
components/RecipeLayout.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, ReactNode } from 'react';
|
||||||
|
import RecipesSidebar, { type FilterState } from './RecipesSidebar';
|
||||||
|
|
||||||
|
interface RecipeLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
onFilterChange?: (filters: FilterState) => void;
|
||||||
|
showFilters?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeLayout({
|
||||||
|
children,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
onFilterChange,
|
||||||
|
showFilters = true
|
||||||
|
}: RecipeLayoutProps) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((filters: FilterState) => {
|
||||||
|
if (onFilterChange) {
|
||||||
|
onFilterChange(filters);
|
||||||
|
}
|
||||||
|
}, [onFilterChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Mobile Overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile menu button - only visible on mobile */}
|
||||||
|
<div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
aria-label="Open filters sidebar"
|
||||||
|
aria-expanded={sidebarOpen}
|
||||||
|
aria-controls="recipes-sidebar"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">☰</span>
|
||||||
|
<span>Filters</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
{/* Sidebar - Always visible on desktop (lg+), slide-out on mobile */}
|
||||||
|
<aside
|
||||||
|
id="recipes-sidebar"
|
||||||
|
className={`
|
||||||
|
fixed lg:relative z-50 lg:z-0
|
||||||
|
bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700
|
||||||
|
w-64 lg:w-64
|
||||||
|
h-screen lg:h-auto
|
||||||
|
transition-transform duration-300 ease-in-out
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
|
`}
|
||||||
|
aria-label="Recipe filters"
|
||||||
|
>
|
||||||
|
<div className="h-full lg:sticky lg:top-0 flex flex-col p-4 overflow-y-auto">
|
||||||
|
{/* Mobile close button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="lg:hidden absolute top-4 right-4 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-xl"
|
||||||
|
aria-label="Close filters sidebar"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sidebar content */}
|
||||||
|
{showFilters && (
|
||||||
|
<RecipesSidebar
|
||||||
|
categories={categories}
|
||||||
|
tags={tags}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
components/RecipePageClient.tsx
Normal file
102
components/RecipePageClient.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import RecipeTabs from './RecipeTabs';
|
||||||
|
import type { Recipe } from '@/lib/recipes';
|
||||||
|
import type { RecipeSection } from '@/lib/parseRecipe';
|
||||||
|
|
||||||
|
interface RecipePageClientProps {
|
||||||
|
recipe: Recipe;
|
||||||
|
sections: RecipeSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipePageClient({ recipe, sections }: RecipePageClientProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-6">
|
||||||
|
<article className="max-w-4xl mx-auto">
|
||||||
|
{/* Back navigation */}
|
||||||
|
<nav aria-label="Breadcrumb">
|
||||||
|
<Link
|
||||||
|
href="/recipes"
|
||||||
|
className="inline-flex items-center gap-2 mb-6 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
|
aria-label="Back to recipes list"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
<span>Back to Recipes</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<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" role="list" aria-label="Recipe timing and details">
|
||||||
|
<div className="flex items-center gap-2" role="listitem">
|
||||||
|
<span className="font-medium">Prep:</span>
|
||||||
|
<span>{recipe.prepTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2" role="listitem">
|
||||||
|
<span className="font-medium">Cook:</span>
|
||||||
|
<span>{recipe.cookTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2" role="listitem">
|
||||||
|
<span className="font-medium">Total:</span>
|
||||||
|
<span>{recipe.totalTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2" role="listitem">
|
||||||
|
<span className="font-medium">Servings:</span>
|
||||||
|
<span>{recipe.servings}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2" role="listitem">
|
||||||
|
<span className="font-medium">Difficulty:</span>
|
||||||
|
<span className="capitalize">{recipe.difficulty}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2" role="list" aria-label="Recipe tags">
|
||||||
|
{recipe.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
role="listitem"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<RecipeTabs sections={sections} folderPath={recipe.folderPath} />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
components/RecipeTabs.tsx
Normal file
154
components/RecipeTabs.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import type { RecipeSection } from '@/lib/parseRecipe';
|
||||||
|
|
||||||
|
interface RecipeTabsProps {
|
||||||
|
sections: RecipeSection[];
|
||||||
|
folderPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeTabs({ sections, folderPath }: RecipeTabsProps) {
|
||||||
|
const tabOrder = ['Photos', 'Ingredients', 'Instructions', 'Notes', 'References'];
|
||||||
|
const orderedSections = tabOrder
|
||||||
|
.map(tabName => sections.find(s => s.title === tabName))
|
||||||
|
.filter((s): s is RecipeSection => s !== undefined);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(orderedSections[0]?.title || '');
|
||||||
|
|
||||||
|
if (orderedSections.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSection = orderedSections.find(s => s.title === activeTab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||||
|
role="region"
|
||||||
|
aria-label="Recipe sections"
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<nav className="flex overflow-x-auto" role="tablist" aria-label="Recipe section tabs">
|
||||||
|
{orderedSections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.title}
|
||||||
|
onClick={() => setActiveTab(section.title)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === section.title}
|
||||||
|
aria-controls={`tab-panel-${section.title.toLowerCase()}`}
|
||||||
|
id={`tab-${section.title.toLowerCase()}`}
|
||||||
|
className={`flex-shrink-0 px-6 py-4 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
||||||
|
activeTab === section.title
|
||||||
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 bg-white dark:bg-gray-800'
|
||||||
|
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
{activeSection && (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id={`tab-panel-${activeSection.title.toLowerCase()}`}
|
||||||
|
aria-labelledby={`tab-${activeSection.title.toLowerCase()}`}
|
||||||
|
className={`prose prose-lg dark:prose-invert max-w-none
|
||||||
|
prose-headings:text-gray-900 dark:prose-headings:text-white
|
||||||
|
prose-p:text-gray-700 dark:prose-p:text-gray-300
|
||||||
|
prose-strong:text-gray-900 dark:prose-strong:text-white
|
||||||
|
prose-ol:list-decimal prose-ol:pl-6 prose-ol:space-y-2
|
||||||
|
prose-ul:list-disc prose-ul:pl-6 prose-ul:space-y-2
|
||||||
|
prose-li:text-gray-700 dark:prose-li:text-gray-300
|
||||||
|
prose-li:marker:text-gray-500 dark:prose-li:marker:text-gray-400
|
||||||
|
prose-code:text-gray-900 dark:prose-code:text-gray-100
|
||||||
|
prose-pre:bg-gray-100 dark:prose-pre:bg-gray-900
|
||||||
|
prose-table:border-collapse
|
||||||
|
prose-th:border prose-th:border-gray-300 dark:prose-th:border-gray-600
|
||||||
|
prose-td:border prose-td:border-gray-300 dark:prose-td:border-gray-600
|
||||||
|
${activeSection.title === 'Photos' ? 'prose-img:w-full prose-img:max-w-2xl prose-img:mx-auto' : ''}
|
||||||
|
`}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
ol: ({ children, ...props }) => (
|
||||||
|
<ol className="list-decimal pl-6 space-y-2 my-4" {...props}>
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
ul: ({ children, ...props }) => (
|
||||||
|
<ul className="list-disc pl-6 space-y-2 my-4" {...props}>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
li: ({ children, ...props }) => (
|
||||||
|
<li className="text-gray-700 dark:text-gray-300" {...props}>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
img: ({ src, alt }) => {
|
||||||
|
// Convert relative paths to public URLs
|
||||||
|
const srcString = typeof src === 'string' ? src : '';
|
||||||
|
const imageSrc = srcString.startsWith('./')
|
||||||
|
? `/recipes/${folderPath}/${srcString.replace('./', '')}`.replace(/\\/g, '/')
|
||||||
|
: srcString;
|
||||||
|
// Use img directly to avoid nesting issues with paragraphs
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt || 'Recipe image'}
|
||||||
|
className="rounded-lg shadow-md w-full max-w-2xl mx-auto my-6 block"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
em: ({ children }) => {
|
||||||
|
if (activeSection.title === 'Photos') {
|
||||||
|
return (
|
||||||
|
<span className="block text-center text-sm text-gray-600 dark:text-gray-400 italic my-2">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <em className="italic">{children}</em>;
|
||||||
|
},
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
h4: ({ children }) => (
|
||||||
|
<h4 className="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
// Don't override <p> - let ReactMarkdown handle it to avoid hydration errors
|
||||||
|
code: ({ inline, children, ...props }: any) => {
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded text-sm" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className="block p-4 bg-gray-100 dark:bg-gray-900 rounded-lg overflow-x-auto" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeSection.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/SelectedTags.tsx
Normal file
44
components/SelectedTags.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface SelectedTagsProps {
|
||||||
|
tags: string[];
|
||||||
|
onRemove: (tag: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectedTags({ tags, onRemove, onClear }: SelectedTagsProps) {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" role="region" aria-label="Selected tags">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300" id="selected-tags-heading">
|
||||||
|
Selected Tags ({tags.length})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
aria-label={`Clear all ${tags.length} selected tags`}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5" role="list" aria-labelledby="selected-tags-heading">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => onRemove(tag)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||||
|
role="listitem"
|
||||||
|
aria-label={`Remove ${tag} tag`}
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
<span className="text-blue-600 dark:text-blue-300" aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
components/TagSelector.tsx
Normal file
120
components/TagSelector.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface TagSelectorProps {
|
||||||
|
availableTags: string[];
|
||||||
|
selectedTags: string[];
|
||||||
|
onToggleTag: (tag: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagSelector({ availableTags, selectedTags, onToggleTag }: TagSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const filteredTags = availableTags.filter((tag) =>
|
||||||
|
tag.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center justify-between"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-controls="tag-selector-dropdown"
|
||||||
|
aria-label="Add tags to filter"
|
||||||
|
>
|
||||||
|
<span>Add Tags</span>
|
||||||
|
<span className="text-gray-400" aria-hidden="true">{isOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
id="tag-selector-dropdown"
|
||||||
|
className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg z-50 max-h-80 flex flex-col"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Tag selection"
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<label htmlFor="tag-search" className="sr-only">Search tags</label>
|
||||||
|
<input
|
||||||
|
id="tag-search"
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search tags..."
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autoFocus
|
||||||
|
aria-describedby="tag-search-description"
|
||||||
|
/>
|
||||||
|
<span id="tag-search-description" className="sr-only">
|
||||||
|
Filter available tags by name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag list */}
|
||||||
|
<div className="overflow-y-auto p-2" role="group" aria-label="Available tags">
|
||||||
|
{filteredTags.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{filteredTags.map((tag) => (
|
||||||
|
<label
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTags.includes(tag)}
|
||||||
|
onChange={() => onToggleTag(tag)}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
|
aria-label={`${selectedTags.includes(tag) ? 'Deselect' : 'Select'} ${tag} tag`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">{tag}</span>
|
||||||
|
{selectedTags.includes(tag) && (
|
||||||
|
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400" aria-hidden="true">✓</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No tags found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-2 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span aria-live="polite" aria-atomic="true">
|
||||||
|
{selectedTags.length} {selectedTags.length === 1 ? 'tag' : 'tags'} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="px-2 py-1 text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
aria-label="Close tag selector"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
lib/parseRecipe.ts
Normal file
45
lib/parseRecipe.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export interface RecipeSection {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecipeSections(markdownContent: string): RecipeSection[] {
|
||||||
|
const lines = markdownContent.split('\n');
|
||||||
|
const sections: RecipeSection[] = [];
|
||||||
|
let currentSection: RecipeSection | null = null;
|
||||||
|
let inFrontmatter = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (line.trim() === '---') {
|
||||||
|
inFrontmatter = !inFrontmatter;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFrontmatter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
if (currentSection) {
|
||||||
|
sections.push(currentSection);
|
||||||
|
}
|
||||||
|
currentSection = {
|
||||||
|
title: line.replace('## ', '').trim(),
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
} else if (currentSection) {
|
||||||
|
currentSection.content += line + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection) {
|
||||||
|
sections.push(currentSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.map(section => ({
|
||||||
|
...section,
|
||||||
|
content: section.content.trim(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
16
public/authors.json
Normal file
16
public/authors.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"id": "jake",
|
||||||
|
"name": "jake",
|
||||||
|
"fullName": "Paul Wilson Smith",
|
||||||
|
"bio": "Home cook and food enthusiast sharing tested recipes and cooking techniques.",
|
||||||
|
"email": "jake@runyan.dev",
|
||||||
|
"website": "https://jake.runyan.dev",
|
||||||
|
"links": {
|
||||||
|
"github": "https://github.com/runyanjake"
|
||||||
|
},
|
||||||
|
"joinDate": "2026-01-01"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
216
public/recipes/README.md
Normal file
216
public/recipes/README.md
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# Recipe Content Structure
|
||||||
|
|
||||||
|
This directory contains all recipe content for the cooking website.
|
||||||
|
|
||||||
|
## Folder Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
public/recipes/
|
||||||
|
├── appetizers/
|
||||||
|
├── mains/
|
||||||
|
├── desserts/
|
||||||
|
├── sides/
|
||||||
|
├── beverages/
|
||||||
|
└── breads/
|
||||||
|
```
|
||||||
|
|
||||||
|
Each recipe follows this structure:
|
||||||
|
```
|
||||||
|
public/recipes/category/
|
||||||
|
└── YYYY.MM.DD-recipe-slug/
|
||||||
|
├── YYYY.MM.DD-recipe-slug.mdx
|
||||||
|
└── assets/
|
||||||
|
├── hero.jpg
|
||||||
|
├── step1.jpg
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** All recipe content (MDX files and images) lives together in `public/recipes/` for better organization and readability. This keeps everything for a recipe in one place.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
display: true
|
||||||
|
displayPhoto: "./assets/hero.jpg"
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
- `display` - Boolean to control visibility (set to false to hide recipe)
|
||||||
|
- `displayPhoto` - Relative path to display photo (e.g., "./assets/hero.jpg")
|
||||||
|
|
||||||
|
**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
|
||||||
|
display: true
|
||||||
|
displayPhoto: "./assets/hero.jpg"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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 recipe folder: `public/recipes/[category]/YYYY.MM.DD-recipe-name/`
|
||||||
|
2. Create `YYYY.MM.DD-recipe-name.mdx` with frontmatter and content
|
||||||
|
3. Create `assets/` subfolder for images
|
||||||
|
4. Add images to the `assets/` folder
|
||||||
|
5. Reference images in MDX using relative paths: `./assets/image.jpg`
|
||||||
|
6. Build locally to verify rendering
|
||||||
|
7. Commit and push (everything is tracked in git)
|
||||||
|
|
||||||
|
## 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,101 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
display: true
|
||||||
|
displayPhoto: "./assets/not-found.svg"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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. Place **chicken breasts** between two sheets of **plastic wrap** and pound to an even 1/2-inch thickness using a meat mallet.
|
||||||
|
2. 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. Dredge each breast in **flour** (shake off excess), dip in **egg** mixture, then press firmly into **breadcrumb** mixture, coating both sides thoroughly.
|
||||||
|
5. In a large skillet, heat **olive oil** over medium-high heat until shimmering.
|
||||||
|
6. 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 to 400°F (200°C) or turn on broiler.
|
||||||
|
8. Place fried **chicken** in a baking dish. Spoon **marinara sauce** over each piece, then top with **mozzarella** and remaining **Parmesan**.
|
||||||
|
9. Bake for 10-12 minutes (or broil for 3-4 minutes) until cheese is melted and bubbly.
|
||||||
|
10. 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,148 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
<svg
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
id="svg2"
|
||||||
|
sodipodi:docname="_svgclean2.svg"
|
||||||
|
viewBox="0 0 260 310"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.3.1 r9886"
|
||||||
|
>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
bordercolor="#666666"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-y="19"
|
||||||
|
fit-margin-left="0"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
fit-margin-top="0"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:zoom="1.4142136"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-height="768"
|
||||||
|
showgrid="false"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
inkscape:cx="-57.248774"
|
||||||
|
inkscape:cy="-575.52494"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0"
|
||||||
|
inkscape:window-width="1024"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
>
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid3769"
|
||||||
|
originy="-752.36218px"
|
||||||
|
enabled="true"
|
||||||
|
originx="-35.03125px"
|
||||||
|
visible="true"
|
||||||
|
snapvisiblegridlinesonly="true"
|
||||||
|
type="xygrid"
|
||||||
|
empspacing="4"
|
||||||
|
/>
|
||||||
|
</sodipodi:namedview
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
transform="translate(-242.06 -371.19)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
id="rect3757"
|
||||||
|
style="stroke:#636363;stroke-width:6;fill:#ffffff"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m38 53v304h254v-226.22l-77.78-77.78h-176.22z"
|
||||||
|
transform="translate(207.06 321.19)"
|
||||||
|
/>
|
||||||
|
<g
|
||||||
|
id="g3915"
|
||||||
|
transform="translate(0 17.678)"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
id="rect3759"
|
||||||
|
style="stroke-linejoin:round;stroke:#a6a6a6;stroke-linecap:round;stroke-width:6;fill:#ffffff"
|
||||||
|
height="150"
|
||||||
|
width="130"
|
||||||
|
y="451.19"
|
||||||
|
x="307.06"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="path3761"
|
||||||
|
d="m343.53 497.65 57.065 57.065"
|
||||||
|
style="stroke-linejoin:round;stroke:#e00000;stroke-linecap:round;stroke-width:12;fill:none"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="path3763"
|
||||||
|
style="stroke-linejoin:round;stroke:#e00000;stroke-linecap:round;stroke-width:12;fill:none"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m400.6 497.65-57.065 57.065"
|
||||||
|
/>
|
||||||
|
</g
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
id="rect3911"
|
||||||
|
sodipodi:nodetypes="ccc"
|
||||||
|
style="stroke-linejoin:round;stroke:#636363;stroke-width:6;fill:none"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m499.06 451.97h-77.782v-77.782"
|
||||||
|
/>
|
||||||
|
</g
|
||||||
|
>
|
||||||
|
<metadata
|
||||||
|
id="metadata13"
|
||||||
|
>
|
||||||
|
<rdf:RDF
|
||||||
|
>
|
||||||
|
<cc:Work
|
||||||
|
>
|
||||||
|
<dc:format
|
||||||
|
>image/svg+xml</dc:format
|
||||||
|
>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage"
|
||||||
|
/>
|
||||||
|
<cc:license
|
||||||
|
rdf:resource="http://creativecommons.org/licenses/publicdomain/"
|
||||||
|
/>
|
||||||
|
<dc:publisher
|
||||||
|
>
|
||||||
|
<cc:Agent
|
||||||
|
rdf:about="http://openclipart.org/"
|
||||||
|
>
|
||||||
|
<dc:title
|
||||||
|
>Openclipart</dc:title
|
||||||
|
>
|
||||||
|
</cc:Agent
|
||||||
|
>
|
||||||
|
</dc:publisher
|
||||||
|
>
|
||||||
|
</cc:Work
|
||||||
|
>
|
||||||
|
<cc:License
|
||||||
|
rdf:about="http://creativecommons.org/licenses/publicdomain/"
|
||||||
|
>
|
||||||
|
<cc:permits
|
||||||
|
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||||
|
/>
|
||||||
|
<cc:permits
|
||||||
|
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||||
|
/>
|
||||||
|
<cc:permits
|
||||||
|
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||||
|
/>
|
||||||
|
</cc:License
|
||||||
|
>
|
||||||
|
</rdf:RDF
|
||||||
|
>
|
||||||
|
</metadata
|
||||||
|
>
|
||||||
|
</svg
|
||||||
|
>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
display: true
|
||||||
|
displayPhoto: "./assets/not-found.svg"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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 to 375°F (190°C). Line two baking sheets with **parchment paper**.
|
||||||
|
2. In a medium bowl, whisk together **flour**, **baking soda**, and **salt**. Set aside.
|
||||||
|
|
||||||
|
### Make the Dough
|
||||||
|
3. 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. Beat in **eggs** one at a time, then add **vanilla extract**. Mix until well combined, scraping down the sides of the bowl.
|
||||||
|
5. With mixer on low speed, gradually add the **flour** mixture. Mix just until no flour streaks remain (don't overmix).
|
||||||
|
6. Use a spatula or wooden spoon to fold in **chocolate chips** until evenly distributed.
|
||||||
|
|
||||||
|
### Chill (Important!)
|
||||||
|
7. 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. Use a cookie scoop or tablespoon to form balls (about 2 tablespoons each). Place 2 inches apart on prepared baking sheets.
|
||||||
|
9. If using, sprinkle a tiny pinch of **flaky sea salt** on top of each dough ball.
|
||||||
|
10. Bake for 10-12 minutes until edges are golden brown but centers still look slightly underdone.
|
||||||
|
11. Let cookies cool on the baking sheet for 5 minutes (they'll continue to set), then transfer to a wire rack.
|
||||||
|
|
||||||
|
### Serve
|
||||||
|
12. 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,148 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
<svg
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
id="svg2"
|
||||||
|
sodipodi:docname="_svgclean2.svg"
|
||||||
|
viewBox="0 0 260 310"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.3.1 r9886"
|
||||||
|
>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
bordercolor="#666666"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-y="19"
|
||||||
|
fit-margin-left="0"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
fit-margin-top="0"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:zoom="1.4142136"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-height="768"
|
||||||
|
showgrid="false"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
inkscape:cx="-57.248774"
|
||||||
|
inkscape:cy="-575.52494"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0"
|
||||||
|
inkscape:window-width="1024"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
>
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid3769"
|
||||||
|
originy="-752.36218px"
|
||||||
|
enabled="true"
|
||||||
|
originx="-35.03125px"
|
||||||
|
visible="true"
|
||||||
|
snapvisiblegridlinesonly="true"
|
||||||
|
type="xygrid"
|
||||||
|
empspacing="4"
|
||||||
|
/>
|
||||||
|
</sodipodi:namedview
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
transform="translate(-242.06 -371.19)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
id="rect3757"
|
||||||
|
style="stroke:#636363;stroke-width:6;fill:#ffffff"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m38 53v304h254v-226.22l-77.78-77.78h-176.22z"
|
||||||
|
transform="translate(207.06 321.19)"
|
||||||
|
/>
|
||||||
|
<g
|
||||||
|
id="g3915"
|
||||||
|
transform="translate(0 17.678)"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
id="rect3759"
|
||||||
|
style="stroke-linejoin:round;stroke:#a6a6a6;stroke-linecap:round;stroke-width:6;fill:#ffffff"
|
||||||
|
height="150"
|
||||||
|
width="130"
|
||||||
|
y="451.19"
|
||||||
|
x="307.06"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="path3761"
|
||||||
|
d="m343.53 497.65 57.065 57.065"
|
||||||
|
style="stroke-linejoin:round;stroke:#e00000;stroke-linecap:round;stroke-width:12;fill:none"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="path3763"
|
||||||
|
style="stroke-linejoin:round;stroke:#e00000;stroke-linecap:round;stroke-width:12;fill:none"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m400.6 497.65-57.065 57.065"
|
||||||
|
/>
|
||||||
|
</g
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
id="rect3911"
|
||||||
|
sodipodi:nodetypes="ccc"
|
||||||
|
style="stroke-linejoin:round;stroke:#636363;stroke-width:6;fill:none"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m499.06 451.97h-77.782v-77.782"
|
||||||
|
/>
|
||||||
|
</g
|
||||||
|
>
|
||||||
|
<metadata
|
||||||
|
id="metadata13"
|
||||||
|
>
|
||||||
|
<rdf:RDF
|
||||||
|
>
|
||||||
|
<cc:Work
|
||||||
|
>
|
||||||
|
<dc:format
|
||||||
|
>image/svg+xml</dc:format
|
||||||
|
>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage"
|
||||||
|
/>
|
||||||
|
<cc:license
|
||||||
|
rdf:resource="http://creativecommons.org/licenses/publicdomain/"
|
||||||
|
/>
|
||||||
|
<dc:publisher
|
||||||
|
>
|
||||||
|
<cc:Agent
|
||||||
|
rdf:about="http://openclipart.org/"
|
||||||
|
>
|
||||||
|
<dc:title
|
||||||
|
>Openclipart</dc:title
|
||||||
|
>
|
||||||
|
</cc:Agent
|
||||||
|
>
|
||||||
|
</dc:publisher
|
||||||
|
>
|
||||||
|
</cc:Work
|
||||||
|
>
|
||||||
|
<cc:License
|
||||||
|
rdf:about="http://creativecommons.org/licenses/publicdomain/"
|
||||||
|
>
|
||||||
|
<cc:permits
|
||||||
|
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||||
|
/>
|
||||||
|
<cc:permits
|
||||||
|
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||||
|
/>
|
||||||
|
<cc:permits
|
||||||
|
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||||
|
/>
|
||||||
|
</cc:License
|
||||||
|
>
|
||||||
|
</rdf:RDF
|
||||||
|
>
|
||||||
|
</metadata
|
||||||
|
>
|
||||||
|
</svg
|
||||||
|
>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
title: "Instant Pot Chicken Noodle Soup"
|
||||||
|
slug: "chicken-noodle-soup"
|
||||||
|
date: "2026-02-08"
|
||||||
|
lastUpdated: "2026-02-08"
|
||||||
|
category: "soup"
|
||||||
|
tags: ["soup", "chicken", "comfort-food", "instant-pot", "one-pot", "sick-day", "meal-prep"]
|
||||||
|
dietary: []
|
||||||
|
cookTime: 20
|
||||||
|
prepTime: 10
|
||||||
|
totalTime: 30
|
||||||
|
difficulty: "easy"
|
||||||
|
servings: 8
|
||||||
|
author: "pws"
|
||||||
|
description: "A comforting chicken noodle soup perfect for rainy or sick days. Made easy in the Instant Pot but can be adapted for stovetop cooking."
|
||||||
|
featured: false
|
||||||
|
display: true
|
||||||
|
displayPhoto: "./assets/chicken-noodle-soup.png"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Instant Pot Chicken Noodle Soup
|
||||||
|
|
||||||
|
A great soup for rainy or sick days. This comforting classic is made simple with the Instant Pot, though it can easily be adapted for stovetop cooking. The recipe makes enough to fuel a whole sick week!
|
||||||
|
|
||||||
|
## Photos
|
||||||
|
|
||||||
|

|
||||||
|
*Comforting homemade chicken noodle soup*
|
||||||
|
|
||||||
|
## Ingredients
|
||||||
|
|
||||||
|
### Main Ingredients
|
||||||
|
- 2 tablespoons unsalted butter
|
||||||
|
- 1 large onion, chopped
|
||||||
|
- 2 medium carrots, chopped
|
||||||
|
- 2 stalks celery, chopped
|
||||||
|
- Kosher salt and fresh ground pepper to taste
|
||||||
|
- 1 teaspoon thyme
|
||||||
|
- 1 tablespoon parsley
|
||||||
|
- 1 tablespoon oregano
|
||||||
|
- 1 chicken bouillon cube or powder
|
||||||
|
- 4 cups chicken broth
|
||||||
|
- 2 pounds chicken (usually one Safeway or Costco chicken)
|
||||||
|
- 4 cups water
|
||||||
|
- 2 cups uncooked egg noodles
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Turn your Instant Pot to the saute setting.
|
||||||
|
2. Add the **butter** and cook until melted. Add the **onion**, **carrots**, and **celery** and saute for 3 minutes until the **onion** softens and becomes translucent.
|
||||||
|
3. Season with **salt** and **pepper**, then add the **thyme**, **parsley**, **oregano**, and **chicken bouillon**. Stir to combine.
|
||||||
|
4. Pour in the **chicken broth**. Add the **chicken** pieces and another 4 cups of **water**.
|
||||||
|
5. Close the lid. Set the Instant Pot to the Soup setting and set the timer to 7 minutes on high pressure.
|
||||||
|
6. Once the Instant Pot cycle is complete, wait until the natural release cycle is complete before opening the instant pot.
|
||||||
|
7. Remove the **chicken** pieces from the soup and shred with two forks.
|
||||||
|
8. Add the **noodles** to the soup and set the Instant Pot to the saute setting again. Cook for another 6 minutes uncovered, or until the **noodles** are cooked.
|
||||||
|
9. Turn off the Instant Pot. Add the shredded **chicken** back to the pot, taste for seasoning and adjust as necessary. Garnish with additional **parsley** if preferred.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Meal Prep Tips
|
||||||
|
- **Make it last**: This recipe can make nearly a whole week of soup. Double it if you're feeling dangerous.
|
||||||
|
- **Noodle absorption**: If doing meal prep with lots of noodles or macaroni, they tend to soak up the broth. Double the water and broth amounts if you want it to keep in the fridge and retain liquid, versus becoming more of a soup-casserole.
|
||||||
|
|
||||||
|
### Seasoning Tips
|
||||||
|
- **Don't oversalt**: Watch the amount of salt in this recipe - it's pretty easy to over-salt if you're not careful, especially with the bouillon cube.
|
||||||
|
|
||||||
|
### Stovetop Adaptation
|
||||||
|
This recipe can be made on the stovetop by simmering the ingredients in a large pot for about 30-40 minutes until the chicken is cooked through, then following the same steps for shredding and adding noodles.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Jo Cooks - Instant Pot Chicken Noodle Soup](https://www.jocooks.com/recipes/instant-pot-chicken-noodle-soup/)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Loading…
x
Reference in New Issue
Block a user