recipe-manager/frontend/src/pages/ImportUrlPage.tsx

294 lines
9.8 KiB
TypeScript

import { useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { createRecipe, importRecipeFromUrl } from '../services/api';
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
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);
}
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,
};
}
export function ImportUrlPage() {
const navigate = useNavigate();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
const [result, setResult] = useState<UrlImportResult | null>(null);
const [draft, setDraft] = useState<RecipeDraft | null>(null);
const [draftError, setDraftError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setLoading(true);
setError(null);
setErrorType(null);
setResult(null);
setDraft(null);
setDraftError(null);
try {
const imported = 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.');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
const details = getImportErrorDetails(message);
setErrorType(details.type);
setError(details.message);
} finally {
setLoading(false);
}
};
const handleSave = async (event: 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);
if (!title) {
setDraftError('Title is required.');
return;
}
if (ingredients.length === 0) {
setDraftError('At least one ingredient is required.');
return;
}
if (instructions.length === 0) {
setDraftError('At least one instruction step is required.');
return;
}
setIsSaving(true);
setDraftError(null);
try {
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';
setDraftError(message);
setIsSaving(false);
}
};
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>
<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"
/>
</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>
</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>
)}
</div>
);
}