diff --git a/frontend/src/pages/ImportUrlPage.tsx b/frontend/src/pages/ImportUrlPage.tsx index 137665e..f5989b7 100644 --- a/frontend/src/pages/ImportUrlPage.tsx +++ b/frontend/src/pages/ImportUrlPage.tsx @@ -1,5 +1,4 @@ -import { useState } from 'react'; -import type { FormEvent } from 'react'; +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'; @@ -9,65 +8,76 @@ type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic'; function toTextBlock(items: string[]): string { return items.join('\n'); } - function toList(text: string): string[] { - return text - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); + return text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); } - function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } { const normalized = message.toLowerCase(); - if (normalized.includes('valid url')) { return { type: 'invalid-url', message: 'Please enter a valid URL (including https://).', }; } - if (normalized.includes('timed out')) { return { type: 'timeout', message: 'The import request timed out. Please try again in a moment.', }; } - if (normalized.includes('network error') || normalized.includes('could not fetch the page')) { return { type: 'generic', message: 'We could not reach that recipe page right now. Please try again in a moment.', }; } - if (normalized.includes('did not return an html page')) { return { type: 'generic', message: 'That link did not point to an HTML recipe page. Try the direct recipe URL.', }; } - return { type: 'generic', 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 })); +} export function ImportUrlPage() { const navigate = useNavigate(); - - const [url, setUrl] = useState(''); - const [loading, setLoading] = useState(false); + const [url, setUrl] = useState(''); + const [loading, setLoading] = useState(false); 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 [isSaving, setIsSaving] = useState(false); - const handleSubmit = async (event: FormEvent) => { + // When result/draft loads from import, update the edit text states + useEffect(() => { + if (draft) { + setIngredientLines(draftIngredientsToStringArray(draft.ingredients)); + setInstructionLines(Array.isArray(draft.instructions) ? draft.instructions.filter(Boolean) : []); + } else { + setIngredientLines([]); + setInstructionLines([]); + } + }, [draft]); + + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setLoading(true); setError(null); @@ -75,16 +85,11 @@ export function ImportUrlPage() { setResult(null); setDraft(null); setDraftError(null); - try { - const imported = await importRecipeFromUrl(url); + const imported: UrlImportResult = await importRecipeFromUrl(url); setResult(imported); - setDraft(imported.draft_recipe); - - if (!imported.draft_recipe) { - setErrorType('parse-failure'); - setError('We could fetch this page, but could not find recipe fields to import.'); - } + const importedDraft = imported.draft_recipe ?? null; + setDraft(importedDraft); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to import recipe URL'; const details = getImportErrorDetails(message); @@ -95,17 +100,15 @@ export function ImportUrlPage() { } }; - const handleSave = async (event: FormEvent) => { + const handleSave = async (event: React.FormEvent) => { event.preventDefault(); if (!draft) { setDraftError('No draft recipe to save.'); return; } - const title = draft.title.trim(); - const ingredients = draft.ingredients.map((item) => item.trim()).filter(Boolean); - const instructions = draft.instructions.map((item) => item.trim()).filter(Boolean); - + const ingredients = ingredientStringsToDraftArray(ingredientLines.filter(Boolean)); + const instructions = instructionLines.filter(Boolean); if (!title) { setDraftError('Title is required.'); return; @@ -118,17 +121,10 @@ export function ImportUrlPage() { setDraftError('At least one instruction step is required.'); return; } - setIsSaving(true); setDraftError(null); - try { - const created = await createRecipe({ - ...draft, - title, - ingredients, - instructions, - }); + const created = await createRecipe({ ...draft, title, ingredients, instructions }); navigate(`/recipe/${created.id}`); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to save recipe'; @@ -140,154 +136,16 @@ export function ImportUrlPage() { return (

Import from URL

-

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

- +

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" - /> + + 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" />
- - +
- - {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: {result.json_ld_blocks.length}

-
- - {draft ? ( -
-

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