Initial microservice-importer setup with NestJS backend and Next.js frontend

This commit is contained in:
Nils-Johan Gynther
2026-04-12 16:58:23 +02:00
commit 1608eb4d70
32 changed files with 1619 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
+49
View File
@@ -0,0 +1,49 @@
import Link from 'next/link';
export default function Navigation() {
return (
<nav
style={{
background: '#f9f9f9',
borderBottom: '1px solid #ddd',
padding: '0.75rem 1rem',
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
marginBottom: '1.5rem',
alignItems: 'center',
}}
>
<Link
href="/"
style={{
padding: '0.5rem 0.75rem',
background: '#fff',
border: '1px solid #ddd',
borderRadius: '4px',
textDecoration: 'none',
color: '#0070f3',
fontSize: '0.9rem',
fontWeight: 500,
}}
>
🏠 Hem
</Link>
<Link
href="/import"
style={{
padding: '0.5rem 0.75rem',
background: '#fff',
border: '1px solid #ddd',
borderRadius: '4px',
textDecoration: 'none',
color: '#0070f3',
fontSize: '0.9rem',
fontWeight: 500,
}}
>
Snabbimport
</Link>
</nav>
);
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://localhost:3001';
export async function POST(request: NextRequest) {
const body = await request.text();
const res = await fetch(`${API_BASE}/api/recipes/parse-markdown`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import { useState } from 'react';
import Navigation from '../Navigation';
import { parseErrorResponse } from '../../lib/error-handler';
export default function ImportPage() {
const [quickImportUrl, setQuickImportUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<any>(null);
const handleQuickImport = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setResult(null);
setIsLoading(true);
try {
const input = quickImportUrl.trim();
if (!input) {
setError('Vänligen ange en URL eller filsökväg');
setIsLoading(false);
return;
}
// Försök importera från URL eller fil
const res = await fetch('/api/quick-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
setError(errorMessage || 'Importen misslyckades. Kontrollera att länken eller filsökvägen är korrekt.');
setIsLoading(false);
return;
}
const data = await res.json();
if (data.markdown) {
setResult(data);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Något oväntad gick fel';
setError(`Fel: ${message}`);
} finally {
setIsLoading(false);
}
};
return (
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '1.5rem' }}>Importera recept</h1>
{/* IMPORT-SEKTION */}
<div
style={{
background: '#fef3c7',
border: '2px solid #f59e0b',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '2rem',
}}
>
<h2 style={{ margin: '0 0 0.5rem 0', fontSize: '1.1rem', color: '#92400e' }}>
Snabbimport
</h2>
<p style={{ margin: '0 0 1rem 0', color: '#92400e', fontSize: '0.9rem' }}>
Klistra in en receptlänk från ICA eller annan webbsida:
</p>
<form onSubmit={handleQuickImport} style={{ display: 'grid', gap: '0.75rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0.5rem' }}>
<input
type="text"
value={quickImportUrl}
onChange={(e) => setQuickImportUrl(e.target.value)}
placeholder="https://www.ica.se/recept/..."
style={{
padding: '0.75rem',
border: '1px solid #d97706',
borderRadius: '4px',
fontSize: '0.95rem',
boxSizing: 'border-box',
}}
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !quickImportUrl.trim()}
style={{
padding: '0.75rem 1.5rem',
background: '#f59e0b',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: isLoading || !quickImportUrl.trim() ? 'not-allowed' : 'pointer',
opacity: isLoading || !quickImportUrl.trim() ? 0.6 : 1,
fontSize: '0.95rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{isLoading ? 'Laddar...' : '→'}
</button>
</div>
{error && (
<p
style={{
margin: '0.5rem 0 0 0',
color: '#991b1b',
background: '#fee2e2',
padding: '0.75rem',
borderRadius: '4px',
fontSize: '0.85rem',
}}
>
{error}
</p>
)}
</form>
</div>
{/* RESULT */}
{result && (
<div
style={{
background: '#ecfdf5',
border: '2px solid #10b981',
borderRadius: '8px',
padding: '1.5rem',
}}
>
<h2 style={{ margin: '0 0 1rem 0', color: '#059669' }}> Recept importerat</h2>
<div
style={{
background: '#fff',
border: '1px solid #d1fae5',
borderRadius: '4px',
padding: '1rem',
maxHeight: '400px',
overflowY: 'auto',
}}
>
<pre
style={{
margin: 0,
fontSize: '0.85rem',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
}}
>
{result.markdown}
</pre>
</div>
<p style={{ margin: '1rem 0 0 0', fontSize: '0.9rem', color: '#059669' }}>
Källa: {result.source === 'ica' ? 'ICA' : 'Annan webbsida'}
</p>
</div>
)}
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Microservice Importer',
description: 'Snabbimport av recept från webben',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="sv">
<body style={{ fontFamily: 'Arial, sans-serif', margin: 0 }}>
{children}
</body>
</html>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Link from 'next/link';
import Navigation from './Navigation';
export default function HomePage() {
return (
<main style={{ padding: '1rem', maxWidth: '700px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '1.5rem' }}>Microservice Importer</h1>
<div style={{ display: 'grid', gap: '1rem' }}>
<Link href="/import" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
Importera recept från URL
</Link>
</div>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://localhost:3001';
export async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
// Använd alltid relativ path i webbläsaren för att undvika mixed content
const url = typeof window === 'undefined'
? (path.startsWith('http') ? path : `${API_BASE}${path}`)
: path;
const res = await fetch(url, {
...init,
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
...(init?.headers || {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`API ${res.status}: ${text}`);
}
return res.json();
}
export { API_BASE };
+45
View File
@@ -0,0 +1,45 @@
/**
* Utility för att parse HTTP-responses och extrahera tydliga felmeddelanden
*/
export async function parseErrorResponse(response: Response): Promise<string> {
const status = response.status;
try {
const data = await response.json();
// Om backend skickade ett felmeddelande
if (data.message) {
return data.message;
}
if (data.error) {
return data.error;
}
if (data.details) {
return data.details;
}
} catch {
// Inte JSON, försök text
try {
const text = await response.text();
if (text && text.length < 200) {
return text;
}
} catch {
// Inget text-innehål
}
}
// Fallback baserat på HTTP-status
const defaultMessages: Record<number, string> = {
400: 'Ogiltiga data. Kontrollera dina inmatningar.',
401: 'Du är inte autentiserad. Logga in.',
403: 'Du har inte behörighet till detta.',
404: 'Resursen hittades inte.',
409: 'Konflikten med befintlig data.',
422: 'Valideringen misslyckades. Kontrollera dina inmatningar.',
500: 'Serverfel. Försök igen senare.',
503: 'Tjänsten är inte tillgänglig.',
};
return defaultMessages[status] || `Fel (${status}). Försök igen senare.`;
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "recipe-importer-frontend",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000"
},
"dependencies": {
"next": "16.2",
"react": "19.2",
"react-dom": "19.2"
},
"devDependencies": {
"@types/node": "22.15.29",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"typescript": "5.4.5"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{ "name": "next" }
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}