feat(frontend): add import from URL page and form
This commit is contained in:
parent
87e9181e11
commit
276e03cc87
2
TODO.md
2
TODO.md
|
|
@ -31,7 +31,7 @@ MVP is functionally complete (core app + docs + tests).
|
|||
- [x] Add import endpoint tests (valid recipe page, non-recipe page, malformed JSON-LD)
|
||||
|
||||
### Phase 2: Import UI
|
||||
- [ ] Add “Import from URL” UI page/form in frontend
|
||||
- [x] Add “Import from URL” UI page/form in frontend
|
||||
- [ ] Show parsed preview (title, ingredients, steps, source URL)
|
||||
- [ ] Allow edit-before-save flow, then save to existing create recipe API
|
||||
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { RecipeListPage } from './pages/RecipeListPage';
|
|||
import { RecipeDetailPage } from './pages/RecipeDetailPage';
|
||||
import { CookModePage } from './pages/CookModePage';
|
||||
import { NotFoundPage } from './pages/NotFoundPage';
|
||||
import { ImportUrlPage } from './pages/ImportUrlPage';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { ToastContainer } from './components/Toast';
|
||||
import { useToast } from './hooks/useToast';
|
||||
|
|
@ -65,6 +66,9 @@ function App() {
|
|||
<Link to="/recipe/new" className={linkClass('/recipe/new')}>
|
||||
Add Recipe
|
||||
</Link>
|
||||
<Link to="/import/url" className={linkClass('/import/url')}>
|
||||
Import URL
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,6 +80,7 @@ function App() {
|
|||
<Route path="/recipe/new" element={<RecipeDetailPage />} />
|
||||
<Route path="/recipe/:id" element={<RecipeDetailPage />} />
|
||||
<Route path="/recipe/:id/cook" element={<CookModePage />} />
|
||||
<Route path="/import/url" element={<ImportUrlPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { importRecipeFromUrl } from '../services/api';
|
||||
import type { UrlImportResult } from '../types/recipe';
|
||||
|
||||
export function ImportUrlPage() {
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<UrlImportResult | null>(null);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const imported = await importRecipeFromUrl(url);
|
||||
setResult(imported);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(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'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 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-green-900 mb-1">Import fetched successfully</h3>
|
||||
<p className="text-green-800 text-sm">Source: {result.source_url}</p>
|
||||
<p className="text-green-800 text-sm">JSON-LD blocks found: {result.json_ld_blocks.length}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* API client for Recipe Manager backend
|
||||
*/
|
||||
|
||||
import type { Recipe, Tag, ApiResponse } from '../types/recipe';
|
||||
import type { Recipe, 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
|
||||
|
|
@ -239,3 +239,28 @@ export async function deleteTag(id: number): Promise<void> {
|
|||
throw new Error(result.error || 'Failed to delete tag');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Import recipe data from URL
|
||||
*/
|
||||
export async function importRecipeFromUrl(url: string): Promise<UrlImportResult> {
|
||||
const response = await fetch(`${API_BASE_URL}/import/url`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import URL: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result: ApiResponse<UrlImportResult> = await response.json();
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || 'Failed to import URL');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,3 +34,14 @@ export interface ApiResponse<T> {
|
|||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* URL import result returned by backend import endpoint
|
||||
*/
|
||||
export interface UrlImportResult {
|
||||
source_url: string;
|
||||
html: string;
|
||||
json_ld_blocks: string[];
|
||||
draft_recipe: Recipe | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue