New import in version 0.1

This commit is contained in:
Nils-Johan Gynther
2026-04-11 15:38:24 +02:00
parent 8552c6f757
commit 5448da1b98
12 changed files with 868 additions and 7 deletions
+86
View File
@@ -0,0 +1,86 @@
# Recipe App
En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på vad du har hemma, spara recept och se direkt om du har allt du behöver för att laga en rätt.
> För teknisk detaljinformation, se [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md).
---
## Funktioner
- **Hemmavaror** — lägg till, redigera och konsumera varor. Filtrera på plats och bäst före-datum.
- **Recept** — skapa och redigera recept med ingredienser och tillagningsinstruktioner (Markdown-stöd).
- **Receptjämförelse** — se direkt vilka ingredienser du har hemma och vad som saknas.
- **Importera recept från Markdown** — klistra in ett recept i ett enkelt textformat, granska matchade produkter och spara med ett klick.
- **Admin: Produkter** — hantera produktnamn och slå ihop dubbletter.
---
## Kom igång
### Förutsättningar
- Docker och Docker Compose
- En `proxy`-nätverk i Docker (extern, hanteras av Caddy eller liknande)
### Starta applikationen
```bash
# Bygg och starta alla tjänster
docker compose build
docker compose up -d
```
### Bygg om enbart backend (t.ex. efter kodändringar)
```bash
docker compose build recipe-api
docker compose up -d recipe-api
```
---
## Importera recept från Markdown
Gå till **Recept → Lägg till nytt recept → Importera från Markdown** och klistra in ett recept i följande format:
```markdown
# Köttfärssås
En klassisk köttfärssås med massa smak.
## Ingredienser
- 500 g köttfärs
- 1 st lök
- 2 msk tomatpuré
- 1 dl grädde (vispgrädde)
## Tillvägagångssätt
Hacka löken och stek den mjuk i lite olja. Tillsätt köttfärsen...
```
Systemet tolkar texten, föreslår matchande produkter från databasen och låter dig granska och justera innan receptet sparas.
---
## Projektstruktur
```
recipe-app/
├── frontend/ # Next.js (App Router)
├── backend/ # NestJS REST API
├── recipe-document-converter/ # Markdown-parserbibliotek
├── db/init/ # SQL-initialiseringsskript
├── compose.yml # Docker Compose
└── backup_recipe_app.sh # Backupskript
```
---
## Backup
```bash
bash backup_recipe_app.sh
```
Säkerhetskopierar källkod och Docker-images till konfigurerad backupmapp.
+171
View File
@@ -1,5 +1,7 @@
# Teknisk beskrivning av Recipe App # Teknisk beskrivning av Recipe App
> Se [README.md](README.md) för användarinformation och kom-igång-guide.
## Översikt ## Översikt
Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och inköpsplanering. Systemet är byggt med Next.js (frontend), NestJS (backend), Prisma ORM och MariaDB. Applikationen är containeriserad med Docker och använder Caddy som reverse proxy. Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och inköpsplanering. Systemet är byggt med Next.js (frontend), NestJS (backend), Prisma ORM och MariaDB. Applikationen är containeriserad med Docker och använder Caddy som reverse proxy.
@@ -114,6 +116,7 @@ Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och
- Skapa, redigera, ta bort recept - Skapa, redigera, ta bort recept
- Jämför mot hemmavaror (räcker/saknas/enhetskonflikt) - Jämför mot hemmavaror (räcker/saknas/enhetskonflikt)
- Visar instruktioner och saknade ingredienser - Visar instruktioner och saknade ingredienser
- **Importera recept från Markdown** (se nedan)
### Produkter (Admin) ### Produkter (Admin)
- Sök, sortera, redigera canonical name - Sök, sortera, redigera canonical name
@@ -135,12 +138,180 @@ Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och
- `POST /api/recipes` Skapa recept - `POST /api/recipes` Skapa recept
- `PATCH /api/recipes/:id` Uppdatera recept - `PATCH /api/recipes/:id` Uppdatera recept
- `DELETE /api/recipes/:id` Ta bort recept - `DELETE /api/recipes/:id` Ta bort recept
- `POST /api/recipes/parse-markdown` Tolka Markdown-recept (se nedan)
- `GET /api/products` Lista produkter - `GET /api/products` Lista produkter
- `PATCH /api/products/:id` Uppdatera produkt - `PATCH /api/products/:id` Uppdatera produkt
- `GET /health` Hälsokontroll - `GET /health` Hälsokontroll
--- ---
---
## Receptimport via Markdown
### Syfte
Användaren kan importera ett recept skrivet i ett enkelt Markdown-format istället för att fylla i formularet manuellt. Systemet tolkar texten, föreslår matchande produkter från databasen och låter användaren granska och bekräfta innan receptet sparas.
### Markdown-format
```markdown
# Receptnamn
Valfri beskrivning av receptet.
## Ingredienser
- 500 g köttfärs
- 1 st lök
- 2 msk tomatpuré
- 1 dl grädde (vispgrädde)
## Tillvägagångssätt
Stek löken i lite smör. Tillsats köttfärsen...
```
Regler:
- Rad med `#` tolkas som receptnamn
- Text mellan `#`-rubriken och `## Ingredienser` tolkas som beskrivning
- Rader under `## Ingredienser` med mönstret `- ANTAL ENHET NAMN` tolkas som ingredienser
- Text i parentes efter ingrediensnamnet (`(vispgrädde)`) sparas som anteckning
- Text under `## Tillvägagångssätt` (eller `## Instruktioner`) tolkas som instruktioner
### Arkitektur
```
Användaren Frontend Backend Bibliotek
(klistrar in MD) → /recipes/import → POST /api/ → recipe-document-
ImportRecipePage recipes/ converter/
parse-markdown parseRecipeMarkdown()
Granskar förslag
Väljer produkter
POST /api/recipes (befintlig endpoint)
```
### Komponenterna
#### `recipe-document-converter/` (fristående TypeScript-bibliotek)
Ett eget npm-paket som inte har externa beroenden. Det enda som exporteras är:
```typescript
parseRecipeMarkdown(markdown: string): ParsedRecipe
```
Returnerar:
```typescript
type ParsedRecipe = {
name: string;
description?: string;
instructions?: string;
ingredients: Array<{
rawName: string; // fråntext, t.ex. "köttfärs"
quantity: number; // t.ex. 500
unit: string; // t.ex. "g"
note?: string; // text i parentes, t.ex. "nötfärs"
}>;
};
```
Biblioteket kompileras i ett separat Docker-byggsteg och länkas till backend via `"recipe-document-converter": "file:../recipe-document-converter"` i `backend/package.json`.
#### Backend — `POST /api/recipes/parse-markdown`
Endpoint som tar emot `{ markdown: string }` och returnerar det tolkade receptet åtsamman med produktmatchförslag för varje ingrediens.
Matchningslogik:
1. Anropar `parseRecipeMarkdown()` från biblioteket
2. Hämtar alla aktiva produkter ur databasen
3. Jämför varje ingrediensnamn mot `product.canonicalName` / `product.normalizedName` med tre metoder i ordning:
- **Exakt match** (efter normalisering) → 100 poäng
- **Delsträngsmatch** → 70 poäng
- **Levenshtein-likhet** → 0100 poäng (filtreras under 40)
4. Returnerar upp till 5 förslag per ingrediens, sorterade efter poäng
Svar:
```json
{
"name": "Köttfärssås",
"description": "En klassisk...",
"instructions": "Stek löken...",
"ingredients": [
{
"rawName": "köttfärs",
"quantity": 500,
"unit": "g",
"suggestions": [
{ "productId": 12, "productName": "Köttfärs", "score": 100 },
{ "productId": 34, "productName": "Blandfärs", "score": 55 }
]
}
]
}
```
#### Frontend — `/recipes/import`
En 3-stegsvy (client component):
| Steg | Innehåll |
|------|----------|
| 1. Klistra in | Textarea för Markdown + "Tolka recept"-knapp |
| 2. Granska | Redigerbara fält för namn/beskrivning/instruktioner; varje ingrediens har en dropdown med föreslagna produkter överst, sedan alla produkter |
| 3. Spara | Knapp som POSTar till befintlig `POST /api/recipes` |
Ingrediensräder med ingen matchning markeras visuellt (gul ram) så att användaren ser att de behöver väljas manuellt. Receptet sparas inte förrän minst en ingrediens har en vald produkt.
Flöde i Next.js:
```
/recipes/import
└─ ImportRecipePage.tsx (client component, 3-stegsflödet)
/api/parse-markdown-proxy
└─ route.ts (POST-proxy till backend, omgår CORS)
```
### Docker-bygget
Backend-Dockerfilen använder nu projektets rot (`.`) som byggkontext. Converter-biblioteket kompileras i en separat stage:
```dockerfile
# Stage 1: Bygg converter-biblioteket
FROM node:22-alpine AS converter-build
WORKDIR /converter
COPY recipe-document-converter/package.json ./
RUN npm install
COPY recipe-document-converter/src ./src
COPY recipe-document-converter/tsconfig.json ./
RUN npm run build
# Stage 2: Installera backend-beroenden (converter kopieras in)
FROM node:22-alpine AS deps
WORKDIR /app
COPY --from=converter-build /converter /recipe-document-converter
...
```
Bygga om backend efter ändringar:
```bash
docker compose build recipe-api
```
### Relevanta filer
| Fil | Syfte |
|-----|-------|
| `recipe-document-converter/src/parser.ts` | Markdown-parser |
| `recipe-document-converter/src/index.ts` | Biblioteksexport |
| `backend/src/recipes/dto/parse-markdown.dto.ts` | Inkommande DTO |
| `backend/src/recipes/recipes.controller.ts` | Nytt endpoint |
| `backend/src/recipes/recipes.service.ts` | Matchningslogik |
| `frontend/app/recipes/import/ImportRecipePage.tsx` | 3-stegsvy |
| `frontend/app/api/parse-markdown-proxy/route.ts` | Proxy-route |
---
## Säkerhet ## Säkerhet
- Ingen auth i grundutförande (kan enkelt byggas på) - Ingen auth i grundutförande (kan enkelt byggas på)
+19 -6
View File
@@ -1,19 +1,32 @@
# Stage 1: Installera beroenden # Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-api:local .
# Stage 1: Bygg recipe-document-converter
FROM node:22-alpine AS converter-build
WORKDIR /converter
COPY recipe-document-converter/package.json ./
RUN npm install
COPY recipe-document-converter/src ./src
COPY recipe-document-converter/tsconfig.json ./
RUN npm run build
# Stage 2: Installera backend-beroenden
FROM node:22-alpine AS deps FROM node:22-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json ./ # Gör converter tillgänglig för npm:s file:-referens (../recipe-document-converter från /app)
COPY prisma ./prisma COPY --from=converter-build /converter /recipe-document-converter
COPY backend/package.json ./
COPY backend/prisma ./prisma
RUN npm install RUN npm install
# Stage 2: Bygg applikationen # Stage 3: Bygg applikationen
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY backend/ .
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN npm run build
# Stage 3: Kör applikationen # Stage 4: Kör applikationen
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
+1
View File
@@ -11,6 +11,7 @@
"prisma:deploy": "prisma migrate deploy" "prisma:deploy": "prisma migrate deploy"
}, },
"dependencies": { "dependencies": {
"recipe-document-converter": "file:../recipe-document-converter",
"@nestjs/common": "^10.3.0", "@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0", "@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^10.3.0",
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class ParseMarkdownDto {
@IsString()
@MinLength(1)
markdown!: string;
}
@@ -1,11 +1,17 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common';
import { RecipesService } from './recipes.service'; import { RecipesService } from './recipes.service';
import { CreateRecipeDto } from './dto/create-recipe.dto'; import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
@Controller('recipes') @Controller('recipes')
export class RecipesController { export class RecipesController {
constructor(private readonly recipesService: RecipesService) {} constructor(private readonly recipesService: RecipesService) {}
@Post('parse-markdown')
parseMarkdown(@Body() dto: ParseMarkdownDto) {
return this.recipesService.parseMarkdown(dto);
}
@Get() @Get()
findAll() { findAll() {
return this.recipesService.findAll(); return this.recipesService.findAll();
+83
View File
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateRecipeDto } from './dto/create-recipe.dto'; import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { parseRecipeMarkdown } from 'recipe-document-converter';
@Injectable() @Injectable()
export class RecipesService { export class RecipesService {
@@ -384,4 +386,85 @@ export class RecipesService {
return recipe; return recipe;
} }
async parseMarkdown(dto: ParseMarkdownDto) {
const parsed = parseRecipeMarkdown(dto.markdown);
const allProducts = await this.prisma.product.findMany({
where: { isActive: true },
select: { id: true, name: true, canonicalName: true, normalizedName: true },
});
// Normalisera en sträng för jämförelse (lowercase, trim, ta bort skiljetecken)
const normalize = (s: string) =>
s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' ');
// Enkel Levenshtein-distans
const levenshtein = (a: string, b: string): number => {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] =
a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
};
const ingredientsWithSuggestions = parsed.ingredients.map((ingredient) => {
const query = normalize(ingredient.rawName);
const scored = allProducts
.map((product) => {
const targetName = normalize(product.canonicalName || product.name);
const targetNormalized = normalize(product.normalizedName);
// Exakt träff på normalizedName prioriteras
if (targetNormalized === query || targetName === query) {
return { product, score: 100 };
}
// Delsträng-match
if (targetName.includes(query) || query.includes(targetName)) {
return { product, score: 70 };
}
// Levenshtein-baserad likhet
const dist = levenshtein(query, targetName);
const maxLen = Math.max(query.length, targetName.length);
const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100);
return { product, score: similarity };
})
.filter((s) => s.score >= 40)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map((s) => ({
productId: s.product.id,
productName: s.product.canonicalName || s.product.name,
score: s.score,
}));
return {
rawName: ingredient.rawName,
quantity: ingredient.quantity,
unit: ingredient.unit,
note: ingredient.note,
suggestions: scored,
};
});
return {
name: parsed.name,
description: parsed.description,
instructions: parsed.instructions,
ingredients: ingredientsWithSuggestions,
};
}
} }
+3
View File
@@ -12,6 +12,9 @@ services:
- recipe-internal - recipe-internal
recipe-api: recipe-api:
build:
context: .
dockerfile: backend/Dockerfile
image: recipe-api:local image: recipe-api:local
container_name: recipe-api container_name: recipe-api
restart: unless-stopped restart: unless-stopped
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
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' },
});
}
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { fetchJson } from '../../../lib/api'; import { fetchJson } from '../../../lib/api';
import { parseErrorResponse } from '../../../lib/error-handler'; import { parseErrorResponse } from '../../../lib/error-handler';
import type { Product } from '../../../features/inventory/types'; import type { Product } from '../../../features/inventory/types';
@@ -138,7 +139,24 @@ export default function CreateRecipePage() {
return ( return (
<main style={{ padding: '1rem', maxWidth: '1000px', margin: '0 auto' }}> <main style={{ padding: '1rem', maxWidth: '1000px', margin: '0 auto' }}>
<Navigation /> <Navigation />
<h1 style={{ marginBottom: '1rem' }}>Lägg till nytt recept</h1> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h1 style={{ margin: 0 }}>Lägg till nytt recept</h1>
<Link
href="/recipes/import"
style={{
padding: '0.5rem 1rem',
background: '#f0f0f0',
color: '#333',
borderRadius: '4px',
textDecoration: 'none',
fontWeight: 500,
fontSize: '0.9rem',
border: '1px solid #ddd',
}}
>
Importera från Markdown
</Link>
</div>
{error && <p style={{ color: 'crimson', backgroundColor: '#ffe5e5', padding: '0.75rem', borderRadius: '4px', marginBottom: '1rem' }}>{error}</p>} {error && <p style={{ color: 'crimson', backgroundColor: '#ffe5e5', padding: '0.75rem', borderRadius: '4px', marginBottom: '1rem' }}>{error}</p>}
@@ -0,0 +1,447 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { fetchJson } from '../../../lib/api';
import { parseErrorResponse } from '../../../lib/error-handler';
import type { Product } from '../../../features/inventory/types';
import Navigation from '../../Navigation';
type ProductSuggestion = {
productId: number;
productName: string;
score: number;
};
type ParsedIngredientRow = {
rawName: string;
quantity: number;
unit: string;
note?: string;
suggestions: ProductSuggestion[];
// Valda värden (redigerbara i steg 2)
selectedProductId: number;
editedQuantity: string;
editedUnit: string;
editedNote: string;
};
type ParseResult = {
name: string;
description?: string;
instructions?: string;
ingredients: ParsedIngredientRow[];
};
const UNIT_OPTIONS = [
{ value: '', label: 'Välj enhet' },
{ value: 'g', label: 'g (gram)' },
{ value: 'kg', label: 'kg (kilogram)' },
{ value: 'hg', label: 'hg (hektogram)' },
{ value: 'ml', label: 'ml (milliliter)' },
{ value: 'dl', label: 'dl (deciliter)' },
{ value: 'l', label: 'l (liter)' },
{ value: 'st', label: 'st (styck)' },
{ value: 'tsk', label: 'tsk (tesked)' },
{ value: 'msk', label: 'msk (matsked)' },
];
type Step = 'input' | 'review' | 'saving';
export default function ImportRecipePage() {
const router = useRouter();
const [step, setStep] = useState<Step>('input');
const [markdown, setMarkdown] = useState('');
const [parsed, setParsed] = useState<ParseResult | null>(null);
const [editedName, setEditedName] = useState('');
const [editedDescription, setEditedDescription] = useState('');
const [editedInstructions, setEditedInstructions] = useState('');
const [ingredients, setIngredients] = useState<ParsedIngredientRow[]>([]);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const [isParsing, setIsParsing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchJson<Product[]>('/api/products')
.then(setAllProducts)
.catch(console.error);
}, []);
const handleParse = async () => {
if (!markdown.trim()) return;
setIsParsing(true);
setError(null);
try {
const res = await fetch('/api/parse-markdown-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markdown }),
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
throw new Error(errorMessage);
}
const data = await res.json();
const rows: ParsedIngredientRow[] = data.ingredients.map(
(ing: Omit<ParsedIngredientRow, 'selectedProductId' | 'editedQuantity' | 'editedUnit' | 'editedNote'>) => ({
...ing,
selectedProductId: ing.suggestions[0]?.productId ?? 0,
editedQuantity: String(ing.quantity),
editedUnit: ing.unit,
editedNote: ing.note ?? '',
}),
);
setParsed(data);
setEditedName(data.name);
setEditedDescription(data.description ?? '');
setEditedInstructions(data.instructions ?? '');
setIngredients(rows);
setStep('review');
} catch (err) {
const message = err instanceof Error ? err.message : 'Något gick fel vid tolkning.';
setError(message);
} finally {
setIsParsing(false);
}
};
const updateIngredient = (index: number, field: keyof ParsedIngredientRow, value: string | number) => {
setIngredients((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const removeIngredient = (index: number) => {
setIngredients((prev) => prev.filter((_, i) => i !== index));
};
const handleSave = async () => {
setIsSaving(true);
setError(null);
const validIngredients = ingredients.filter((ing) => ing.selectedProductId > 0);
if (validIngredients.length === 0) {
setError('Minst en ingrediens med vald produkt krävs.');
setIsSaving(false);
return;
}
const body = {
name: editedName,
description: editedDescription || undefined,
instructions: editedInstructions || undefined,
ingredients: validIngredients.map((ing) => ({
productId: ing.selectedProductId,
quantity: Number(ing.editedQuantity),
unit: ing.editedUnit,
note: ing.editedNote || undefined,
})),
};
try {
const res = await fetch('/api/recipes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
throw new Error(errorMessage);
}
router.push('/recipes');
} catch (err) {
const message = err instanceof Error ? err.message : 'Något gick fel vid sparning.';
setError(message);
} finally {
setIsSaving(false);
}
};
return (
<main style={{ padding: '1rem', maxWidth: '1000px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '0.5rem' }}>Importera recept från Markdown</h1>
{/* Steg-indikator */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', fontSize: '0.9rem', color: '#666' }}>
<span style={{ fontWeight: step === 'input' ? 700 : 400, color: step === 'input' ? '#0070f3' : '#666' }}>
1. Klistra in
</span>
<span></span>
<span style={{ fontWeight: step === 'review' ? 700 : 400, color: step === 'review' ? '#0070f3' : '#666' }}>
2. Granska
</span>
<span></span>
<span style={{ color: '#999' }}>3. Spara</span>
</div>
{error && (
<p style={{ color: 'crimson', backgroundColor: '#ffe5e5', padding: '0.75rem', borderRadius: '4px', marginBottom: '1rem' }}>
{error}
</p>
)}
{/* STEG 1: Markdown-inmatning */}
{step === 'input' && (
<section style={{ display: 'grid', gap: '1rem' }}>
<div style={{ background: '#f9f9f9', border: '1px solid #ddd', borderRadius: '8px', padding: '1rem', fontSize: '0.875rem', color: '#555' }}>
<strong>Förväntat format:</strong>
<pre style={{ margin: '0.5rem 0 0', fontFamily: 'monospace', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{`# Receptnamn
Valfri beskrivning av receptet.
## Ingredienser
- 500 g köttfärs
- 1 st lök
- 2 msk tomatpuré
- 1 dl grädde (vispgrädde)
## Tillvägagångssätt
Stek löken i lite smör. Tillsätt köttfärsen...`}</pre>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
Klistra in ditt recept i Markdown-format
</label>
<textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
placeholder="# Mitt recept&#10;&#10;## Ingredienser&#10;- 500 g köttfärs"
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
minHeight: '300px',
fontFamily: 'monospace',
boxSizing: 'border-box',
}}
/>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={handleParse}
disabled={isParsing || !markdown.trim()}
style={{
padding: '0.75rem 1.5rem',
background: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isParsing || !markdown.trim() ? 'not-allowed' : 'pointer',
opacity: isParsing || !markdown.trim() ? 0.6 : 1,
fontSize: '1rem',
fontWeight: 500,
}}
>
{isParsing ? 'Tolkar...' : 'Tolka recept'}
</button>
<button
onClick={() => router.push('/recipes')}
style={{ padding: '0.75rem 1rem', background: 'transparent', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '1rem' }}
>
Avbryt
</button>
</div>
</section>
)}
{/* STEG 2: Granskning */}
{step === 'review' && parsed && (
<section style={{ display: 'grid', gap: '1.5rem' }}>
{/* Receptdetaljer */}
<div style={{ display: 'grid', gap: '1rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Receptdetaljer</h2>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>Receptnamn *</label>
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>Beskrivning</label>
<textarea
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem', minHeight: '80px', fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>Instruktioner</label>
<textarea
value={editedInstructions}
onChange={(e) => setEditedInstructions(e.target.value)}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem', minHeight: '150px', fontFamily: 'monospace', boxSizing: 'border-box' }}
/>
</div>
</div>
{/* Ingredienser */}
<div style={{ display: 'grid', gap: '0.75rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>
Ingredienser{' '}
<span style={{ fontWeight: 400, fontSize: '0.875rem', color: '#666' }}>
välj produkt från databasen för varje rad
</span>
</h2>
{ingredients.map((ing, index) => (
<div
key={index}
style={{ display: 'grid', gap: '0.5rem', padding: '0.75rem', background: '#f9f9f9', borderRadius: '6px', border: '1px solid #eee' }}
>
{/* Rå text från Markdown */}
<div style={{ fontSize: '0.85rem', color: '#888', fontStyle: 'italic' }}>
Tolkad som: <strong style={{ color: '#555' }}>{ing.rawName}</strong>
{ing.suggestions.length > 0 && (
<span style={{ marginLeft: '0.5rem', color: '#0070f3' }}>
(bästa match: {ing.suggestions[0].productName}, {ing.suggestions[0].score}%)
</span>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr auto', gap: '0.5rem', alignItems: 'end' }}>
{/* Produktval */}
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem', fontWeight: 600 }}>Produkt *</label>
<select
value={ing.selectedProductId}
onChange={(e) => updateIngredient(index, 'selectedProductId', Number(e.target.value))}
style={{ width: '100%', padding: '0.5rem', border: ing.selectedProductId === 0 ? '1px solid #f59e0b' : '1px solid #ddd', borderRadius: '4px', fontSize: '0.9rem', background: ing.selectedProductId === 0 ? '#fffbeb' : 'white' }}
>
<option value={0}> Välj produkt </option>
{/* Förslag från backend (sorterade efter matchningspoäng) */}
{ing.suggestions.length > 0 && (
<optgroup label="Föreslagna matchningar">
{ing.suggestions.map((s) => (
<option key={s.productId} value={s.productId}>
{s.productName} ({s.score}%)
</option>
))}
</optgroup>
)}
<optgroup label="Alla produkter">
{allProducts.map((p) => (
<option key={p.id} value={p.id}>
{p.canonicalName || p.name}
</option>
))}
</optgroup>
</select>
</div>
{/* Mängd */}
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem', fontWeight: 600 }}>Mängd</label>
<input
type="number"
value={ing.editedQuantity}
onChange={(e) => updateIngredient(index, 'editedQuantity', e.target.value)}
min="0.01"
step="0.01"
style={{ width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.9rem', boxSizing: 'border-box' }}
/>
</div>
{/* Enhet */}
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem', fontWeight: 600 }}>Enhet</label>
<select
value={ing.editedUnit}
onChange={(e) => updateIngredient(index, 'editedUnit', e.target.value)}
style={{ width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.9rem' }}
>
{UNIT_OPTIONS.map((u) => (
<option key={u.value} value={u.value}>{u.label}</option>
))}
</select>
</div>
{/* Ta bort */}
<button
onClick={() => removeIngredient(index)}
title="Ta bort ingrediens"
style={{ padding: '0.5rem 0.75rem', background: '#fee2e2', color: '#dc2626', border: '1px solid #fca5a5', borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', alignSelf: 'end' }}
>
</button>
</div>
{/* Anteckning */}
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem', color: '#666' }}>Anteckning</label>
<input
type="text"
value={ing.editedNote}
onChange={(e) => updateIngredient(index, 'editedNote', e.target.value)}
placeholder="Valfri anteckning..."
style={{ width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.85rem', boxSizing: 'border-box' }}
/>
</div>
</div>
))}
{ingredients.length === 0 && (
<p style={{ color: '#888', fontStyle: 'italic', margin: 0 }}>
Inga ingredienser tolkades från Markdown-texten.
</p>
)}
</div>
{/* Knappar */}
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={handleSave}
disabled={isSaving || !editedName.trim() || ingredients.filter((i) => i.selectedProductId > 0).length === 0}
style={{
padding: '0.75rem 1.5rem',
background: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isSaving ? 'not-allowed' : 'pointer',
opacity: isSaving ? 0.6 : 1,
fontSize: '1rem',
fontWeight: 500,
}}
>
{isSaving ? 'Sparar...' : 'Spara recept'}
</button>
<button
onClick={() => setStep('input')}
style={{ padding: '0.75rem 1rem', background: 'transparent', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '1rem' }}
>
Redigera Markdown
</button>
<button
onClick={() => router.push('/recipes')}
style={{ padding: '0.75rem 1rem', background: 'transparent', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '1rem' }}
>
Avbryt
</button>
</div>
</section>
)}
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import ImportRecipePage from './ImportRecipePage';
export default function Page() {
return <ImportRecipePage />;
}