Initial microservice-importer setup with NestJS backend and Next.js frontend
This commit is contained in:
@@ -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"]
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user