Fix ImportUrlPage.tsx ingredient draft-edit type bridging: safe conversion between string[] UI state and RecipeDraft object[] shape. Preserves step 4 search+tag.

This commit is contained in:
Paul Huliganga 2026-03-25 16:59:41 -04:00
parent 14c0cbb94c
commit 2ffb1da919
1 changed files with 42 additions and 184 deletions

View File

@ -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<string>('');
const [loading, setLoading] = useState<boolean>(false);
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(false);
const [isSaving, setIsSaving] = useState<boolean>(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<HTMLFormElement>) => {
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<HTMLFormElement>) => {
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 (
<div className="max-w-3xl mx-auto">
<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&apos;ll try to fetch the page and extract recipe data.
</p>
<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="bg-white rounded-lg shadow p-6 space-y-4">
<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"
/>
<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" />
</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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Importing…' : 'Import URL'}
</button>
<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 disabled:opacity-50 disabled:cursor-not-allowed">{loading ? 'Importing…' : 'Import URL'}</button>
</form>
{error && (
<div
className={`mt-4 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>
)}
{result && (
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-6 space-y-4">
<div>
<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: {result.json_ld_blocks.length}</p>
</div>
{draft ? (
<form onSubmit={handleSave} className="space-y-4">
<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">
{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"
/>
</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(draft.ingredients)}
onChange={(event) => setDraft({ ...draft, ingredients: toList(event.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</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(draft.instructions)}
onChange={(event) => setDraft({ ...draft, instructions: toList(event.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</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"
/>
</div>
<div className="flex gap-3">
<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"
>
{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"
>
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>
)}
</div>
)}
{error && (<div className={`mt-4 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>)}
{result && (<div className="mt-4 bg-white border border-gray-200 rounded-lg p-6 space-y-4"><div><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-4"><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">{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" /></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" /></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" /></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" /></div><div className="flex gap-3"><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">{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">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>)}</div>)}
</div>
);
}