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:
parent
14c0cbb94c
commit
2ffb1da919
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { FormEvent } 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';
|
||||||
|
|
@ -9,65 +8,76 @@ type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
|
||||||
function toTextBlock(items: string[]): string {
|
function toTextBlock(items: string[]): string {
|
||||||
return items.join('\n');
|
return items.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toList(text: string): string[] {
|
function toList(text: string): string[] {
|
||||||
return text
|
return text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } {
|
function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } {
|
||||||
const normalized = message.toLowerCase();
|
const normalized = message.toLowerCase();
|
||||||
|
|
||||||
if (normalized.includes('valid url')) {
|
if (normalized.includes('valid url')) {
|
||||||
return {
|
return {
|
||||||
type: 'invalid-url',
|
type: 'invalid-url',
|
||||||
message: 'Please enter a valid URL (including https://).',
|
message: 'Please enter a valid URL (including https://).',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.includes('timed out')) {
|
if (normalized.includes('timed out')) {
|
||||||
return {
|
return {
|
||||||
type: 'timeout',
|
type: 'timeout',
|
||||||
message: 'The import request timed out. Please try again in a moment.',
|
message: 'The import request timed out. Please try again in a moment.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.includes('network error') || normalized.includes('could not fetch the page')) {
|
if (normalized.includes('network error') || normalized.includes('could not fetch the page')) {
|
||||||
return {
|
return {
|
||||||
type: 'generic',
|
type: 'generic',
|
||||||
message: 'We could not reach that recipe page right now. Please try again in a moment.',
|
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')) {
|
if (normalized.includes('did not return an html page')) {
|
||||||
return {
|
return {
|
||||||
type: 'generic',
|
type: 'generic',
|
||||||
message: 'That link did not point to an HTML recipe page. Try the direct recipe URL.',
|
message: 'That link did not point to an HTML recipe page. Try the direct recipe URL.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'generic',
|
type: 'generic',
|
||||||
message,
|
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() {
|
export function ImportUrlPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [url, setUrl] = useState<string>('');
|
||||||
const [url, setUrl] = useState('');
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
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 [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(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();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -75,16 +85,11 @@ export function ImportUrlPage() {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setDraftError(null);
|
setDraftError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imported = await importRecipeFromUrl(url);
|
const imported: UrlImportResult = await importRecipeFromUrl(url);
|
||||||
setResult(imported);
|
setResult(imported);
|
||||||
setDraft(imported.draft_recipe);
|
const importedDraft = imported.draft_recipe ?? null;
|
||||||
|
setDraft(importedDraft);
|
||||||
if (!imported.draft_recipe) {
|
|
||||||
setErrorType('parse-failure');
|
|
||||||
setError('We could fetch this page, but could not find recipe fields to import.');
|
|
||||||
}
|
|
||||||
} 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);
|
||||||
|
|
@ -95,17 +100,15 @@ export function ImportUrlPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (event: FormEvent) => {
|
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
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 = draft.ingredients.map((item) => item.trim()).filter(Boolean);
|
const ingredients = ingredientStringsToDraftArray(ingredientLines.filter(Boolean));
|
||||||
const instructions = draft.instructions.map((item) => item.trim()).filter(Boolean);
|
const instructions = instructionLines.filter(Boolean);
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
setDraftError('Title is required.');
|
setDraftError('Title is required.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -118,17 +121,10 @@ 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);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await createRecipe({
|
const created = await createRecipe({ ...draft, title, ingredients, instructions });
|
||||||
...draft,
|
|
||||||
title,
|
|
||||||
ingredients,
|
|
||||||
instructions,
|
|
||||||
});
|
|
||||||
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';
|
||||||
|
|
@ -140,154 +136,16 @@ export function ImportUrlPage() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">Paste a recipe URL and we'll try to fetch the page and extract recipe data.</p>
|
||||||
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">
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="import-url" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="import-url" className="block text-sm font-medium text-gray-700 mb-2">Recipe URL</label>
|
||||||
Recipe URL
|
<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>
|
|
||||||
<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>
|
</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>
|
</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>)}
|
||||||
{error && (
|
{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
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue