Compare commits

..

No commits in common. "00d5aca70f9845d7d3a2966fc0fa7179bff5fb3c" and "d601895982179e8b115615c04cdc4261108e282a" have entirely different histories.

19 changed files with 207 additions and 341 deletions

View File

@ -31,17 +31,15 @@ app/ # Next.js App Router pages
components/ components/
Header.tsx / Footer.tsx # Site chrome Header.tsx / Footer.tsx # Site chrome
RecipesClient.tsx # Recipe listing with filter state RecipesClient.tsx # Recipe listing with filter state
RecipeLayout.tsx # Recipe listing layout: filter button, Sidebar + results RecipeLayout.tsx # Sidebar layout (mobile drawer, desktop persistent)
Sidebar.tsx # Responsive sidebar shell (mobile drawer, desktop sticky column)
RecipesSidebar.tsx # Search + category/tag facet lists with result counts RecipesSidebar.tsx # Search + category/tag facet lists with result counts
FacetGroup.tsx # Collapsible sidebar facet section FacetGroup.tsx # Collapsible sidebar facet section
ActiveFilters.tsx # Removable chips for active filters (above results) ActiveFilters.tsx # Removable chips for active filters (above results)
RecipeGridCard.tsx # Recipe grid card for listing page RecipeGridCard.tsx # Recipe grid card for listing page
RecipeTabs.tsx # Tabbed recipe sections (client) RecipeTabs.tsx # Tabbed recipe sections (client)
RecipeMarkdown.tsx # Renders a parsed markdown tree to React (server) RecipeMarkdown.tsx # Renders a parsed markdown tree to React (server)
markdown-styles.ts # Shared body typography classes (recipe tabs, About page) RecipeCardPanel.tsx # Recipe Card tab: preview, download, print (server)
RecipeCardPanel.tsx # Recipe Card tab: card image previews (server) PrintCardButton.tsx # Prints card images at 5×7 in (client)
RecipeCardImage.tsx # Card preview that enlarges to full screen on click (client)
RecipePageLayout.tsx # Recipe detail page layout (server component) RecipePageLayout.tsx # Recipe detail page layout (server component)
lib/ lib/

View File

@ -3,7 +3,6 @@
## State and Data Flow ## State and Data Flow
- **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar - **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar
- **Sidebar** is the generic shell: a modal drawer below `lg`, a sticky column at `lg`+. While open it locks page scroll, closes on Escape/overlay/resize to `lg`, and moves focus in (restoring it on close). Its body has `overscroll-contain` so drawer scrolling never chains to the page. `onClose` must be stable (`useCallback`) since its effects depend on it
- **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange` - **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange`
- **RecipesClient** owns filter state (synced to the URL), the filtered recipe list, and facet counts (all memoised with `useMemo`). Each facet's counts apply every filter except its own; options with zero results are hidden - **RecipesClient** owns filter state (synced to the URL), the filtered recipe list, and facet counts (all memoised with `useMemo`). Each facet's counts apply every filter except its own; options with zero results are hidden
- **ActiveFilters** renders removable chips for the active filters above the results grid - **ActiveFilters** renders removable chips for the active filters above the results grid
@ -17,6 +16,5 @@
- Paragraphs containing only images are converted to `<figure>` elements during parsing (caption from the image title), so there's no `<p><figure>` nesting - Paragraphs containing only images are converted to `<figure>` elements during parsing (caption from the image title), so there's no `<p><figure>` nesting
- Card fonts are read from `node_modules/@fontsource/*` at build time; satori needs woff/ttf (not woff2) and doesn't support variable fonts - Card fonts are read from `node_modules/@fontsource/*` at build time; satori needs woff/ttf (not woff2) and doesn't support variable fonts
- `lib/recipes.ts` uses Node.js `fs` — server-side only; never import in client components - `lib/recipes.ts` uses Node.js `fs` — server-side only; never import in client components
- Build warnings about `<img>` vs `<Image />` in RecipeMarkdown and RecipeCardImage are intentional - Build warnings about `<img>` vs `<Image />` in RecipeMarkdown and RecipeCardPanel are intentional
- The About page is hand-written JSX, not markdown; it shares body typography with recipe tabs through `MARKDOWN_BODY_CLASSES`. Change spacing there rather than per page. Class strings must live in `components/` or `app/` — Tailwind doesn't scan `lib/`
- Never add redundant ARIA roles on semantic elements (`<main>`, `<aside>`, `<footer>` already carry implicit roles) - Never add redundant ARIA roles on semantic elements (`<main>`, `<aside>`, `<footer>` already carry implicit roles)

View File

@ -42,7 +42,7 @@ Markdown before the first section renders as intro prose above the tabs; markdow
### Recipe card (opt in) ### Recipe card (opt in)
- `::card` adds a Recipe Card tab with a printable 5×7 in PNG generated from `:::ingredients` and `:::instructions`. The tab always sits after Notes and before References, wherever the directive appears in the file - `::card` adds a Recipe Card tab with a printable 5×7 in PNG generated from `:::ingredients` and `:::instructions`
- To give the card a shorter version, use the container form. The outer fence needs **four** colons; any section left out falls back to the full one: - To give the card a shorter version, use the container form. The outer fence needs **four** colons; any section left out falls back to the full one:
```md ```md

View File

@ -1,5 +1,4 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { MARKDOWN_BODY_CLASSES } from '@/components/markdown-styles';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'About - PWS Recipes', title: 'About - PWS Recipes',
@ -8,44 +7,48 @@ export const metadata: Metadata = {
export default function AboutPage() { export default function AboutPage() {
return ( return (
<article className="max-w-4xl mx-auto"> <div className="max-w-3xl space-y-8">
<header className="mb-8 space-y-4"> <div className="space-y-4">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white"> <h1 className="text-4xl font-bold text-gray-900 dark:text-white">
About PWS Recipes About PWS Recipes
</h1> </h1>
<p className="text-xl text-gray-600 dark:text-gray-400"> <p className="text-lg text-gray-600 dark:text-gray-400">
Explore recipes in a user-friendly manner on a website that prioritizes your cooking experience over monetizing your clicks. Explore recipes in a user-friendly manner on a website that prioritizes your cooking experience over monetizing your clicks.
</p> </p>
</header> </div>
<div className={`space-y-8 ${MARKDOWN_BODY_CLASSES} <div className="prose dark:prose-invert max-w-none">
[&_h2]:text-2xl [&_h2]:font-bold [&_h2]:text-gray-900 dark:[&_h2]:text-white [&_h2]:mb-3 <section className="space-y-4">
`}> <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
<section> The PWS Mission
<h2>The PWS Mission</h2> </h2>
<p> <p className="text-gray-600 dark:text-gray-400">
We believe that cooking should be accessible, enjoyable, and creative. Our goal while collecting and organizing these recipes is to make knowledge readily available and keep your cooking experience a cathartic and fun one. We believe that cooking should be accessible, enjoyable, and creative. Our goal while collecting and organizing these recipes is to make knowledge readily available and keep your cooking experience a cathartic and fun one.
</p> </p>
</section> </section>
<section> <section className="space-y-4 pt-6">
<h2>Content Structure</h2> <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
<p> Content Structure
</h2>
<p className="text-gray-600 dark:text-gray-400">
Each recipe is organized in its own folder with: Each recipe is organized in its own folder with:
</p> </p>
<ul> <ul className="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Markdown file with metadata frontmatter, recipe content, and instructions</li> <li>Markdown file with metadata frontmatter, recipe content, and instructions</li>
<li>Assets folder for images and other media</li> <li>Assets folder for images and other media</li>
</ul> </ul>
</section> </section>
<section> <section className="space-y-4 pt-6">
<h2>Technology</h2> <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
<p> 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. Built with modern web technologies including Next.js, React, TypeScript, and Tailwind CSS to ensure fast performance.
</p> </p>
</section> </section>
</div> </div>
</article> </div>
); );
} }

View File

@ -65,20 +65,22 @@ export default async function RecipePage({ params }: RecipePageProps) {
const content = getRecipeContent(recipe); const content = getRecipeContent(recipe);
const cardImages = await getRecipeCardImages(recipe);
const cardTabs: RecipeTab[] = cardImages
? [{ id: 'card', title: 'Recipe Card', content: <RecipeCardPanel title={recipe.title} images={cardImages} /> }]
: [];
// The card goes right before References, which is always the last tab
const tabs: RecipeTab[] = SECTION_NAMES.flatMap((name) => { const tabs: RecipeTab[] = SECTION_NAMES.flatMap((name) => {
const section = content.sections[name]; const section = content.sections[name];
const sectionTabs: RecipeTab[] = section return section
? [{ id: name, title: SECTION_TITLES[name], content: <RecipeMarkdown content={section} recipe={recipe} /> }] ? [{ id: name, title: SECTION_TITLES[name], content: <RecipeMarkdown content={section} recipe={recipe} /> }]
: []; : [];
return name === 'references' ? [...cardTabs, ...sectionTabs] : sectionTabs;
}); });
const cardImages = await getRecipeCardImages(recipe);
if (cardImages) {
tabs.push({
id: 'card',
title: 'Recipe Card',
content: <RecipeCardPanel title={recipe.title} images={cardImages} />,
});
}
return ( return (
<RecipePageLayout recipe={recipe}> <RecipePageLayout recipe={recipe}>
<RecipeMarkdown content={content.intro} recipe={recipe} /> <RecipeMarkdown content={content.intro} recipe={recipe} />

View File

@ -0,0 +1,44 @@
'use client';
interface PrintCardButtonProps {
imageUrls: string[];
className?: string;
}
// Prints the card images at their physical size, one per page, from a hidden iframe
export default function PrintCardButton({ imageUrls, className }: PrintCardButtonProps) {
const handlePrint = () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('aria-hidden', 'true');
iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
iframe.srcdoc = `<!doctype html><html><head><style>
@page { size: 7in 5in; margin: 0; }
html, body { margin: 0; }
img { display: block; width: 7in; height: 5in; break-after: page; }
img:last-child { break-after: auto; }
</style></head><body>${imageUrls
.map((url) => `<img src="${new URL(url, window.location.href).href}" alt="">`)
.join('')}</body></html>`;
iframe.onload = async () => {
const printWindow = iframe.contentWindow;
if (!printWindow) return;
await Promise.all(
Array.from(printWindow.document.images).map((img) => img.decode().catch(() => undefined))
);
printWindow.addEventListener('afterprint', () => iframe.remove());
printWindow.focus();
printWindow.print();
};
document.body.appendChild(iframe);
};
return (
<button type="button" onClick={handlePrint} className={className}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9V3h12v6M6 18H4a1 1 0 01-1-1v-6a2 2 0 012-2h14a2 2 0 012 2v6a1 1 0 01-1 1h-2M7 14h10v7H7z" />
</svg>
Print
</button>
);
}

View File

@ -1,60 +0,0 @@
'use client';
import { useRef } from 'react';
interface RecipeCardImageProps {
src: string;
alt: string;
}
// A card preview that enlarges to fill the screen when clicked. The native <dialog>
// sits above the tab's overflow clipping and handles Escape and focus; clicking
// anywhere closes it.
export default function RecipeCardImage({ src, alt }: RecipeCardImageProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const open = () => {
document.documentElement.style.overflow = 'hidden';
dialogRef.current?.showModal();
};
return (
<>
<button
type="button"
onClick={open}
aria-label={`View ${alt} full screen`}
className="block w-full cursor-zoom-in rounded-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<img
src={src}
alt={alt}
width={2100}
height={1500}
loading="lazy"
className="h-auto w-full rounded-md border border-gray-200 dark:border-gray-700 bg-white shadow-sm"
/>
</button>
<dialog
ref={dialogRef}
aria-label={alt}
onClick={() => dialogRef.current?.close()}
onClose={() => { document.documentElement.style.overflow = ''; }}
className="fixed inset-0 m-0 h-full max-h-none w-full max-w-none cursor-zoom-out bg-black/90 p-2 open:block"
>
<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" />
<button
type="button"
autoFocus
aria-label="Close full screen view"
className="absolute right-3 top-3 rounded-full bg-black/60 p-2 text-white hover:bg-black/80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</dialog>
</>
);
}

View File

@ -1,24 +1,51 @@
import type { RecipeCardImage as RecipeCardImageData } from '@/lib/recipe-card'; import type { RecipeCardImage } from '@/lib/recipe-card';
import RecipeCardImage from './RecipeCardImage'; import PrintCardButton from './PrintCardButton';
interface RecipeCardPanelProps { interface RecipeCardPanelProps {
title: string; title: string;
images: RecipeCardImageData[]; images: RecipeCardImage[];
} }
const buttonClass =
'inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors';
export default function RecipeCardPanel({ title, images }: RecipeCardPanelProps) { export default function RecipeCardPanel({ title, images }: RecipeCardPanelProps) {
const doubleSided = images.length > 1; const doubleSided = images.length > 1;
return ( return (
<div className={doubleSided ? 'grid gap-6 xl:grid-cols-2' : ''}> <div className="space-y-6">
{images.map((image) => ( <p className="!mt-0 text-sm text-gray-600 dark:text-gray-400">
<figure key={image.fileName} className="!my-0"> A 5×7 in card at 300 DPI. Print it on 5×7 photo paper or card stock
<RecipeCardImage {doubleSided ? ', with the front and back on either side.' : '.'}
src={image.url} </p>
alt={`${title} recipe card${doubleSided ? ` (${image.label.toLowerCase()})` : ''}`}
/> <div className={doubleSided ? 'grid gap-6 xl:grid-cols-2' : ''}>
</figure> {images.map((image) => (
))} <figure key={image.fileName} className="!my-0">
<img
src={image.url}
alt={`${title} recipe card${doubleSided ? ` (${image.label.toLowerCase()})` : ''}`}
width={2100}
height={1500}
loading="lazy"
className="h-auto w-full rounded-md border border-gray-200 dark:border-gray-700 bg-white shadow-sm"
/>
{doubleSided && <figcaption>{image.label}</figcaption>}
</figure>
))}
</div>
<div className="flex flex-wrap gap-3">
{images.map((image) => (
<a key={image.fileName} href={image.url} download={image.downloadName} className={buttonClass}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
</svg>
{doubleSided ? `Download ${image.label.toLowerCase()}` : 'Download PNG'}
</a>
))}
<PrintCardButton imageUrls={images.map((image) => image.url)} className={buttonClass} />
</div>
</div> </div>
); );
} }

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useCallback, ReactNode } from 'react'; import { useState, useCallback, useEffect, ReactNode } from 'react';
import Sidebar from './Sidebar';
import RecipesSidebar from './RecipesSidebar'; import RecipesSidebar from './RecipesSidebar';
import type { FacetCounts, FilterState } from '@/lib/types'; import type { FacetCounts, FilterState } from '@/lib/types';
@ -30,17 +29,33 @@ export default function RecipeLayout({
onFilterChange(newFilters); onFilterChange(newFilters);
}, [onFilterChange]); }, [onFilterChange]);
const closeSidebar = useCallback(() => setSidebarOpen(false), []); // Close the mobile drawer on Escape
useEffect(() => {
if (!sidebarOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSidebarOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [sidebarOpen]);
const activeFilterCount = const activeFilterCount =
(filters.search ? 1 : 0) + (filters.category ? 1 : 0) + filters.selectedTags.length; (filters.search ? 1 : 0) + (filters.category ? 1 : 0) + filters.selectedTags.length;
return ( return (
<div> <div>
{/* Mobile Overlay */}
{sidebarOpen && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile filter button */} {/* Mobile filter button */}
<div className="lg:hidden mb-4"> <div className="lg:hidden mb-4">
<button <button
type="button"
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
aria-expanded={sidebarOpen} aria-expanded={sidebarOpen}
@ -59,29 +74,55 @@ export default function RecipeLayout({
</div> </div>
<div className="flex lg:gap-10"> <div className="flex lg:gap-10">
<Sidebar {/* Sidebar - sticky column on desktop (lg+), slide-out drawer on mobile */}
<aside
id="recipes-sidebar" id="recipes-sidebar"
title="Filters" className={`
open={sidebarOpen} fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw]
onClose={closeSidebar} bg-white dark:bg-gray-900
footer={ transition-transform duration-300 ease-in-out
<button lg:sticky lg:top-8 lg:z-0 lg:w-64 lg:max-w-none lg:shrink-0 lg:self-start
type="button" lg:max-h-[calc(100vh-4rem)] lg:bg-transparent lg:translate-x-0 lg:transition-none
onClick={closeSidebar} ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors" `}
> aria-label="Recipe filters"
Show {resultCount} {resultCount === 1 ? 'recipe' : 'recipes'}
</button>
}
> >
<RecipesSidebar <div className="flex h-full flex-col lg:max-h-[calc(100vh-4rem)]">
categories={categories} {/* Mobile drawer header */}
tags={tags} <div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
facetCounts={facetCounts} <span className="text-base font-semibold text-gray-900 dark:text-white">Filters</span>
filters={filters} <button
onFilterChange={handleFilterChange} onClick={() => setSidebarOpen(false)}
/> className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
</Sidebar> aria-label="Close filters"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 lg:p-0 lg:pr-2">
<RecipesSidebar
categories={categories}
tags={tags}
facetCounts={facetCounts}
filters={filters}
onFilterChange={handleFilterChange}
/>
</div>
{/* Mobile drawer footer */}
<div className="lg:hidden border-t border-gray-200 dark:border-gray-800 p-4">
<button
onClick={() => setSidebarOpen(false)}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
>
Show {resultCount} {resultCount === 1 ? 'recipe' : 'recipes'}
</button>
</div>
</div>
</aside>
{/* Main content */} {/* Main content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { MARKDOWN_BODY_CLASSES } from './markdown-styles';
export interface RecipeTab { export interface RecipeTab {
id: string; id: string;
@ -66,7 +65,21 @@ export default function RecipeTabs({ tabs }: { tabs: RecipeTab[] }) {
id={`tab-panel-${tab.id}`} id={`tab-panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`} aria-labelledby={`tab-${tab.id}`}
hidden={tab.id !== activeId} hidden={tab.id !== activeId}
className={`p-8 ${MARKDOWN_BODY_CLASSES}`} className="p-8 max-w-none text-base leading-relaxed
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-gray-900 dark:[&_h3]:text-white [&_h3]:mt-5 [&_h3]:mb-2
[&_p]:text-gray-700 dark:[&_p]:text-gray-300 [&_p]:my-2
[&_strong]:text-gray-900 dark:[&_strong]:text-white
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-2 [&_ol]:my-4
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_ul]:my-4
[&_li]:text-gray-700 dark:[&_li]:text-gray-300
[&_li]:marker:text-gray-500 dark:[&_li]:marker:text-gray-400
[&_code]:text-gray-900 dark:[&_code]:text-gray-100 [&_code]:bg-gray-100 dark:[&_code]:bg-gray-800 [&_code]:rounded [&_code]:px-1.5 [&_code]:py-0.5
[&_pre]:bg-gray-100 dark:[&_pre]:bg-gray-900
[&_table]:border-collapse
[&_th]:border [&_th]:border-gray-300 dark:[&_th]:border-gray-600
[&_td]:border [&_td]:border-gray-300 dark:[&_td]:border-gray-600
[&_figure]:my-6
[&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-sm [&_figcaption]:italic [&_figcaption]:text-gray-600 dark:[&_figcaption]:text-gray-400"
> >
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
{tab.title} {tab.title}

View File

@ -1,114 +0,0 @@
'use client';
import { useEffect, useRef, type ReactNode } from 'react';
interface SidebarProps {
id: string;
title: string;
open: boolean;
onClose: () => void;
footer?: ReactNode;
children: ReactNode;
}
// Matches Tailwind's `lg` breakpoint, where the sidebar becomes a persistent column
const DESKTOP_QUERY = '(min-width: 1024px)';
// Responsive sidebar: a modal drawer below `lg`, a sticky column at `lg` and up.
// While the drawer is open the page behind it can't scroll, and scrolling inside
// it never chains through to the page.
export default function Sidebar({ id, title, open, onClose, footer, children }: SidebarProps) {
const panelRef = useRef<HTMLDivElement>(null);
// Lock page scroll, close on Escape, and move focus into the drawer while open
useEffect(() => {
if (!open) return;
const root = document.documentElement;
const previousOverflow = root.style.overflow;
root.style.overflow = 'hidden';
const previousFocus = document.activeElement as HTMLElement | null;
panelRef.current?.focus({ preventScroll: true });
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
root.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
previousFocus?.focus({ preventScroll: true });
};
}, [open, onClose]);
// The drawer only exists below `lg`; close it if the viewport grows past that
useEffect(() => {
if (!open) return;
const query = window.matchMedia(DESKTOP_QUERY);
const handleChange = () => {
if (query.matches) onClose();
};
handleChange();
query.addEventListener('change', handleChange);
return () => query.removeEventListener('change', handleChange);
}, [open, onClose]);
return (
<>
{open && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/50 overscroll-none"
onClick={onClose}
aria-hidden="true"
/>
)}
<aside
id={id}
aria-label={title}
className={`
fixed inset-y-0 left-0 z-50 h-dvh w-80 max-w-[85vw]
bg-white dark:bg-gray-900 shadow-xl
transition-[transform,visibility] duration-300 ease-in-out
${open ? 'visible translate-x-0' : 'invisible -translate-x-full'}
lg:visible lg:sticky lg:top-8 lg:z-0 lg:h-auto lg:max-h-[calc(100vh-4rem)] lg:w-64 lg:max-w-none
lg:shrink-0 lg:self-start lg:translate-x-0 lg:bg-transparent lg:shadow-none lg:transition-none
`}
>
<div
ref={panelRef}
tabIndex={-1}
className="flex h-full flex-col focus:outline-none lg:max-h-[calc(100vh-4rem)]"
>
{/* Drawer header (mobile only) */}
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
<span className="text-base font-semibold text-gray-900 dark:text-white">{title}</span>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={`Close ${title.toLowerCase()}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrolls on its own; overscroll-contain stops it chaining to the page */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 lg:p-0 lg:pr-2">
{children}
</div>
{footer && (
<div className="lg:hidden border-t border-gray-200 dark:border-gray-800 p-4">
{footer}
</div>
)}
</div>
</aside>
</>
);
}

View File

@ -1,18 +0,0 @@
// Typography for markdown-style body content (recipe tabs, About page).
// Lives in components/ because Tailwind only scans components/ and app/ for class names,
// and outside RecipeTabs because a 'use client' module can't share values with server components.
export const MARKDOWN_BODY_CLASSES = `max-w-none text-base leading-relaxed
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-gray-900 dark:[&_h3]:text-white [&_h3]:mt-5 [&_h3]:mb-2
[&_p]:text-gray-700 dark:[&_p]:text-gray-300 [&_p]:my-2
[&_strong]:text-gray-900 dark:[&_strong]:text-white
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-2 [&_ol]:my-4
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_ul]:my-4
[&_li]:text-gray-700 dark:[&_li]:text-gray-300
[&_li]:marker:text-gray-500 dark:[&_li]:marker:text-gray-400
[&_code]:text-gray-900 dark:[&_code]:text-gray-100 [&_code]:bg-gray-100 dark:[&_code]:bg-gray-800 [&_code]:rounded [&_code]:px-1.5 [&_code]:py-0.5
[&_pre]:bg-gray-100 dark:[&_pre]:bg-gray-900
[&_table]:border-collapse
[&_th]:border [&_th]:border-gray-300 dark:[&_th]:border-gray-600
[&_td]:border [&_td]:border-gray-300 dark:[&_td]:border-gray-600
[&_figure]:my-6
[&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-sm [&_figcaption]:italic [&_figcaption]:text-gray-600 dark:[&_figcaption]:text-gray-400`;

View File

@ -74,6 +74,7 @@ export interface RecipeCardImage {
label: string; label: string;
url: string; url: string;
fileName: string; fileName: string;
downloadName: string;
} }
let fonts: Font[] | null = null; let fonts: Font[] | null = null;
@ -403,6 +404,7 @@ export async function getRecipeCardImages(recipe: Recipe): Promise<RecipeCardIma
label: FACE_LABELS[face.name], label: FACE_LABELS[face.name],
fileName: `${face.name}.png`, fileName: `${face.name}.png`,
url: recipeCardImageUrl(recipe, `${face.name}.png`), url: recipeCardImageUrl(recipe, `${face.name}.png`),
downloadName: `${recipe.slug}-recipe-card${face.name === 'card' ? '' : `-${face.name}`}.png`,
})); }));
} }

View File

@ -88,7 +88,7 @@ Markdown before the first section is intro prose; markdown after the last is out
#### Recipe Card (optional) #### Recipe Card (optional)
Add `::card` on its own line for a **Recipe Card** tab: a 5×7 in, 300 DPI PNG built from the ingredients and instructions. Click the image to view it full screen. Long recipes are split into a front and back automatically. The tab always sits after Notes and before References, wherever `::card` appears in the file. Add `::card` on its own line for a **Recipe Card** tab: a 5×7 in, 300 DPI PNG built from the ingredients and instructions, ready to download or print. Long recipes are split into a front and back automatically.
To put a shorter version on the card, use the container form with **four** colons. Leave out a section to use the full one: To put a shorter version on the card, use the container form with **four** colons. Leave out a section to use the full one:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -12,13 +12,13 @@ author: "jake"
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." 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 featured: false
display: true display: true
displayPhoto: "./assets/chicken-noodle-soup.jpg" displayPhoto: ""
--- ---
A comforting chicken noodle soup perfect for rainy or sick days. Made easy in the Instant Pot but can be adapted for stovetop cooking A comforting chicken noodle soup perfect for rainy or sick days. Made easy in the Instant Pot but can be adapted for stovetop cooking
:::photos :::photos
![Chicken Noodle Soup](./assets/chicken-noodle-soup.jpg "Chicken Noodle Soup") ![Image Coming Soon](./assets/not-found.svg "Image Coming Soon")
::: :::
:::ingredients :::ingredients

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

View File

@ -1,70 +0,0 @@
---
title: "Chicken Tortilla Soup"
slug: "chicken-tortilla-soup"
date: "2026-11-06"
lastUpdated: "2026-11-06"
category: "soup"
tags: ["soup", "mexican", "chicken"]
cookTime: 30
prepTime: 15
servings: 6
author: "jake"
description: "Chicken tortilla soup made easy with a rotisserie chicken, topped with homemade crispy tortilla strips."
featured: false
display: true
displayPhoto: "./assets/chicken-tortilla-soup.jpg"
---
:::photos
![Chicken Tortilla Soup](./assets/chicken-tortilla-soup.jpg "Chicken Tortilla Soup")
:::
:::ingredients
### Soup
- 2 tablespoons light olive oil
- 1 large yellow onion, chopped
- 1-2 jalapeños, seeded and diced
- 4 garlic cloves, minced
- 1 rotisserie chicken
- 32 oz chicken broth
- 1 can (28 oz) crushed tomatoes
- 1 can (15 oz) corn, drained and rinsed
- 1 can (15 oz) black beans, drained and rinsed
- 1 1/2 teaspoons chili powder
- 1 1/2 teaspoons ground cumin
- 1 teaspoon salt, or to taste
- 1/3 cup cilantro, chopped, plus more to garnish
- 1 1/2 tablespoons lime juice (from 1 lime), or to taste
### Tortilla Strips
- 1/4 cup olive oil
- 8 corn tortillas (6 inch)
### Toppings
- 1 large avocado, diced
- 1 lime, cut into wedges
- Sour cream
:::
:::instructions
### Tortilla Strips
1. Heat the **olive oil** in a pan over medium-high heat. Cut the **tortillas** into thin strips and fry in batches until crisp. Drain on a paper towel and set aside, adding more **oil** between batches as needed.
### Soup
1. Heat the **olive oil** in a large pot over medium-high heat. Add the **onion** and **jalapeños** and sauté until softened. Add the **garlic** and stir for 30-60 seconds, until fragrant.
2. Break down the **rotisserie chicken**: pull the meat off the bones, discard the skin and bones, and shred the meat.
3. Add the **chicken broth**, **crushed tomatoes**, **corn**, **black beans**, **chili powder**, **cumin**, and **salt** to the pot. Bring to a boil, then simmer for about 20 minutes.
4. Add the shredded **chicken** to the soup and simmer another 5 minutes. Remove from heat and stir in the **cilantro** and **lime juice**.
5. Serve garnished with **tortilla strips**, **avocado**, **sour cream**, fresh **cilantro**, and **lime wedges**.
:::
:::notes
### Storage
- Refrigerate for 3-4 days or freeze for up to 2 months. Store without toppings.
:::
:::references
- Adapted from **[Natasha's Kitchen - Chicken Tortilla Soup](https://natashaskitchen.com/chicken-tortilla-soup-recipe/)**
:::
::card