feat(frontend): add edit-before-save flow for URL imports
This commit is contained in:
parent
4c512a5161
commit
e1f5019006
2
TODO.md
2
TODO.md
|
|
@ -33,7 +33,7 @@ MVP is functionally complete (core app + docs + tests).
|
||||||
### Phase 2: Import UI
|
### Phase 2: Import UI
|
||||||
- [x] Add “Import from URL” UI page/form in frontend
|
- [x] Add “Import from URL” UI page/form in frontend
|
||||||
- [x] Show parsed preview (title, ingredients, steps, source URL)
|
- [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)
|
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
|
||||||
|
|
||||||
### Phase 3: Fallback Parsing + Hardening
|
### Phase 3: Fallback Parsing + Hardening
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,44 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { importRecipeFromUrl } from '../services/api';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { UrlImportResult } from '../types/recipe';
|
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() {
|
export function ImportUrlPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<UrlImportResult | 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) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setDraft(null);
|
||||||
|
setDraftError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imported = await importRecipeFromUrl(url);
|
const imported = await importRecipeFromUrl(url);
|
||||||
setResult(imported);
|
setResult(imported);
|
||||||
|
setDraft(imported.draft_recipe);
|
||||||
} 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';
|
||||||
setError(message);
|
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 (
|
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>
|
||||||
|
|
@ -74,30 +137,92 @@ export function ImportUrlPage() {
|
||||||
<p className="text-sm text-gray-600">JSON-LD blocks found: {result.json_ld_blocks.length}</p>
|
<p className="text-sm text-gray-600">JSON-LD blocks found: {result.json_ld_blocks.length}</p>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Ingredients</h5>
|
<label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<ul className="list-disc list-inside space-y-1 text-gray-800">
|
Ingredients (one per line)
|
||||||
{result.draft_recipe.ingredients.map((ingredient, index) => (
|
</label>
|
||||||
<li key={`${ingredient}-${index}`}>{ingredient}</li>
|
<textarea
|
||||||
))}
|
id="draft-ingredients"
|
||||||
</ul>
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Steps</h5>
|
<label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<ol className="list-decimal list-inside space-y-1 text-gray-800">
|
Steps (one per line)
|
||||||
{result.draft_recipe.instructions.map((instruction, index) => (
|
</label>
|
||||||
<li key={`${instruction}-${index}`}>{instruction}</li>
|
<textarea
|
||||||
))}
|
id="draft-instructions"
|
||||||
</ol>
|
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>
|
||||||
</>
|
|
||||||
|
<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">
|
<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.
|
Could not parse a recipe preview from this URL.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* API client for Recipe Manager backend
|
* 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
|
// Use relative URL - nginx will proxy to backend in production
|
||||||
// For local development (npm run dev), configure vite.config.ts proxy
|
// 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
|
* 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`, {
|
const response = await fetch(`${API_BASE_URL}/recipes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,21 @@ export interface Recipe {
|
||||||
last_cooked_at?: number;
|
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
|
* Tag data model
|
||||||
*/
|
*/
|
||||||
|
|
@ -43,5 +58,5 @@ export interface UrlImportResult {
|
||||||
source_url: string;
|
source_url: string;
|
||||||
html: string;
|
html: string;
|
||||||
json_ld_blocks: string[];
|
json_ld_blocks: string[];
|
||||||
draft_recipe: Recipe | null;
|
draft_recipe: RecipeDraft | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue