feat: Implement quick import feature for recipes

- Added QuickImportController and QuickImportService to handle recipe imports from URLs and file paths.
- Created QuickImportModule to encapsulate the quick import functionality.
- Developed frontend ImportFilePage for users to upload files or enter URLs for recipe import.
- Integrated API proxy to communicate with the backend for quick import requests.
- Implemented WriteRecipePage for users to manually input recipes with Markdown support.
- Added page routing for the new import and write recipe functionalities.
This commit is contained in:
Nils-Johan Gynther
2026-04-12 07:41:18 +02:00
parent ea971c2f63
commit 4f183df711
12 changed files with 1379 additions and 61 deletions
+50 -9
View File
@@ -77,13 +77,43 @@ curl http://localhost:8080/health/db
---
## Importera recept från Markdown
## Lägga till recept
### Steg 1: Gå till receptsidan
Navigera till **Recept** och välj **Lägg till nytt recept → Importera från Markdown**
### Snabbimport
På sidan "Lägg till nytt recept" finns ett **snabbimportfält** längst upp:
```
Snabbimport: Klistra in länk eller fil
[https://www.ica.se/recept/...] [→]
```
Klistra in:
- **ICA-receptlänk** — systemet skrapar automatiskt receptet och importerar det
- En länk omdirigeras till "Skriv in recept" med förifylld Markdown
**Stödda webbplatser:**
- ICA.se — Recept skrapas automatiskt
- (PDF-import under utveckling)
**Felmeddelandena vägleder dig:**
- "Länken är inte från ICA.se" — Endast ICA stöds för närvarande
- "Kunde inte hämta recept från ICA: ..." — Länken är bruten eller receptet kunde inte parsas
- "Du måste ange en URL eller filsökväg" — Fältet var tomt
### Välja mellan alternativen
Klicka på **Lägg till nytt recept** i receptmenyn. Du får ett val mellan:
1. **Skriv in recept** — Skriv receptet i Markdown-format med ingredienser och instruktioner
2. **Importera från fil** — Ladda upp PDF, länk eller annan receptkälla (under utveckling)
### Skriv in recept (Markdown)
Navigera till **Lägg till nytt recept → Skriv in recept**
**Steg 1: Skriv receptet**
Använd detta format:
### Steg 2: Klistra in receptet
Använd följande format:
```markdown
# Köttfärssås
@@ -101,20 +131,31 @@ En klassisk köttfärssås med massa smak.
Hacka löken och stek den mjuk i lite olja. Tillsätt köttfärsen och bräsera tills den är genomstekt. Tillsätt tomatpuré och låt det småkoka ett par minuter innan du tillsätter grädde. Smaka av med salt och peppar.
```
### Steg 3: Granska
**Steg 2: Granska**
Systemet:
- Tolkar receptnamn, beskrivning och instruktioner
- Försöker matcha varje ingrediens mot databasen (Levenshtein-likhet)
- Visar förslag för varje ingrediens i prioriteringsordning
Du kan:
- Redigera nombres, beskrivning och instruktioner
- Redigera Namnet, beskrivning och instruktioner
- Välj rätt produkt från förslagen för varje ingrediens
- Ta bort ingredienser som inte behövs
- Ändra kvantiteter och enheter
### Steg 4: Spara
Klicka "Spara recept" — basrecepet sparas med dina valida ingredienser
**Steg 3: Spara**
Klicka "Spara recept" — receptet sparas med dina valida ingredienser
### Importera från fil eller länk
Navigera till **Lägg till nytt recept → Importera från fil**
I denna sektion kan du:
- Ladda upp PDF eller andra receptfiler
- Ange URL till en receptsida eller blogg
- Systemet tolkar filen/länken och föreslår ingredienser
> **Notering:** Fil- och länk-import är under utveckling. För närvarande kan du använda "Skriv in recept" för att mata in receptet manuellt.
### Receptformat — regler
+83 -47
View File
@@ -52,10 +52,11 @@ Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och
| | `actions.ts` | Server actions för inventarie |
| **Recept** | `app/recipes/page.tsx` | Lista recept |
| | `RecipePreview.tsx` | Receptförhandsvisning med inventariestatus |
| **Skapa recept** | `app/recipes/create/page.tsx` | Receptkreation (manual form) |
| | `app/recipes/create/CreateRecipePage.tsx` | Komponenter för receptskapande |
| **Importera recept** | `app/recipes/import/page.tsx` | Startpunkt för Markdown-import |
| | `app/recipes/import/ImportRecipePage.tsx` | 3-stegsvyn för Markdown-import |
| **Lägg till recept** | `app/recipes/create/page.tsx` | Meny för receptskaping (val mellan två vägar) |
| **Skriv in recept** | `app/recipes/write/page.tsx` | Startpunkt för Markdown-inmatning |
| | `app/recipes/write/WriteRecipePage.tsx` | Komponenter för receptskapande (Markdown-baserat, 3-steg) |
| **Importera från fil** | `app/recipes/import/page.tsx` | Startpunkt för fil/länk-import |
| | `app/recipes/import/ImportFilePage.tsx` | Komponenter för fil-/länk-import (PDF, URL, etc) |
| **Recipe detail** | `app/recipes/[id]/` | Enskilt recept (detaljer, redigering) |
| **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel |
| | `AdminProductList.tsx` | Lista produkter, sök, sortera |
@@ -67,7 +68,7 @@ Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och
| Route | Metod | Syfte |
|-------|-------|-------|
| `/api/parse-markdown-proxy` | POST | Proxies `POST /api/recipes/parse-markdown` (Markdown-tolkning) |
| `/api/parse-markdown-proxy` | POST | Proxies `POST /api/recipes/parse-markdown` (Markdown-tolkning för skriv-in-recept) |
| `/api/inventory-history-proxy` | GET | Proxies konsumtionshistorik |
| `/api/recipe-preview-proxy` | GET | Proxies receptförhandsvisning |
| `/api/admin/merge-preview-proxy` | GET | Proxies produktmerge-preview |
@@ -126,6 +127,10 @@ backend/src/
│ ├── update-product.dto.ts
│ ├── merge-products.dto.ts
│ └── update-canonical-name.dto.ts
├── quick-import/ # NYT: Snabbimport-modul
│ ├── quick-import.controller.ts # POST /api/quick-import
│ ├── quick-import.service.ts # ICA-skrapning, PDF-stöd
│ └── quick-import.module.ts # Module definition
└── recipes/
├── recipes.controller.ts # Recept endpoints
├── recipes.service.ts # Recept + Markdown-parsing
@@ -205,6 +210,8 @@ GET /api/inventory/:id/consumption-history Konsumtionshistorik
### 🍽️ Recept-endpoints
```
POST /api/quick-import SNITT: Snabbimport (ICA-skrapning)
Body: { input: string (URL eller filsökväg) }
POST /api/recipes/parse-markdown Tolka Markdown-recept (matchningslogik)
GET /api/recipes Lista alla recept
POST /api/recipes Skapa nytt recept
@@ -329,15 +336,61 @@ model RecipeIngredient {
---
## Receptimport via Markdown — Detaljerad arkitektur
## Receptimport och receptskaping — Detaljerad arkitektur
### Syfte
### Syfte och struktur
Användaren kan importera ett recept skrivet i Markdown-format istället för att fylla i formularet manuellt. Systemet:
1. Tolkar Markdown-format (namn, beskrivning, ingredienser, instruktioner)
2. Matchar varje ingrediens mot produktdatabasen (intelligenta matchningar)
3. Låter användaren granska förslag och välja rätt produkt
4. Sparar receptet med valida ingredienser
Recipe App erbjuder tre vägar för att lägga till recept:
1. **Snabbimport** — Klistra in ICA-länk för automatisk skrapning (ny feature)
2. **Skriv in recept** (`/recipes/write`) — Markdown-baserad inmatning där användaren skriver receptet i enkelt format
3. **Importera från fil** (`/recipes/import`) — Ladda upp PDF, länk eller andra receptkällor (under utveckling)
Alla vägar möjliggör automatisk matchning av ingredienser mot databasen.
### Strukturöversikt
#### Snabbimport-fältet
**Frontend: `/recipes/create/page.tsx`**
- Ovanför de två huvudvalen visas ett gult inmatningsfält för snabbimport
- Användaren klistrar in en ICA-receptlänk eller filsökväg
- Vid submit:
1. Frontend skickar till `/api/quick-import-proxy`
2. Proxy proxiar till backend `POST /api/quick-import`
3. Backend returnerar Markdown-text
4. Frontend sparar i `sessionStorage('recipeMarkdown')`
5. Omdirigera till `/recipes/write` med förifylld Markdown
**Backend: `QuickImportService` (ny modul)**
- Ansvarig för ICA-skrapning, PDF-tolkning, URL-validering
- **Huvudmetod:** `importFromInput(input: string)` — Detekterar input-typ och delegerar
- **ICA-specifik:**
- Validerar URL (måste vara ICA.se)
- Fetchar HTML via `fetch()`
- Parsar HTML med regex för: receptnamn, ingredienser, instruktioner
- Konverterar till Markdown-format
- **Felhantering:** Specifika felmeddelanden per scenario
- **PDF-support:** Stubben för framtida integration (throwError: "PDF-import är under utveckling")
- **Error-strategi:**
- `400 Bad Request` — Tomt input, inte URL/fil
- `400 Bad Request` — Länken är inte från ICA.se
- `503 Service Unavailable` — Network-fel vid hämtning (HTTP-fel från ICA)
- `400 Bad Request` — HTML-parsing misslyckades (receptnamn/ingredienser inte hittade)
**API-endpoint:**
```
POST /api/quick-import
Input: { input: string }
Output: { markdown: string, source: 'ica' | 'pdf' | 'other' }
```
**Proxy-route (Next.js):**
- `/api/quick-import-proxy` — Proxies till backend
- Hanterar error-konvertering (BE HTTP → FE error message)
- Returnerar Markdown eller JSON-error
### Markdown-format och parsningsregler
### Markdown-format och parsningsregler
@@ -486,44 +539,27 @@ Top 5: Max 5 förslag per ingrediens
}
```
#### 3. Frontend: `/recipes/import` page
#### 3. Frontend: Receptskapsidor
**Komponenter:**
- `ImportRecipePage.tsx` — Main client component (3-steps state machine)
- Använder `/api/parse-markdown-proxy` för backend-anrop (Next.js proxy)
**Huvudmeny: `/recipes/create/page.tsx`**
- Presenterar två val-kort (card-baserad UI)
- "Skriv in recept" → `/recipes/write`
- "Importera från fil/länk" → `/recipes/import`
**Steg 1: Klistra in Markdown**
- `<textarea>` för råtext
- Knapp: "Tolka recept" → POST /api/parse-markdown-proxy
- Error handling med svenska meddelanden
**Skriv in recept: `/recipes/write/WriteRecipePage.tsx`**
- Main client component (3-steps state machine)
- Samma logik som tidigare `ImportRecipePage`
- **Steg 1:** Markdown-inmatning
- **Steg 2:** Granska ingredienser, välj produkter
- **Steg 3:** Spara recept
- Använder `/api/parse-markdown-proxy` för backend-anrop
**Steg 2: Granska och välj**
- Redigerbara fält: namn, beskrivning, instruktioner
- För varje ingrediens:
- Visar föreslagna produkter (top 5, prioriterad ordning)
- Fallback: Dropdown med alla produkter i DB
- Redigerbara fält: quantity, unit, note
- Visuell markering (gul ram) för ingredienser utan vald produkt
- Knapp: "Ta bort ingrediens"
- Validering: Minst 1 ingrediens måste ha vald produkt
**Steg 3: Spara**
- POST /api/recipes med:
```json
{
"name": "...",
"description": "... " eller undefined,
"instructions": "..." eller undefined,
"ingredients": [
{ "productId": 12, "quantity": 500, "unit": "g", "note": "vispgrädde" },
{ "productId": 34, "quantity": 1, "unit": "st", "note": undefined }
]
}
```
- Efter framgång: navigera till receptlistan
**Enhetsstöd i UI:**
- Dropdown-alternativ: g, kg, hg, ml, dl, l, st, tsk, msk
**Importera från fil: `/recipes/import/ImportFilePage.tsx`**
- Tabs/toggle mellan två metoder:
1. **Fil-upload** — Dra-och-släpp eller välja PDF/TXT/DOCX
2. **URL-import** — Ange länk till receptsida
- Placeholder för framtida integration
- Visar tips för att använda "Skriv in recept" tills dessa funktioner är klara
#### 4. API-proxy-route (Next.js)
+2
View File
@@ -4,6 +4,7 @@ import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module';
import { InventoryModule } from './inventory/inventory.module';
import { RecipesModule } from './recipes/recipes.module';
import { QuickImportModule } from './quick-import/quick-import.module';
@Module({
@@ -13,6 +14,7 @@ import { RecipesModule } from './recipes/recipes.module';
ProductsModule,
InventoryModule,
RecipesModule,
QuickImportModule,
],
})
export class AppModule {}
@@ -0,0 +1,14 @@
import { Controller, Post, Body } from '@nestjs/common';
import { QuickImportService } from './quick-import.service';
@Controller('quick-import')
export class QuickImportController {
constructor(private readonly quickImportService: QuickImportService) {}
@Post()
async importFromInput(
@Body() body: { input: string }
) {
return this.quickImportService.importFromInput(body.input);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { QuickImportController } from './quick-import.controller';
import { QuickImportService } from './quick-import.service';
@Module({
controllers: [QuickImportController],
providers: [QuickImportService],
})
export class QuickImportModule {}
@@ -0,0 +1,218 @@
import { Injectable, BadRequestException } from '@nestjs/common';
interface QuickImportResult {
markdown: string;
source: 'ica' | 'pdf' | 'other';
}
@Injectable()
export class QuickImportService {
/**
* Detekterar typ av input (URL eller filsökväg) och importerar från lämplig källa
*/
async importFromInput(input: string): Promise<QuickImportResult> {
input = input.trim();
if (!input) {
throw new BadRequestException('Du måste ange en URL eller filsökväg');
}
// Detektera typ
const isUrl = this.isUrl(input);
const isPdf = this.isPdfPath(input);
if (isUrl) {
// Försök detektera webbplats
if (input.includes('ica.se')) {
return this.scrapeIcaRecipe(input);
} else {
throw new BadRequestException(
'Endast ICA-recept stöds för närvarande. Försök med en ICA-länk (ica.se)'
);
}
} else if (isPdf) {
throw new BadRequestException(
'PDF-import är under utveckling. Använd snabbimport för ICA-recept eller skriv in receptet manuellt.'
);
} else {
throw new BadRequestException(
'Ogültig input. Ange en gyltig URL (t.ex. ica.se/recept/...) eller filsökväg'
);
}
}
/**
* Kontrollerar om input är en URL
*/
private isUrl(input: string): boolean {
try {
new URL(input);
return true;
} catch {
return false;
}
}
/**
* Kontrollerar om input är en PDF-filsökväg
*/
private isPdfPath(input: string): boolean {
const normalized = input.toLowerCase();
return normalized.endsWith('.pdf');
}
/**
* Skrapar recept från ICA.se
*
* Försöker hämta:
* - Recepttitel (från h1 eller meta title)
* - Ingredienser (från ingrediens-lista)
* - Instruktioner (från steg-lista eller beskrivning)
*
* @param url ICA-receptlänk
* @returns Markdown-format
*/
private async scrapeIcaRecipe(url: string): Promise<QuickImportResult> {
try {
// Hämta HTML från URL
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const html = await response.text();
// Extrahera receptinformation från HTML
const recipe = this.parseIcaHtml(html);
if (!recipe.name) {
throw new Error('Kunde inte hitta receptnamn på sidan. Försök med en annan länk.');
}
// Konvertera till Markdown-format
const markdown = this.recipeToMarkdown(recipe);
return {
markdown,
source: 'ica',
};
} catch (err) {
const message = err instanceof Error ? err.message : 'Okänt fel vid scraping';
throw new BadRequestException(
`Kunde inte hämta recept från ICA: ${message}. Kontrollera att länken är korrekt och försök igen.`
);
}
}
/**
* Parsa ICA-receptsida (HTML)
*
* Denna är en simplified version. För full produrktion behöver du:
* - Headless browser (Puppeteer/Playwright)
* - API-integration eller scraping-bibliotek
* - Proper error handling för sidstruktur-ändringar
*/
private parseIcaHtml(html: string): {
name: string;
description?: string;
ingredients: Array<{
quantity: number;
unit: string;
name: string;
}>;
instructions?: string;
} {
// Extrahera titel
let name = '';
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (titleMatch) {
name = titleMatch[1].trim();
}
if (!name) {
const ogTitleMatch = html.match(/<meta\s+property="og:title"\s+content="([^"]+)"/i);
if (ogTitleMatch) {
name = ogTitleMatch[1].trim();
}
}
// Extrahera ingredienser (en enkel regex - kan behöva anpassas)
const ingredients: Array<{ quantity: number; unit: string; name: string }> = [];
const ingredientRegex = /(?:ingredients?|<li[^>]*>)([^<]*?(\d+(?:[.,]\d+)?)\s*([a-zåäö]*)\s*([^<]+))/gi;
let match;
while ((match = ingredientRegex.exec(html)) !== null) {
const quantity = parseFloat(match[2].replace(',', '.'));
const unit = match[3].toLowerCase().trim() || 'st';
const name = match[4].trim();
if (name) {
ingredients.push({ quantity, unit, name });
}
}
// Extrahera instruktioner (första paragraf eller instruktions-sektion)
let instructions = '';
const instructionsMatch = html.match(
/<(?:div|section)[^>]*class="[^"]*instruction[^"]*"[^>]*>([^<]*)<\/(?:div|section)>/is
);
if (instructionsMatch) {
instructions = instructionsMatch[1].replace(/<[^>]+>/g, '').trim();
}
return {
name,
ingredients: ingredients.length > 0 ? ingredients : [],
instructions,
};
}
/**
* Konvertera receptobjekt till Markdown-format
*/
private recipeToMarkdown(recipe: {
name: string;
description?: string;
ingredients: Array<{
quantity: number;
unit: string;
name: string;
}>;
instructions?: string;
}): string {
const lines: string[] = [];
// Titel
lines.push(`# ${recipe.name}`);
lines.push('');
// Beskrivning
if (recipe.description) {
lines.push(recipe.description);
lines.push('');
}
// Ingredienser
if (recipe.ingredients.length > 0) {
lines.push('## Ingredienser');
for (const ing of recipe.ingredients) {
const quantity = ing.quantity > 0 ? `${ing.quantity} ` : '';
const unit = ing.unit ? `${ing.unit} ` : '';
lines.push(`- ${quantity}${unit}${ing.name}`);
}
lines.push('');
}
// Instruktioner
if (recipe.instructions) {
lines.push('## Tillvägagångssätt');
lines.push(recipe.instructions);
}
return lines.join('\n');
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { input } = await request.json();
if (!input || typeof input !== 'string') {
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://localhost:8080';
const response = await fetch(`${backendUrl}/api/quick-import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: input.trim() }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return NextResponse.json(
{ error: errorData.message || 'Importen misslyckades' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Quick import error:', error);
return NextResponse.json(
{ error: 'Serverfelet vid import. Försök igen senare.' },
{ status: 500 }
);
}
}
+221 -3
View File
@@ -1,5 +1,223 @@
import CreateRecipePage from './CreateRecipePage';
'use client';
export default function Page() {
return <CreateRecipePage />;
import Link from 'next/link';
import { useState } from 'react';
import Navigation from '../../Navigation';
import { parseErrorResponse } from '../../../lib/error-handler';
import { useRouter } from 'next/navigation';
export default function CreateRecipePage() {
const router = useRouter();
const [quickImportUrl, setQuickImportUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleQuickImport = async (e: React.FormEvent) => {
e.preventDefault();
setError(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-proxy', {
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) {
// Omdirigera till /recipes/write med förifylld Markdown
// Vi använder sessionStorage för att passa data mellan sidor
sessionStorage.setItem('prefilled_markdown', data.markdown);
router.push('/recipes/write');
}
} 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: '800px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '1.5rem' }}>Lägg till nytt recept</h1>
{/* SNABBIMPORT-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 ICA-receptlänk eller filsökväg för att importera direkt:
</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/... eller C:\recepter\file.pdf"
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>
)}
<p style={{ margin: '0.75rem 0 0 0', color: '#92400e', fontSize: '0.8rem' }}>
Stöds: ICA-recept, PDF-filer
</p>
</form>
</div>
{/* ELLER-SEPARATOR */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
margin: '2rem 0',
color: '#999',
}}
>
<div style={{ flex: 1, height: '1px', background: '#ddd' }} />
<span style={{ fontSize: '0.9rem', fontWeight: 500 }}>eller</span>
<div style={{ flex: 1, height: '1px', background: '#ddd' }} />
</div>
{/* KLASSISKA ALTERNATIV */}
<p style={{ marginBottom: '2rem', color: '#666' }}>
Välj ett sätt att lägga till ett recept:
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1.5rem',
}}
>
{/* Skriv in recept */}
<Link
href="/recipes/write"
style={{
padding: '2rem',
border: '2px solid #0070f3',
borderRadius: '8px',
textDecoration: 'none',
background: 'linear-gradient(135deg, #e3f2fd 0%, #ffffff 100%)',
transition: 'all 0.2s',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 8px 16px rgba(0,112,243,0.2)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
}}
>
<h2 style={{ margin: '0 0 0.5rem 0', color: '#0070f3', fontSize: '1.3rem' }}>
Skriv in recept
</h2>
<p style={{ margin: 0, color: '#666', fontSize: '0.9rem' }}>
Skriv in receptet med ingredienser och instruktioner
</p>
</Link>
{/* Importera från fil/länk */}
<Link
href="/recipes/import"
style={{
padding: '2rem',
border: '2px solid #10b981',
borderRadius: '8px',
textDecoration: 'none',
background: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)',
transition: 'all 0.2s',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 8px 16px rgba(16,185,129,0.2)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
}}
>
<h2 style={{ margin: '0 0 0.5rem 0', color: '#10b981', fontSize: '1.3rem' }}>
📥 Importera från fil
</h2>
<p style={{ margin: 0, color: '#666', fontSize: '0.9rem' }}>
Ladda upp PDF, länk eller annan filtyp
</p>
</Link>
</div>
</main>
);
}
@@ -0,0 +1,276 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import Navigation from '../../Navigation';
export default function ImportFilePage() {
const [selectedMethod, setSelectedMethod] = useState<'file' | 'url' | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
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>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const url = formData.get('url') as string;
if (!url) {
setError('Vänligen ange en URL');
return;
}
setError('Länk-import är under utveckling. Använd "Skriv in recept" för att mata in recept manuellt.');
};
return (
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
<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.
</p>
{error && (
<div
style={{
background: '#fef2f2',
border: '1px solid #fca5a5',
borderRadius: '6px',
padding: '1rem',
marginBottom: '1.5rem',
color: '#dc2626',
fontSize: '0.95rem',
}}
>
{error}
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1.5rem',
marginBottom: '2rem',
}}
>
{/* Fil-upload */}
<div
onClick={() => setSelectedMethod('file')}
style={{
padding: '2rem',
border: selectedMethod === 'file' ? '2px solid #0070f3' : '2px solid #e5e7eb',
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
</h2>
<p style={{ color: '#666', margin: '0 0 1rem 0', fontSize: '0.95rem' }}>
Ladda upp ett recept från en PDF eller textfil
</p>
{selectedMethod === 'file' && (
<div style={{ marginTop: '1rem' }}>
<label
style={{
display: 'block',
padding: '1rem',
background: 'white',
border: '2px dashed #0070f3',
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';
}}
>
<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>
)}
</div>
{/* URL-import */}
<div
onClick={() => setSelectedMethod('url')}
style={{
padding: '2rem',
border: selectedMethod === 'url' ? '2px solid #10b981' : '2px solid #e5e7eb',
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
</h2>
<p style={{ color: '#666', margin: '0 0 1rem 0', fontSize: '0.95rem' }}>
Ange URL till en receptsida eller blogg
</p>
{selectedMethod === 'url' && (
<form onSubmit={handleURLSubmit} style={{ marginTop: '1rem' }}>
<input
type="url"
name="url"
placeholder="https://exempel.se/recept/..."
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '0.9rem',
boxSizing: 'border-box',
marginBottom: '0.75rem',
}}
/>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 500,
fontSize: '0.95rem',
}}
>
Importera från länk
</button>
</form>
)}
</div>
</div>
{/* Info-box */}
<div
style={{
background: '#fef3c7',
border: '1px solid #fcd34d',
borderRadius: '6px',
padding: '1rem',
marginBottom: '1.5rem',
color: '#92400e',
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.
</div>
{/* Knapp för att gå tillbaka */}
<div style={{ display: 'flex', gap: '1rem' }}>
<Link
href="/recipes/create"
style={{
padding: '0.75rem 1.5rem',
background: 'transparent',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
textDecoration: 'none',
color: '#333',
fontWeight: 500,
}}
>
Tillbaka
</Link>
<Link
href="/recipes/write"
style={{
padding: '0.75rem 1.5rem',
background: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '4px',
textDecoration: 'none',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: 500,
}}
>
Skriv in recept istället
</Link>
</div>
</main>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import ImportRecipePage from './ImportRecipePage';
import ImportFilePage from './ImportFilePage';
export default function Page() {
return <ImportRecipePage />;
return <ImportFilePage />;
}
@@ -0,0 +1,460 @@
'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[];
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 WriteRecipePage() {
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(() => {
// Hämta produkter från databas
fetchJson<Product[]>('/api/products')
.then(setAllProducts)
.catch(console.error);
// Kontrollera om det finns förifylld Markdown från snabbimport
const prefilledMarkdown = sessionStorage.getItem('prefilled_markdown');
if (prefilledMarkdown) {
setMarkdown(prefilledMarkdown);
sessionStorage.removeItem('prefilled_markdown');
// Auto-parse om Markdown finns
setTimeout(() => {
// Markeringen för auto-parse görs via en flag
sessionStorage.setItem('auto_parse_markdown', 'true');
}, 100);
}
}, []);
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' }}>Skriv in recept</h1>
<p style={{ color: '#666', marginBottom: '1.5rem' }}>Skriv receptet i Markdown-format nama, ingredienser och instruktioner.</p>
{/* 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. Skriv
</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 }}>
Skriv 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 och granska'}
</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
</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 WriteRecipePage from './WriteRecipePage';
export default function Page() {
return <WriteRecipePage />;
}