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
+141
View File
@@ -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 (
<div
style={{
width: '100%',
height: '160px',
background: '#e9ecef',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.5rem',
fontWeight: 700,
color: '#868e96',
borderRadius: '8px 8px 0 0',
userSelect: 'none',
}}
>
{initial}
</div>
);
}
export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) {
const [search, setSearch] = useState('');
const filtered = recipes.filter((r) =>
r.name.toLowerCase().includes(search.toLowerCase()),
);
return (
<div>
<div style={{ marginBottom: '1.25rem' }}>
<input
type="text"
placeholder="Sök efter recept..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
width: '100%',
padding: '0.6rem 1rem',
fontSize: '1rem',
border: '1px solid #ced4da',
borderRadius: '24px',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{filtered.length === 0 && (
<p style={{ color: '#868e96', textAlign: 'center', marginTop: '2rem' }}>
{search ? 'Inga recept matchar sökningen.' : 'Inga recept tillagda ännu.'}
</p>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: '1rem',
}}
>
{filtered.map((recipe) => (
<Link
key={recipe.id}
href={`/recipes/${recipe.id}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<div
style={{
border: '1px solid #dee2e6',
borderRadius: '8px',
overflow: 'hidden',
transition: 'box-shadow 0.15s',
background: '#fff',
cursor: 'pointer',
}}
onMouseEnter={(e) =>
((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 ? (
<img
src={recipe.imageUrl}
alt={recipe.name}
style={{
width: '100%',
height: '160px',
objectFit: 'cover',
display: 'block',
}}
/>
) : (
<RecipePlaceholder name={recipe.name} />
)}
<div style={{ padding: '0.75rem 1rem' }}>
<h3
style={{
margin: 0,
fontSize: '1rem',
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{recipe.name}
</h3>
{recipe.description && (
<p
style={{
margin: '0.25rem 0 0',
fontSize: '0.85rem',
color: '#868e96',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
} as React.CSSProperties}
>
{recipe.description}
</p>
)}
</div>
</div>
</Link>
))}
</div>
</div>
);
}