From 0134390f5ea51866fe05410572ca597d1ce9e7c9 Mon Sep 17 00:00:00 2001 From: ordinarthur Date: Tue, 7 Apr 2026 21:00:54 +0200 Subject: [PATCH] refactor: cleanup, security hardening & frontend dedup Backend: - Remove malicious crypto dep; use node:crypto - Add helmet + rate-limit (100 req/min) - CORS whitelist via CORS_ORIGINS env - Validate required env vars on boot (fail fast) - Health endpoint + clean shutdown (SIGINT/SIGTERM) - Multipart limits (15MB / 1 file) - Fix findUnique composite where bug (use findFirst) - Wrap JSON.parse(generatedRecipe) in try/catch - Isolate DALL-E best-effort; ENABLE_IMAGE_GENERATION toggle - Lazy MinIO client, safe TLS handling - Uniform fastify.hashPassword/comparePassword - Proper audio cleanup on delete - ESLint flat config, Prettier, .env.example, .editorconfig Frontend: - Delete 10 orphan/duplicate components - Remove orphan pages/recipe/, data/recipes.ts, root src/ - Fix /reset-password route order (was unreachable) - Remove unused ky dep Docs: - README rewritten to match real routes and env vars Co-Authored-By: Claude Opus 4.6 (1M context) --- .editorconfig | 12 + .gitignore | 6 +- README.md | 182 +++++----- backend/.env.example | 29 ++ backend/.gitignore | 8 +- backend/.prettierrc.json | 8 + backend/eslint.config.js | 19 + backend/package.json | 11 +- backend/src/plugins/ai.js | 256 ++++++------- backend/src/routes/recipes.js | 144 ++++---- backend/src/routes/users.js | 13 +- backend/src/server.js | 75 +++- backend/src/utils/env.js | 34 ++ backend/src/utils/storage.js | 68 ++-- backend/uploads/.gitkeep | 0 backend/uploads/1741985930301-recording.mp3 | Bin 20520 -> 0 bytes backend/uploads/1741986084481-recording.mp3 | Bin 20520 -> 0 bytes backend/uploads/1742040018269-recording.mp3 | Bin 19152 -> 0 bytes backend/uploads/1742040080066-recording.mp3 | Bin 19152 -> 0 bytes frontend/package.json | 1 - frontend/src/App.tsx | 29 +- frontend/src/components/LoginForm.tsx | 67 ---- frontend/src/components/Navbar.tsx | 45 --- frontend/src/components/RecipeDetail.tsx | 85 ----- frontend/src/components/RecipeForm.tsx | 70 ---- frontend/src/components/RecipeList.tsx | 76 ---- frontend/src/components/recipe-card.tsx | 111 ------ frontend/src/components/recipe-list.tsx | 104 ------ frontend/src/components/recipe-modal.tsx | 98 ----- frontend/src/components/recipe-skeleton.tsx | 30 -- frontend/src/components/search-filters.tsx | 93 ----- frontend/src/data/recipes.ts | 176 --------- frontend/src/pages/recipe/Recipes.tsx | 15 - package-lock.json | 383 -------------------- src/components/RecipeForm.tsx | 70 ---- src/components/RecipeList.tsx | 66 ---- src/services/AuthService.ts | 114 ------ src/services/RecipeService.ts | 103 ------ 38 files changed, 526 insertions(+), 2075 deletions(-) create mode 100644 .editorconfig create mode 100644 backend/.env.example create mode 100644 backend/.prettierrc.json create mode 100644 backend/eslint.config.js create mode 100644 backend/src/utils/env.js create mode 100644 backend/uploads/.gitkeep delete mode 100644 backend/uploads/1741985930301-recording.mp3 delete mode 100644 backend/uploads/1741986084481-recording.mp3 delete mode 100644 backend/uploads/1742040018269-recording.mp3 delete mode 100644 backend/uploads/1742040080066-recording.mp3 delete mode 100644 frontend/src/components/LoginForm.tsx delete mode 100644 frontend/src/components/Navbar.tsx delete mode 100644 frontend/src/components/RecipeDetail.tsx delete mode 100644 frontend/src/components/RecipeForm.tsx delete mode 100644 frontend/src/components/RecipeList.tsx delete mode 100644 frontend/src/components/recipe-card.tsx delete mode 100644 frontend/src/components/recipe-list.tsx delete mode 100644 frontend/src/components/recipe-modal.tsx delete mode 100644 frontend/src/components/recipe-skeleton.tsx delete mode 100644 frontend/src/components/search-filters.tsx delete mode 100644 frontend/src/data/recipes.ts delete mode 100644 frontend/src/pages/recipe/Recipes.tsx delete mode 100644 package-lock.json delete mode 100644 src/components/RecipeForm.tsx delete mode 100644 src/components/RecipeList.tsx delete mode 100644 src/services/AuthService.ts delete mode 100644 src/services/RecipeService.ts 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 814fd8c3193126589f77c439694febd7f314f88c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20520 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 M0{Fka`JLB)0geK|CjbBd diff --git a/backend/uploads/1741986084481-recording.mp3 b/backend/uploads/1741986084481-recording.mp3 deleted file mode 100644 index 814fd8c3193126589f77c439694febd7f314f88c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20520 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 M0{Fka`JLB)0geK|CjbBd diff --git a/backend/uploads/1742040018269-recording.mp3 b/backend/uploads/1742040018269-recording.mp3 deleted file mode 100644 index 6f0e56142404a2aa3e1ba7e3876e5b15b7d9774d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19152 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 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