From d5b360fd62cb81cd629afc7ee5cb89d0d4b83877 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 16 Apr 2026 18:18:27 +0200 Subject: [PATCH] Refactor logging in IcaRecipeParser and QuickImportService to use NestJS Logger - Updated IcaRecipeParser to replace console.log statements with Logger for better logging practices. - Enhanced QuickImportService with Logger for consistent logging and error handling. - Changed quantity validation in CreateRecipeIngredientDto and CreateRecipeDto to allow zero. - Removed CanonicalNameForm and NameForm components from the frontend. - Updated EditProductForm to use a select dropdown for categories instead of a text input. - Added validation for product name, canonical name, and category length in product update action. - Refactored unit options into a separate file for better reusability across components. - Removed unused API fetching functions and inventory types to clean up the codebase. --- .../quick-import/parsers/generic.parser.ts | 10 +- .../src/quick-import/parsers/ica.parser.ts | 10 +- .../src/quick-import/quick-import.service.ts | 38 +- .../dto/create-recipe-ingredient.dto.ts | 2 +- backend/src/recipes/dto/create-recipe.dto.ts | 2 +- .../app/admin/products/CanonicalNameForm.tsx | 79 --- .../app/admin/products/EditProductForm.tsx | 18 +- frontend/app/admin/products/NameForm.tsx | 0 frontend/app/admin/products/actions.ts | 5 + frontend/app/inventory/InventoryEditForm.tsx | 18 +- frontend/app/inventory/InventoryForm.tsx | 18 +- .../app/recipes/[id]/RecipeDetailClient.tsx | 18 +- .../app/recipes/create/CreateRecipePage.tsx | 476 ------------------ .../app/recipes/import/ImportRecipePage.tsx | 19 +- .../app/recipes/write/WriteRecipePage.tsx | 19 +- frontend/lib/error-handler.ts | 24 - frontend/lib/units.ts | 17 + src/features/inventory/types.ts | 30 -- src/lib/api.ts | 22 - 19 files changed, 74 insertions(+), 751 deletions(-) delete mode 100644 frontend/app/admin/products/CanonicalNameForm.tsx delete mode 100644 frontend/app/admin/products/NameForm.tsx delete mode 100644 frontend/app/recipes/create/CreateRecipePage.tsx create mode 100644 frontend/lib/units.ts delete mode 100644 src/features/inventory/types.ts delete mode 100644 src/lib/api.ts diff --git a/backend/src/quick-import/parsers/generic.parser.ts b/backend/src/quick-import/parsers/generic.parser.ts index a8242ee3..779a71a0 100644 --- a/backend/src/quick-import/parsers/generic.parser.ts +++ b/backend/src/quick-import/parsers/generic.parser.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { RecipeParser, ParsedRecipe } from './base.parser'; /** @@ -6,13 +7,14 @@ import { RecipeParser, ParsedRecipe } from './base.parser'; * Denna är mer permissiv än site-specifika parsers */ export class GenericRecipeParser extends RecipeParser { + private readonly logger = new Logger(GenericRecipeParser.name); canHandle(url: string): boolean { // Denna parser hanterar alltid (är fallback) return true; } parse(html: string): ParsedRecipe { - console.log('[GenericParser] Parsing recipe from unknown site...'); + this.logger.log('Parsing recipe from unknown site...'); // Extrahera og:image för bildurl-fallback const ogImage = this.extractOgImage(html); @@ -31,15 +33,15 @@ export class GenericRecipeParser extends RecipeParser { : jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe'); if (recipe) { - console.log('[GenericParser] ✓ JSON-LD data found'); + this.logger.log('JSON-LD data found'); return this.extractFromJsonLd(recipe, ogImage); } } catch (err) { - console.log('[GenericParser] JSON-LD parsing failed'); + this.logger.warn('JSON-LD parsing failed'); } } - console.log('[GenericParser] No JSON-LD found, using HTML parsing'); + this.logger.log('No JSON-LD found, using HTML parsing'); return this.parseFromHtml(html, ogImage); } diff --git a/backend/src/quick-import/parsers/ica.parser.ts b/backend/src/quick-import/parsers/ica.parser.ts index 49259f3c..80440aec 100644 --- a/backend/src/quick-import/parsers/ica.parser.ts +++ b/backend/src/quick-import/parsers/ica.parser.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { RecipeParser, ParsedRecipe } from './base.parser'; /** @@ -5,12 +6,13 @@ import { RecipeParser, ParsedRecipe } from './base.parser'; * Använder JSON-LD structured data som primär källa */ export class IcaRecipeParser extends RecipeParser { + private readonly logger = new Logger(IcaRecipeParser.name); canHandle(url: string): boolean { return /ica\.se\/recept/i.test(url); } parse(html: string): ParsedRecipe { - console.log('[IcaParser] Parsing ICA recipe...'); + this.logger.log('Parsing ICA recipe...'); // Extrahera og:image för bildurl-fallback const ogImage = this.extractOgImage(html); @@ -31,16 +33,16 @@ export class IcaRecipeParser extends RecipeParser { : jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe'); if (recipe) { - console.log('[IcaParser] ✓ JSON-LD recipe found'); + this.logger.log('JSON-LD recipe found'); return this.extractFromJsonLd(recipe, ogImage); } } catch (err) { - console.log('[IcaParser] JSON-LD parsing failed:', err); + this.logger.warn(`JSON-LD parsing failed: ${err}`); } } // Fallback: HTML parsing (sällan nödvändigt för ICA) - console.log('[IcaParser] Falling back to HTML parsing'); + this.logger.log('Falling back to HTML parsing'); return this.parseFromHtml(html, ogImage); } diff --git a/backend/src/quick-import/quick-import.service.ts b/backend/src/quick-import/quick-import.service.ts index 66b393d0..e2d3809e 100644 --- a/backend/src/quick-import/quick-import.service.ts +++ b/backend/src/quick-import/quick-import.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + Logger, ServiceUnavailableException, UnsupportedMediaTypeException, } from '@nestjs/common'; @@ -25,24 +26,26 @@ type UploadKind = 'pdf' | 'image'; @Injectable() export class QuickImportService { + private readonly logger = new Logger(QuickImportService.name); + /** * Detekterar typ av input (URL eller filsökväg) och importerar från lämplig källa */ async importFromInput(input: string): Promise { const trimmed = input.trim(); - console.log('[QuickImport] Mottog input:', trimmed); + this.logger.log(`Mottog input: ${trimmed}`); if (!trimmed) { throw new BadRequestException('Du måste ange en URL eller ladda upp en fil'); } if (this.isUrl(trimmed)) { - console.log('[QuickImport] Detekterade URL, försöker scrapa...'); + this.logger.log('Detekterade URL, försöker scrapa...'); return this.scrapeRecipeFromUrl(trimmed); } if (this.looksLikeLocalFile(trimmed)) { - console.log('[QuickImport] Försöker läsa lokal fil:', trimmed); + this.logger.log(`Försöker läsa lokal fil: ${trimmed}`); try { const buffer = await fs.readFile(trimmed); return this.importFromUpload({ @@ -51,7 +54,7 @@ export class QuickImportService { mimetype: this.getMimeTypeFromExtension(trimmed), } as Express.Multer.File); } catch (error) { - console.error('[QuickImport] Kunde inte läsa lokal fil:', error); + this.logger.error('Kunde inte läsa lokal fil:', error); throw new BadRequestException( 'Kunde inte läsa filen. Använd filuppladdning i gränssnittet eller kontrollera sökvägen.', ); @@ -68,7 +71,7 @@ export class QuickImportService { throw new BadRequestException('Ingen fil skickades med.'); } - console.log('[QuickImport] Mottog uppladdad fil:', file.originalname, file.mimetype); + this.logger.log(`Mottog uppladdad fil: ${file.originalname} (${file.mimetype})`); const kind = this.getUploadKind(file); if (kind === 'pdf') { @@ -154,7 +157,7 @@ export class QuickImportService { throw error; } - console.error('[QuickImport] PDF ERROR:', error); + this.logger.error('PDF-import misslyckades', error); throw new ServiceUnavailableException('PDF-importen misslyckades.'); } } @@ -176,7 +179,7 @@ export class QuickImportService { throw error; } - console.error('[QuickImport] OCR ERROR:', error); + this.logger.error('OCR-import misslyckades', error); throw new ServiceUnavailableException('OCR-importen misslyckades.'); } finally { await worker.terminate(); @@ -262,7 +265,7 @@ export class QuickImportService { */ private async scrapeRecipeFromUrl(url: string): Promise { try { - console.log('[QuickImport] Hämtar HTML från:', url); + this.logger.log(`Hämtar HTML från: ${url}`); const response = await fetch(url, { headers: { @@ -271,14 +274,14 @@ export class QuickImportService { }, }); - console.log('[QuickImport] HTTP status:', response.status); + this.logger.log(`HTTP status: ${response.status}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const html = await response.text(); - console.log('[QuickImport] HTML längd:', html.length, 'tecken'); + this.logger.log(`HTML längd: ${html.length} tecken`); const parsers: RecipeParser[] = [ new IcaRecipeParser(), @@ -288,7 +291,7 @@ export class QuickImportService { let recipe = null; for (const parser of parsers) { if (parser.canHandle(url)) { - console.log('[QuickImport] Använder parser:', parser.constructor.name); + this.logger.log(`Använder parser: ${parser.constructor.name}`); recipe = parser.parse(html); break; } @@ -298,17 +301,14 @@ export class QuickImportService { throw new Error('Ingen parserutrustning tillgänglig'); } - console.log('[QuickImport] Parsad recept:', { - name: recipe.name, - ingredienser: recipe.ingredients.length, - }); + this.logger.log(`Parsad recept: ${recipe.name} (${recipe.ingredients.length} ingredienser)`); if (!recipe.name) { throw new Error('Kunde inte hitta receptnamn på sidan. Försök med en annan länk.'); } const markdown = this.recipeToMarkdown(recipe, url); - console.log('[QuickImport] Markdown genererad, längd:', markdown.length); + this.logger.log(`Markdown genererad, längd: ${markdown.length}`); let source: 'ica' | 'pdf' | 'image' | 'other' = 'other'; if (/ica\.se/i.test(url)) { @@ -320,9 +320,9 @@ export class QuickImportService { if (recipe.imageUrl) { try { imageUrl = await downloadAndOptimizeImage(recipe.imageUrl, IMAGE_DEST_DIR); - console.log('[QuickImport] Bild optimerad och sparad:', imageUrl); + this.logger.log(`Bild optimerad och sparad: ${imageUrl}`); } catch (imgErr) { - console.warn('[QuickImport] Kunde inte ladda ner bild:', imgErr); + this.logger.warn(`Kunde inte ladda ner bild: ${imgErr}`); } } @@ -333,7 +333,7 @@ export class QuickImportService { }; } catch (err) { const message = err instanceof Error ? err.message : 'Okänt fel vid scraping'; - console.error('[QuickImport] ERROR:', message); + this.logger.error(`Scraping misslyckades: ${message}`); throw new BadRequestException( `Kunde inte hämta recept: ${message}. Kontrollera att länken är korrekt och försök igen.` ); diff --git a/backend/src/recipes/dto/create-recipe-ingredient.dto.ts b/backend/src/recipes/dto/create-recipe-ingredient.dto.ts index f3a6c1f5..8e8ad426 100755 --- a/backend/src/recipes/dto/create-recipe-ingredient.dto.ts +++ b/backend/src/recipes/dto/create-recipe-ingredient.dto.ts @@ -5,7 +5,7 @@ export class CreateRecipeIngredientDto { productId!: number; @IsNumber() - @Min(0.01) + @Min(0) quantity!: number; @IsString() diff --git a/backend/src/recipes/dto/create-recipe.dto.ts b/backend/src/recipes/dto/create-recipe.dto.ts index cc87249d..d8f2bb45 100644 --- a/backend/src/recipes/dto/create-recipe.dto.ts +++ b/backend/src/recipes/dto/create-recipe.dto.ts @@ -15,7 +15,7 @@ class CreateRecipeIngredientDto { productId!: number; @IsNumber() - @Min(0.01) + @Min(0) quantity!: number; @IsString() diff --git a/frontend/app/admin/products/CanonicalNameForm.tsx b/frontend/app/admin/products/CanonicalNameForm.tsx deleted file mode 100644 index 989593ea..00000000 --- a/frontend/app/admin/products/CanonicalNameForm.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client'; - -import { useState, useTransition } from 'react'; -import { updateCanonicalName } from '../../inventory/actions'; - -type Props = { - id: number; - currentCanonicalName: string | null; -}; - -export default function CanonicalNameForm({ - id, - currentCanonicalName, -}: Props) { - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); - - return ( -
{ - e.preventDefault(); - setError(null); - - const form = e.currentTarget; - const formData = new FormData(form); - - startTransition(async () => { - try { - await updateCanonicalName(formData); - } catch (err) { - setError(err instanceof Error ? err.message : 'Okänt fel'); - } - }); - }} - style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }} - > - - - - {error ? {error} : null} -
- ); -} \ No newline at end of file diff --git a/frontend/app/admin/products/EditProductForm.tsx b/frontend/app/admin/products/EditProductForm.tsx index 354afa20..d7da5402 100644 --- a/frontend/app/admin/products/EditProductForm.tsx +++ b/frontend/app/admin/products/EditProductForm.tsx @@ -110,13 +110,23 @@ export default function EditProductForm({ product }: Props) {
diff --git a/frontend/app/admin/products/NameForm.tsx b/frontend/app/admin/products/NameForm.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/app/admin/products/actions.ts b/frontend/app/admin/products/actions.ts index 20a1e0a1..e018b4bb 100644 --- a/frontend/app/admin/products/actions.ts +++ b/frontend/app/admin/products/actions.ts @@ -9,6 +9,11 @@ export async function updateProduct(formData: FormData) { const canonicalName = String(formData.get('canonicalName') || '').trim(); const category = String(formData.get('category') || '').trim(); + if (!name) throw new Error('Namn får inte vara tomt.'); + if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.'); + if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.'); + if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.'); + const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/app/inventory/InventoryEditForm.tsx b/frontend/app/inventory/InventoryEditForm.tsx index 8e85a16c..96835c55 100644 --- a/frontend/app/inventory/InventoryEditForm.tsx +++ b/frontend/app/inventory/InventoryEditForm.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from 'react'; import { updateInventoryItem } from './actions'; import type { InventoryItem } from '../../features/inventory/types'; +import { UNIT_OPTIONS } from '../../lib/units'; type Props = { item: InventoryItem; @@ -35,23 +36,6 @@ function parseQuantityInput(input: string, defaultUnit: string) { return { quantity: value, unit: defaultUnit }; } -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' }, -]; - const LOCATION_OPTIONS = [ { value: '', label: 'Välj plats' }, { value: 'Kyl', label: 'Kyl' }, diff --git a/frontend/app/inventory/InventoryForm.tsx b/frontend/app/inventory/InventoryForm.tsx index 59df7921..5fc3a929 100644 --- a/frontend/app/inventory/InventoryForm.tsx +++ b/frontend/app/inventory/InventoryForm.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { createInventoryItem } from './actions'; import type { Product } from '../../features/inventory/types'; +import { UNIT_OPTIONS } from '../../lib/units'; type Props = { products: Product[]; @@ -13,23 +14,6 @@ export default function InventoryForm({ products }: Props) { const [error, setError] = useState(null); const [isOpen, setIsOpen] = useState(false); - 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' }, - ]; - const LOCATION_OPTIONS = [ { value: '', label: 'Välj plats' }, { value: 'Kyl', label: 'Kyl' }, diff --git a/frontend/app/recipes/[id]/RecipeDetailClient.tsx b/frontend/app/recipes/[id]/RecipeDetailClient.tsx index b5c0d5e3..42a68fc7 100644 --- a/frontend/app/recipes/[id]/RecipeDetailClient.tsx +++ b/frontend/app/recipes/[id]/RecipeDetailClient.tsx @@ -9,6 +9,7 @@ import type { } from '../../../features/inventory/types'; import { fetchJson } from '../../../lib/api'; import { parseErrorResponse } from '../../../lib/error-handler'; +import { UNIT_OPTIONS } from '../../../lib/units'; // ────────────────────────────────────────────── // Hjälpfunktioner @@ -50,23 +51,6 @@ function StatusBadge({ status }: { status: 'enough' | 'missing' | 'unit_mismatch ); } -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 // ────────────────────────────────────────────── diff --git a/frontend/app/recipes/create/CreateRecipePage.tsx b/frontend/app/recipes/create/CreateRecipePage.tsx deleted file mode 100644 index ce008e84..00000000 --- a/frontend/app/recipes/create/CreateRecipePage.tsx +++ /dev/null @@ -1,476 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { fetchJson } from '../../../lib/api'; -import { parseErrorResponse } from '../../../lib/error-handler'; -import type { Product } from '../../../features/inventory/types'; -import Navigation from '../../Navigation'; - -const MARKDOWN_HELP = ` -**Fetstil:** **text** eller __text__ -*Kursiv:* *text* eller _text_ -• Punktlista: - punkt eller * punkt -# Rubrik 1 -## Rubrik 2 -### Rubrik 3 -`; - -function SimpleMarkdownPreview({ text }: { text: string }) { - const lines = text.split('\n'); - - return ( -
- {lines.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}
; - })} -
- ); -} - -export default function CreateRecipePage() { - const router = useRouter(); - const [recipe, setRecipe] = useState({ - name: '', - description: '', - instructions: '', - ingredients: [{ productId: 0, quantity: '', unit: '', note: '', location: '' }], - }); - const [products, setProducts] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [showPreview, setShowPreview] = useState(false); - const [showMarkdownHelp, setShowMarkdownHelp] = useState(false); - - useEffect(() => { - fetchJson('/api/products') - .then(setProducts) - .catch(console.error); - }, []); - - const handleIngredientChange = (index: number, field: string, value: string | number) => { - const newIngredients = [...recipe.ingredients]; - newIngredients[index] = { ...newIngredients[index], [field]: value }; - setRecipe({ ...recipe, ingredients: newIngredients }); - }; - - const addIngredient = () => { - setRecipe({ - ...recipe, - ingredients: [...recipe.ingredients, { productId: 0, quantity: '', unit: '', note: '', location: '' }], - }); - }; - - const removeIngredient = (index: number) => { - const newIngredients = [...recipe.ingredients]; - newIngredients.splice(index, 1); - setRecipe({ ...recipe, ingredients: newIngredients }); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(null); - - // Konvertera quantity till number för varje ingrediens - const recipeToSend = { - ...recipe, - ingredients: recipe.ingredients.map(({ location: _loc, ...ing }) => ({ - ...ing, - quantity: Number(ing.quantity), - })), - }; - - try { - const response = await fetch('/api/recipes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(recipeToSend), - }); - - if (!response.ok) { - const errorMessage = await parseErrorResponse(response); - throw new Error(errorMessage); - } - - router.push('/recipes'); - } catch (err) { - const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade. Försök igen.'; - setError(message); - } finally { - setIsLoading(false); - } - }; - - 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: 'krm', label: 'krm (kryddmått)' }, - { value: 'port', label: 'port (portioner)' }, - { value: 'efter smak', label: 'Efter smak' }, - { value: 'förp', label: 'förp (förpackning)' }, - { value: 'klyfta', label: 'klyfta' }, - ]; - - const LOCATION_OPTIONS = [ - { value: '', label: 'Välj plats' }, - { value: 'Kyl', label: 'Kyl' }, - { value: 'Frys', label: 'Frys' }, - { value: 'Skafferi', label: 'Skafferi' }, - { value: 'Annat', label: 'Annat' }, - ]; - - return ( -
- -
-

Lägg till nytt recept

- - Importera från Markdown - -
- - {error &&

{error}

} - -
- {/* Receptdetaljer */} -
-

Receptdetaljer

- -
- - setRecipe({ ...recipe, name: e.target.value })} - required - style={{ - width: '100%', - padding: '0.75rem', - border: '1px solid #ddd', - borderRadius: '4px', - fontSize: '1rem', - minHeight: '44px', - boxSizing: 'border-box', - }} - /> -
- -
- -