feat(frontend): polish recipe detail and import flow visuals
This commit is contained in:
parent
ca11d9d878
commit
79d10730a2
|
|
@ -2,9 +2,10 @@ import { useState, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { createRecipe, importRecipeFromUrl } from '../services/api';
|
import { createRecipe, importRecipeFromUrl } from '../services/api';
|
||||||
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
|
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 ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
|
||||||
|
type ImportStage = 'idle' | 'fetching' | 'parsing' | 'review' | 'saving' | 'done' | 'error';
|
||||||
|
|
||||||
function toTextBlock(items: string[]): string {
|
function toTextBlock(items: string[]): string {
|
||||||
return items.join('\n');
|
return items.join('\n');
|
||||||
|
|
@ -43,16 +44,25 @@ function getImportErrorDetails(message: string): { type: ImportErrorType; messag
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Converts recipe-draft shape (object[] — {item, ...}) to string[] for textarea editing
|
|
||||||
function draftIngredientsToStringArray(ingredients: RecipeDraft['ingredients']): string[] {
|
function draftIngredientsToStringArray(ingredients: RecipeDraft['ingredients']): string[] {
|
||||||
if (!Array.isArray(ingredients)) return [];
|
if (!Array.isArray(ingredients)) return [];
|
||||||
return ingredients.map((x) => x && typeof x === 'object' && typeof x.item === 'string' ? x.item : String(x));
|
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'] {
|
function ingredientStringsToDraftArray(strings: string[]): RecipeDraft['ingredients'] {
|
||||||
return strings.map((s) => ({ item: s, quantity: null, unit: null, notes: null }));
|
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() {
|
export function ImportUrlPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [url, setUrl] = useState<string>('');
|
const [url, setUrl] = useState<string>('');
|
||||||
|
|
@ -60,14 +70,13 @@ export function ImportUrlPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
|
const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
|
||||||
const [result, setResult] = useState<UrlImportResult | 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 [ingredientLines, setIngredientLines] = useState<string[]>([]);
|
||||||
const [instructionLines, setInstructionLines] = useState<string[]>([]);
|
const [instructionLines, setInstructionLines] = useState<string[]>([]);
|
||||||
const [draft, setDraft] = useState<RecipeDraft | null>(null);
|
const [draft, setDraft] = useState<RecipeDraft | null>(null);
|
||||||
const [draftError, setDraftError] = useState<string | null>(null);
|
const [draftError, setDraftError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
const [stage, setStage] = useState<ImportStage>('idle');
|
||||||
|
|
||||||
// When result/draft loads from import, update the edit text states
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft) {
|
if (draft) {
|
||||||
setIngredientLines(draftIngredientsToStringArray(draft.ingredients));
|
setIngredientLines(draftIngredientsToStringArray(draft.ingredients));
|
||||||
|
|
@ -86,16 +95,22 @@ export function ImportUrlPage() {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setDraftError(null);
|
setDraftError(null);
|
||||||
|
setStage('fetching');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// brief staged transition for visual progress communication
|
||||||
|
setTimeout(() => setStage((current) => (current === 'fetching' ? 'parsing' : current)), 450);
|
||||||
const imported: UrlImportResult = await importRecipeFromUrl(url);
|
const imported: UrlImportResult = await importRecipeFromUrl(url);
|
||||||
setResult(imported);
|
setResult(imported);
|
||||||
const importedDraft = imported.draft_recipe ?? null;
|
const importedDraft = imported.draft_recipe ?? null;
|
||||||
setDraft(importedDraft);
|
setDraft(importedDraft);
|
||||||
|
setStage('review');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
|
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
|
||||||
const details = getImportErrorDetails(message);
|
const details = getImportErrorDetails(message);
|
||||||
setErrorType(details.type);
|
setErrorType(details.type);
|
||||||
setError(details.message);
|
setError(details.message);
|
||||||
|
setStage('error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -107,9 +122,11 @@ export function ImportUrlPage() {
|
||||||
setDraftError('No draft recipe to save.');
|
setDraftError('No draft recipe to save.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = draft.title.trim();
|
const title = draft.title.trim();
|
||||||
const ingredients = ingredientStringsToDraftArray(ingredientLines.filter(Boolean));
|
const ingredients = ingredientStringsToDraftArray(ingredientLines.filter(Boolean));
|
||||||
const instructions = instructionLines.filter(Boolean);
|
const instructions = instructionLines.filter(Boolean);
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
setDraftError('Title is required.');
|
setDraftError('Title is required.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -122,77 +139,144 @@ export function ImportUrlPage() {
|
||||||
setDraftError('At least one instruction step is required.');
|
setDraftError('At least one instruction step is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setDraftError(null);
|
setDraftError(null);
|
||||||
|
setStage('saving');
|
||||||
try {
|
try {
|
||||||
const created = await createRecipe({ ...draft, title, ingredients, instructions });
|
const created = await createRecipe({ ...draft, title, ingredients, instructions });
|
||||||
|
setStage('done');
|
||||||
navigate(`/recipe/${created.id}`);
|
navigate(`/recipe/${created.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save recipe';
|
const message = err instanceof Error ? err.message : 'Failed to save recipe';
|
||||||
setDraftError(message);
|
setDraftError(message);
|
||||||
|
setStage('error');
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const progress = stageProgress(stage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-8">
|
<div className="mx-auto max-w-3xl py-8">
|
||||||
<div className="bg-white rounded-2xl shadow-card p-7 border border-gray-100 mb-8">
|
<section
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
|
className="relative mb-8 overflow-hidden border border-slate-200/80 bg-white/90 shadow-card"
|
||||||
<p className="text-gray-600 mb-6">Paste a recipe URL and we'll try to fetch the page and extract recipe data.</p>
|
style={{ borderRadius: radius.lg }}
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
>
|
||||||
<div>
|
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-blue-100 via-indigo-50 to-violet-100" />
|
||||||
<label htmlFor="import-url" className="block text-sm font-medium text-gray-700 mb-2">Recipe URL</label>
|
<div className="relative px-6 pb-6 pt-5 md:px-7">
|
||||||
<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="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>
|
</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'}
|
<div className="mb-5 rounded-lg border border-slate-200 bg-slate-50/80 p-3">
|
||||||
</button>
|
<div className="mb-2 flex items-center justify-between text-sm">
|
||||||
</form>
|
<span className="font-semibold text-slate-700">Import Progress</span>
|
||||||
{error && (
|
<span className="text-slate-500">{progress}%</span>
|
||||||
<div className={`mt-6 border rounded-lg p-4 ${errorType === 'parse-failure' ? 'bg-amber-50 border-amber-200' : 'bg-red-50 border-red-200'}`}>
|
</div>
|
||||||
<p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}>
|
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||||
<strong>{errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'}</strong> {error}
|
<div className="h-full rounded-full bg-gradient-to-r from-blue-500 to-indigo-500 transition-all duration-500" style={{ width: `${progress}%` }} />
|
||||||
</p>
|
</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>
|
||||||
)}
|
|
||||||
</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 && (
|
{result && (
|
||||||
<div className="mt-1 bg-white border border-gray-200 rounded-2xl p-7 space-y-4 shadow-card mb-7">
|
<section className="mb-7 mt-1 space-y-5 rounded-2xl border border-gray-200 bg-white p-7 shadow-card">
|
||||||
<div>
|
<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>
|
<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">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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{draft ? (
|
{draft ? (
|
||||||
<form onSubmit={handleSave} className="space-y-5">
|
<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>
|
<div>
|
||||||
<label htmlFor="draft-title" className="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
<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 px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">Ingredients (one per line)</label>
|
<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 px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">Steps (one per line)</label>
|
<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 px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="draft-source-url" className="block text-sm font-medium text-gray-700 mb-1">Source URL</label>
|
<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 px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
<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>
|
||||||
<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'}
|
{isSaving ? 'Saving…' : 'Save Recipe'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import { useRecipe } from '../hooks/useRecipe';
|
||||||
import { useToastContext } from '../App';
|
import { useToastContext } from '../App';
|
||||||
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
|
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
|
||||||
import { createRecipe, updateRecipe, deleteRecipe, fetchRecipeTags, assignTagToRecipe, removeTagFromRecipe } from '../services/api';
|
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() {
|
export function RecipeDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
@ -32,7 +33,6 @@ export function RecipeDetailPage() {
|
||||||
}
|
}
|
||||||
}, [recipeId, toast]);
|
}, [recipeId, toast]);
|
||||||
|
|
||||||
// Compose FE ingredients to BE Ingredient[] shape with dummies for missing fields
|
|
||||||
function toApiIngredients(ingredients: string[]): Ingredient[] {
|
function toApiIngredients(ingredients: string[]): Ingredient[] {
|
||||||
return ingredients.map((item, idx) => ({
|
return ingredients.map((item, idx) => ({
|
||||||
id: 0,
|
id: 0,
|
||||||
|
|
@ -45,11 +45,9 @@ export function RecipeDetailPage() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
|
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
|
||||||
try {
|
try {
|
||||||
if (recipeId === null) {
|
if (recipeId === null) {
|
||||||
// Compose to API input shape (fill dummies)
|
|
||||||
const newRecipe = await createRecipe({
|
const newRecipe = await createRecipe({
|
||||||
...data,
|
...data,
|
||||||
ingredients: toApiIngredients(data.ingredients),
|
ingredients: toApiIngredients(data.ingredients),
|
||||||
|
|
@ -64,7 +62,6 @@ export function RecipeDetailPage() {
|
||||||
ingredients: toApiIngredients(data.ingredients),
|
ingredients: toApiIngredients(data.ingredients),
|
||||||
instructions: data.instructions,
|
instructions: data.instructions,
|
||||||
});
|
});
|
||||||
// Tag syncing (remove/add)
|
|
||||||
const currentTagIds = recipeTags.map(t => t.id);
|
const currentTagIds = recipeTags.map(t => t.id);
|
||||||
const newTagIds = tags.map(t => t.id);
|
const newTagIds = tags.map(t => t.id);
|
||||||
for (const tagId of currentTagIds) {
|
for (const tagId of currentTagIds) {
|
||||||
|
|
@ -99,157 +96,214 @@ export function RecipeDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading State
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24">
|
<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>
|
<div className="inline-block h-9 w-9 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||||
<p className="mt-6 text-gray-500 text-base font-medium">Loading recipe...</p>
|
<p className="mt-6 text-base font-medium text-gray-500">Loading recipe...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Error State
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<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="text-xl text-red-800 font-bold mb-3">Error Loading Recipe</h3>
|
<h3 className="mb-3 text-xl font-bold text-red-800">Error Loading Recipe</h3>
|
||||||
<p className="text-red-600 text-base mb-2">{error}</p>
|
<p className="mb-2 text-base text-red-600">{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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// New Recipe
|
|
||||||
if (recipeId === null) {
|
if (recipeId === null) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto pt-8">
|
<div className="mx-auto max-w-3xl pt-8">
|
||||||
<div className="mb-6 pb-1 border-b border-gray-200">
|
<div
|
||||||
<h2 className="text-3xl font-bold text-gray-900">Create New Recipe</h2>
|
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"
|
||||||
<p className="mt-1 text-base text-gray-500">Fill in the details below to add a new recipe</p>
|
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>
|
||||||
<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" />
|
<RecipeForm initialTags={[]} onSubmit={handleSubmit} onCancel={() => navigate('/')} submitLabel="Create Recipe" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Recipe Not Found
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return (
|
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">
|
<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="text-xl text-yellow-800 font-bold mb-2">Recipe Not Found</h3>
|
<h3 className="mb-2 text-xl font-bold text-yellow-800">Recipe Not Found</h3>
|
||||||
<p className="text-yellow-600 text-base mb-2">The recipe you are looking for does not exist.</p>
|
<p className="mb-2 text-base text-yellow-600">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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Edit Mode
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto pt-8">
|
<div className="mx-auto max-w-3xl pt-8">
|
||||||
<div className="mb-6 pb-1 border-b border-gray-200">
|
<div
|
||||||
<h2 className="text-3xl font-bold text-gray-900">Edit Recipe</h2>
|
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"
|
||||||
<p className="mt-1 text-base text-gray-500">Update recipe information below</p>
|
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>
|
||||||
<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" />
|
<RecipeForm recipe={recipe} initialTags={recipeTags} onSubmit={handleSubmit} onCancel={() => setIsEditing(false)} submitLabel="Save Changes" />
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto pt-8">
|
<div className="mx-auto max-w-5xl 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">
|
<section
|
||||||
<div className="flex-1 min-w-0">
|
className="relative mb-6 overflow-hidden border border-slate-200/80 bg-white/95 shadow-card"
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-1 break-words">{recipe.title}</h2>
|
style={{ borderRadius: radius.lg }}
|
||||||
{recipe.description && (
|
>
|
||||||
<p className="mt-1 text-lg text-gray-600 break-words">{recipe.description}</p>
|
<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">
|
||||||
{recipeTags.length > 0 && (
|
<div className="mb-5 flex items-start justify-between gap-5">
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
{recipeTags.map(tag => (
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-orange-600">Recipe Details</p>
|
||||||
<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>
|
<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>
|
||||||
)}
|
<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>
|
||||||
<div className="flex flex-col gap-3 min-w-[120px]">
|
</section>
|
||||||
<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>
|
{statCards.length > 0 && (
|
||||||
{!deleteConfirm ? (
|
<section className="mb-6 grid grid-cols-1 gap-4 text-center md:grid-cols-3">
|
||||||
<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>
|
{statCards.map((stat) => (
|
||||||
) : (
|
<div key={stat.label} className="rounded-xl border border-slate-200/80 bg-white/90 p-4 shadow-card">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="mb-1 text-2xl" aria-hidden="true">{stat.icon}</div>
|
||||||
<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>
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{stat.label}</div>
|
||||||
<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>
|
<div className="mt-1 text-lg font-bold text-gray-900">{stat.value}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6 text-center">
|
|
||||||
{recipe.servings && (
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<div className="bg-gray-50 rounded-lg p-4 shadow-card">
|
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
|
||||||
<div className="text-sm text-gray-500 mb-1">Servings</div>
|
<div className="border-b border-slate-100 bg-gradient-to-r from-blue-50 to-white px-6 py-4">
|
||||||
<div className="text-lg font-semibold text-gray-900">{recipe.servings}</div>
|
<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>
|
</div>
|
||||||
)}
|
<ul className="space-y-3 px-6 py-5">
|
||||||
{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">
|
|
||||||
{Array.isArray(recipe.ingredients) ? recipe.ingredients.map((ingredient, index) => (
|
{Array.isArray(recipe.ingredients) ? recipe.ingredients.map((ingredient, index) => (
|
||||||
<li key={index} className="flex items-center gap-3">
|
<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-block w-3 h-3 bg-primary rounded-full"></span>
|
<span className="mt-1 inline-block h-2.5 w-2.5 rounded-full bg-blue-500"></span>
|
||||||
<span className="text-gray-800 font-mono text-base">{'item' in ingredient ? ingredient.item : ingredient}</span>
|
<span className="font-mono text-base text-gray-800">{'item' in ingredient ? ingredient.item : ingredient}</span>
|
||||||
</li>
|
</li>
|
||||||
)) : null}
|
)) : null}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</section>
|
||||||
<div className="bg-white rounded-xl shadow-card p-6">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">Instructions</h3>
|
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
|
||||||
<ol className="space-y-4 list-none">
|
<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) => (
|
{Array.isArray(recipe.instructions) ? recipe.instructions.map((instruction, index) => (
|
||||||
<li key={index} className="flex items-start gap-3">
|
<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 items-center justify-center w-8 h-8 bg-primary text-white rounded-full text-base font-bold">{index + 1}</span>
|
<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="text-gray-800 pt-[2px] text-base leading-6">{instruction}</span>
|
<span className="pt-1 text-base leading-6 text-gray-800">{instruction}</span>
|
||||||
</li>
|
</li>
|
||||||
)) : null}
|
)) : null}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(recipe.source_url || recipe.notes) && (
|
{(recipe.source_url || recipe.notes) && (
|
||||||
<div className="mt-8 bg-white rounded-xl shadow-card p-6">
|
<section className="mt-8 overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">Additional Information</h3>
|
<div className="border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-4">
|
||||||
{recipe.source_url && (
|
<h3 className="text-xl font-semibold text-gray-900">Additional Information</h3>
|
||||||
<div className="mb-4">
|
</div>
|
||||||
<div className="text-sm font-medium text-gray-700 mb-1">Source</div>
|
<div className="space-y-5 px-6 py-5">
|
||||||
<a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:text-blue-700 underline break-all">{recipe.source_url}</a>
|
{recipe.source_url && (
|
||||||
</div>
|
<div>
|
||||||
)}
|
<div className="mb-1 text-sm font-medium text-gray-700">Source</div>
|
||||||
{recipe.notes && (
|
<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>
|
</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>
|
{recipe.notes && (
|
||||||
</div>
|
<div>
|
||||||
)}
|
<div className="mb-1 text-sm font-medium text-gray-700">Notes</div>
|
||||||
</div>
|
<p className="whitespace-pre-wrap text-gray-800">{recipe.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue