feat(frontend): polish recipe detail and import flow visuals

This commit is contained in:
Paul Huliganga 2026-03-26 16:32:12 -04:00
parent ca11d9d878
commit 79d10730a2
2 changed files with 285 additions and 147 deletions

View File

@ -2,9 +2,10 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { createRecipe, importRecipeFromUrl } from '../services/api';
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
import { colors, radius, shadows } from '../theme';
import { radius } from '../theme';
type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
type ImportStage = 'idle' | 'fetching' | 'parsing' | 'review' | 'saving' | 'done' | 'error';
function toTextBlock(items: string[]): string {
return items.join('\n');
@ -43,16 +44,25 @@ function getImportErrorDetails(message: string): { type: ImportErrorType; messag
message,
};
}
// Converts recipe-draft shape (object[] — {item, ...}) to string[] for textarea editing
function draftIngredientsToStringArray(ingredients: RecipeDraft['ingredients']): string[] {
if (!Array.isArray(ingredients)) return [];
return ingredients.map((x) => x && typeof x === 'object' && typeof x.item === 'string' ? x.item : String(x));
}
// Converts string[] (from textarea) to recipe draft ingredient object[]
function ingredientStringsToDraftArray(strings: string[]): RecipeDraft['ingredients'] {
return strings.map((s) => ({ item: s, quantity: null, unit: null, notes: null }));
}
function stageProgress(stage: ImportStage): number {
switch (stage) {
case 'fetching': return 25;
case 'parsing': return 60;
case 'review': return 80;
case 'saving': return 95;
case 'done': return 100;
default: return 0;
}
}
export function ImportUrlPage() {
const navigate = useNavigate();
const [url, setUrl] = useState<string>('');
@ -60,14 +70,13 @@ export function ImportUrlPage() {
const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
const [result, setResult] = useState<UrlImportResult | null>(null);
// UI edit: keep ingredient/instructions as raw strings for editing, sync to draft before save
const [ingredientLines, setIngredientLines] = useState<string[]>([]);
const [instructionLines, setInstructionLines] = useState<string[]>([]);
const [draft, setDraft] = useState<RecipeDraft | null>(null);
const [draftError, setDraftError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [stage, setStage] = useState<ImportStage>('idle');
// When result/draft loads from import, update the edit text states
useEffect(() => {
if (draft) {
setIngredientLines(draftIngredientsToStringArray(draft.ingredients));
@ -86,16 +95,22 @@ export function ImportUrlPage() {
setResult(null);
setDraft(null);
setDraftError(null);
setStage('fetching');
try {
// brief staged transition for visual progress communication
setTimeout(() => setStage((current) => (current === 'fetching' ? 'parsing' : current)), 450);
const imported: UrlImportResult = await importRecipeFromUrl(url);
setResult(imported);
const importedDraft = imported.draft_recipe ?? null;
setDraft(importedDraft);
setStage('review');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
const details = getImportErrorDetails(message);
setErrorType(details.type);
setError(details.message);
setStage('error');
} finally {
setLoading(false);
}
@ -107,9 +122,11 @@ export function ImportUrlPage() {
setDraftError('No draft recipe to save.');
return;
}
const title = draft.title.trim();
const ingredients = ingredientStringsToDraftArray(ingredientLines.filter(Boolean));
const instructions = instructionLines.filter(Boolean);
if (!title) {
setDraftError('Title is required.');
return;
@ -122,77 +139,144 @@ export function ImportUrlPage() {
setDraftError('At least one instruction step is required.');
return;
}
setIsSaving(true);
setDraftError(null);
setStage('saving');
try {
const created = await createRecipe({ ...draft, title, ingredients, instructions });
setStage('done');
navigate(`/recipe/${created.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save recipe';
setDraftError(message);
setStage('error');
setIsSaving(false);
}
};
const progress = stageProgress(stage);
return (
<div className="max-w-2xl mx-auto py-8">
<div className="bg-white rounded-2xl shadow-card p-7 border border-gray-100 mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
<p className="text-gray-600 mb-6">Paste a recipe URL and we'll try to fetch the page and extract recipe data.</p>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="import-url" className="block text-sm font-medium text-gray-700 mb-2">Recipe URL</label>
<input id="import-url" type="url" required value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://example.com/my-recipe" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-base shadow-sm" />
<div className="mx-auto max-w-3xl py-8">
<section
className="relative mb-8 overflow-hidden border border-slate-200/80 bg-white/90 shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-blue-100 via-indigo-50 to-violet-100" />
<div className="relative px-6 pb-6 pt-5 md:px-7">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-blue-700">Smart Import</p>
<h2 className="text-3xl font-bold text-gray-900">Import from URL</h2>
<p className="mt-1 text-gray-600">Paste a recipe URL and we'll fetch, parse, and prep it for your cookbook.</p>
</div>
<div className="hidden rounded-xl border border-white/80 bg-white/70 p-4 text-3xl md:block">🔎</div>
</div>
<button type="submit" disabled={loading} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow disabled:opacity-50 disabled:cursor-not-allowed">
{loading ? 'Importing…' : 'Import URL'}
</button>
</form>
{error && (
<div className={`mt-6 border rounded-lg p-4 ${errorType === 'parse-failure' ? 'bg-amber-50 border-amber-200' : 'bg-red-50 border-red-200'}`}>
<p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}>
<strong>{errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'}</strong> {error}
</p>
<div className="mb-5 rounded-lg border border-slate-200 bg-slate-50/80 p-3">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-semibold text-slate-700">Import Progress</span>
<span className="text-slate-500">{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
<div className="h-full rounded-full bg-gradient-to-r from-blue-500 to-indigo-500 transition-all duration-500" style={{ width: `${progress}%` }} />
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
{[
{ key: 'fetching', label: 'Fetch page' },
{ key: 'parsing', label: 'Parse data' },
{ key: 'review', label: 'Review draft' },
{ key: 'saving', label: 'Save recipe' },
].map((step, index) => {
const activeIndex = ['idle', 'fetching', 'parsing', 'review', 'saving', 'done', 'error'].indexOf(stage);
const stepIndex = index + 1;
const done = stage === 'done' || activeIndex > stepIndex;
const active = activeIndex === stepIndex;
return (
<div
key={step.key}
className={`rounded-md border px-2 py-1.5 ${done ? 'border-green-200 bg-green-50 text-green-700' : active ? 'border-blue-200 bg-blue-50 text-blue-700' : 'border-slate-200 bg-white text-slate-500'}`}
>
{done ? '✓ ' : active ? '● ' : '○ '}{step.label}
</div>
);
})}
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="import-url" className="mb-2 block text-sm font-medium text-gray-700">Recipe URL</label>
<input
id="import-url"
type="url"
required
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://example.com/my-recipe"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-base shadow-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
</div>
<button type="submit" disabled={loading} className="rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
{loading ? 'Importing…' : 'Import URL'}
</button>
</form>
{error && (
<div className={`mt-6 rounded-lg border p-4 ${errorType === 'parse-failure' ? 'border-amber-200 bg-amber-50' : 'border-red-200 bg-red-50'}`}>
<p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}>
<strong>{errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'}</strong> {error}
</p>
</div>
)}
</div>
</section>
{result && (
<div className="mt-1 bg-white border border-gray-200 rounded-2xl p-7 space-y-4 shadow-card mb-7">
<div>
<section className="mb-7 mt-1 space-y-5 rounded-2xl border border-gray-200 bg-white p-7 shadow-card">
<div className="rounded-xl border border-indigo-100 bg-gradient-to-r from-indigo-50 to-white px-4 py-3">
<h3 className="font-semibold text-gray-900">Parsed Preview</h3>
<p className="text-sm text-gray-600">Source: {result.source_url}</p>
<p className="text-sm text-gray-600">JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}</p>
</div>
{draft ? (
<form onSubmit={handleSave} className="space-y-5">
<p className="text-sm text-gray-600">Review and edit before saving.</p>{draftError && (<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800 text-sm mb-2">{draftError}</div>)}
<p className="text-sm text-gray-600">Review and edit before saving.</p>
{draftError && (<div className="mb-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">{draftError}</div>)}
<div>
<label htmlFor="draft-title" className="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input id="draft-title" type="text" required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
<label htmlFor="draft-title" className="mb-1 block text-sm font-medium text-gray-700">Title</label>
<input id="draft-title" type="text" required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" />
</div>
<div>
<label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">Ingredients (one per line)</label>
<textarea id="draft-ingredients" rows={8} value={toTextBlock(ingredientLines)} onChange={e => setIngredientLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
<label htmlFor="draft-ingredients" className="mb-1 block text-sm font-medium text-gray-700">Ingredients (one per line)</label>
<textarea id="draft-ingredients" rows={8} value={toTextBlock(ingredientLines)} onChange={e => setIngredientLines(toList(e.target.value))} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" />
</div>
<div>
<label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">Steps (one per line)</label>
<textarea id="draft-instructions" rows={10} value={toTextBlock(instructionLines)} onChange={e => setInstructionLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
<label htmlFor="draft-instructions" className="mb-1 block text-sm font-medium text-gray-700">Steps (one per line)</label>
<textarea id="draft-instructions" rows={10} value={toTextBlock(instructionLines)} onChange={e => setInstructionLines(toList(e.target.value))} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" />
</div>
<div>
<label htmlFor="draft-source-url" className="block text-sm font-medium text-gray-700 mb-1">Source URL</label>
<input id="draft-source-url" type="url" value={draft.source_url ?? ''} onChange={(event) => setDraft({ ...draft, source_url: event.target.value.trim() ? event.target.value : undefined })} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
<label htmlFor="draft-source-url" className="mb-1 block text-sm font-medium text-gray-700">Source URL</label>
<input id="draft-source-url" type="url" value={draft.source_url ?? ''} onChange={(event) => setDraft({ ...draft, source_url: event.target.value.trim() ? event.target.value : undefined })} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" />
</div>
<div className="flex gap-3 mt-2">
<button type="submit" disabled={isSaving} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed shadow">
<div className="mt-2 flex gap-3">
<button type="submit" disabled={isSaving} className="rounded-lg bg-green-600 px-4 py-2 font-medium text-white shadow hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50">
{isSaving ? 'Saving…' : 'Save Recipe'}
</button>
<Link to="/recipe/new" className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium shadow-sm">Open full editor</Link>
<Link to="/recipe/new" className="rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 shadow-sm hover:bg-gray-50">Open full editor</Link>
</div>
</form>
) : (
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded p-3 text-sm">Could not parse a recipe preview from this URL.</p>
<p className="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">Could not parse a recipe preview from this URL.</p>
)}
</div>
</section>
)}
</div>
);

View File

@ -4,10 +4,11 @@ import { useRecipe } from '../hooks/useRecipe';
import { useToastContext } from '../App';
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
import { createRecipe, updateRecipe, deleteRecipe, fetchRecipeTags, assignTagToRecipe, removeTagFromRecipe } from '../services/api';
import type { Tag, Recipe, Ingredient } from '../types/recipe';
import type { Tag, Ingredient } from '../types/recipe';
import { radius } from '../theme';
/**
* RecipeDetailPage - View, create, and edit recipes (Visually polished)
* RecipeDetailPage - View, create, and edit recipes (visual refresh - task 3)
*/
export function RecipeDetailPage() {
const { id } = useParams();
@ -32,7 +33,6 @@ export function RecipeDetailPage() {
}
}, [recipeId, toast]);
// Compose FE ingredients to BE Ingredient[] shape with dummies for missing fields
function toApiIngredients(ingredients: string[]): Ingredient[] {
return ingredients.map((item, idx) => ({
id: 0,
@ -45,11 +45,9 @@ export function RecipeDetailPage() {
}));
}
// Handle form submission
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
try {
if (recipeId === null) {
// Compose to API input shape (fill dummies)
const newRecipe = await createRecipe({
...data,
ingredients: toApiIngredients(data.ingredients),
@ -64,7 +62,6 @@ export function RecipeDetailPage() {
ingredients: toApiIngredients(data.ingredients),
instructions: data.instructions,
});
// Tag syncing (remove/add)
const currentTagIds = recipeTags.map(t => t.id);
const newTagIds = tags.map(t => t.id);
for (const tagId of currentTagIds) {
@ -99,157 +96,214 @@ export function RecipeDetailPage() {
}
};
// Loading State
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<div className="inline-block animate-spin rounded-full h-9 w-9 border-b-2 border-primary"></div>
<p className="mt-6 text-gray-500 text-base font-medium">Loading recipe...</p>
<div className="inline-block h-9 w-9 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="mt-6 text-base font-medium text-gray-500">Loading recipe...</p>
</div>
);
}
// Error State
if (error) {
return (
<div className="max-w-xl mx-auto bg-red-50 border border-red-200 rounded-xl shadow-card p-8 mt-12 flex flex-col items-center">
<h3 className="text-xl text-red-800 font-bold mb-3">Error Loading Recipe</h3>
<p className="text-red-600 text-base mb-2">{error}</p>
<Link to="/" className="mt-4 px-4 py-2 bg-primary text-white rounded-md font-medium hover:bg-blue-700"> Back to recipes</Link>
<div className="mx-auto mt-12 flex max-w-xl flex-col items-center rounded-xl border border-red-200 bg-red-50 p-8 shadow-card">
<h3 className="mb-3 text-xl font-bold text-red-800">Error Loading Recipe</h3>
<p className="mb-2 text-base text-red-600">{error}</p>
<Link to="/" className="mt-4 rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-blue-700"> Back to recipes</Link>
</div>
);
}
// New Recipe
if (recipeId === null) {
return (
<div className="max-w-2xl mx-auto pt-8">
<div className="mb-6 pb-1 border-b border-gray-200">
<h2 className="text-3xl font-bold text-gray-900">Create New Recipe</h2>
<p className="mt-1 text-base text-gray-500">Fill in the details below to add a new recipe</p>
<div className="mx-auto max-w-3xl pt-8">
<div
className="mb-6 overflow-hidden border border-blue-100 bg-gradient-to-br from-white via-blue-50/70 to-indigo-50/80 shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="flex items-center justify-between px-6 py-5">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-blue-600">New Recipe</p>
<h2 className="text-3xl font-bold text-gray-900">Create New Recipe</h2>
<p className="mt-1 text-base text-gray-600">Fill in the details below to add a new recipe.</p>
</div>
<div className="hidden rounded-xl border border-blue-100 bg-white/80 p-4 text-3xl md:block">🧾</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-card p-8">
<div className="rounded-xl bg-white p-8 shadow-card">
<RecipeForm initialTags={[]} onSubmit={handleSubmit} onCancel={() => navigate('/')} submitLabel="Create Recipe" />
</div>
</div>
);
}
// Recipe Not Found
if (!recipe) {
return (
<div className="max-w-md mx-auto bg-yellow-50 border border-yellow-200 rounded-xl shadow-card p-8 mt-12 flex flex-col items-center">
<h3 className="text-xl text-yellow-800 font-bold mb-2">Recipe Not Found</h3>
<p className="text-yellow-600 text-base mb-2">The recipe you are looking for does not exist.</p>
<Link to="/" className="mt-4 px-4 py-2 bg-primary text-white rounded-md font-medium hover:bg-blue-700"> Back to recipes</Link>
<div className="mx-auto mt-12 flex max-w-md flex-col items-center rounded-xl border border-yellow-200 bg-yellow-50 p-8 shadow-card">
<h3 className="mb-2 text-xl font-bold text-yellow-800">Recipe Not Found</h3>
<p className="mb-2 text-base text-yellow-600">The recipe you are looking for does not exist.</p>
<Link to="/" className="mt-4 rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-blue-700"> Back to recipes</Link>
</div>
);
}
// Edit Mode
if (isEditing) {
return (
<div className="max-w-2xl mx-auto pt-8">
<div className="mb-6 pb-1 border-b border-gray-200">
<h2 className="text-3xl font-bold text-gray-900">Edit Recipe</h2>
<p className="mt-1 text-base text-gray-500">Update recipe information below</p>
<div className="mx-auto max-w-3xl pt-8">
<div
className="mb-6 overflow-hidden border border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-indigo-50/70 shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="flex items-center justify-between px-6 py-5">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-violet-600">Editing</p>
<h2 className="text-3xl font-bold text-gray-900">Edit Recipe</h2>
<p className="mt-1 text-base text-gray-600">Update recipe information below.</p>
</div>
<div className="hidden rounded-xl border border-violet-100 bg-white/80 p-4 text-3xl md:block"></div>
</div>
</div>
<div className="bg-white rounded-xl shadow-card p-8">
<div className="rounded-xl bg-white p-8 shadow-card">
<RecipeForm recipe={recipe} initialTags={recipeTags} onSubmit={handleSubmit} onCancel={() => setIsEditing(false)} submitLabel="Save Changes" />
</div>
</div>
);
}
// View Recipe
const statCards = [
recipe.servings ? { label: 'Servings', value: String(recipe.servings), icon: '🍽️' } : null,
recipe.prep_time_minutes ? { label: 'Prep Time', value: `${recipe.prep_time_minutes} min`, icon: '⏱️' } : null,
recipe.cook_time_minutes ? { label: 'Cook Time', value: `${recipe.cook_time_minutes} min`, icon: '🔥' } : null,
].filter(Boolean) as Array<{ label: string; value: string; icon: string }>;
return (
<div className="max-w-4xl mx-auto pt-8">
<div className="bg-white rounded-xl shadow-card p-8 mb-6 flex flex-col sm:flex-row items-start justify-between gap-6">
<div className="flex-1 min-w-0">
<h2 className="text-4xl font-bold text-gray-900 mb-1 break-words">{recipe.title}</h2>
{recipe.description && (
<p className="mt-1 text-lg text-gray-600 break-words">{recipe.description}</p>
)}
{recipeTags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{recipeTags.map(tag => (
<span key={tag.id} className="px-3 py-1 rounded-full text-xs font-medium text-white shadow" style={{ backgroundColor: tag.color || '#3B82F6' }}>{tag.name}</span>
))}
<div className="mx-auto max-w-5xl pt-8">
<section
className="relative mb-6 overflow-hidden border border-slate-200/80 bg-white/95 shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="absolute inset-x-0 top-0 h-28 bg-gradient-to-r from-orange-100 via-amber-50 to-blue-100" />
<div className="relative px-6 pb-6 pt-7 md:px-8">
<div className="mb-5 flex items-start justify-between gap-5">
<div className="min-w-0 flex-1">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-orange-600">Recipe Details</p>
<h2 className="mb-1 break-words text-4xl font-extrabold text-gray-900">{recipe.title}</h2>
{recipe.description && (
<p className="mt-2 max-w-3xl break-words text-base text-gray-600 md:text-lg">{recipe.description}</p>
)}
{recipeTags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{recipeTags.map(tag => (
<span
key={tag.id}
className="rounded-full px-3 py-1 text-xs font-semibold text-white shadow"
style={{ backgroundColor: tag.color || '#3B82F6' }}
>
{tag.name}
</span>
))}
</div>
)}
</div>
)}
<div className="hidden rounded-2xl border border-white/80 bg-white/75 p-4 text-4xl shadow-sm md:block">🍲</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<button onClick={() => setIsEditing(true)} className="rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-colors hover:bg-blue-700">Edit Recipe</button>
<Link to={`/recipe/${recipe.id}/cook`} className="rounded-lg bg-green-600 px-4 py-2.5 text-center font-semibold text-white shadow transition-colors hover:bg-green-700">Cook Mode</Link>
{!deleteConfirm ? (
<button onClick={() => setDeleteConfirm(true)} className="rounded-lg bg-red-600 px-4 py-2.5 font-semibold text-white shadow transition-colors hover:bg-red-700">Delete Recipe</button>
) : (
<div className="grid grid-cols-2 gap-2 sm:col-span-2 lg:col-span-2">
<button
onClick={handleDelete}
disabled={isDeleting}
className="rounded-lg bg-red-700 px-3 py-2.5 text-sm font-semibold text-white shadow transition-colors hover:bg-red-800 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{isDeleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={() => setDeleteConfirm(false)}
disabled={isDeleting}
className="rounded-lg bg-slate-200 px-3 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-300 disabled:cursor-not-allowed disabled:opacity-60"
>
Cancel
</button>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-3 min-w-[120px]">
<button onClick={() => setIsEditing(true)} className="w-full px-4 py-2 bg-primary text-white rounded-md shadow hover:bg-blue-700 font-medium transition-colors">Edit</button>
<Link to={`/recipe/${recipe.id}/cook`} className="w-full px-4 py-2 bg-success text-white rounded-md shadow hover:bg-green-700 font-medium text-center transition-colors">Cook Mode</Link>
{!deleteConfirm ? (
<button onClick={() => setDeleteConfirm(true)} className="w-full px-4 py-2 bg-error text-white rounded-md shadow font-medium hover:bg-red-700 transition-colors">Delete</button>
) : (
<div className="flex flex-col gap-2">
<button onClick={handleDelete} disabled={isDeleting} className="w-full px-3 py-2 bg-error text-white rounded-md hover:bg-red-700 text-sm font-medium shadow disabled:bg-gray-400 disabled:cursor-not-allowed">{isDeleting ? 'Deleting...' : 'Confirm Delete'}</button>
<button onClick={() => setDeleteConfirm(false)} disabled={isDeleting} className="w-full px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm font-medium shadow disabled:opacity-50 disabled:cursor-not-allowed">Cancel</button>
</section>
{statCards.length > 0 && (
<section className="mb-6 grid grid-cols-1 gap-4 text-center md:grid-cols-3">
{statCards.map((stat) => (
<div key={stat.label} className="rounded-xl border border-slate-200/80 bg-white/90 p-4 shadow-card">
<div className="mb-1 text-2xl" aria-hidden="true">{stat.icon}</div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{stat.label}</div>
<div className="mt-1 text-lg font-bold text-gray-900">{stat.value}</div>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6 text-center">
{recipe.servings && (
<div className="bg-gray-50 rounded-lg p-4 shadow-card">
<div className="text-sm text-gray-500 mb-1">Servings</div>
<div className="text-lg font-semibold text-gray-900">{recipe.servings}</div>
))}
</section>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
<div className="border-b border-slate-100 bg-gradient-to-r from-blue-50 to-white px-6 py-4">
<h3 className="text-xl font-semibold text-gray-900">Ingredients</h3>
<p className="text-sm text-gray-500">Everything you need before you start cooking.</p>
</div>
)}
{recipe.prep_time_minutes && (
<div className="bg-gray-50 rounded-lg p-4 shadow-card">
<div className="text-sm text-gray-500 mb-1">Prep Time</div>
<div className="text-lg font-semibold text-gray-900">{recipe.prep_time_minutes} min</div>
</div>
)}
{recipe.cook_time_minutes && (
<div className="bg-gray-50 rounded-lg p-4 shadow-card">
<div className="text-sm text-gray-500 mb-1">Cook Time</div>
<div className="text-lg font-semibold text-gray-900">{recipe.cook_time_minutes} min</div>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-xl shadow-card p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-6">Ingredients</h3>
<ul className="space-y-3">
<ul className="space-y-3 px-6 py-5">
{Array.isArray(recipe.ingredients) ? recipe.ingredients.map((ingredient, index) => (
<li key={index} className="flex items-center gap-3">
<span className="inline-block w-3 h-3 bg-primary rounded-full"></span>
<span className="text-gray-800 font-mono text-base">{'item' in ingredient ? ingredient.item : ingredient}</span>
<li key={index} className="flex items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/70 px-3 py-2.5">
<span className="mt-1 inline-block h-2.5 w-2.5 rounded-full bg-blue-500"></span>
<span className="font-mono text-base text-gray-800">{'item' in ingredient ? ingredient.item : ingredient}</span>
</li>
)) : null}
</ul>
</div>
<div className="bg-white rounded-xl shadow-card p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-6">Instructions</h3>
<ol className="space-y-4 list-none">
</section>
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
<div className="border-b border-slate-100 bg-gradient-to-r from-orange-50 to-white px-6 py-4">
<h3 className="text-xl font-semibold text-gray-900">Instructions</h3>
<p className="text-sm text-gray-500">Follow these steps for best results.</p>
</div>
<ol className="space-y-3 px-6 py-5">
{Array.isArray(recipe.instructions) ? recipe.instructions.map((instruction, index) => (
<li key={index} className="flex items-start gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 bg-primary text-white rounded-full text-base font-bold">{index + 1}</span>
<span className="text-gray-800 pt-[2px] text-base leading-6">{instruction}</span>
<li key={index} className="flex items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/70 px-3 py-2.5">
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm font-bold text-white">{index + 1}</span>
<span className="pt-1 text-base leading-6 text-gray-800">{instruction}</span>
</li>
)) : null}
</ol>
</div>
</section>
</div>
{(recipe.source_url || recipe.notes) && (
<div className="mt-8 bg-white rounded-xl shadow-card p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">Additional Information</h3>
{recipe.source_url && (
<div className="mb-4">
<div className="text-sm font-medium text-gray-700 mb-1">Source</div>
<a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:text-blue-700 underline break-all">{recipe.source_url}</a>
</div>
)}
{recipe.notes && (
<div>
<div className="text-sm font-medium text-gray-700 mb-1">Notes</div>
<p className="text-gray-800 whitespace-pre-wrap">{recipe.notes}</p>
</div>
)}
</div>
<section className="mt-8 overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
<div className="border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-4">
<h3 className="text-xl font-semibold text-gray-900">Additional Information</h3>
</div>
<div className="space-y-5 px-6 py-5">
{recipe.source_url && (
<div>
<div className="mb-1 text-sm font-medium text-gray-700">Source</div>
<a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline hover:text-blue-700">{recipe.source_url}</a>
</div>
)}
{recipe.notes && (
<div>
<div className="mb-1 text-sm font-medium text-gray-700">Notes</div>
<p className="whitespace-pre-wrap text-gray-800">{recipe.notes}</p>
</div>
)}
</div>
</section>
)}
<div className="mt-8 text-center">
<Link to="/" className="text-primary hover:text-blue-700 font-medium"> Back to all recipes</Link>
<Link to="/" className="font-medium text-primary hover:text-blue-700"> Back to all recipes</Link>
</div>
</div>
);