mirror of
https://github.com/runyanjake/cooking.git
synced 2026-11-10 23:24:50 -08:00
Merge pull request #5 from runyanjake/agent/swe-worker/56672a38a701
PWS-11: Simplify Recipe Card tab and move it before References
This commit is contained in:
commit
b757db5b2a
@ -39,8 +39,8 @@ components/
|
|||||||
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)
|
||||||
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/
|
||||||
|
|||||||
@ -17,5 +17,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 RecipeCardPanel are intentional
|
- Build warnings about `<img>` vs `<Image />` in RecipeMarkdown and RecipeCardImage are intentional
|
||||||
- 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)
|
||||||
|
|||||||
@ -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`
|
- `::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:
|
- 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
|
||||||
|
|||||||
@ -65,22 +65,20 @@ 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];
|
||||||
return section
|
const sectionTabs: RecipeTab[] = 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} />
|
||||||
|
|||||||
@ -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 type { RecipeCardImage as RecipeCardImageData } from '@/lib/recipe-card';
|
||||||
import PrintCardButton from './PrintCardButton';
|
import RecipeCardImage from './RecipeCardImage';
|
||||||
|
|
||||||
interface RecipeCardPanelProps {
|
interface RecipeCardPanelProps {
|
||||||
title: string;
|
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) {
|
export default function RecipeCardPanel({ title, images }: RecipeCardPanelProps) {
|
||||||
const doubleSided = images.length > 1;
|
const doubleSided = images.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={doubleSided ? 'grid gap-6 xl:grid-cols-2' : ''}>
|
||||||
<p className="!mt-0 text-sm text-gray-600 dark:text-gray-400">
|
{images.map((image) => (
|
||||||
A 5×7 in card at 300 DPI. Print it on 5×7 photo paper or card stock
|
<figure key={image.fileName} className="!my-0">
|
||||||
{doubleSided ? ', with the front and back on either side.' : '.'}
|
<RecipeCardImage
|
||||||
</p>
|
src={image.url}
|
||||||
|
alt={`${title} recipe card${doubleSided ? ` (${image.label.toLowerCase()})` : ''}`}
|
||||||
<div className={doubleSided ? 'grid gap-6 xl:grid-cols-2' : ''}>
|
/>
|
||||||
{images.map((image) => (
|
</figure>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,7 +74,6 @@ 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;
|
||||||
@ -404,7 +403,6 @@ 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`,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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, 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:
|
To put a shorter version on the card, use the container form with **four** colons. Leave out a section to use the full one:
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user