add more data + image on recipe

This commit is contained in:
Arthur Barre 2025-03-13 22:17:39 +01:00
parent bd5891ff1b
commit 223b593495
11 changed files with 164 additions and 395 deletions

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Recipe" ADD COLUMN "cookingTime" INTEGER;
ALTER TABLE "Recipe" ADD COLUMN "difficulty" TEXT;
ALTER TABLE "Recipe" ADD COLUMN "imageUrl" TEXT;
ALTER TABLE "Recipe" ADD COLUMN "preparationTime" INTEGER;
ALTER TABLE "Recipe" ADD COLUMN "servings" INTEGER;
ALTER TABLE "Recipe" ADD COLUMN "steps" TEXT;
ALTER TABLE "Recipe" ADD COLUMN "tips" TEXT;

View File

@ -21,14 +21,21 @@ model User {
}
model Recipe {
id String @id @default(uuid())
title String
ingredients String
userPrompt String
id String @id @default(uuid())
title String
ingredients String
userPrompt String
generatedRecipe String
audioUrl String?
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
audioUrl String?
imageUrl String?
preparationTime Int?
cookingTime Int?
servings Int?
difficulty String?
steps String?
tips String?
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -24,20 +24,34 @@ module.exports = fp(async function (fastify, opts) {
// Génération de recette avec 01-mini
fastify.decorate('generateRecipe', async (ingredients, prompt) => {
const completion = await openai.chat.completions.create({
model: "gpt-3.5-turbo", // Remplacer par 01-mini quand disponible
model: "gpt-4o-mini", // Remplacer par 01-mini quand disponible
messages: [
{
role: "system",
content: "Tu es un chef cuisinier expert qui crée des recettes délicieuses et faciles à réaliser."
content: "Tu es un chef cuisinier expert qui crée des recettes délicieuses et faciles à réaliser. Tu dois toujours répondre avec un objet JSON valide contenant les champs suivants: titre, ingredients, etapes, temps_preparation (en minutes), temps_cuisson (en minutes), portions, difficulte (facile, moyen, difficile), et conseils."
},
{
role: "user",
content: `Voici les ingrédients disponibles: ${ingredients}. ${prompt || 'Propose une recette avec ces ingrédients.'}`
content: `Voici les ingrédients disponibles: ${ingredients}. ${prompt || 'Propose une recette avec ces ingrédients.'} Réponds uniquement avec un objet JSON.`
}
],
response_format: { type: "json_object" },
});
return completion.choices[0].message.content;
const recipeData = JSON.parse(completion.choices[0].message.content);
// Génération de l'image du plat
const imageResponse = await openai.images.generate({
model: "dall-e-3",
prompt: `Une photo culinaire professionnelle et appétissante du plat "${recipeData.titre}". Le plat est présenté sur une belle assiette, avec un éclairage professionnel, style photographie gastronomique.`,
n: 1,
size: "1024x1024",
});
// Ajouter l'URL de l'image à l'objet recette
recipeData.image_url = imageResponse.data[0].url;
return recipeData;
});
// Gestion du téléchargement de fichiers audio

View File

@ -47,15 +47,31 @@ module.exports = async function (fastify, opts) {
const ingredients = transcription;
// Générer la recette
const generatedRecipe = await fastify.generateRecipe(ingredients, "Crée une recette délicieuse et détaillée");
const recipeData = await fastify.generateRecipe(ingredients, "Crée une recette délicieuse et détaillée");
// Convertir les tableaux en chaînes de caractères pour la base de données
const ingredientsString = Array.isArray(recipeData.ingredients)
? recipeData.ingredients.join('\n')
: recipeData.ingredients;
const stepsString = Array.isArray(recipeData.etapes)
? recipeData.etapes.join('\n')
: recipeData.etapes;
// Créer la recette en base de données
const recipe = await fastify.prisma.recipe.create({
data: {
title: `Recette du ${new Date().toLocaleDateString()}`,
ingredients,
title: recipeData.titre,
ingredients: ingredientsString,
userPrompt: transcription,
generatedRecipe,
generatedRecipe: JSON.stringify(recipeData),
imageUrl: recipeData.image_url,
preparationTime: recipeData.temps_preparation,
cookingTime: recipeData.temps_cuisson,
servings: recipeData.portions,
difficulty: recipeData.difficulte,
steps: stepsString,
tips: recipeData.conseils,
audioUrl: audioPath,
userId: request.user.id
}
@ -66,7 +82,13 @@ module.exports = async function (fastify, opts) {
id: recipe.id,
title: recipe.title,
ingredients: recipe.ingredients,
generatedRecipe: recipe.generatedRecipe,
preparationTime: recipe.preparationTime,
cookingTime: recipe.cookingTime,
servings: recipe.servings,
difficulty: recipe.difficulty,
steps: recipe.steps,
tips: recipe.tips,
imageUrl: recipe.imageUrl,
createdAt: recipe.createdAt
}
};
@ -86,7 +108,11 @@ module.exports = async function (fastify, opts) {
id: true,
title: true,
ingredients: true,
generatedRecipe: true,
preparationTime: true,
cookingTime: true,
servings: true,
difficulty: true,
imageUrl: true,
createdAt: true
}
});
@ -112,7 +138,13 @@ module.exports = async function (fastify, opts) {
return reply.code(404).send({ error: 'Recette non trouvée' });
}
return { recipe };
// Vous pouvez choisir de parser le JSON ici ou le laisser tel quel
const recipeData = {
...recipe,
generatedRecipe: JSON.parse(recipe.generatedRecipe)
};
return { recipe: recipeData };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la récupération de la recette' });

View File

@ -28,9 +28,9 @@ export function Header() {
const navItems = [
{ name: "Accueil", path: "/", icon: Home, public: true },
{ name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
{ name: "Mes recettes", path: "/recipes", icon: BookOpen, public: false },
{ name: "Favoris", path: "/favorites", icon: Heart, public: false },
{ name: "Profil", path: "/profile", icon: User, public: false },
// { name: "Mes recettes", path: "/recipes", icon: BookOpen, public: false },
// { name: "Favoris", path: "/favorites", icon: Heart, public: false },
// { name: "Profil", path: "/profile", icon: User, public: false },
]
const filteredNavItems = navItems.filter((item) => item.public || isAuthenticated)

View File

@ -46,22 +46,43 @@ export default function RecipeDetail() {
// ✅ GET RECIPE DETAILS
const recipeData = await recipeService.getRecipeById(id)
// Optionnel : conversion ingrédients / instructions si nécessaire
let ingredients = recipeData.ingredients
if (typeof ingredients === "string") {
ingredients = ingredients.split("\n").filter((item) => item.trim() !== "")
// Traiter les données JSON de la recette
let parsedRecipe = recipeData;
// Si generatedRecipe est une chaîne JSON, la parser
if (typeof recipeData.generatedRecipe === 'string') {
try {
parsedRecipe.generatedRecipe = JSON.parse(recipeData.generatedRecipe);
} catch (e) {
console.log("La recette n'est pas au format JSON:", e);
}
}
// Correction: déclarer la variable instructions
let instructions: string[] = []
if (recipeData.generatedRecipe) {
instructions = recipeData.generatedRecipe.split("\n").filter((item) => item.trim() !== "")
// Préparer les ingrédients
let ingredients = parsedRecipe.ingredients;
if (typeof ingredients === "string") {
ingredients = ingredients.split("\n").filter((item) => item.trim() !== "");
}
// Préparer les étapes
let steps = parsedRecipe.steps || [];
if (typeof steps === "string") {
steps = steps.split("\n").filter((item) => item.trim() !== "");
} else if (parsedRecipe.generatedRecipe && parsedRecipe.generatedRecipe.etapes) {
steps = parsedRecipe.generatedRecipe.etapes;
}
setRecipe({
...recipeData,
...parsedRecipe,
ingredients,
instructions, // Ajouter instructions au recipe
instructions: steps,
// Utiliser les données structurées si disponibles
title: parsedRecipe.title || (parsedRecipe.generatedRecipe?.titre || parsedRecipe.title),
preparationTime: parsedRecipe.preparationTime || parsedRecipe.generatedRecipe?.temps_preparation,
cookingTime: parsedRecipe.cookingTime || parsedRecipe.generatedRecipe?.temps_cuisson,
servings: parsedRecipe.servings || parsedRecipe.generatedRecipe?.portions,
difficulty: parsedRecipe.difficulty || parsedRecipe.generatedRecipe?.difficulte,
tips: parsedRecipe.tips || parsedRecipe.generatedRecipe?.conseils,
})
// ✅ GET FAVORITE RECIPES & CHECK IF FAVORITE
@ -397,6 +418,17 @@ export default function RecipeDetail() {
</li>
)}
</ol>
{/* Conseils du chef */}
{recipe.tips && (
<div className="mt-8 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 flex items-center">
<ChefHat className="h-5 w-5 mr-2 text-amber-600 dark:text-amber-400" />
Conseils du chef
</h3>
<p className="text-amber-800 dark:text-amber-300">{recipe.tips}</p>
</div>
)}
</CardContent>
</Card>
</div>

View File

@ -27,7 +27,24 @@ export default function RecipeList() {
try {
setLoading(true)
const data = await recipeService.getRecipes()
setRecipes(data)
// Traiter les données pour s'assurer que les propriétés sont correctement formatées
const processedData = data.map(recipe => {
// Convertir les difficultés pour correspondre aux filtres
let normalizedDifficulty = recipe.difficulty;
if (recipe.difficulty === "facile") normalizedDifficulty = "Facile";
if (recipe.difficulty === "moyen") normalizedDifficulty = "Moyen";
if (recipe.difficulty === "difficile") normalizedDifficulty = "Difficile";
return {
...recipe,
difficulty: normalizedDifficulty,
// Ajouter des tags par défaut si nécessaire
tags: recipe.tags || []
};
});
setRecipes(processedData);
} catch (err) {
setError("Impossible de charger les recettes")
console.error(err)
@ -88,14 +105,14 @@ export default function RecipeList() {
)}
<div className="flex flex-col mx-4 md:mx-4 sm:flex-row sm:items-center sm:justify-between mb-6 mt-6">
{/* <Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveFilter}>
<Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveFilter}>
<TabsList className="grid grid-cols-4 w-full sm:w-auto">
<TabsTrigger value="all">Toutes</TabsTrigger>
<TabsTrigger value="easy">Faciles</TabsTrigger>
<TabsTrigger value="quick">Rapides</TabsTrigger>
<TabsTrigger value="vegetarian">Végé</TabsTrigger>
</TabsList>
</Tabs> */}
</Tabs>
{
filteredRecipes.length !== 0 &&
<Button
@ -153,6 +170,16 @@ interface RecipeCardProps {
function RecipeCard({ recipe, index }: RecipeCardProps) {
const navigate = useNavigate()
// Normaliser la difficulté pour l'affichage des badges
const difficultyClass = {
"Facile": "bg-green-500/80 text-white hover:bg-green-600/80",
"facile": "bg-green-500/80 text-white hover:bg-green-600/80",
"Moyen": "bg-yellow-500/80 text-white hover:bg-yellow-600/80",
"moyen": "bg-yellow-500/80 text-white hover:bg-yellow-600/80",
"Difficile": "bg-red-500/80 text-white hover:bg-red-600/80",
"difficile": "bg-red-500/80 text-white hover:bg-red-600/80"
}[recipe.difficulty || ""] || "bg-gray-500/80 text-white hover:bg-gray-600/80";
return (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.05 }}>
<Card className="overflow-hidden h-full border-none shadow-md hover:shadow-lg transition-all duration-300">
@ -173,19 +200,9 @@ function RecipeCard({ recipe, index }: RecipeCardProps) {
</div>
<div className="absolute top-2 right-2 flex gap-1">
{recipe.difficulty === "Facile" && (
<Badge variant="secondary" className="bg-green-500/80 text-white hover:bg-green-600/80">
Facile
</Badge>
)}
{recipe.difficulty === "Moyen" && (
<Badge variant="secondary" className="bg-yellow-500/80 text-white hover:bg-yellow-600/80">
Moyen
</Badge>
)}
{recipe.difficulty === "Difficile" && (
<Badge variant="secondary" className="bg-red-500/80 text-white hover:bg-red-600/80">
Difficile
{recipe.difficulty && (
<Badge variant="secondary" className={difficultyClass}>
{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}
</Badge>
)}
{(recipe.preparationTime || 0) <= 30 && (
@ -198,7 +215,7 @@ function RecipeCard({ recipe, index }: RecipeCardProps) {
<CardContent className="p-4">
<div className="flex flex-wrap gap-1 mb-3">
{recipe.tags?.slice(0, 3).map((tag) => (
{recipe.tags && Array.isArray(recipe.tags) && recipe.tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="outline"
@ -210,12 +227,10 @@ function RecipeCard({ recipe, index }: RecipeCardProps) {
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
{recipe.preparationTime && (
<div className="flex items-center">
<Clock className="h-3 w-3 mr-1" />
<span>{recipe.preparationTime} min</span>
</div>
)}
<div className="flex items-center">
<Clock className="h-3 w-3 mr-1" />
<span>{recipe.preparationTime || 0} min</span>
</div>
{recipe.servings && (
<div className="flex items-center">

View File

@ -1,22 +0,0 @@
{
"name": "recorder",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recorder",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"vmsg": "^0.4.0"
}
},
"node_modules/vmsg": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/vmsg/-/vmsg-0.4.0.tgz",
"integrity": "sha512-46BBqRSfqdFGUpO2j+Hpz8T9YE5uWG0/PWal1PT+R1o8NEthtjG/XWl4HzbB8hIHpg/UtmKvsxL2OKQBrIYcHQ==",
"license": "CC0-1.0"
}
}
}

View File

@ -1,15 +0,0 @@
{
"name": "recorder",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"vmsg": "^0.4.0"
}
}

View File

@ -1,302 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enregistreur Audio</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
color: #333;
}
h1 {
text-align: center;
color: #e67e22;
}
.container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-top: 20px;
}
.recorder-controls {
display: flex;
justify-content: center;
margin: 20px 0;
}
button {
background-color: #e67e22;
color: white;
border: none;
border-radius: 4px;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
button:hover {
background-color: #d35400;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.recordings {
margin-top: 20px;
}
.recording-item {
background-color: #f5f5f5;
border-radius: 4px;
padding: 15px;
margin-bottom: 10px;
}
.recording-info {
margin-bottom: 10px;
font-size: 14px;
color: #666;
}
audio {
width: 100%;
}
.status {
text-align: center;
margin: 10px 0;
font-style: italic;
color: #666;
}
.pulse {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.mic-icon {
width: 20px;
height: 20px;
}
</style>
<!-- Charger le script vmsg avant notre script principal -->
<script src="https://unpkg.com/vmsg@0.3.0/vmsg.js"></script>
</head>
<body>
<h1>Enregistreur Audio</h1>
<div class="container">
<p>Enregistrez votre voix et écoutez le résultat. Les enregistrements sont stockés localement dans votre navigateur.
</p>
<div id="status" class="status">Prêt à enregistrer</div>
<div class="recorder-controls">
<button id="recordButton">
<svg class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
Commencer l'enregistrement
</button>
</div>
<div id="recordings" class="recordings">
<h3>Vos enregistrements</h3>
<div id="recordingsList"></div>
</div>
</div>
<script>
// Attendre que vmsg soit complètement chargé
window.onload = function () {
// Vérifier si vmsg est disponible
if (typeof vmsg === 'undefined') {
console.error("La bibliothèque vmsg n'a pas été chargée correctement");
document.getElementById('status').textContent = "Erreur: Impossible de charger l'enregistreur audio";
document.getElementById('recordButton').disabled = true;
return;
}
// Initialiser le recorder
const recorder = new vmsg.Recorder({
wasmURL: "https://unpkg.com/vmsg@0.3.0/vmsg.wasm"
});
const recordButton = document.getElementById('recordButton');
const statusElement = document.getElementById('status');
const recordingsList = document.getElementById('recordingsList');
let isLoading = false;
let isRecording = false;
let recordings = [];
// Fonction pour mettre à jour l'interface utilisateur
function updateUI() {
if (isLoading) {
recordButton.disabled = true;
statusElement.textContent = isRecording
? "Arrêt de l'enregistrement..."
: "Initialisation de l'enregistrement...";
} else if (isRecording) {
recordButton.innerHTML = `
<svg class="mic-icon pulse" viewBox="0 0 24 24" fill="none" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
Arrêter l'enregistrement
`;
recordButton.style.backgroundColor = '#e74c3c';
statusElement.textContent = "Enregistrement en cours...";
} else {
recordButton.disabled = false;
recordButton.innerHTML = `
<svg class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
Commencer l'enregistrement
`;
recordButton.style.backgroundColor = '#e67e22';
statusElement.textContent = "Prêt à enregistrer";
}
}
// Fonction pour gérer l'enregistrement
async function toggleRecording() {
isLoading = true;
updateUI();
if (isRecording) {
try {
const blob = await recorder.stopRecording();
const url = URL.createObjectURL(blob);
const timestamp = new Date().toLocaleString();
const size = (blob.size / 1024).toFixed(2);
recordings.push({ url, timestamp, size });
renderRecordings();
isRecording = false;
} catch (e) {
console.error("Erreur lors de l'arrêt de l'enregistrement:", e);
alert("Une erreur est survenue lors de l'arrêt de l'enregistrement.");
}
} else {
try {
await recorder.initAudio();
await recorder.initWorker();
recorder.startRecording();
isRecording = true;
} catch (e) {
console.error("Erreur lors du démarrage de l'enregistrement:", e);
alert("Impossible d'accéder au microphone. Vérifiez les permissions.");
}
}
isLoading = false;
updateUI();
}
// Fonction pour afficher les enregistrements
function renderRecordings() {
recordingsList.innerHTML = '';
if (recordings.length === 0) {
recordingsList.innerHTML = '<p>Aucun enregistrement pour le moment.</p>';
return;
}
recordings.forEach((recording, index) => {
const recordingItem = document.createElement('div');
recordingItem.className = 'recording-item';
recordingItem.innerHTML = `
<div class="recording-info">
<strong>Enregistrement #${index + 1}</strong> - ${recording.timestamp} (${recording.size} KB)
</div>
<audio controls src="${recording.url}"></audio>
<div style="margin-top: 10px;">
<button class="download-btn" data-index="${index}" style="background-color: #3498db;">
Télécharger
</button>
<button class="delete-btn" data-index="${index}" style="background-color: #e74c3c; margin-left: 10px;">
Supprimer
</button>
</div>
`;
recordingsList.appendChild(recordingItem);
});
// Ajouter les écouteurs d'événements pour les boutons
document.querySelectorAll('.download-btn').forEach(btn => {
btn.addEventListener('click', function () {
const index = parseInt(this.dataset.index);
const recording = recordings[index];
const a = document.createElement('a');
a.href = recording.url;
a.download = `enregistrement-${index + 1}.mp3`;
a.click();
});
});
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', function () {
const index = parseInt(this.dataset.index);
URL.revokeObjectURL(recordings[index].url);
recordings.splice(index, 1);
renderRecordings();
});
});
}
// Initialiser l'interface
renderRecordings();
updateUI();
// Ajouter l'écouteur d'événement pour le bouton d'enregistrement
recordButton.addEventListener('click', toggleRecording);
};
</script>
</body>
</html>