From 276e03cc87611e1e9722508765d1460c1362761e Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 24 Mar 2026 22:07:36 -0400 Subject: [PATCH] feat(frontend): add import from URL page and form --- TODO.md | 2 +- frontend/src/App.tsx | 5 ++ frontend/src/pages/ImportUrlPage.tsx | 78 ++++++++++++++++++++++++++++ frontend/src/services/api.ts | 27 +++++++++- frontend/src/types/recipe.ts | 11 ++++ 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/ImportUrlPage.tsx diff --git a/TODO.md b/TODO.md index e3dadd9..26a8dee 100644 --- a/TODO.md +++ b/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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b6794b7..c1341ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { Add Recipe + + Import URL + @@ -76,6 +80,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/ImportUrlPage.tsx b/frontend/src/pages/ImportUrlPage.tsx new file mode 100644 index 0000000..2518a82 --- /dev/null +++ b/frontend/src/pages/ImportUrlPage.tsx @@ -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(null); + const [result, setResult] = useState(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 ( +
+

Import from URL

+

+ Paste a recipe URL and we'll try to fetch the page and extract recipe data. +

+ +
+
+ + 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" + /> +
+ + +
+ + {error && ( +
+

+ Error: {error} +

+
+ )} + + {result && ( +
+

Import fetched successfully

+

Source: {result.source_url}

+

JSON-LD blocks found: {result.json_ld_blocks.length}

+
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 330a2b4..f6647ef 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 { throw new Error(result.error || 'Failed to delete tag'); } } + + +/** + * Import recipe data from URL + */ +export async function importRecipeFromUrl(url: string): Promise { + 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 = await response.json(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to import URL'); + } + + return result.data; +} diff --git a/frontend/src/types/recipe.ts b/frontend/src/types/recipe.ts index dec9453..94f3d8d 100644 --- a/frontend/src/types/recipe.ts +++ b/frontend/src/types/recipe.ts @@ -34,3 +34,14 @@ export interface ApiResponse { 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; +}