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.
This commit is contained in:
Nils-Johan Gynther
2026-04-15 19:46:50 +02:00
parent a2038ffbec
commit 73bf5193c4
20 changed files with 933 additions and 49 deletions
@@ -12,6 +12,7 @@ export interface ParsedRecipe {
note?: string;
}>;
instructions?: string;
imageUrl?: string;
}
export abstract class RecipeParser {
@@ -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(
/<script[^>]*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(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|| html.match(/<meta[^>]+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,
};
}
}
+27 -4
View File
@@ -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(
/<script[^>]*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(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|| html.match(/<meta[^>]+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[^>]*>([^<]+)<\/h1>/i);
if (titleMatch) {
@@ -133,6 +155,7 @@ export class IcaRecipeParser extends RecipeParser {
description,
ingredients,
instructions,
imageUrl: ogImage,
};
}
}