feat(frontend): add edit-before-save flow for URL imports

This commit is contained in:
Paul Huliganga 2026-03-24 23:20:42 -04:00
parent 4c512a5161
commit e1f5019006
4 changed files with 162 additions and 22 deletions

View File

@ -33,7 +33,7 @@ MVP is functionally complete (core app + docs + tests).
### Phase 2: Import UI
- [x] Add “Import from URL” UI page/form in frontend
- [x] Show parsed preview (title, ingredients, steps, source URL)
- [ ] Allow edit-before-save flow, then save to existing create recipe API
- [x] Allow edit-before-save flow, then save to existing create recipe API
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
### Phase 3: Fallback Parsing + Hardening

View File

@ -1,23 +1,44 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { importRecipeFromUrl } from '../services/api';
import type { UrlImportResult } from '../types/recipe';
import { Link, useNavigate } from 'react-router-dom';
import { createRecipe, importRecipeFromUrl } from '../services/api';
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
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);
}
export function ImportUrlPage() {
const navigate = useNavigate();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | 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);
setResult(null);
setDraft(null);
setDraftError(null);
try {
const imported = await importRecipeFromUrl(url);
setResult(imported);
setDraft(imported.draft_recipe);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
setError(message);
@ -26,6 +47,48 @@ export function ImportUrlPage() {
}
};
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>
@ -74,30 +137,92 @@ export function ImportUrlPage() {
<p className="text-sm text-gray-600">JSON-LD blocks found: {result.json_ld_blocks.length}</p>
</div>
{result.draft_recipe ? (
<>
{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>
<h4 className="text-lg font-semibold text-gray-900">{result.draft_recipe.title}</h4>
<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>
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Ingredients</h5>
<ul className="list-disc list-inside space-y-1 text-gray-800">
{result.draft_recipe.ingredients.map((ingredient, index) => (
<li key={`${ingredient}-${index}`}>{ingredient}</li>
))}
</ul>
<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>
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Steps</h5>
<ol className="list-decimal list-inside space-y-1 text-gray-800">
{result.draft_recipe.instructions.map((instruction, index) => (
<li key={`${instruction}-${index}`}>{instruction}</li>
))}
</ol>
<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.

View File

@ -2,7 +2,7 @@
* API client for Recipe Manager backend
*/
import type { Recipe, Tag, ApiResponse, UrlImportResult } from '../types/recipe';
import type { Recipe, RecipeDraft, Tag, ApiResponse, UrlImportResult } from '../types/recipe';
// Use relative URL - nginx will proxy to backend in production
// For local development (npm run dev), configure vite.config.ts proxy
@ -61,7 +61,7 @@ export async function fetchRecipe(id: number): Promise<Recipe> {
/**
* Create a new recipe
*/
export async function createRecipe(recipe: Omit<Recipe, 'id' | 'created_at' | 'updated_at' | 'last_cooked_at'>): Promise<Recipe> {
export async function createRecipe(recipe: RecipeDraft): Promise<Recipe> {
const response = await fetch(`${API_BASE_URL}/recipes`, {
method: 'POST',
headers: {

View File

@ -17,6 +17,21 @@ export interface Recipe {
last_cooked_at?: number;
}
/**
* Recipe payload used for create/import/edit-before-save flows
*/
export interface RecipeDraft {
title: string;
description?: string;
ingredients: string[];
instructions: string[];
source_url?: string;
notes?: string;
servings?: number;
prep_time_minutes?: number;
cook_time_minutes?: number;
}
/**
* Tag data model
*/
@ -43,5 +58,5 @@ export interface UrlImportResult {
source_url: string;
html: string;
json_ld_blocks: string[];
draft_recipe: Recipe | null;
draft_recipe: RecipeDraft | null;
}