diff --git a/frontend/src/pages/ImportUrlPage.tsx b/frontend/src/pages/ImportUrlPage.tsx index b1a7bfc..6a6e6dd 100644 --- a/frontend/src/pages/ImportUrlPage.tsx +++ b/frontend/src/pages/ImportUrlPage.tsx @@ -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(''); @@ -60,14 +70,13 @@ export function ImportUrlPage() { const [error, setError] = useState(null); const [errorType, setErrorType] = useState(null); const [result, setResult] = useState(null); - // UI edit: keep ingredient/instructions as raw strings for editing, sync to draft before save const [ingredientLines, setIngredientLines] = useState([]); const [instructionLines, setInstructionLines] = useState([]); const [draft, setDraft] = useState(null); const [draftError, setDraftError] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [stage, setStage] = useState('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 ( -
-
-

Import from URL

-

Paste a recipe URL and we'll try to fetch the page and extract recipe data.

-
-
- - 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" /> +
+
+
+
+
+
+

Smart Import

+

Import from URL

+

Paste a recipe URL and we'll fetch, parse, and prep it for your cookbook.

+
+
🔎
- - - {error && ( -
-

- {errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'} {error} -

+ +
+
+ Import Progress + {progress}% +
+
+
+
+
+ {[ + { 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 ( +
+ {done ? '✓ ' : active ? '● ' : '○ '}{step.label} +
+ ); + })} +
- )} -
+ +
+
+ + 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" + /> +
+ +
+ + {error && ( +
+

+ {errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'} {error} +

+
+ )} +
+
+ {result && ( -
-
+
+

Parsed Preview

Source: {result.source_url}

JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}

+ {draft ? (
-

Review and edit before saving.

{draftError && (
{draftError}
)} +

Review and edit before saving.

+ {draftError && (
{draftError}
)} +
- - setDraft({ ...draft, title: event.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" /> + + setDraft({ ...draft, title: event.target.value })} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" />
+
- -