From 73bf5193c4f0f7ad90a081d03fca35717d0a8ff3 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 15 Apr 2026 19:46:50 +0200 Subject: [PATCH] feat: add image handling to recipes - Implemented image downloading and optimization in QuickImportService. - Added imageUrl field to CreateRecipeDto for recipe creation. - Created an endpoint in RecipesController to update recipe images. - Enhanced RecipesService to handle image URL updates and optimizations. - Updated Docker Compose to mount a volume for recipe images. - Refactored frontend to display images in recipe grids and detail views. - Added a new utility function for downloading and optimizing images. - Created a new API route for handling image uploads. - Introduced RecipeGrid component for better recipe display. - Updated RecipeDetailClient to manage image updates and display. - Added migration for new imageUrl column in the Recipe table. --- backend/Dockerfile | 2 +- backend/package.json | 5 +- .../migration.sql | 2 + backend/prisma/schema.prisma | 13 +- backend/src/common/utils/download-image.ts | 89 ++++ .../src/quick-import/parsers/base.parser.ts | 1 + .../quick-import/parsers/generic.parser.ts | 31 +- .../src/quick-import/parsers/ica.parser.ts | 31 +- .../src/quick-import/quick-import.service.ts | 16 + backend/src/recipes/dto/create-recipe.dto.ts | 4 + backend/src/recipes/recipes.controller.ts | 14 + backend/src/recipes/recipes.service.ts | 31 ++ compose.yml | 5 + frontend/app/api/recipes/[id]/image/route.ts | 25 + frontend/app/recipes/RecipeGrid.tsx | 141 +++++ .../app/recipes/[id]/RecipeDetailClient.tsx | 498 ++++++++++++++++++ frontend/app/recipes/[id]/edit/page.tsx | 18 +- frontend/app/recipes/[id]/page.tsx | 27 + frontend/app/recipes/page.tsx | 28 +- frontend/features/inventory/types.ts | 1 + 20 files changed, 933 insertions(+), 49 deletions(-) create mode 100644 backend/prisma/migrations/20260415000000_add_recipe_image_url/migration.sql create mode 100644 backend/src/common/utils/download-image.ts create mode 100644 frontend/app/api/recipes/[id]/image/route.ts create mode 100644 frontend/app/recipes/RecipeGrid.tsx create mode 100644 frontend/app/recipes/[id]/RecipeDetailClient.tsx create mode 100644 frontend/app/recipes/[id]/page.tsx diff --git a/backend/Dockerfile b/backend/Dockerfile index 2595c793..7f2ebd87 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,4 +28,4 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/dist ./dist EXPOSE 8080 -CMD ["node", "dist/main"] +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"] diff --git a/backend/package.json b/backend/package.json index 16e282af..b66cabdc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,9 @@ "pdf-parse": "^1.1.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "tesseract.js": "^6.0.1" + "sharp": "^0.33.5", + "tesseract.js": "^6.0.1", + "uuid": "^11.1.0" }, "devDependencies": { "@nestjs/cli": "^10.3.0", @@ -31,6 +33,7 @@ "@types/multer": "^1.4.12", "@types/node": "^22.15.29", "@types/pdf-parse": "^1.1.5", + "@types/uuid": "^10.0.0", "prisma": "^6.12.0", "typescript": "^5.4.5" } diff --git a/backend/prisma/migrations/20260415000000_add_recipe_image_url/migration.sql b/backend/prisma/migrations/20260415000000_add_recipe_image_url/migration.sql new file mode 100644 index 00000000..2b1fc752 --- /dev/null +++ b/backend/prisma/migrations/20260415000000_add_recipe_image_url/migration.sql @@ -0,0 +1,2 @@ +-- Migration: add imageUrl to Recipe +ALTER TABLE `Recipe` ADD COLUMN `imageUrl` VARCHAR(191) NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cb8bf622..75f3351a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -60,12 +60,13 @@ model InventoryConsumption { } model Recipe { - id Int @id @default(autoincrement()) - name String - description String? - instructions String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + name String + description String? + instructions String? @db.Text + imageUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt ingredients RecipeIngredient[] } diff --git a/backend/src/common/utils/download-image.ts b/backend/src/common/utils/download-image.ts new file mode 100644 index 00000000..38523446 --- /dev/null +++ b/backend/src/common/utils/download-image.ts @@ -0,0 +1,89 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as sharp from 'sharp'; +import { v4 as uuidv4 } from 'uuid'; + +/** Privata IP-ranges som ska blockeras (SSRF-skydd) */ +const BLOCKED_HOSTNAMES = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.0\.0\.0|::1|fc00:|fe80:)/i; + +/** + * Laddar ner en bild från en extern URL, optimerar den med sharp och + * sparar till destDir. Returnerar relativ URL för användning i DB (/images/{uuid}.jpg). + * + * SSRF-skydd: + * - Kräver https:// + * - Blockar privata IP-adresser och localhost + * - Timeout 10s, max 5 MB + * - Validerar att content-type är image/* + */ +export async function downloadAndOptimizeImage( + sourceUrl: string, + destDir: string, +): Promise { + // Protokollvalidering + if (!sourceUrl.startsWith('https://')) { + throw new Error('Bild-URL måste använda https://'); + } + + // SSRF: blockera privata hostnames + let hostname: string; + try { + hostname = new URL(sourceUrl).hostname; + } catch { + throw new Error('Ogiltig bild-URL'); + } + + if (BLOCKED_HOSTNAMES.test(hostname)) { + throw new Error('Bild-URL pekar på ett blockerat nätverk'); + } + + // Ladda ner bilden + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + let response: Response; + try { + response = await fetch(sourceUrl, { + signal: controller.signal, + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RecipeApp/1.0)' }, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status} vid nedladdning av bild`); + } + + // Validera content-type + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.startsWith('image/')) { + throw new Error(`Ogiltig content-type: ${contentType}`); + } + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > 5 * 1024 * 1024) { + throw new Error('Bilden är för stor (max 5 MB)'); + } + + const imageBuffer = Buffer.from(arrayBuffer); + + // Skapa destDir om det inte finns + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + const filename = `${uuidv4()}.jpg`; + const outputPath = path.join(destDir, filename); + + // Optimera med sharp + await (sharp as unknown as (input: Buffer) => sharp.Sharp)(imageBuffer) + .resize(1200, 800, { + fit: 'inside', + withoutEnlargement: true, + }) + .jpeg({ quality: 80 }) + .toFile(outputPath); + + // Returnera relativ sökväg för DB-lagring + return `/images/${filename}`; +} diff --git a/backend/src/quick-import/parsers/base.parser.ts b/backend/src/quick-import/parsers/base.parser.ts index b6984952..6413680f 100644 --- a/backend/src/quick-import/parsers/base.parser.ts +++ b/backend/src/quick-import/parsers/base.parser.ts @@ -12,6 +12,7 @@ export interface ParsedRecipe { note?: string; }>; instructions?: string; + imageUrl?: string; } export abstract class RecipeParser { diff --git a/backend/src/quick-import/parsers/generic.parser.ts b/backend/src/quick-import/parsers/generic.parser.ts index 04d86b76..a8242ee3 100644 --- a/backend/src/quick-import/parsers/generic.parser.ts +++ b/backend/src/quick-import/parsers/generic.parser.ts @@ -14,6 +14,9 @@ export class GenericRecipeParser extends RecipeParser { parse(html: string): ParsedRecipe { console.log('[GenericParser] Parsing recipe from unknown site...'); + // Extrahera og:image för bildurl-fallback + const ogImage = this.extractOgImage(html); + // Försöka extrahera JSON-LD recipe data const jsonLdMatch = html.match( /]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i @@ -29,7 +32,7 @@ export class GenericRecipeParser extends RecipeParser { if (recipe) { console.log('[GenericParser] ✓ JSON-LD data found'); - return this.extractFromJsonLd(recipe); + return this.extractFromJsonLd(recipe, ogImage); } } catch (err) { console.log('[GenericParser] JSON-LD parsing failed'); @@ -37,13 +40,31 @@ export class GenericRecipeParser extends RecipeParser { } console.log('[GenericParser] No JSON-LD found, using HTML parsing'); - return this.parseFromHtml(html); + return this.parseFromHtml(html, ogImage); } - private extractFromJsonLd(recipe: any): ParsedRecipe { + private extractOgImage(html: string): string | undefined { + const match = html.match(/]+property="og:image"[^>]+content="([^"]+)"/i) + || html.match(/]+content="([^"]+)"[^>]+property="og:image"/i); + return match ? match[1].trim() : undefined; + } + + private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe { const name = recipe.name || ''; const description = recipe.description || ''; + // Extrahera bildurl från JSON-LD + let imageUrl: string | undefined = ogImage; + if (recipe.image) { + if (typeof recipe.image === 'string') { + imageUrl = recipe.image; + } else if (Array.isArray(recipe.image) && recipe.image.length > 0) { + imageUrl = typeof recipe.image[0] === 'string' ? recipe.image[0] : recipe.image[0]?.url; + } else if (recipe.image?.url) { + imageUrl = recipe.image.url; + } + } + const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = []; if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) { for (const ing of recipe.recipeIngredient) { @@ -75,10 +96,11 @@ export class GenericRecipeParser extends RecipeParser { description, ingredients, instructions, + imageUrl, }; } - private parseFromHtml(html: string): ParsedRecipe { + private parseFromHtml(html: string, ogImage?: string): ParsedRecipe { // Försöka hitta titel let name = ''; @@ -143,6 +165,7 @@ export class GenericRecipeParser extends RecipeParser { description, ingredients, instructions, + imageUrl: ogImage, }; } } diff --git a/backend/src/quick-import/parsers/ica.parser.ts b/backend/src/quick-import/parsers/ica.parser.ts index 361a710f..49259f3c 100644 --- a/backend/src/quick-import/parsers/ica.parser.ts +++ b/backend/src/quick-import/parsers/ica.parser.ts @@ -12,6 +12,9 @@ export class IcaRecipeParser extends RecipeParser { parse(html: string): ParsedRecipe { console.log('[IcaParser] Parsing ICA recipe...'); + // Extrahera og:image för bildurl-fallback + const ogImage = this.extractOgImage(html); + // Försöka extrahera JSON-LD recipe data (ICA använder detta) const jsonLdMatch = html.match( /]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i @@ -29,7 +32,7 @@ export class IcaRecipeParser extends RecipeParser { if (recipe) { console.log('[IcaParser] ✓ JSON-LD recipe found'); - return this.extractFromJsonLd(recipe); + return this.extractFromJsonLd(recipe, ogImage); } } catch (err) { console.log('[IcaParser] JSON-LD parsing failed:', err); @@ -38,16 +41,34 @@ export class IcaRecipeParser extends RecipeParser { // Fallback: HTML parsing (sällan nödvändigt för ICA) console.log('[IcaParser] Falling back to HTML parsing'); - return this.parseFromHtml(html); + return this.parseFromHtml(html, ogImage); } - private extractFromJsonLd(recipe: any): ParsedRecipe { + private extractOgImage(html: string): string | undefined { + const match = html.match(/]+property="og:image"[^>]+content="([^"]+)"/i) + || html.match(/]+content="([^"]+)"[^>]+property="og:image"/i); + return match ? match[1].trim() : undefined; + } + + private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe { // Extrahera titel const name = recipe.name || ''; // Extrahera beskrivning const description = recipe.description || ''; + // Extrahera bildurl från JSON-LD (kan vara sträng eller array) + let imageUrl: string | undefined = ogImage; + if (recipe.image) { + if (typeof recipe.image === 'string') { + imageUrl = recipe.image; + } else if (Array.isArray(recipe.image) && recipe.image.length > 0) { + imageUrl = typeof recipe.image[0] === 'string' ? recipe.image[0] : recipe.image[0]?.url; + } else if (recipe.image?.url) { + imageUrl = recipe.image.url; + } + } + // Extrahera ingredienser const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = []; if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) { @@ -81,10 +102,11 @@ export class IcaRecipeParser extends RecipeParser { description, ingredients, instructions, + imageUrl, }; } - private parseFromHtml(html: string): ParsedRecipe { + private parseFromHtml(html: string, ogImage?: string): ParsedRecipe { let name = ''; const titleMatch = html.match(/]*>([^<]+)<\/h1>/i); if (titleMatch) { @@ -133,6 +155,7 @@ export class IcaRecipeParser extends RecipeParser { description, ingredients, instructions, + imageUrl: ogImage, }; } } diff --git a/backend/src/quick-import/quick-import.service.ts b/backend/src/quick-import/quick-import.service.ts index 43c5b531..66b393d0 100644 --- a/backend/src/quick-import/quick-import.service.ts +++ b/backend/src/quick-import/quick-import.service.ts @@ -11,10 +11,14 @@ import { createWorker } from 'tesseract.js'; import { IcaRecipeParser } from './parsers/ica.parser'; import { GenericRecipeParser } from './parsers/generic.parser'; import { RecipeParser } from './parsers/base.parser'; +import { downloadAndOptimizeImage } from '../common/utils/download-image'; + +const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images'; export interface QuickImportResult { markdown: string; source: 'ica' | 'pdf' | 'image' | 'other'; + imageUrl?: string; } type UploadKind = 'pdf' | 'image'; @@ -311,9 +315,21 @@ export class QuickImportService { source = 'ica'; } + // Ladda ner och optimera bild om parser hittade en + let imageUrl: string | undefined; + if (recipe.imageUrl) { + try { + imageUrl = await downloadAndOptimizeImage(recipe.imageUrl, IMAGE_DEST_DIR); + console.log('[QuickImport] Bild optimerad och sparad:', imageUrl); + } catch (imgErr) { + console.warn('[QuickImport] Kunde inte ladda ner bild:', imgErr); + } + } + return { markdown, source, + imageUrl, }; } catch (err) { const message = err instanceof Error ? err.message : 'Okänt fel vid scraping'; diff --git a/backend/src/recipes/dto/create-recipe.dto.ts b/backend/src/recipes/dto/create-recipe.dto.ts index e81f9509..cc87249d 100644 --- a/backend/src/recipes/dto/create-recipe.dto.ts +++ b/backend/src/recipes/dto/create-recipe.dto.ts @@ -38,6 +38,10 @@ export class CreateRecipeDto { @IsString() instructions?: string; + @IsOptional() + @IsString() + imageUrl?: string; + @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) diff --git a/backend/src/recipes/recipes.controller.ts b/backend/src/recipes/recipes.controller.ts index 43774040..435e5a9f 100644 --- a/backend/src/recipes/recipes.controller.ts +++ b/backend/src/recipes/recipes.controller.ts @@ -1,8 +1,14 @@ import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common'; +import { IsString } from 'class-validator'; import { RecipesService } from './recipes.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; +class UpdateImageDto { + @IsString() + sourceUrl!: string; +} + @Controller('recipes') export class RecipesController { constructor(private readonly recipesService: RecipesService) {} @@ -45,4 +51,12 @@ export class RecipesController { async remove(@Param('id', ParseIntPipe) id: number) { return this.recipesService.remove(id); } + + @Post(':id/image') + async updateImage( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateImageDto, + ) { + return this.recipesService.updateImage(id, dto.sourceUrl); + } } \ No newline at end of file diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 9ab08e1c..3c877aea 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -3,6 +3,9 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; +import { downloadAndOptimizeImage } from '../common/utils/download-image'; + +const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images'; // Lokala typdefiniitioner (tidigare från recipe-document-converter) interface ParsedIngredient { @@ -340,6 +343,7 @@ export class RecipesService { name: updateRecipeDto.name, description: updateRecipeDto.description || null, instructions: updateRecipeDto.instructions || null, + ...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }), ingredients: { create: updateRecipeDto.ingredients.map((ingredient) => ({ productId: ingredient.productId, @@ -374,12 +378,39 @@ export class RecipesService { await this.prisma.recipe.delete({ where: { id } }); } + async updateImage(id: number, sourceUrl: string) { + const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } }); + if (!existingRecipe) { + throw new NotFoundException(`Recipe with id ${id} not found`); + } + + const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR); + + return this.prisma.recipe.update({ + where: { id }, + data: { imageUrl }, + include: { ingredients: { include: { product: true } } }, + }); + } + async create(createRecipeDto: CreateRecipeDto) { + // Om imageUrl är en extern URL — ladda ner och optimera + let imageUrl: string | null = createRecipeDto.imageUrl || null; + if (imageUrl && imageUrl.startsWith('http')) { + try { + imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR); + } catch (err) { + console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err); + imageUrl = null; + } + } + const recipe = await this.prisma.recipe.create({ data: { name: createRecipeDto.name, description: createRecipeDto.description || null, instructions: createRecipeDto.instructions || null, + imageUrl, ingredients: { create: createRecipeDto.ingredients.map((ingredient) => ({ productId: ingredient.productId, diff --git a/compose.yml b/compose.yml index 2c1b2db2..3e197a64 100644 --- a/compose.yml +++ b/compose.yml @@ -8,6 +8,8 @@ services: restart: unless-stopped environment: NEXT_PUBLIC_API_URL: "http://recipe-api:8080" + volumes: + - recipe_images:/app/public/images depends_on: recipe-api: condition: service_healthy @@ -30,6 +32,8 @@ services: restart: unless-stopped environment: DATABASE_URL: "mysql://root:${MARIADB_ROOT_PASSWORD}@recipe-db:3306/${MARIADB_DATABASE}" + volumes: + - recipe_images:/app/recipe-images depends_on: recipe-db: condition: service_healthy @@ -68,6 +72,7 @@ services: volumes: recipe_db_data: + recipe_images: networks: proxy: diff --git a/frontend/app/api/recipes/[id]/image/route.ts b/frontend/app/api/recipes/[id]/image/route.ts new file mode 100644 index 00000000..357c6d2d --- /dev/null +++ b/frontend/app/api/recipes/[id]/image/route.ts @@ -0,0 +1,25 @@ +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, + { params }: { params: { id: string } }, +) { + const { id } = await params; + const body = await request.text(); + + const res = await fetch(`${API_BASE}/api/recipes/${id}/image`, { + 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' }, + }); +} diff --git a/frontend/app/recipes/RecipeGrid.tsx b/frontend/app/recipes/RecipeGrid.tsx new file mode 100644 index 00000000..c5a0f280 --- /dev/null +++ b/frontend/app/recipes/RecipeGrid.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import type { Recipe } from '../../features/inventory/types'; + +function RecipePlaceholder({ name }: { name: string }) { + const initial = name.trim().charAt(0).toUpperCase() || '?'; + return ( +
+ {initial} +
+ ); +} + +export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) { + const [search, setSearch] = useState(''); + + const filtered = recipes.filter((r) => + r.name.toLowerCase().includes(search.toLowerCase()), + ); + + return ( +
+
+ setSearch(e.target.value)} + style={{ + width: '100%', + padding: '0.6rem 1rem', + fontSize: '1rem', + border: '1px solid #ced4da', + borderRadius: '24px', + outline: 'none', + boxSizing: 'border-box', + }} + /> +
+ + {filtered.length === 0 && ( +

+ {search ? 'Inga recept matchar sökningen.' : 'Inga recept tillagda ännu.'} +

+ )} + +
+ {filtered.map((recipe) => ( + +
+ ((e.currentTarget as HTMLDivElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.12)') + } + onMouseLeave={(e) => + ((e.currentTarget as HTMLDivElement).style.boxShadow = 'none') + } + > + {recipe.imageUrl ? ( + {recipe.name} + ) : ( + + )} +
+

+ {recipe.name} +

+ {recipe.description && ( +

+ {recipe.description} +

+ )} +
+
+ + ))} +
+
+ ); +} diff --git a/frontend/app/recipes/[id]/RecipeDetailClient.tsx b/frontend/app/recipes/[id]/RecipeDetailClient.tsx new file mode 100644 index 00000000..b5c0d5e3 --- /dev/null +++ b/frontend/app/recipes/[id]/RecipeDetailClient.tsx @@ -0,0 +1,498 @@ +'use client'; + +import { useState, useEffect, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import type { + Recipe, + Product, + RecipeInventoryPreview, +} from '../../../features/inventory/types'; +import { fetchJson } from '../../../lib/api'; +import { parseErrorResponse } from '../../../lib/error-handler'; + +// ────────────────────────────────────────────── +// Hjälpfunktioner +// ────────────────────────────────────────────── + +function SimpleMarkdownPreview({ text }: { text: string }) { + return ( +
+ {text.split('\n').map((line, i) => { + if (line.startsWith('# ')) return

{line.slice(2)}

; + if (line.startsWith('## ')) return

{line.slice(3)}

; + if (line.startsWith('- ') || line.startsWith('* ')) return
• {line.slice(2)}
; + if (line.trim() === '') return
; + return
{line}
; + })} +
+ ); +} + +function StatusBadge({ status }: { status: 'enough' | 'missing' | 'unit_mismatch' }) { + const styles = { + enough: { label: 'Räcker', color: '#1f5f2c', background: '#ecf8ee', border: '#b9e0bf' }, + missing: { label: 'Saknas', color: '#8b0000', background: '#ffeaea', border: '#f1b5b5' }, + unit_mismatch: { label: 'Enhetskonflikt', color: '#8a4b00', background: '#fff4e5', border: '#f0cf9b' }, + }[status]; + + return ( + + {styles.label} + + ); +} + +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)' }, + { value: 'port', label: 'port (portioner)' }, + { value: 'efter smak', label: 'Efter smak' }, + { value: 'förp', label: 'förp (förpackning)' }, + { value: 'klyfta', label: 'klyfta' }, +]; + +// ────────────────────────────────────────────── +// Huvud-komponent +// ────────────────────────────────────────────── + +export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe: Recipe }) { + const router = useRouter(); + const [recipe, setRecipe] = useState(initialRecipe); + const [isEditing, setIsEditing] = useState(false); + const [isLiked, setIsLiked] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + // Redigeringsformulär-state + const [form, setForm] = useState({ + name: initialRecipe.name, + description: initialRecipe.description || '', + instructions: initialRecipe.instructions || '', + imageUrl: initialRecipe.imageUrl || '', + ingredients: initialRecipe.ingredients.map((ing) => ({ + productId: ing.productId, + quantity: String(ing.quantity), + unit: ing.unit, + note: ing.note || '', + })), + }); + + // Produktlista för ingrediens-väljare + const [products, setProducts] = useState([]); + + // Inventarieförhandsgranskning + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [isPreviewing, startPreviewTransition] = useTransition(); + + // Bilduppdatering + const [imageUrlInput, setImageUrlInput] = useState(''); + const [imageError, setImageError] = useState(null); + const [isUploadingImage, setIsUploadingImage] = useState(false); + + // localStorage: gilla + useEffect(() => { + const liked = localStorage.getItem(`recipe-liked-${recipe.id}`) === 'true'; + setIsLiked(liked); + }, [recipe.id]); + + // Ladda produkter för redigera-läge + useEffect(() => { + if (isEditing && products.length === 0) { + fetchJson('/api/products').then(setProducts).catch(console.error); + } + }, [isEditing, products.length]); + + // ── Gilla ── + const toggleLike = () => { + const next = !isLiked; + setIsLiked(next); + localStorage.setItem(`recipe-liked-${recipe.id}`, String(next)); + }; + + // ── Inventarieförhandsgranskning ── + const loadPreview = () => { + setPreviewError(null); + startPreviewTransition(async () => { + try { + const res = await fetch(`/api/recipe-preview-proxy?id=${recipe.id}`, { cache: 'no-store' }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + setPreview(await res.json()); + } catch (err) { + setPreviewError(err instanceof Error ? err.message : 'Fel vid hämtning av inventariedata'); + } + }); + }; + + // ── Ta bort recept ── + const handleDelete = async () => { + if (!confirm(`Ta bort receptet "${recipe.name}"? Det går inte att ångra.`)) return; + setIsDeleting(true); + try { + const res = await fetch(`/api/recipes/${recipe.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + router.push('/recipes'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte ta bort receptet.'); + setIsDeleting(false); + } + }; + + // ── Spara redigering ── + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSaving(true); + setError(null); + try { + const body = { + ...form, + ingredients: form.ingredients.map((ing) => ({ + ...ing, + quantity: Number(ing.quantity), + })), + }; + const res = await fetch(`/api/recipes/${recipe.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const updated: Recipe = await res.json(); + setRecipe(updated); + setIsEditing(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte spara receptet.'); + } finally { + setIsSaving(false); + } + }; + + // ── Uppdatera bild ── + const handleImageUpdate = async () => { + if (!imageUrlInput.trim()) return; + setIsUploadingImage(true); + setImageError(null); + try { + const res = await fetch(`/api/recipes/${recipe.id}/image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceUrl: imageUrlInput.trim() }), + }); + if (!res.ok) throw new Error(await parseErrorResponse(res)); + const updated: Recipe = await res.json(); + setRecipe(updated); + setForm((f) => ({ ...f, imageUrl: updated.imageUrl || '' })); + setImageUrlInput(''); + } catch (err) { + setImageError(err instanceof Error ? err.message : 'Kunde inte hämta bilden.'); + } finally { + setIsUploadingImage(false); + } + }; + + // ── Ingrediens-hjälpfunktioner (redigera-läge) ── + const setIngredientField = (idx: number, field: string, value: string | number) => { + setForm((f) => { + const ings = [...f.ingredients]; + ings[idx] = { ...ings[idx], [field]: value }; + return { ...f, ingredients: ings }; + }); + }; + + const addIngredient = () => + setForm((f) => ({ + ...f, + ingredients: [...f.ingredients, { productId: 0, quantity: '', unit: '', note: '' }], + })); + + const removeIngredient = (idx: number) => + setForm((f) => { + const ings = [...f.ingredients]; + ings.splice(idx, 1); + return { ...f, ingredients: ings }; + }); + + // ────────────────────────────────────────────── + // VY-LÄGE + // ────────────────────────────────────────────── + if (!isEditing) { + return ( +
+ {/* Bild */} + {recipe.imageUrl ? ( + {recipe.name} + ) : ( +
+ {recipe.name.charAt(0).toUpperCase()} +
+ )} + + {/* Titel + knappar */} +
+

{recipe.name}

+
+ + + + +
+
+ + {error &&

{error}

} + + {recipe.description && ( +

{recipe.description}

+ )} + + {/* Ingredienser */} +
+

Ingredienser

+
    + {recipe.ingredients.map((ing) => ( +
  • + + {Number(ing.quantity)} {ing.unit} + + {ing.product.canonicalName || ing.product.name} + {ing.note && ({ing.note})} +
  • + ))} +
+
+ + {/* Instruktioner */} + {recipe.instructions && ( +
+

Instruktioner

+ +
+ )} + + {/* Inventarieförhandsgranskning */} + {(preview || previewError) && ( +
+

Inventariegranskning

+ {previewError &&

{previewError}

} + {preview && ( + <> +
+ Totalt: {preview.summary.totalIngredients} + Räcker: {preview.summary.enoughCount} + Saknas: {preview.summary.missingCount} + {preview.summary.unitMismatchCount > 0 && ( + Enhetskonflikt: {preview.summary.unitMismatchCount} + )} +
+
    + {preview.ingredients.map((ing) => ( +
  • + + {ing.productName} + {' '} + {ing.requiredQuantity} {ing.requiredUnit} + {ing.status !== 'enough' && ing.missingQuantity > 0 && ( + <> — saknar {ing.missingQuantity} {ing.requiredUnit} + )} + + + +
  • + ))} +
+ + )} +
+ )} +
+ ); + } + + // ────────────────────────────────────────────── + // REDIGERA-LÄGE + // ────────────────────────────────────────────── + return ( +
+
+

Redigera recept

+ +
+ + {error &&

{error}

} + +
+ {/* Bild-uppdatering */} +
+

Bild

+ {recipe.imageUrl && ( + + )} +
+ setImageUrlInput(e.target.value)} + style={inputStyle} + /> + +
+ {imageError &&

{imageError}

} +
+ + {/* Grundinfo */} +
+

Receptdetaljer

+ + setForm((f) => ({ ...f, name: e.target.value }))} style={{ ...inputStyle, marginBottom: '1rem' }} /> + + +