feat: Implement PDF recipe parser and quick import service for file and URL inputs

This commit is contained in:
Nils-Johan Gynther
2026-04-14 22:24:28 +02:00
parent e90fd2d670
commit 1ce1318bf5
10 changed files with 758 additions and 194 deletions
+17 -33
View File
@@ -2,48 +2,32 @@ import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
console.log('[QuickImportProxy] Mottog POST-anrop');
const { input } = await request.json();
console.log('[QuickImportProxy] Input från request:', input);
const contentType = request.headers.get('content-type') ?? '';
const isMultipart = contentType.includes('multipart/form-data');
const backendUrl = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || 'http://recipe-api:8080';
if (!input || typeof input !== 'string') {
console.log('[QuickImportProxy] Validering misslyckades');
return NextResponse.json(
{ error: 'Du måste ange en URL eller filsökväg' },
{ status: 400 }
);
}
// Anropa backend
const backendUrl = process.env.NEXT_PUBLIC_API_URL || 'http://recipe-api:8080';
console.log('[QuickImportProxy] Anropar backend på:', backendUrl + '/api/quick-import');
const response = await fetch(`${backendUrl}/api/quick-import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: input.trim() }),
body: isMultipart
? await request.formData()
: JSON.stringify(await request.json()),
headers: isMultipart ? undefined : { 'Content-Type': 'application/json' },
cache: 'no-store',
});
console.log('[QuickImportProxy] Backend svar status:', response.status);
const text = await response.text();
if (!response.ok) {
console.log('[QuickImportProxy] Backend returnerade error');
const errorData = await response.json().catch(() => ({}));
console.log('[QuickImportProxy] Error data:', errorData);
return NextResponse.json(
{ error: errorData.message || 'Importen misslyckades' },
{ status: response.status }
);
}
const data = await response.json();
console.log('[QuickImportProxy] Framgång! Markdown längd:', data.markdown?.length);
return NextResponse.json(data);
return new NextResponse(text, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') ?? 'application/json',
},
});
} catch (error) {
console.error('[QuickImportProxy] EXCEPTION:', error);
return NextResponse.json(
{ error: 'Serverfelet vid import. Försök igen senare.' },
{ status: 500 }
{ message: 'Kunde inte nå importtjänsten.' },
{ status: 503 },
);
}
}
+3
View File
@@ -16,6 +16,9 @@ export default function HomePage() {
<Link href="/recipes" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
till recept
</Link>
<Link href="/recipes/import" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
Importera recept från PDF eller bild
</Link>
</div>
</main>
);
+110 -118
View File
@@ -1,43 +1,85 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Navigation from '../../Navigation';
import { parseErrorResponse } from '../../../lib/error-handler';
export default function ImportFilePage() {
const [selectedMethod, setSelectedMethod] = useState<'file' | 'url' | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const router = useRouter();
const [selectedMethod, setSelectedMethod] = useState<'file' | 'url' | null>('file');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [url, setUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setUploadProgress(0);
// Placeholder för filuppladdning
// I framtiden kan detta hanteras med backend-endpoint för PDF-parsing
if (file.type === 'application/pdf') {
setError('PDF-import är under utveckling. Använd "Skriv in recept" för att mata in recept manuellt.');
} else {
setError('Endast PDF-filer stöds för närvarande.');
}
setUploadProgress(0);
};
const handleURLSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleFileSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const url = formData.get('url') as string;
if (!url) {
setError('Vänligen ange en URL');
if (!selectedFile) {
setError('Välj en PDF eller bildfil först.');
return;
}
setError('Länk-import är under utveckling. Använd "Skriv in recept" för att mata in recept manuellt.');
setError(null);
setIsLoading(true);
try {
const formData = new FormData();
formData.append('file', selectedFile);
const res = await fetch('/api/quick-import-proxy', {
method: 'POST',
body: formData,
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
throw new Error(errorMessage);
}
const data = await res.json();
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
router.push('/recipes/write');
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
} finally {
setIsLoading(false);
}
};
const handleUrlSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!url.trim()) {
setError('Vänligen ange en URL.');
return;
}
setError(null);
setIsLoading(true);
try {
const res = await fetch('/api/quick-import-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: url.trim() }),
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
throw new Error(errorMessage);
}
const data = await res.json();
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
router.push('/recipes/write');
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
} finally {
setIsLoading(false);
}
};
return (
@@ -45,7 +87,7 @@ export default function ImportFilePage() {
<Navigation />
<h1 style={{ marginBottom: '0.5rem' }}>Importera från fil eller länk</h1>
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
Ladda upp en receptfil (PDF) eller ange en URL för att importera ett recept.
Ladda upp en PDF eller bild för OCR, eller ange en receptlänk.
</p>
{error && (
@@ -60,7 +102,7 @@ export default function ImportFilePage() {
fontSize: '0.95rem',
}}
>
{error}
{error}
</div>
)}
@@ -72,7 +114,6 @@ export default function ImportFilePage() {
marginBottom: '2rem',
}}
>
{/* Fil-upload */}
<div
onClick={() => setSelectedMethod('file')}
style={{
@@ -81,84 +122,48 @@ export default function ImportFilePage() {
borderRadius: '8px',
background: selectedMethod === 'file' ? '#f0f9ff' : '#f9fafb',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
<h2 style={{ margin: '0 0 1rem 0', fontSize: '1.2rem', color: '#0070f3' }}>
📄 Ladda upp fil
Ladda upp PDF eller bild
</h2>
<p style={{ color: '#666', margin: '0 0 1rem 0', fontSize: '0.95rem' }}>
Ladda upp ett recept från en PDF eller textfil
Stöd för PDF, PNG, JPG, JPEG, WEBP och BMP.
</p>
{selectedMethod === 'file' && (
<div style={{ marginTop: '1rem' }}>
<label
<form onSubmit={handleFileSubmit} style={{ display: 'grid', gap: '0.75rem' }}>
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg,.webp,.bmp"
onChange={(e) => setSelectedFile(e.target.files?.[0] ?? null)}
style={{
display: 'block',
padding: '1rem',
padding: '0.75rem',
background: 'white',
border: '2px dashed #0070f3',
border: '1px solid #cbd5e1',
borderRadius: '6px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onDragOver={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.background = '#e3f2fd';
}}
onDragLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = 'white';
/>
<button
type="submit"
disabled={!selectedFile || isLoading}
style={{
padding: '0.75rem',
background: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: !selectedFile || isLoading ? 'not-allowed' : 'pointer',
opacity: !selectedFile || isLoading ? 0.6 : 1,
fontWeight: 600,
}}
>
<input
type="file"
accept=".pdf,.txt,.docx"
onChange={handleFileUpload}
style={{ display: 'none' }}
/>
<p style={{ margin: '0', color: '#0070f3', fontWeight: 600 }}>
Dra och släpp fil här
</p>
<p style={{ margin: '0.5rem 0 0 0', color: '#999', fontSize: '0.85rem' }}>
eller klicka för att välja
</p>
<p style={{ margin: '0.5rem 0 0 0', color: '#999', fontSize: '0.8rem' }}>
PDF, TXT, DOCX stöds
</p>
</label>
{uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ marginTop: '0.75rem' }}>
<div
style={{
width: '100%',
height: '6px',
background: '#e5e7eb',
borderRadius: '3px',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
background: '#0070f3',
width: `${uploadProgress}%`,
transition: 'width 0.3s',
}}
/>
</div>
<p style={{ margin: '0.5rem 0 0 0', fontSize: '0.85rem', color: '#666' }}>
{uploadProgress}%
</p>
</div>
)}
</div>
{isLoading ? 'Importerar...' : 'Importera fil'}
</button>
</form>
)}
</div>
{/* URL-import */}
<div
onClick={() => setSelectedMethod('url')}
style={{
@@ -167,21 +172,21 @@ export default function ImportFilePage() {
borderRadius: '8px',
background: selectedMethod === 'url' ? '#f0fdf4' : '#f9fafb',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
<h2 style={{ margin: '0 0 1rem 0', fontSize: '1.2rem', color: '#10b981' }}>
🔗 Länk till recept
Länk till recept
</h2>
<p style={{ color: '#666', margin: '0 0 1rem 0', fontSize: '0.95rem' }}>
Ange URL till en receptsida eller blogg
Ange URL till exempelvis ICA eller en annan receptsida.
</p>
{selectedMethod === 'url' && (
<form onSubmit={handleURLSubmit} style={{ marginTop: '1rem' }}>
<form onSubmit={handleUrlSubmit} style={{ display: 'grid', gap: '0.75rem' }}>
<input
type="url"
name="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://exempel.se/recept/..."
style={{
width: '100%',
@@ -190,11 +195,11 @@ export default function ImportFilePage() {
borderRadius: '6px',
fontSize: '0.9rem',
boxSizing: 'border-box',
marginBottom: '0.75rem',
}}
/>
<button
type="submit"
disabled={!url.trim() || isLoading}
style={{
width: '100%',
padding: '0.75rem',
@@ -202,41 +207,33 @@ export default function ImportFilePage() {
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 500,
cursor: !url.trim() || isLoading ? 'not-allowed' : 'pointer',
opacity: !url.trim() || isLoading ? 0.6 : 1,
fontWeight: 600,
fontSize: '0.95rem',
}}
>
Importera från länk
{isLoading ? 'Importerar...' : 'Importera från länk'}
</button>
</form>
)}
</div>
</div>
{/* Info-box */}
<div
style={{
background: '#fef3c7',
border: '1px solid #fcd34d',
background: '#f0fdf4',
border: '1px solid #86efac',
borderRadius: '6px',
padding: '1rem',
marginBottom: '1.5rem',
color: '#92400e',
color: '#166534',
fontSize: '0.9rem',
}}
>
<strong>💡 Tips:</strong> För närvarande är PDF och länk-import under utveckling. Du kan{' '}
<Link
href="/recipes/write"
style={{ color: '#0070f3', textDecoration: 'none', fontWeight: 600 }}
>
skriv in receptet manuellt
</Link>{' '}
eller prova att ladda upp en fil och se om det fungerar.
Efter import öppnas receptet automatiskt i redigeringsläget.
</div>
{/* Knapp för att gå tillbaka */}
<div style={{ display: 'flex', gap: '1rem' }}>
<Link
href="/recipes/create"
@@ -245,14 +242,12 @@ export default function ImportFilePage() {
background: 'transparent',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
textDecoration: 'none',
color: '#333',
fontWeight: 500,
}}
>
Tillbaka
Tillbaka
</Link>
<Link
href="/recipes/write"
@@ -260,11 +255,8 @@ export default function ImportFilePage() {
padding: '0.75rem 1.5rem',
background: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '4px',
textDecoration: 'none',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: 500,
}}
>