diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 88cca80..264b21e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,10 @@ build/ *.swp *.swo +# Uploads (keep the folder, ignore contents) +backend/uploads/* +!backend/uploads/.gitkeep + # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/README.md b/README.md index be67a9a..68a9055 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,117 @@ # Freedge -Freedge is a web application that generates personalized recipes based on the ingredients available in the user's fridge. The application is built on a modern fullstack architecture with a lightweight and fast backend, an integrated database, and a smooth user interface. +Freedge génère des recettes personnalisées à partir des ingrédients dictés à l'oral. Le son est transcrit (Whisper), une recette est générée (GPT-4o-mini) et une image optionnelle est produite (DALL-E 3). -## Tech Stack +## Stack -- **Frontend**: React.js + TailwindCSS + ShadCN -- **Backend**: Fastify + Prisma + SQLite -- **AI**: ChatGPT API for recipe generation -- **Payments**: Stripe (subscriptions) +- **Frontend** : React 19 + Vite + TailwindCSS + ShadCN/UI + React Router +- **Backend** : Fastify 4 + Prisma 5 + SQLite +- **IA** : OpenAI (Whisper, GPT-4o-mini, DALL-E 3) +- **Stockage** : MinIO (S3-compatible) avec fallback local +- **Paiement** : Stripe (client créé à l'inscription — intégration abonnement à finaliser) +- **Auth** : JWT + Google OAuth -## Project Structure +## Structure ``` freedge/ -├── frontend/ # React frontend application -│ ├── public/ # Static assets -│ └── src/ # Source code -│ ├── components/ # Reusable UI components -│ ├── pages/ # Application pages -│ ├── services/ # API service integrations -│ └── utils/ # Utility functions -│ -├── backend/ # Fastify API server -│ ├── prisma/ # Prisma schema and migrations -│ └── src/ # Source code -│ ├── routes/ # API route definitions -│ ├── controllers/ # Request handlers -│ ├── services/ # Business logic -│ └── models/ # Data models -│ -└── README.md # Project documentation +├── backend/ +│ ├── prisma/ # Schéma + migrations SQLite +│ └── src/ +│ ├── plugins/ # auth, ai, stripe, google-auth +│ ├── routes/ # auth, recipes, users +│ ├── utils/ # env, storage (MinIO), email, resend +│ └── server.js +└── frontend/ + └── src/ + ├── api/ # Clients HTTP (auth, user, recipe) + ├── components/ # UI shadcn + composants métier + ├── hooks/ # useAuth, useMobile, useAudioRecorder + ├── layouts/ # MainLayout + └── pages/ # Home, Auth/*, Recipes/*, Profile, ResetPassword ``` -## Getting Started +## Prérequis -### Prerequisites +- Node.js ≥ 18 +- pnpm (recommandé) ou npm +- Une clé API OpenAI +- (Optionnel) Un serveur MinIO pour le stockage images/audio +- (Optionnel) Un compte Resend pour les emails -- Node.js (v16+) -- npm or yarn -- SQLite +## Démarrage -### Installation +```bash +# Installation +cd backend && npm install +cd ../frontend && pnpm install -1. Clone the repository - ``` - git clone https://github.com/yourusername/freedge.git - cd freedge - ``` +# Variables d'environnement backend (.env dans backend/) +cp .env.example .env # puis éditer +# Requis : DATABASE_URL, JWT_SECRET, OPENAI_API_KEY -2. Install backend dependencies - ``` - cd backend - npm install - ``` +# Base de données +cd backend +npx prisma migrate dev +npx prisma generate -3. Set up environment variables - - Create a `.env` file in the backend directory (or modify the existing one) - - Add your OpenAI API key and Stripe keys +# Lancement +npm run dev # backend sur :3000 +cd ../frontend && pnpm dev # frontend sur :5173 +``` -4. Set up the database - ``` - npx prisma migrate dev --name init - npx prisma generate - ``` +## Variables d'environnement backend -5. Install frontend dependencies - ``` - cd ../frontend - npm install - ``` +| Variable | Requis | Description | +|---|---|---| +| `DATABASE_URL` | ✅ | URL Prisma (ex: `file:./prisma/dev.db`) | +| `JWT_SECRET` | ✅ | Clé JWT (≥ 32 caractères recommandé) | +| `OPENAI_API_KEY` | ✅ | Clé API OpenAI | +| `PORT` | ❌ | Port du serveur (défaut 3000) | +| `CORS_ORIGINS` | ❌ | Origines autorisées, séparées par virgule | +| `FRONTEND_URL` | ❌ | URL frontend pour les emails de reset | +| `ENABLE_IMAGE_GENERATION` | ❌ | `false` pour désactiver DALL-E | +| `OPENAI_TEXT_MODEL` | ❌ | Défaut `gpt-4o-mini` | +| `STRIPE_SECRET_KEY` | ❌ | Clé Stripe (pour créer les customers) | +| `MINIO_ENDPOINT` / `MINIO_PORT` / `MINIO_USE_SSL` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` / `MINIO_BUCKET` | ❌ | Config MinIO ; fallback local sinon | +| `MINIO_ALLOW_SELF_SIGNED` | ❌ | `true` pour autoriser TLS auto-signé (DEV uniquement) | +| `RESEND_API_KEY` | ❌ | Clé Resend pour les emails | -6. Start the development servers +## Routes API (préfixe `/`) - In the backend directory: - ``` - npm run dev - ``` +### Auth (`/auth`) +- `POST /auth/register` — Inscription email + mot de passe +- `POST /auth/login` — Connexion +- `POST /auth/google-auth` — Connexion/inscription via Google OAuth - In the frontend directory: - ``` - npm run dev - ``` +### Utilisateurs (`/users`) +- `GET /users/profile` — Profil courant (🔒) +- `PUT /users/profile` — Mise à jour du nom (🔒) +- `PUT /users/change-password` — Changement de mot de passe (🔒) +- `PUT /users/change-email` — Changement d'email (🔒) +- `POST /users/forgot-password` — Demande de réinitialisation +- `POST /users/reset-password` — Réinitialisation avec token +- `DELETE /users/account` — Suppression du compte (🔒) -7. Open your browser and navigate to `http://localhost:5173` +### Recettes (`/recipes`) — toutes 🔒 +- `POST /recipes/create` — Upload audio + transcription + génération +- `GET /recipes/list` — Liste les recettes de l'utilisateur +- `GET /recipes/:id` — Détail d'une recette +- `DELETE /recipes/:id` — Supprime une recette -## Features +### Divers +- `GET /health` — Healthcheck -- User authentication with JWT -- Ingredient management -- AI-powered recipe generation -- Subscription management with Stripe -- Recipe history +🔒 = nécessite un JWT `Authorization: Bearer ` -## API Routes +## Sécurité -All routes are prefixed with `/api`. +- Helmet + rate-limit (100 req/min) activés +- CORS whitelisté via `CORS_ORIGINS` +- JWT signé, expiration 7 jours +- Bcrypt (10 rounds) pour les mots de passe +- Validation des variables d'environnement au démarrage -### Authentication -- `POST /auth/register` - Create a new user account -- `POST /auth/login` - Login and get JWT token - -### Profile Management -- `GET /profile` - Get user profile -- `PUT /profile` - Update user profile - -### Ingredients -- `GET /ingredients` - Get user's ingredients -- `POST /ingredients` - Add a new ingredient -- `DELETE /ingredients/:id` - Delete an ingredient - -### Recipes -- `POST /recipes/generate` - Generate a recipe based on ingredients -- `GET /recipes/history` - Get recipe history -- `GET /recipes/:id` - Get a specific recipe - -### Subscriptions -- `POST /subscriptions/create-checkout-session` - Create a Stripe checkout session -- `GET /subscriptions/status` - Get subscription status - -## License +## Licence MIT diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..bdcfd33 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,29 @@ +# ---- Requis ---- +DATABASE_URL="file:./prisma/dev.db" +JWT_SECRET="change-me-please-use-at-least-32-characters" +OPENAI_API_KEY="sk-..." + +# ---- Serveur ---- +PORT=3000 +LOG_LEVEL=info +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +FRONTEND_URL=http://localhost:5173 + +# ---- IA ---- +OPENAI_TEXT_MODEL=gpt-4o-mini +ENABLE_IMAGE_GENERATION=true + +# ---- Stripe (optionnel) ---- +STRIPE_SECRET_KEY= + +# ---- MinIO (optionnel — fallback local sinon) ---- +MINIO_ENDPOINT= +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_BUCKET=freedge +MINIO_ALLOW_SELF_SIGNED=false + +# ---- Email (optionnel) ---- +RESEND_API_KEY= diff --git a/backend/.gitignore b/backend/.gitignore index 40b878d..28c7166 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,7 @@ -node_modules/ \ No newline at end of file +node_modules/ +.env +*.db +*.db-journal +uploads/* +!uploads/.gitkeep +prisma/dev.db* diff --git a/backend/.prettierrc.json b/backend/.prettierrc.json new file mode 100644 index 0000000..fc783bf --- /dev/null +++ b/backend/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/backend/eslint.config.js b/backend/eslint.config.js new file mode 100644 index 0000000..24ea66b --- /dev/null +++ b/backend/eslint.config.js @@ -0,0 +1,19 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: 'commonjs', + globals: { + ...globals.node, + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-console': 'off', + }, + }, +]; diff --git a/backend/package.json b/backend/package.json index 71057a3..60b3d2b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,15 +7,18 @@ "start": "node src/server.js", "dev": "nodemon src/server.js", "migrate": "prisma migrate dev", - "studio": "prisma studio" + "studio": "prisma studio", + "lint": "eslint src", + "format": "prettier --write \"src/**/*.js\"" }, "dependencies": { "@fastify/cors": "^8.5.0", + "@fastify/helmet": "^11.1.1", "@fastify/jwt": "^7.0.0", "@fastify/multipart": "^8.0.0", + "@fastify/rate-limit": "^9.1.0", "@prisma/client": "^5.0.0", "bcrypt": "^5.1.1", - "crypto": "^1.0.1", "dotenv": "^16.3.1", "fastify": "^4.19.0", "fastify-plugin": "^4.5.0", @@ -27,7 +30,11 @@ "stripe": "^12.12.0" }, "devDependencies": { + "@eslint/js": "^9.21.0", + "eslint": "^9.21.0", + "globals": "^15.15.0", "nodemon": "^3.0.1", + "prettier": "^3.3.0", "prisma": "^5.0.0" } } diff --git a/backend/src/plugins/ai.js b/backend/src/plugins/ai.js index 98f937b..9de09a7 100644 --- a/backend/src/plugins/ai.js +++ b/backend/src/plugins/ai.js @@ -1,76 +1,58 @@ const fp = require('fastify-plugin'); const { OpenAI } = require('openai'); const fs = require('fs'); -const util = require('util'); -const { pipeline } = require('stream'); -const pump = util.promisify(pipeline); -const fetch = require('node-fetch'); -const { Readable } = require('stream'); -const { uploadFile, getFileUrl } = require('../utils/storage'); const path = require('path'); const os = require('os'); +const { pipeline } = require('stream/promises'); +const { Readable } = require('stream'); +const { uploadFile, getFileUrl } = require('../utils/storage'); + +const ENABLE_IMAGE_GENERATION = process.env.ENABLE_IMAGE_GENERATION !== 'false'; module.exports = fp(async function (fastify, opts) { - const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY - }); - + const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); fastify.decorate('openai', openai); - // Fonction utilitaire pour convertir un buffer en stream - const bufferToStream = (buffer) => { - const readable = new Readable(); - readable.push(buffer); - readable.push(null); - return readable; - }; + const bufferToStream = (buffer) => Readable.from(buffer); - // Fonction pour télécharger un fichier temporaire const downloadToTemp = async (url, extension = '.tmp') => { const response = await fetch(url); if (!response.ok) { throw new Error(`Échec du téléchargement: ${response.statusText}`); } - - const buffer = await response.arrayBuffer(); + const buffer = Buffer.from(await response.arrayBuffer()); const tempFilePath = path.join(os.tmpdir(), `openai-${Date.now()}${extension}`); - fs.writeFileSync(tempFilePath, Buffer.from(buffer)); - + fs.writeFileSync(tempFilePath, buffer); return { path: tempFilePath, - buffer: Buffer.from(buffer), + buffer, cleanup: () => { try { fs.unlinkSync(tempFilePath); } catch (err) { - fastify.log.error(`Erreur lors de la suppression du fichier temporaire: ${err.message}`); + fastify.log.warn(`Suppression temp échouée: ${err.message}`); } - } + }, }; }; - // Transcription audio avec Whisper + // --- Transcription audio --- fastify.decorate('transcribeAudio', async (audioInput) => { let tempFile = null; let audioPath = null; try { - // Déterminer le type d'entrée audio if (typeof audioInput === 'string') { - // C'est déjà un chemin de fichier audioPath = audioInput; } else if (audioInput && audioInput.url) { - // C'est un résultat de Minio avec une URL tempFile = await downloadToTemp(audioInput.url, '.mp3'); audioPath = tempFile.path; } else if (audioInput && audioInput.localPath) { - // C'est un résultat local audioPath = audioInput.localPath; } else { throw new Error("Format d'entrée audio non valide"); } - // Effectuer la transcription const transcription = await openai.audio.transcriptions.create({ file: fs.createReadStream(audioPath), model: 'whisper-1', @@ -78,138 +60,120 @@ module.exports = fp(async function (fastify, opts) { return transcription.text; } catch (error) { - fastify.log.error(`Erreur lors de la transcription audio: ${error.message}`); + fastify.log.error(`Erreur transcription audio: ${error.message}`); throw error; } finally { - // Nettoyer le fichier temporaire si nécessaire - if (tempFile) { - tempFile.cleanup(); - } + if (tempFile) tempFile.cleanup(); } }); - // Génération de recette avec GPT-4o-mini - fastify.decorate('generateRecipe', async (ingredients, prompt) => { + // --- Génération d'image (best-effort, isolée) --- + async function generateRecipeImage(title) { + if (!ENABLE_IMAGE_GENERATION) return null; try { - const completion = await openai.chat.completions.create({ - model: "gpt-4o-mini", - messages: [ - { - role: "system", - 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.'} Réponds uniquement avec un objet JSON.` - } - ], - response_format: { type: "json_object" }, - }); - - const recipeData = JSON.parse(completion.choices[0].message.content); - - // Génération de l'image du plat avec DALL-E 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.`, + model: 'dall-e-3', + prompt: `Une photo culinaire professionnelle et appétissante du plat "${title}", éclairage studio, style gastronomie.`, n: 1, - size: "1024x1024", + size: '1024x1024', }); - // Télécharger l'image depuis l'URL OpenAI const imageUrl = imageResponse.data[0].url; const response = await fetch(imageUrl); + if (!response.ok) throw new Error(`Téléchargement image: ${response.statusText}`); const imageBuffer = Buffer.from(await response.arrayBuffer()); - // Préparer le fichier pour Minio - const sanitizedTitle = recipeData.titre.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); + const sanitizedTitle = title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); const fileName = `${sanitizedTitle}-${Date.now()}.jpg`; - const folderPath = 'recipes'; - // Créer un objet file compatible avec uploadFile - const file = { - filename: fileName, - file: bufferToStream(imageBuffer) - }; - - // Uploader vers Minio - const filePath = await uploadFile(file, folderPath); - const minioUrl = await getFileUrl(filePath); - - // Ajouter l'URL à l'objet recette - recipeData.image_url = minioUrl; - - return recipeData; - } catch (error) { - fastify.log.error(`Erreur lors de la génération de recette: ${error.message}`); - throw error; - } - }); - - // Gestion du téléchargement de fichiers audio - fastify.decorate('saveAudioFile', async (file) => { - try { - // Vérifier que le fichier est valide - if (!file || !file.filename) { - throw new Error("Fichier audio invalide"); - } - - // Préparer le nom de fichier - const fileName = `${Date.now()}-${file.filename}`; - const folderPath = 'audio'; - - // Si le fichier est déjà un stream, l'utiliser directement - // Sinon, le convertir en stream - const fileToUpload = { - filename: fileName, - file: file.file || bufferToStream(file) - }; - - // Uploader vers Minio - const filePath = await uploadFile(fileToUpload, folderPath); - const minioUrl = await getFileUrl(filePath); - - return { - success: true, - url: minioUrl, - path: filePath - }; - } catch (error) { - fastify.log.error(`Erreur lors de l'upload audio: ${error.message}`); - - // Fallback au stockage local try { - const uploadDir = './uploads'; - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); - } - - const filename = `${Date.now()}-${file.filename}`; - const filepath = `${uploadDir}/${filename}`; - - // Gérer différents types d'entrée - if (Buffer.isBuffer(file.file)) { - fs.writeFileSync(filepath, file.file); - } else if (file.file && typeof file.file.pipe === 'function') { - await pump(file.file, fs.createWriteStream(filepath)); - } else if (Buffer.isBuffer(file)) { - fs.writeFileSync(filepath, file); - } else { - throw new Error("Format de fichier non pris en charge"); - } - - return { - success: true, - localPath: filepath, - isLocal: true - }; - } catch (localError) { - fastify.log.error(`Erreur lors du stockage local: ${localError.message}`); - return { - success: false, - error: `Erreur Minio: ${error.message}. Erreur locale: ${localError.message}` - }; + const filePath = await uploadFile( + { filename: fileName, file: bufferToStream(imageBuffer) }, + 'recipes' + ); + return await getFileUrl(filePath); + } catch (storageErr) { + fastify.log.warn(`Upload image vers MinIO échoué: ${storageErr.message}`); + // Fallback: on retourne null plutôt que de faire échouer toute la génération + return null; } + } catch (err) { + fastify.log.warn(`Génération image DALL-E échouée: ${err.message}`); + return null; } + } + + // --- Génération de recette --- + fastify.decorate('generateRecipe', async (ingredients, prompt) => { + const completion = await openai.chat.completions.create({ + model: process.env.OPENAI_TEXT_MODEL || 'gpt-4o-mini', + messages: [ + { + role: 'system', + 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.' + } Réponds uniquement avec un objet JSON.`, + }, + ], + response_format: { type: 'json_object' }, + }); + + let recipeData; + try { + recipeData = JSON.parse(completion.choices[0].message.content); + } catch (err) { + fastify.log.error(`Réponse OpenAI non-JSON: ${err.message}`); + throw new Error('La génération de recette a retourné un format invalide'); + } + + // Image en best-effort — n'échoue jamais la création de recette + recipeData.image_url = await generateRecipeImage(recipeData.titre || 'recette'); + + return recipeData; }); -}); \ No newline at end of file + + // --- Sauvegarde fichier audio --- + fastify.decorate('saveAudioFile', async (file) => { + if (!file || !file.filename) { + throw new Error('Fichier audio invalide'); + } + + const fileName = `${Date.now()}-${file.filename}`; + + // Tenter MinIO + try { + const filePath = await uploadFile( + { filename: fileName, file: file.file || bufferToStream(file) }, + 'audio' + ); + const url = await getFileUrl(filePath); + return { success: true, url, path: filePath }; + } catch (err) { + fastify.log.warn(`Upload MinIO échoué, fallback local: ${err.message}`); + } + + // Fallback local + const uploadDir = './uploads'; + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + const filepath = `${uploadDir}/${fileName}`; + + if (Buffer.isBuffer(file.file)) { + fs.writeFileSync(filepath, file.file); + } else if (file.file && typeof file.file.pipe === 'function') { + await pipeline(file.file, fs.createWriteStream(filepath)); + } else if (Buffer.isBuffer(file)) { + fs.writeFileSync(filepath, file); + } else { + throw new Error('Format de fichier non pris en charge'); + } + + return { success: true, localPath: filepath, isLocal: true }; + }); +}); diff --git a/backend/src/routes/recipes.js b/backend/src/routes/recipes.js index bc988a0..c1cea72 100644 --- a/backend/src/routes/recipes.js +++ b/backend/src/routes/recipes.js @@ -1,13 +1,17 @@ const fs = require('fs'); -const util = require('util'); -const { pipeline } = require('stream'); -const pump = util.promisify(pipeline); const multipart = require('@fastify/multipart'); +const { deleteFile } = require('../utils/storage'); + +const FREE_PLAN_LIMIT = 5; module.exports = async function (fastify, opts) { - fastify.register(multipart); + fastify.register(multipart, { + limits: { + fileSize: 15 * 1024 * 1024, // 15 MB max pour un upload audio + files: 1, + }, + }); - // Middleware d'authentification fastify.addHook('preHandler', fastify.authenticate); // Créer une recette @@ -19,65 +23,62 @@ module.exports = async function (fastify, opts) { return reply.code(400).send({ error: 'Fichier audio requis' }); } - // Vérifier le plan de l'utilisateur - const user = await fastify.prisma.user.findUnique({ - where: { id: request.user.id } - }); + // Vérifier le plan de l'utilisateur (et compter atomiquement) + const [user, recipeCount] = await Promise.all([ + fastify.prisma.user.findUnique({ where: { id: request.user.id } }), + fastify.prisma.recipe.count({ where: { userId: request.user.id } }), + ]); - if (user.subscription === 'free') { - // Vérifier le nombre de recettes pour les utilisateurs gratuits - const recipeCount = await fastify.prisma.recipe.count({ - where: { userId: user.id } - }); - - if (recipeCount >= 5) { - return reply.code(403).send({ - error: 'Limite de recettes atteinte pour le plan gratuit. Passez au plan premium pour créer plus de recettes.' - }); - } + if (!user) { + return reply.code(404).send({ error: 'Utilisateur non trouvé' }); } - // Sauvegarder le fichier audio - const audioResult = await fastify.saveAudioFile(data); + const isFree = !user.subscription || user.subscription === 'free'; + if (isFree && recipeCount >= FREE_PLAN_LIMIT) { + return reply.code(403).send({ + error: `Limite de ${FREE_PLAN_LIMIT} recettes atteinte pour le plan gratuit. Passez au plan premium pour en créer davantage.`, + }); + } - // Extraire l'URL audio (chaîne de caractères) pour Prisma - const audioUrl = audioResult.url || (audioResult.localPath || null); + // Sauvegarder le fichier audio (Minio si dispo, sinon fallback local) + const audioResult = await fastify.saveAudioFile(data); + const audioUrl = audioResult.url || audioResult.localPath || null; + const audioStoragePath = audioResult.path || null; // chemin Minio si applicable // Transcrire l'audio const transcription = await fastify.transcribeAudio(audioResult); - // Extraire les ingrédients du texte transcrit - const ingredients = transcription; + // Générer la recette (image gérée en best-effort, cf. plugin ai) + const recipeData = await fastify.generateRecipe( + transcription, + 'Crée une recette délicieuse et détaillée' + ); - // Générer la recette - 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 + // Normaliser les tableaux en chaînes const ingredientsString = Array.isArray(recipeData.ingredients) ? recipeData.ingredients.join('\n') - : recipeData.ingredients; + : recipeData.ingredients || ''; const stepsString = Array.isArray(recipeData.etapes) ? recipeData.etapes.join('\n') - : recipeData.etapes; + : recipeData.etapes || ''; - // Créer la recette en base de données const recipe = await fastify.prisma.recipe.create({ data: { - title: recipeData.titre, + title: recipeData.titre || 'Recette sans titre', ingredients: ingredientsString, userPrompt: transcription, generatedRecipe: JSON.stringify(recipeData), - imageUrl: recipeData.image_url, - preparationTime: recipeData.temps_preparation, - cookingTime: recipeData.temps_cuisson, - servings: recipeData.portions, - difficulty: recipeData.difficulte, + imageUrl: recipeData.image_url || null, + preparationTime: recipeData.temps_preparation || null, + cookingTime: recipeData.temps_cuisson || null, + servings: recipeData.portions || null, + difficulty: recipeData.difficulte || null, steps: stepsString, - tips: recipeData.conseils, - audioUrl: audioUrl, - userId: request.user.id - } + tips: recipeData.conseils || null, + audioUrl: audioStoragePath || audioUrl, + userId: request.user.id, + }, }); return { @@ -93,8 +94,8 @@ module.exports = async function (fastify, opts) { tips: recipe.tips, imageUrl: recipe.imageUrl, audioUrl: audioUrl, - createdAt: recipe.createdAt - } + createdAt: recipe.createdAt, + }, }; } catch (error) { fastify.log.error(error); @@ -102,7 +103,7 @@ module.exports = async function (fastify, opts) { } }); - // Récupérer la liste des recettes + // Lister les recettes fastify.get('/list', async (request, reply) => { try { const recipes = await fastify.prisma.recipe.findMany({ @@ -117,8 +118,8 @@ module.exports = async function (fastify, opts) { servings: true, difficulty: true, imageUrl: true, - createdAt: true - } + createdAt: true, + }, }); return { recipes }; @@ -131,24 +132,28 @@ module.exports = async function (fastify, opts) { // Récupérer une recette par ID fastify.get('/:id', async (request, reply) => { try { - const recipe = await fastify.prisma.recipe.findUnique({ + // findFirst car le where composite (id + userId) n'est pas unique côté Prisma + const recipe = await fastify.prisma.recipe.findFirst({ where: { id: request.params.id, - userId: request.user.id - } + userId: request.user.id, + }, }); if (!recipe) { return reply.code(404).send({ error: 'Recette non trouvée' }); } - // Vous pouvez choisir de parser le JSON ici ou le laisser tel quel - const recipeData = { - ...recipe, - generatedRecipe: JSON.parse(recipe.generatedRecipe) - }; + let parsed = null; + if (recipe.generatedRecipe) { + try { + parsed = JSON.parse(recipe.generatedRecipe); + } catch (err) { + fastify.log.warn(`generatedRecipe corrompu pour ${recipe.id}: ${err.message}`); + } + } - return { recipe: recipeData }; + return { recipe: { ...recipe, generatedRecipe: parsed } }; } catch (error) { fastify.log.error(error); return reply.code(500).send({ error: 'Erreur lors de la récupération de la recette' }); @@ -159,7 +164,7 @@ module.exports = async function (fastify, opts) { fastify.delete('/:id', async (request, reply) => { try { const recipe = await fastify.prisma.recipe.findUnique({ - where: { id: request.params.id } + where: { id: request.params.id }, }); if (!recipe) { @@ -170,13 +175,22 @@ module.exports = async function (fastify, opts) { return reply.code(403).send({ error: 'Non autorisé' }); } - await fastify.prisma.recipe.delete({ - where: { id: request.params.id } - }); + await fastify.prisma.recipe.delete({ where: { id: request.params.id } }); - // Supprimer le fichier audio si existant - if (recipe.audioUrl && fs.existsSync(recipe.audioUrl)) { - fs.unlinkSync(recipe.audioUrl); + // Best-effort: supprimer le fichier audio associé + if (recipe.audioUrl) { + try { + if (recipe.audioUrl.startsWith('http')) { + // URL Minio — on ne peut rien faire sans stocker la clé. À refactorer + // quand audioUrl stockera explicitement le path plutôt que l'URL. + } else if (recipe.audioUrl.includes('/') && !recipe.audioUrl.startsWith('./')) { + await deleteFile(recipe.audioUrl).catch(() => {}); + } else if (fs.existsSync(recipe.audioUrl)) { + fs.unlinkSync(recipe.audioUrl); + } + } catch (cleanupErr) { + fastify.log.warn(`Nettoyage audio échoué: ${cleanupErr.message}`); + } } return { success: true }; @@ -185,4 +199,4 @@ module.exports = async function (fastify, opts) { return reply.code(500).send({ error: 'Erreur lors de la suppression de la recette' }); } }); -}; \ No newline at end of file +}; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index d2d3dec..653525d 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -1,5 +1,4 @@ -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); +const crypto = require('node:crypto'); const { sendEmail } = require('../utils/email'); module.exports = async function (fastify, opts) { @@ -86,13 +85,13 @@ module.exports = async function (fastify, opts) { return reply.code(400).send({ error: 'Utilisateur non trouvé ou connecté via Google' }); } - const isPasswordValid = await bcrypt.compare(currentPassword, user.password); + const isPasswordValid = await fastify.comparePassword(currentPassword, user.password); if (!isPasswordValid) { return reply.code(400).send({ error: 'Mot de passe actuel incorrect' }); } // Hasher et mettre à jour le nouveau mot de passe - const hashedPassword = await bcrypt.hash(newPassword, 10); + const hashedPassword = await fastify.hashPassword(newPassword); await fastify.prisma.user.update({ where: { id: request.user.id }, data: { password: hashedPassword } @@ -132,7 +131,7 @@ module.exports = async function (fastify, opts) { return reply.code(400).send({ error: 'Utilisateur non trouvé ou connecté via Google' }); } - const isPasswordValid = await bcrypt.compare(password, user.password); + const isPasswordValid = await fastify.comparePassword(password, user.password); if (!isPasswordValid) { return reply.code(400).send({ error: 'Mot de passe incorrect' }); } @@ -258,7 +257,7 @@ module.exports = async function (fastify, opts) { } // Hasher et mettre à jour le nouveau mot de passe - const hashedPassword = await bcrypt.hash(newPassword, 10); + const hashedPassword = await fastify.hashPassword(newPassword); await fastify.prisma.user.update({ where: { id: user.id }, @@ -296,7 +295,7 @@ module.exports = async function (fastify, opts) { return reply.code(400).send({ error: 'Mot de passe requis' }); } - const isPasswordValid = await bcrypt.compare(password, user.password); + const isPasswordValid = await fastify.comparePassword(password, user.password); if (!isPasswordValid) { return reply.code(400).send({ error: 'Mot de passe incorrect' }); } diff --git a/backend/src/server.js b/backend/src/server.js index 4682fbb..d8de5d8 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,45 +1,88 @@ -const fastify = require('fastify')({ logger: true }); -const { PrismaClient } = require('@prisma/client'); require('dotenv').config(); -// Création de l'instance Prisma -const prisma = new PrismaClient(); +const { validateEnv } = require('./utils/env'); +validateEnv(); + +const fastify = require('fastify')({ + logger: { + level: process.env.LOG_LEVEL || 'info', + }, + bodyLimit: 10 * 1024 * 1024, // 10 MB +}); +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); +fastify.decorate('prisma', prisma); + +// --- Sécurité --- +fastify.register(require('@fastify/helmet'), { + contentSecurityPolicy: false, // laissé au frontend / reverse proxy +}); + +fastify.register(require('@fastify/rate-limit'), { + max: 100, + timeWindow: '1 minute', +}); + +// CORS : whitelist via env, fallback dev +const allowedOrigins = (process.env.CORS_ORIGINS || 'http://localhost:5173,http://127.0.0.1:5173') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); -// Plugins fastify.register(require('@fastify/cors'), { - origin: true, // Autoriser toutes les origines en développement - // Ou spécifier correctement les origines autorisées : - // origin: ['http://localhost:5173', 'http://127.0.0.1:5173'], + origin: (origin, cb) => { + // Autoriser les requêtes sans origine (curl, health checks) + if (!origin) return cb(null, true); + if (allowedOrigins.includes(origin)) return cb(null, true); + cb(new Error(`Origin ${origin} non autorisée`), false); + }, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], - credentials: true + credentials: true, }); + +// --- Plugins applicatifs --- fastify.register(require('./plugins/auth')); fastify.register(require('./plugins/stripe')); fastify.register(require('./plugins/ai')); fastify.register(require('./plugins/google-auth')); -// Routes +// --- Routes --- fastify.register(require('./routes/auth'), { prefix: '/auth' }); fastify.register(require('./routes/recipes'), { prefix: '/recipes' }); fastify.register(require('./routes/users'), { prefix: '/users' }); -// Hook pour fermer la connexion Prisma à l'arrêt du serveur + +// Healthcheck +fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() })); + +// Fermeture propre fastify.addHook('onClose', async (instance, done) => { await prisma.$disconnect(); done(); }); -// Décoration pour rendre prisma disponible dans les routes -fastify.decorate('prisma', prisma); +const shutdown = async (signal) => { + fastify.log.info(`${signal} reçu, arrêt en cours...`); + try { + await fastify.close(); + process.exit(0); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +}; +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); -// Démarrage du serveur const start = async () => { try { - await fastify.listen({ port: process.env.PORT || 3000, host: '0.0.0.0' }); + const port = Number(process.env.PORT) || 3000; + await fastify.listen({ port, host: '0.0.0.0' }); } catch (err) { fastify.log.error(err); process.exit(1); } }; -start(); \ No newline at end of file +start(); diff --git a/backend/src/utils/env.js b/backend/src/utils/env.js new file mode 100644 index 0000000..83b22a0 --- /dev/null +++ b/backend/src/utils/env.js @@ -0,0 +1,34 @@ +// Validation des variables d'environnement au démarrage. +// Échoue vite si une variable critique est manquante. + +const REQUIRED = ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY']; + +const OPTIONAL_WARN = [ + 'STRIPE_SECRET_KEY', + 'MINIO_ENDPOINT', + 'MINIO_PORT', + 'MINIO_ACCESS_KEY', + 'MINIO_SECRET_KEY', + 'MINIO_BUCKET', + 'RESEND_API_KEY', + 'FRONTEND_URL', +]; + +function validateEnv(log = console) { + const missing = REQUIRED.filter((k) => !process.env[k]); + if (missing.length > 0) { + log.error(`Variables d'environnement manquantes: ${missing.join(', ')}`); + process.exit(1); + } + + if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) { + log.warn('JWT_SECRET fait moins de 32 caractères, utilisez une clé plus longue en production.'); + } + + const missingOptional = OPTIONAL_WARN.filter((k) => !process.env[k]); + if (missingOptional.length > 0) { + log.warn(`Variables optionnelles non définies: ${missingOptional.join(', ')}`); + } +} + +module.exports = { validateEnv }; diff --git a/backend/src/utils/storage.js b/backend/src/utils/storage.js index 0a3c1c8..0b68ef9 100644 --- a/backend/src/utils/storage.js +++ b/backend/src/utils/storage.js @@ -1,46 +1,70 @@ const Minio = require('minio'); const https = require('https'); -const minioClient = new Minio.Client({ - endPoint: process.env.MINIO_ENDPOINT.replace(/^https?:\/\//, ''), - port: parseInt(process.env.MINIO_PORT), - useSSL: process.env.MINIO_USE_SSL === 'true', - accessKey: process.env.MINIO_ACCESS_KEY, - secretKey: process.env.MINIO_SECRET_KEY, - pathStyle: true, - transport: { - agent: new https.Agent({ - rejectUnauthorized: false - }) +let minioClient = null; + +function getClient() { + if (minioClient) return minioClient; + + if (!process.env.MINIO_ENDPOINT || !process.env.MINIO_ACCESS_KEY) { + return null; } -}); + + const useSSL = process.env.MINIO_USE_SSL === 'true'; + const allowSelfSigned = process.env.MINIO_ALLOW_SELF_SIGNED === 'true'; + + const clientOpts = { + endPoint: process.env.MINIO_ENDPOINT.replace(/^https?:\/\//, ''), + port: parseInt(process.env.MINIO_PORT, 10), + useSSL, + accessKey: process.env.MINIO_ACCESS_KEY, + secretKey: process.env.MINIO_SECRET_KEY, + pathStyle: true, + }; + + // Ne désactiver la vérification TLS que si explicitement demandé. + if (useSSL && allowSelfSigned) { + clientOpts.transport = { + agent: new https.Agent({ rejectUnauthorized: false }), + }; + } + + minioClient = new Minio.Client(clientOpts); + return minioClient; +} const uploadFile = async (file, folderPath) => { + const client = getClient(); + if (!client) throw new Error('MinIO non configuré'); + const fileName = `${Date.now()}-${file.filename}`; const filePath = `${folderPath}/${fileName}`; - await minioClient.putObject(process.env.MINIO_BUCKET, filePath, file.file); + await client.putObject(process.env.MINIO_BUCKET, filePath, file.file); return filePath; }; const deleteFile = async (filePath) => { - await minioClient.removeObject(process.env.MINIO_BUCKET, filePath); + const client = getClient(); + if (!client) return; + await client.removeObject(process.env.MINIO_BUCKET, filePath); }; const getFile = async (filePath) => { - const file = await minioClient.getObject(process.env.MINIO_BUCKET, filePath); - return file; + const client = getClient(); + if (!client) throw new Error('MinIO non configuré'); + return client.getObject(process.env.MINIO_BUCKET, filePath); }; const listFiles = async (folderPath) => { - const files = await minioClient.listObjects(process.env.MINIO_BUCKET, folderPath); - return files; + const client = getClient(); + if (!client) throw new Error('MinIO non configuré'); + return client.listObjects(process.env.MINIO_BUCKET, folderPath); }; const getFileUrl = async (filePath) => { - const url = await minioClient.presignedUrl('GET', process.env.MINIO_BUCKET, filePath); - return url; + const client = getClient(); + if (!client) throw new Error('MinIO non configuré'); + return client.presignedUrl('GET', process.env.MINIO_BUCKET, filePath); }; - - module.exports = { uploadFile, deleteFile, getFile, listFiles, getFileUrl }; diff --git a/backend/uploads/.gitkeep b/backend/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/1741985930301-recording.mp3 b/backend/uploads/1741985930301-recording.mp3 deleted file mode 100644 index 814fd8c..0000000 Binary files a/backend/uploads/1741985930301-recording.mp3 and /dev/null differ diff --git a/backend/uploads/1741986084481-recording.mp3 b/backend/uploads/1741986084481-recording.mp3 deleted file mode 100644 index 814fd8c..0000000 Binary files a/backend/uploads/1741986084481-recording.mp3 and /dev/null differ diff --git a/backend/uploads/1742040018269-recording.mp3 b/backend/uploads/1742040018269-recording.mp3 deleted file mode 100644 index 6f0e561..0000000 Binary files a/backend/uploads/1742040018269-recording.mp3 and /dev/null differ diff --git a/backend/uploads/1742040080066-recording.mp3 b/backend/uploads/1742040080066-recording.mp3 deleted file mode 100644 index 6f0e561..0000000 Binary files a/backend/uploads/1742040080066-recording.mp3 and /dev/null differ diff --git a/frontend/package.json b/frontend/package.json index 11a6e40..2e5e9bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.4.11", - "ky": "^1.7.5", "lucide-react": "^0.478.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a2bd5c..c60b131 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,46 +4,47 @@ import Register from './pages/Auth/Register' import Login from './pages/Auth/Login' import RecipeList from './pages/Recipes/RecipeList' import RecipeDetail from './pages/Recipes/RecipeDetail' -// import Favorites from './pages/Recipes/Favorites' +import RecipeForm from '@/pages/Recipes/RecipeForm' import Profile from './pages/Profile' import Home from './pages/Home' +import ResetPassword from '@/pages/ResetPassword' import { MainLayout } from './layouts/MainLayout' -import RecipeForm from "@/pages/Recipes/RecipeForm" import useAuth from '@/hooks/useAuth' import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards' -import ResetPassword from "@/pages/ResetPassword" function App() { - const { isAuthenticated, isLoading } = useAuth(); + const { isAuthenticated, isLoading } = useAuth() if (isLoading) { - return
Chargement de l'application...
; + return ( +
+ Chargement de l'application... +
+ ) } return ( - {/* Routes d'authentification */} + {/* Auth */} } /> } /> + } /> - {/* Routes publiques */} + {/* Recettes (protégées) */} } /> + } /> } /> - {/* Routes protégées */} - } /> + {/* Profil */} } /> - {/* Route racine avec redirection conditionnelle */} + {/* Racine */} : } /> - {/* Route de fallback pour les URLs non trouvées */} + {/* Fallback — DOIT être la dernière route */} } /> - - {/* Nouvelle route pour la réinitialisation du mot de passe */} - } /> diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx deleted file mode 100644 index 304e10c..0000000 --- a/frontend/src/components/LoginForm.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useState } from 'react'; -import { login } from '../api/auth'; -import { useNavigate } from 'react-router-dom'; - -const LoginForm: React.FC = () => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const navigate = useNavigate(); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(null); - - try { - await login({ email, password }); - navigate('/dashboard'); // Rediriger vers le tableau de bord après connexion - } catch (err) { - if (axios.isAxiosError(err) && err.response) { - setError(err.response.data.error || 'Erreur lors de la connexion'); - } else { - setError('Erreur réseau lors de la connexion'); - } - } finally { - setLoading(false); - } - }; - - return ( -
-

Connexion

- {error &&
{error}
} - -
-
- - setEmail(e.target.value)} - required - /> -
- -
- - setPassword(e.target.value)} - required - /> -
- - -
-
- ); -}; - -export default LoginForm; \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx deleted file mode 100644 index 49986b3..0000000 --- a/frontend/src/components/Navbar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { logout, isAuthenticated, getCurrentUser } from '../api/auth'; - -const Navbar: React.FC = () => { - const navigate = useNavigate(); - const authenticated = isAuthenticated(); - const user = getCurrentUser(); - - const handleLogout = () => { - logout(); - navigate('/login'); - }; - - return ( - - ); -}; - -export default Navbar; \ No newline at end of file diff --git a/frontend/src/components/RecipeDetail.tsx b/frontend/src/components/RecipeDetail.tsx deleted file mode 100644 index 63a74d5..0000000 --- a/frontend/src/components/RecipeDetail.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { getRecipeById, deleteRecipe, Recipe } from '@/api/recipe'; -import axios from 'axios'; - -const RecipeDetail: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const [recipe, setRecipe] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchRecipe = async () => { - if (!id) return; - - try { - const data = await getRecipeById(id); - setRecipe(data); - } catch (err) { - if (axios.isAxiosError(err) && err.response) { - setError(err.response.data.error || 'Erreur lors de la récupération de la recette'); - } else { - setError('Erreur réseau lors de la récupération de la recette'); - } - } finally { - setLoading(false); - } - }; - - fetchRecipe(); - }, [id]); - - const handleDelete = async () => { - if (!id) return; - - if (window.confirm('Êtes-vous sûr de vouloir supprimer cette recette?')) { - try { - await deleteRecipe(id); - navigate('/recipes'); - } catch (err) { - if (axios.isAxiosError(err) && err.response) { - setError(err.response.data.error || 'Erreur lors de la suppression'); - } else { - setError('Erreur réseau lors de la suppression'); - } - } - } - }; - - if (loading) return
Chargement de la recette...
; - if (error) return
{error}
; - if (!recipe) return
Recette non trouvée
; - - return ( -
-

{recipe.title}

- -
-

Ingrédients

-

{recipe.ingredients}

-
- -
-

Recette

-
- {recipe.generatedRecipe.split('\n').map((line, index) => ( -

{line}

- ))} -
-
- -
- - -
-
- ); -}; - -export default RecipeDetail; \ No newline at end of file diff --git a/frontend/src/components/RecipeForm.tsx b/frontend/src/components/RecipeForm.tsx deleted file mode 100644 index daf7ec7..0000000 --- a/frontend/src/components/RecipeForm.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useState } from 'react'; -import { createRecipe } from '@/api/recipe'; -import { useNavigate } from 'react-router-dom'; -import axios from 'axios'; - -const RecipeForm: React.FC = () => { - const [file, setFile] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const navigate = useNavigate(); - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - setFile(e.target.files[0]); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!file) { - setError('Veuillez sélectionner un fichier audio'); - return; - } - - setLoading(true); - setError(null); - - try { - const recipe = await createRecipe(file); - navigate(`/recipes/${recipe.id}`); // Rediriger vers la page de détails de la recette - } catch (err) { - if (axios.isAxiosError(err) && err.response) { - setError(err.response.data.error || 'Erreur lors de la création de la recette'); - } else { - setError('Erreur réseau lors de la création de la recette'); - } - } finally { - setLoading(false); - } - }; - - return ( -
-

Créer une nouvelle recette

- - {error &&
{error}
} - -
-
- - - Enregistrez-vous en listant les ingrédients disponibles -
- - -
-
- ); -}; - -export default RecipeForm; \ No newline at end of file diff --git a/frontend/src/components/RecipeList.tsx b/frontend/src/components/RecipeList.tsx deleted file mode 100644 index cf264fa..0000000 --- a/frontend/src/components/RecipeList.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { getRecipes, deleteRecipe, Recipe } from '@/api/recipe'; -import { Link } from 'react-router-dom'; -import axios from 'axios'; - -const RecipeList: React.FC = () => { - const [recipes, setRecipes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchRecipes = async () => { - try { - const data = await getRecipes(); - setRecipes(data); - } catch (err) { - if (axios.isAxiosError(err) && err.response) { - setError(err.response.data.error || 'Erreur lors de la récupération des recettes'); - } else { - setError('Erreur réseau lors de la récupération des recettes'); - } - } finally { - setLoading(false); - } - }; - - fetchRecipes(); - }, []); - - const handleDelete = async (id: string) => { - if (window.confirm('Êtes-vous sûr de vouloir supprimer cette recette?')) { - try { - await deleteRecipe(id); - setRecipes(recipes.filter(recipe => recipe.id !== id)); - } catch (err) { - if (axios.isAxiosError(err) && err.response) { - setError(err.response.data.error || 'Erreur lors de la suppression'); - } else { - setError('Erreur réseau lors de la suppression'); - } - } - } - }; - - if (loading) return
Chargement des recettes...
; - if (error) return
{error}
; - - return ( -
-

Mes recettes

- - {recipes.length === 0 ? ( -

Vous n'avez pas encore de recettes. Créez-en une!

- ) : ( -
    - {recipes.map(recipe => ( -
  • -

    {recipe.title}

    -

    Ingrédients: {recipe.ingredients}

    -
    - - Voir les détails - - -
    -
  • - ))} -
- )} -
- ); -}; - -export default RecipeList; \ No newline at end of file diff --git a/frontend/src/components/recipe-card.tsx b/frontend/src/components/recipe-card.tsx deleted file mode 100644 index a919fea..0000000 --- a/frontend/src/components/recipe-card.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client" - -import { useState } from "react" -import { Heart, Clock, Share2 } from 'lucide-react' -import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Recipe } from "@/types/recipe" -import { RecipeModal } from "@/components/recipe-modal" - -interface RecipeCardProps { - recipe: Recipe -} - -export function RecipeCard({ recipe }: RecipeCardProps) { - const [isFavorite, setIsFavorite] = useState(recipe.isFavorite) - const [isModalOpen, setIsModalOpen] = useState(false) - - const toggleFavorite = (e: React.MouseEvent) => { - e.stopPropagation() - setIsFavorite(!isFavorite) - } - - const shareRecipe = (e: React.MouseEvent) => { - e.stopPropagation() - // In a real app, this would use the Web Share API or copy to clipboard - alert(`Sharing recipe: ${recipe.title}`) - } - - return ( - <> - setIsModalOpen(true)} - > -
- {recipe.title} -
- -
-
- - -
-

{recipe.title}

-
-
- - {recipe.cookingTime} mins - - {recipe.difficulty} -
-
- - -

- {recipe.description} -

-
- {recipe.tags.slice(0, 3).map(tag => ( - - {tag} - - ))} - {recipe.tags.length > 3 && ( - - +{recipe.tags.length - 3} more - - )} -
-
- - -
- - -
-
-
- - setIsModalOpen(false)} - isFavorite={isFavorite} - onToggleFavorite={toggleFavorite} - onShare={shareRecipe} - /> - - ) -} diff --git a/frontend/src/components/recipe-list.tsx b/frontend/src/components/recipe-list.tsx deleted file mode 100644 index 9a74a99..0000000 --- a/frontend/src/components/recipe-list.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client" - -import { useState } from "react" -import { RecipeCard } from "@/components/recipe-card" -import { recipes } from "@/data/recipes" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { LayoutGrid, List } from 'lucide-react' -import { Heart, Share2 } from 'lucide-react'; // Import missing icons -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { SearchFilters } from "@/components/search-filters"; - - -export default function RecipeList() { - const [viewMode, setViewMode] = useState<"grid" | "list">("grid") - - return ( -
- - -
-

- {recipes.length} Recipes -

- - setViewMode("grid")}> - - Grid - - setViewMode("list")}> - - List - - -
- - -
- {recipes.map(recipe => ( - - ))} -
-
- - -
- {recipes.map(recipe => ( -
-
-
- {recipe.title} -
-
-

{recipe.title}

-
- {recipe.cookingTime} mins - - {recipe.difficulty} -
-

- {recipe.description} -

-
- {recipe.tags.map(tag => ( - - {tag} - - ))} -
-
- -
- - -
-
-
-
-
- ))} -
-
-
-
- ) -} diff --git a/frontend/src/components/recipe-modal.tsx b/frontend/src/components/recipe-modal.tsx deleted file mode 100644 index 2c9ceaf..0000000 --- a/frontend/src/components/recipe-modal.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client" - -import type React from "react" - -import { Clock, Heart, Share2, X } from "lucide-react" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Separator } from "@/components/ui/separator" -import type { Recipe } from "@/types/recipe" - -interface RecipeModalProps { - recipe: Recipe - isOpen: boolean - onClose: () => void - isFavorite: boolean - onToggleFavorite: (e: React.MouseEvent) => void - onShare: (e: React.MouseEvent) => void -} - -export function RecipeModal({ recipe, isOpen, onClose, isFavorite, onToggleFavorite, onShare }: RecipeModalProps) { - return ( - - - - {recipe.title} - - - -
-
-
- {recipe.title} -
- -
-
- - {recipe.cookingTime} mins -
- - {recipe.difficulty} -
- - -
-
- -
- {recipe.tags.map((tag) => ( - - {tag} - - ))} -
- -

{recipe.description}

-
- -
-

Ingredients

-
    - {recipe.ingredients.map((ingredient, index) => ( -
  • - - {ingredient} -
  • - ))} -
- -

Instructions

-
    - {recipe.instructions.map((instruction, index) => ( -
  1. - - {index + 1} - - {instruction} -
  2. - ))} -
-
-
-
-
- ) -} - diff --git a/frontend/src/components/recipe-skeleton.tsx b/frontend/src/components/recipe-skeleton.tsx deleted file mode 100644 index 26abcef..0000000 --- a/frontend/src/components/recipe-skeleton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card" -import { Skeleton } from "@/components/ui/skeleton" - -export function RecipeSkeleton() { - return ( - - - - - - - - - -
- - - -
-
- -
- - -
-
-
- ) -} - diff --git a/frontend/src/components/search-filters.tsx b/frontend/src/components/search-filters.tsx deleted file mode 100644 index cb81d6f..0000000 --- a/frontend/src/components/search-filters.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client" - -import { useState } from "react" -import { Search } from "lucide-react" -import { Input } from "@/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import type { DifficultyLevel, RecipeTag } from "@/types/recipe" - -const difficultyOptions: DifficultyLevel[] = ["easy", "medium", "hard"] -const tagOptions: RecipeTag[] = [ - "vegetarian", - "vegan", - "gluten-free", - "dairy-free", - "quick meal", - "dessert", - "breakfast", - "lunch", - "dinner", - "snack", -] - -export function SearchFilters() { - const [selectedTags, setSelectedTags] = useState([]) - - const toggleTag = (tag: RecipeTag) => { - if (selectedTags.includes(tag)) { - setSelectedTags(selectedTags.filter((t) => t !== tag)) - } else { - setSelectedTags([...selectedTags, tag]) - } - } - - return ( -
-
-
- - -
- -
- - - - - -
-
- -
- {tagOptions.map((tag) => ( - toggleTag(tag)} - > - {tag} - - ))} -
-
- ) -} - diff --git a/frontend/src/data/recipes.ts b/frontend/src/data/recipes.ts deleted file mode 100644 index c9b4499..0000000 --- a/frontend/src/data/recipes.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { Recipe } from "@/types/recipe" - -export const recipes: Recipe[] = [ - { - id: "1", - title: "Avocado Toast with Poached Eggs", - description: "A nutritious breakfast that's quick to prepare and packed with healthy fats and protein.", - imageUrl: "/placeholder.svg?height=300&width=400", - cookingTime: 15, - difficulty: "easy", - tags: ["vegetarian", "breakfast", "quick meal"], - ingredients: [ - "2 slices of sourdough bread", - "1 ripe avocado", - "2 eggs", - "Salt and pepper to taste", - "Red pepper flakes (optional)", - "1 tbsp vinegar", - ], - instructions: [ - "Toast the bread until golden and crispy.", - "Mash the avocado and spread it on the toast. Season with salt and pepper.", - "Bring a pot of water to a simmer, add vinegar.", - "Crack each egg into a small bowl, then gently slide into the simmering water.", - "Poach for 3-4 minutes until whites are set but yolks are still runny.", - "Remove eggs with a slotted spoon and place on top of the avocado toast.", - "Sprinkle with red pepper flakes if desired.", - ], - isFavorite: true, - }, - { - id: "2", - title: "Creamy Mushroom Risotto", - description: "A comforting Italian classic with rich, earthy flavors and a creamy texture.", - imageUrl: "/placeholder.svg?height=300&width=400", - cookingTime: 40, - difficulty: "medium", - tags: ["vegetarian", "dinner"], - ingredients: [ - "1 1/2 cups arborio rice", - "4 cups vegetable broth", - "1/2 cup dry white wine", - "8 oz mushrooms, sliced", - "1 small onion, finely diced", - "2 cloves garlic, minced", - "1/2 cup grated Parmesan cheese", - "2 tbsp butter", - "2 tbsp olive oil", - "Fresh thyme", - "Salt and pepper to taste", - ], - instructions: [ - "In a saucepan, warm the broth over low heat.", - "In a large pan, heat olive oil and sauté onions until translucent.", - "Add mushrooms and garlic, cook until mushrooms are soft.", - "Add rice and stir for 1-2 minutes until translucent at the edges.", - "Pour in wine and stir until absorbed.", - "Add warm broth one ladle at a time, stirring constantly until absorbed before adding more.", - "Continue until rice is creamy and al dente, about 20-25 minutes.", - "Remove from heat, stir in butter and Parmesan.", - "Season with salt, pepper, and fresh thyme.", - ], - isFavorite: false, - }, - { - id: "3", - title: "Berry Smoothie Bowl", - description: "A refreshing and nutritious breakfast bowl topped with fresh fruits and granola.", - imageUrl: "/placeholder.svg?height=300&width=400", - cookingTime: 10, - difficulty: "easy", - tags: ["vegetarian", "vegan", "breakfast", "quick meal"], - ingredients: [ - "1 cup frozen mixed berries", - "1 frozen banana", - "1/2 cup plant-based milk", - "1 tbsp chia seeds", - "Toppings: fresh berries, sliced banana, granola, coconut flakes", - ], - instructions: [ - "Blend frozen berries, banana, and milk until smooth.", - "Pour into a bowl.", - "Top with fresh fruits, granola, and coconut flakes.", - "Sprinkle chia seeds on top.", - ], - isFavorite: true, - }, - { - id: "4", - title: "Lemon Garlic Roast Chicken", - description: "A classic roast chicken with bright lemon and garlic flavors, perfect for Sunday dinner.", - imageUrl: "/placeholder.svg?height=300&width=400", - cookingTime: 90, - difficulty: "medium", - tags: ["dinner"], - ingredients: [ - "1 whole chicken (about 4-5 lbs)", - "2 lemons", - "1 head of garlic", - "3 tbsp olive oil", - "Fresh rosemary and thyme", - "Salt and pepper to taste", - ], - instructions: [ - "Preheat oven to 425°F (220°C).", - "Rinse chicken and pat dry with paper towels.", - "Cut one lemon into quarters and place inside the chicken cavity along with half the garlic and herbs.", - "Rub olive oil all over the chicken and season generously with salt and pepper.", - "Slice the second lemon and arrange around the chicken in a roasting pan with remaining garlic cloves.", - "Roast for 1 hour and 15 minutes or until juices run clear.", - "Let rest for 10-15 minutes before carving.", - ], - isFavorite: false, - }, - { - id: "5", - title: "Chocolate Lava Cake", - description: "Decadent individual chocolate cakes with a gooey, molten center.", - imageUrl: "/placeholder.svg?height=300&width=400", - cookingTime: 25, - difficulty: "medium", - tags: ["dessert"], - ingredients: [ - "4 oz dark chocolate", - "1/2 cup butter", - "1 cup powdered sugar", - "2 eggs", - "2 egg yolks", - "6 tbsp all-purpose flour", - "Vanilla ice cream for serving", - ], - instructions: [ - "Preheat oven to 425°F (220°C) and grease four ramekins.", - "Melt chocolate and butter together in a microwave or double boiler.", - "Whisk in powdered sugar until smooth.", - "Add eggs and egg yolks, whisk well.", - "Fold in flour gently.", - "Pour batter into ramekins and place on a baking sheet.", - "Bake for 12-14 minutes until edges are firm but centers are soft.", - "Let cool for 1 minute, then invert onto plates.", - "Serve immediately with vanilla ice cream.", - ], - isFavorite: true, - }, - { - id: "6", - title: "Fresh Spring Rolls with Peanut Sauce", - description: - "Light and refreshing rice paper rolls filled with vegetables and herbs, served with a tangy peanut dipping sauce.", - imageUrl: "/placeholder.svg?height=300&width=400", - cookingTime: 30, - difficulty: "medium", - tags: ["vegetarian", "vegan", "lunch", "gluten-free"], - ingredients: [ - "8 rice paper wrappers", - "1 cucumber, julienned", - "1 carrot, julienned", - "1 avocado, sliced", - "1 cup bean sprouts", - "Fresh mint and cilantro leaves", - "For sauce: 3 tbsp peanut butter, 1 tbsp soy sauce, 1 tbsp lime juice, 1 tsp honey, water to thin", - ], - instructions: [ - "Prepare all vegetables and herbs.", - "Fill a large bowl with warm water.", - "Dip one rice paper wrapper in water for 10-15 seconds until pliable.", - "Lay wrapper on a clean work surface and place vegetables and herbs in the center.", - "Fold in sides and roll tightly.", - "Repeat with remaining wrappers.", - "For sauce, whisk together all ingredients, adding water until desired consistency.", - "Serve rolls with dipping sauce.", - ], - isFavorite: false, - }, -] - diff --git a/frontend/src/pages/recipe/Recipes.tsx b/frontend/src/pages/recipe/Recipes.tsx deleted file mode 100644 index 3eb3fd9..0000000 --- a/frontend/src/pages/recipe/Recipes.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import RecipeList from "@/components/recipe-list" -import { SearchFilters } from "@/components/search-filters" - -export default function Home() { - return ( -
-

My Recipes

-

Browse your collection of delicious recipes

- - - -
- ) -} - diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index de49f87..0000000 --- a/package-lock.json +++ /dev/null @@ -1,383 +0,0 @@ -{ - "name": "freedge", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "freedge", - "version": "1.0.0", - "license": "MIT", - "devDependencies": { - "concurrently": "^8.0.1" - } - }, - "node_modules/@babel/runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", - "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", - "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "date-fns": "^2.30.0", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "spawn-command": "0.0.2", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": "^14.13.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/spawn-command": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", - "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", - "dev": true - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - } - } -} diff --git a/src/components/RecipeForm.tsx b/src/components/RecipeForm.tsx deleted file mode 100644 index 263ad58..0000000 --- a/src/components/RecipeForm.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useState } from 'react'; -import RecipeService from '../services/RecipeService'; - -const RecipeForm: React.FC = () => { - const [file, setFile] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - setFile(e.target.files[0]); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!file) { - setError('Veuillez sélectionner un fichier audio'); - return; - } - - setLoading(true); - setError(null); - setSuccess(false); - - try { - await RecipeService.createRecipe(file); - setSuccess(true); - setFile(null); - // Réinitialiser le champ de fichier - const fileInput = document.getElementById('audio-file') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - } catch (err) { - setError(err instanceof Error ? err.message : 'Une erreur est survenue'); - } finally { - setLoading(false); - } - }; - - return ( -
-

Créer une nouvelle recette

- - {error &&
{error}
} - {success &&
Recette créée avec succès!
} - -
-
- - - Enregistrez-vous en listant les ingrédients disponibles -
- - -
-
- ); -}; - -export default RecipeForm; \ No newline at end of file diff --git a/src/components/RecipeList.tsx b/src/components/RecipeList.tsx deleted file mode 100644 index 2cfc2a1..0000000 --- a/src/components/RecipeList.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import RecipeService, { Recipe } from '../services/RecipeService'; - -const RecipeList: React.FC = () => { - const [recipes, setRecipes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchRecipes = async () => { - try { - const data = await RecipeService.getRecipes(); - setRecipes(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Une erreur est survenue'); - } finally { - setLoading(false); - } - }; - - fetchRecipes(); - }, []); - - const handleDelete = async (id: string) => { - if (window.confirm('Êtes-vous sûr de vouloir supprimer cette recette?')) { - try { - await RecipeService.deleteRecipe(id); - setRecipes(recipes.filter(recipe => recipe.id !== id)); - } catch (err) { - setError(err instanceof Error ? err.message : 'Erreur lors de la suppression'); - } - } - }; - - if (loading) return
Chargement des recettes...
; - if (error) return
{error}
; - - return ( -
-

Mes recettes

- - {recipes.length === 0 ? ( -

Vous n'avez pas encore de recettes. Créez-en une!

- ) : ( -
    - {recipes.map(recipe => ( -
  • -

    {recipe.title}

    -

    Ingrédients: {recipe.ingredients}

    -
    - - -
    -
  • - ))} -
- )} -
- ); -}; - -export default RecipeList; \ No newline at end of file diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts deleted file mode 100644 index 092f75f..0000000 --- a/src/services/AuthService.ts +++ /dev/null @@ -1,114 +0,0 @@ -import axios from 'axios'; - -const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000'; - -export interface User { - id: string; - email: string; - name: string | null; - subscription: string; -} - -export interface AuthResponse { - user: User; - token: string; -} - -export interface RegisterData { - email: string; - password: string; - name: string; -} - -export interface LoginData { - email: string; - password: string; -} - -class AuthService { - // Stocker le token dans le localStorage - private setToken(token: string): void { - localStorage.setItem('token', token); - } - - // Récupérer le token du localStorage - getToken(): string | null { - return localStorage.getItem('token'); - } - - // Stocker l'utilisateur dans le localStorage - private setUser(user: User): void { - localStorage.setItem('user', JSON.stringify(user)); - } - - // Récupérer l'utilisateur du localStorage - getUser(): User | null { - const userStr = localStorage.getItem('user'); - if (userStr) { - return JSON.parse(userStr); - } - return null; - } - - // Vérifier si l'utilisateur est connecté - isLoggedIn(): boolean { - return !!this.getToken(); - } - - // Inscription d'un nouvel utilisateur - async register(data: RegisterData): Promise { - try { - const response = await axios.post(`${API_URL}/auth/register`, data); - - if (response.data.token) { - this.setToken(response.data.token); - this.setUser(response.data.user); - } - - return response.data; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - throw new Error(error.response.data.error || 'Erreur lors de l\'inscription'); - } - throw new Error('Erreur réseau lors de l\'inscription'); - } - } - - // Connexion d'un utilisateur existant - async login(data: LoginData): Promise { - try { - const response = await axios.post(`${API_URL}/auth/login`, data); - - if (response.data.token) { - this.setToken(response.data.token); - this.setUser(response.data.user); - } - - return response.data; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - throw new Error(error.response.data.error || 'Erreur lors de la connexion'); - } - throw new Error('Erreur réseau lors de la connexion'); - } - } - - // Déconnexion - logout(): void { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - } - - // Obtenir les headers d'authentification - getAuthHeaders() { - const token = this.getToken(); - if (token) { - return { - Authorization: `Bearer ${token}` - }; - } - return {}; - } -} - -export default new AuthService(); \ No newline at end of file diff --git a/src/services/RecipeService.ts b/src/services/RecipeService.ts deleted file mode 100644 index 4cbebc5..0000000 --- a/src/services/RecipeService.ts +++ /dev/null @@ -1,103 +0,0 @@ -import axios from 'axios'; -import AuthService from './AuthService'; - -const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000'; - -export interface Recipe { - id: string; - title: string; - ingredients: string; - generatedRecipe: string; - createdAt: string; - audioUrl?: string; -} - -export interface RecipeResponse { - recipe: Recipe; -} - -export interface RecipesResponse { - recipes: Recipe[]; -} - -class RecipeService { - // Créer une instance axios avec les headers d'authentification - private getAxiosInstance() { - return axios.create({ - baseURL: API_URL, - headers: { - ...AuthService.getAuthHeaders(), - 'Content-Type': 'application/json' - } - }); - } - - // Créer une nouvelle recette à partir d'un fichier audio - async createRecipe(audioFile: File): Promise { - try { - const formData = new FormData(); - formData.append('file', audioFile); - - // Pour les requêtes multipart/form-data, on doit créer une instance spéciale - const axiosInstance = axios.create({ - baseURL: API_URL, - headers: { - ...AuthService.getAuthHeaders(), - 'Content-Type': 'multipart/form-data' - } - }); - - const response = await axiosInstance.post('/recipes/create', formData); - return response.data.recipe; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - throw new Error(error.response.data.error || 'Erreur lors de la création de la recette'); - } - throw new Error('Erreur réseau lors de la création de la recette'); - } - } - - // Récupérer la liste des recettes de l'utilisateur - async getRecipes(): Promise { - try { - const axiosInstance = this.getAxiosInstance(); - const response = await axiosInstance.get('/recipes/list'); - return response.data.recipes; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - throw new Error(error.response.data.error || 'Erreur lors de la récupération des recettes'); - } - throw new Error('Erreur réseau lors de la récupération des recettes'); - } - } - - // Récupérer les détails d'une recette spécifique - async getRecipeById(id: string): Promise { - try { - const axiosInstance = this.getAxiosInstance(); - const response = await axiosInstance.get(`/recipes/${id}`); - return response.data.recipe; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - throw new Error(error.response.data.error || 'Erreur lors de la récupération de la recette'); - } - throw new Error('Erreur réseau lors de la récupération de la recette'); - } - } - - // Supprimer une recette - async deleteRecipe(id: string): Promise { - try { - const axiosInstance = this.getAxiosInstance(); - await axiosInstance.delete(`/recipes/${id}`); - return true; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - throw new Error(error.response.data.error || 'Erreur lors de la suppression de la recette'); - } - throw new Error('Erreur réseau lors de la suppression de la recette'); - } - } -} - -export default new RecipeService(); \ No newline at end of file