mirror of
https://github.com/runyanjake/cooking.git
synced 2026-11-10 15:14:50 -08:00
Compare commits
11 Commits
d601895982
...
00d5aca70f
| Author | SHA1 | Date | |
|---|---|---|---|
| 00d5aca70f | |||
|
|
6c92f2d896 | ||
| 4efb759eec | |||
| 7faa49b447 | |||
| b757db5b2a | |||
|
|
e9fa5e6567 | ||
|
|
5d4515d9d5 | ||
| 39c4b2fab0 | |||
|
|
581f62020c | ||
| 925b8ae021 | |||
|
|
a33ce23f91 |
@ -31,15 +31,17 @@ app/ # Next.js App Router pages
|
||||
components/
|
||||
Header.tsx / Footer.tsx # Site chrome
|
||||
RecipesClient.tsx # Recipe listing with filter state
|
||||
RecipeLayout.tsx # Sidebar layout (mobile drawer, desktop persistent)
|
||||
RecipeLayout.tsx # Recipe listing layout: filter button, Sidebar + results
|
||||
Sidebar.tsx # Responsive sidebar shell (mobile drawer, desktop sticky column)
|
||||
RecipesSidebar.tsx # Search + category/tag facet lists with result counts
|
||||
FacetGroup.tsx # Collapsible sidebar facet section
|
||||
ActiveFilters.tsx # Removable chips for active filters (above results)
|
||||
RecipeGridCard.tsx # Recipe grid card for listing page
|
||||
RecipeTabs.tsx # Tabbed recipe sections (client)
|
||||
RecipeMarkdown.tsx # Renders a parsed markdown tree to React (server)
|
||||
RecipeCardPanel.tsx # Recipe Card tab: preview, download, print (server)
|
||||
PrintCardButton.tsx # Prints card images at 5×7 in (client)
|
||||
markdown-styles.ts # Shared body typography classes (recipe tabs, About page)
|
||||
RecipeCardPanel.tsx # Recipe Card tab: card image previews (server)
|
||||
RecipeCardImage.tsx # Card preview that enlarges to full screen on click (client)
|
||||
RecipePageLayout.tsx # Recipe detail page layout (server component)
|
||||
|
||||
lib/
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
## State and Data Flow
|
||||
|
||||
- **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`
|
||||
- **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
|
||||
@ -16,5 +17,6 @@
|
||||
- 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
|
||||
- `lib/recipes.ts` uses Node.js `fs` — server-side only; never import in client components
|
||||
- Build warnings about `<img>` vs `<Image />` in RecipeMarkdown and RecipeCardPanel are intentional
|
||||
- Build warnings about `<img>` vs `<Image />` in RecipeMarkdown and RecipeCardImage 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)
|
||||
|
||||
@ -42,7 +42,7 @@ Markdown before the first section renders as intro prose above the tabs; markdow
|
||||
|
||||
### Recipe card (opt in)
|
||||
|
||||
- `::card` adds a Recipe Card tab with a printable 5×7 in PNG generated from `:::ingredients` and `:::instructions`
|
||||
- `::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
|
||||
- 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
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { MARKDOWN_BODY_CLASSES } from '@/components/markdown-styles';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'About - PWS Recipes',
|
||||
@ -7,48 +8,44 @@ export const metadata: Metadata = {
|
||||
|
||||
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">
|
||||
<article className="max-w-4xl mx-auto">
|
||||
<header className="mb-8 space-y-4">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white">
|
||||
About PWS Recipes
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
<p className="text-xl 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.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
The PWS Mission
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
<div className={`space-y-8 ${MARKDOWN_BODY_CLASSES}
|
||||
[&_h2]:text-2xl [&_h2]:font-bold [&_h2]:text-gray-900 dark:[&_h2]:text-white [&_h2]:mb-3
|
||||
`}>
|
||||
<section>
|
||||
<h2>The PWS Mission</h2>
|
||||
<p>
|
||||
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>
|
||||
</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">
|
||||
<section>
|
||||
<h2>Content Structure</h2>
|
||||
<p>
|
||||
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">
|
||||
<ul>
|
||||
<li>Markdown file with metadata frontmatter, recipe content, and instructions</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">
|
||||
<section>
|
||||
<h2>Technology</h2>
|
||||
<p>
|
||||
Built with modern web technologies including Next.js, React, TypeScript, and Tailwind CSS to ensure fast performance.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@ -65,22 +65,20 @@ export default async function RecipePage({ params }: RecipePageProps) {
|
||||
|
||||
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 section = content.sections[name];
|
||||
return section
|
||||
const sectionTabs: RecipeTab[] = section
|
||||
? [{ 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 (
|
||||
<RecipePageLayout recipe={recipe}>
|
||||
<RecipeMarkdown content={content.intro} recipe={recipe} />
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
60
components/RecipeCardImage.tsx
Normal file
60
components/RecipeCardImage.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,51 +1,24 @@
|
||||
import type { RecipeCardImage } from '@/lib/recipe-card';
|
||||
import PrintCardButton from './PrintCardButton';
|
||||
import type { RecipeCardImage as RecipeCardImageData } from '@/lib/recipe-card';
|
||||
import RecipeCardImage from './RecipeCardImage';
|
||||
|
||||
interface RecipeCardPanelProps {
|
||||
title: string;
|
||||
images: RecipeCardImage[];
|
||||
images: RecipeCardImageData[];
|
||||
}
|
||||
|
||||
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) {
|
||||
const doubleSided = images.length > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="!mt-0 text-sm text-gray-600 dark:text-gray-400">
|
||||
A 5×7 in card at 300 DPI. Print it on 5×7 photo paper or card stock
|
||||
{doubleSided ? ', with the front and back on either side.' : '.'}
|
||||
</p>
|
||||
|
||||
<div className={doubleSided ? 'grid gap-6 xl:grid-cols-2' : ''}>
|
||||
{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 className={doubleSided ? 'grid gap-6 xl:grid-cols-2' : ''}>
|
||||
{images.map((image) => (
|
||||
<figure key={image.fileName} className="!my-0">
|
||||
<RecipeCardImage
|
||||
src={image.url}
|
||||
alt={`${title} recipe card${doubleSided ? ` (${image.label.toLowerCase()})` : ''}`}
|
||||
/>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import { useState, useCallback, ReactNode } from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
import RecipesSidebar from './RecipesSidebar';
|
||||
import type { FacetCounts, FilterState } from '@/lib/types';
|
||||
|
||||
@ -29,33 +30,17 @@ export default function RecipeLayout({
|
||||
onFilterChange(newFilters);
|
||||
}, [onFilterChange]);
|
||||
|
||||
// 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 closeSidebar = useCallback(() => setSidebarOpen(false), []);
|
||||
|
||||
const activeFilterCount =
|
||||
(filters.search ? 1 : 0) + (filters.category ? 1 : 0) + filters.selectedTags.length;
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
aria-expanded={sidebarOpen}
|
||||
@ -74,55 +59,29 @@ export default function RecipeLayout({
|
||||
</div>
|
||||
|
||||
<div className="flex lg:gap-10">
|
||||
{/* Sidebar - sticky column on desktop (lg+), slide-out drawer on mobile */}
|
||||
<aside
|
||||
<Sidebar
|
||||
id="recipes-sidebar"
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw]
|
||||
bg-white dark:bg-gray-900
|
||||
transition-transform duration-300 ease-in-out
|
||||
lg:sticky lg:top-8 lg:z-0 lg:w-64 lg:max-w-none lg:shrink-0 lg:self-start
|
||||
lg:max-h-[calc(100vh-4rem)] lg:bg-transparent lg:translate-x-0 lg:transition-none
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
aria-label="Recipe filters"
|
||||
title="Filters"
|
||||
open={sidebarOpen}
|
||||
onClose={closeSidebar}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSidebar}
|
||||
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 className="flex h-full flex-col lg:max-h-[calc(100vh-4rem)]">
|
||||
{/* Mobile drawer header */}
|
||||
<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">Filters</span>
|
||||
<button
|
||||
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"
|
||||
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>
|
||||
<RecipesSidebar
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
facetCounts={facetCounts}
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
</Sidebar>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { MARKDOWN_BODY_CLASSES } from './markdown-styles';
|
||||
|
||||
export interface RecipeTab {
|
||||
id: string;
|
||||
@ -65,21 +66,7 @@ export default function RecipeTabs({ tabs }: { tabs: RecipeTab[] }) {
|
||||
id={`tab-panel-${tab.id}`}
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
hidden={tab.id !== activeId}
|
||||
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"
|
||||
className={`p-8 ${MARKDOWN_BODY_CLASSES}`}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{tab.title}
|
||||
|
||||
114
components/Sidebar.tsx
Normal file
114
components/Sidebar.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
components/markdown-styles.ts
Normal file
18
components/markdown-styles.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// 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`;
|
||||
@ -74,7 +74,6 @@ export interface RecipeCardImage {
|
||||
label: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
downloadName: string;
|
||||
}
|
||||
|
||||
let fonts: Font[] | null = null;
|
||||
@ -404,7 +403,6 @@ export async function getRecipeCardImages(recipe: Recipe): Promise<RecipeCardIma
|
||||
label: FACE_LABELS[face.name],
|
||||
fileName: `${face.name}.png`,
|
||||
url: recipeCardImageUrl(recipe, `${face.name}.png`),
|
||||
downloadName: `${recipe.slug}-recipe-card${face.name === 'card' ? '' : `-${face.name}`}.png`,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -88,7 +88,7 @@ Markdown before the first section is intro prose; markdown after the last is out
|
||||
|
||||
#### 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, ready to download or print. Long recipes are split into a front and back automatically.
|
||||
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.
|
||||
|
||||
To put a shorter version on the card, use the container form with **four** colons. Leave out a section to use the full one:
|
||||
|
||||
|
||||
BIN
recipes/soup/chicken-noodle-soup/assets/chicken-noodle-soup.jpg
Normal file
BIN
recipes/soup/chicken-noodle-soup/assets/chicken-noodle-soup.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 614 KiB |
@ -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."
|
||||
featured: false
|
||||
display: true
|
||||
displayPhoto: ""
|
||||
displayPhoto: "./assets/chicken-noodle-soup.jpg"
|
||||
---
|
||||
|
||||
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
|
||||

|
||||

|
||||
:::
|
||||
|
||||
:::ingredients
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 575 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
70
recipes/soup/chicken-tortilla-soup/chicken-tortilla-soup.md
Normal file
70
recipes/soup/chicken-tortilla-soup/chicken-tortilla-soup.md
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
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
|
||||

|
||||
:::
|
||||
|
||||
:::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
|
||||
Loading…
x
Reference in New Issue
Block a user