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) <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-07 21:00:54 +02:00
parent 9b98b3c0c6
commit 0134390f5e
38 changed files with 526 additions and 2075 deletions

12
.editorconfig Normal file
View File

@ -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

4
.gitignore vendored
View File

@ -25,6 +25,10 @@ build/
*.swp *.swp
*.swo *.swo
# Uploads (keep the folder, ignore contents)
backend/uploads/*
!backend/uploads/.gitkeep
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

178
README.md
View File

@ -1,123 +1,117 @@
# Freedge # 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 - **Frontend** : React 19 + Vite + TailwindCSS + ShadCN/UI + React Router
- **Backend**: Fastify + Prisma + SQLite - **Backend** : Fastify 4 + Prisma 5 + SQLite
- **AI**: ChatGPT API for recipe generation - **IA** : OpenAI (Whisper, GPT-4o-mini, DALL-E 3)
- **Payments**: Stripe (subscriptions) - **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/ freedge/
├── frontend/ # React frontend application ├── backend/
│ ├── public/ # Static assets │ ├── prisma/ # Schéma + migrations SQLite
│ └── src/ # Source code │ └── src/
│ ├── components/ # Reusable UI components │ ├── plugins/ # auth, ai, stripe, google-auth
│ ├── pages/ # Application pages │ ├── routes/ # auth, recipes, users
│ ├── services/ # API service integrations │ ├── utils/ # env, storage (MinIO), email, resend
│ └── utils/ # Utility functions │ └── server.js
└── frontend/
├── backend/ # Fastify API server └── src/
│ ├── prisma/ # Prisma schema and migrations ├── api/ # Clients HTTP (auth, user, recipe)
│ └── src/ # Source code ├── components/ # UI shadcn + composants métier
│ ├── routes/ # API route definitions ├── hooks/ # useAuth, useMobile, useAudioRecorder
│ ├── controllers/ # Request handlers ├── layouts/ # MainLayout
│ ├── services/ # Business logic └── pages/ # Home, Auth/*, Recipes/*, Profile, ResetPassword
│ └── models/ # Data models
└── README.md # Project documentation
``` ```
## 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+) ## Démarrage
- npm or yarn
- SQLite
### Installation ```bash
# Installation
cd backend && npm install
cd ../frontend && pnpm install
1. Clone the repository # Variables d'environnement backend (.env dans backend/)
``` cp .env.example .env # puis éditer
git clone https://github.com/yourusername/freedge.git # Requis : DATABASE_URL, JWT_SECRET, OPENAI_API_KEY
cd freedge
```
2. Install backend dependencies # Base de données
```
cd backend cd backend
npm install npx prisma migrate dev
```
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
4. Set up the database
```
npx prisma migrate dev --name init
npx prisma generate npx prisma generate
# Lancement
npm run dev # backend sur :3000
cd ../frontend && pnpm dev # frontend sur :5173
``` ```
5. Install frontend dependencies ## Variables d'environnement backend
```
cd ../frontend
npm install
```
6. Start the development servers | 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 |
In the backend directory: ## Routes API (préfixe `/`)
```
npm run dev
```
In the frontend directory: ### Auth (`/auth`)
``` - `POST /auth/register` — Inscription email + mot de passe
npm run dev - `POST /auth/login` — Connexion
``` - `POST /auth/google-auth` — Connexion/inscription via Google OAuth
7. Open your browser and navigate to `http://localhost:5173` ### 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 (🔒)
## Features ### 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
- User authentication with JWT ### Divers
- Ingredient management - `GET /health` — Healthcheck
- AI-powered recipe generation
- Subscription management with Stripe
- Recipe history
## API Routes 🔒 = nécessite un JWT `Authorization: Bearer <token>`
All routes are prefixed with `/api`. ## Sécurité
### Authentication - Helmet + rate-limit (100 req/min) activés
- `POST /auth/register` - Create a new user account - CORS whitelisté via `CORS_ORIGINS`
- `POST /auth/login` - Login and get JWT token - JWT signé, expiration 7 jours
- Bcrypt (10 rounds) pour les mots de passe
- Validation des variables d'environnement au démarrage
### Profile Management ## Licence
- `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
MIT MIT

29
backend/.env.example Normal file
View File

@ -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=

6
backend/.gitignore vendored
View File

@ -1 +1,7 @@
node_modules/ node_modules/
.env
*.db
*.db-journal
uploads/*
!uploads/.gitkeep
prisma/dev.db*

8
backend/.prettierrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}

19
backend/eslint.config.js Normal file
View File

@ -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',
},
},
];

View File

@ -7,15 +7,18 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "nodemon src/server.js", "dev": "nodemon src/server.js",
"migrate": "prisma migrate dev", "migrate": "prisma migrate dev",
"studio": "prisma studio" "studio": "prisma studio",
"lint": "eslint src",
"format": "prettier --write \"src/**/*.js\""
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/helmet": "^11.1.1",
"@fastify/jwt": "^7.0.0", "@fastify/jwt": "^7.0.0",
"@fastify/multipart": "^8.0.0", "@fastify/multipart": "^8.0.0",
"@fastify/rate-limit": "^9.1.0",
"@prisma/client": "^5.0.0", "@prisma/client": "^5.0.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"crypto": "^1.0.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"fastify": "^4.19.0", "fastify": "^4.19.0",
"fastify-plugin": "^4.5.0", "fastify-plugin": "^4.5.0",
@ -27,7 +30,11 @@
"stripe": "^12.12.0" "stripe": "^12.12.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0",
"eslint": "^9.21.0",
"globals": "^15.15.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^3.3.0",
"prisma": "^5.0.0" "prisma": "^5.0.0"
} }
} }

View File

@ -1,76 +1,58 @@
const fp = require('fastify-plugin'); const fp = require('fastify-plugin');
const { OpenAI } = require('openai'); const { OpenAI } = require('openai');
const fs = require('fs'); 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 path = require('path');
const os = require('os'); 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) { module.exports = fp(async function (fastify, opts) {
const openai = new OpenAI({ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
apiKey: process.env.OPENAI_API_KEY
});
fastify.decorate('openai', openai); fastify.decorate('openai', openai);
// Fonction utilitaire pour convertir un buffer en stream const bufferToStream = (buffer) => Readable.from(buffer);
const bufferToStream = (buffer) => {
const readable = new Readable();
readable.push(buffer);
readable.push(null);
return readable;
};
// Fonction pour télécharger un fichier temporaire
const downloadToTemp = async (url, extension = '.tmp') => { const downloadToTemp = async (url, extension = '.tmp') => {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Échec du téléchargement: ${response.statusText}`); throw new Error(`Échec du téléchargement: ${response.statusText}`);
} }
const buffer = Buffer.from(await response.arrayBuffer());
const buffer = await response.arrayBuffer();
const tempFilePath = path.join(os.tmpdir(), `openai-${Date.now()}${extension}`); const tempFilePath = path.join(os.tmpdir(), `openai-${Date.now()}${extension}`);
fs.writeFileSync(tempFilePath, Buffer.from(buffer)); fs.writeFileSync(tempFilePath, buffer);
return { return {
path: tempFilePath, path: tempFilePath,
buffer: Buffer.from(buffer), buffer,
cleanup: () => { cleanup: () => {
try { try {
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
} catch (err) { } 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) => { fastify.decorate('transcribeAudio', async (audioInput) => {
let tempFile = null; let tempFile = null;
let audioPath = null; let audioPath = null;
try { try {
// Déterminer le type d'entrée audio
if (typeof audioInput === 'string') { if (typeof audioInput === 'string') {
// C'est déjà un chemin de fichier
audioPath = audioInput; audioPath = audioInput;
} else if (audioInput && audioInput.url) { } else if (audioInput && audioInput.url) {
// C'est un résultat de Minio avec une URL
tempFile = await downloadToTemp(audioInput.url, '.mp3'); tempFile = await downloadToTemp(audioInput.url, '.mp3');
audioPath = tempFile.path; audioPath = tempFile.path;
} else if (audioInput && audioInput.localPath) { } else if (audioInput && audioInput.localPath) {
// C'est un résultat local
audioPath = audioInput.localPath; audioPath = audioInput.localPath;
} else { } else {
throw new Error("Format d'entrée audio non valide"); throw new Error("Format d'entrée audio non valide");
} }
// Effectuer la transcription
const transcription = await openai.audio.transcriptions.create({ const transcription = await openai.audio.transcriptions.create({
file: fs.createReadStream(audioPath), file: fs.createReadStream(audioPath),
model: 'whisper-1', model: 'whisper-1',
@ -78,138 +60,120 @@ module.exports = fp(async function (fastify, opts) {
return transcription.text; return transcription.text;
} catch (error) { } catch (error) {
fastify.log.error(`Erreur lors de la transcription audio: ${error.message}`); fastify.log.error(`Erreur transcription audio: ${error.message}`);
throw error; throw error;
} finally { } 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 // --- Génération d'image (best-effort, isolée) ---
fastify.decorate('generateRecipe', async (ingredients, prompt) => { async function generateRecipeImage(title) {
if (!ENABLE_IMAGE_GENERATION) return null;
try { 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({ const imageResponse = await openai.images.generate({
model: "dall-e-3", 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.`, prompt: `Une photo culinaire professionnelle et appétissante du plat "${title}", éclairage studio, style gastronomie.`,
n: 1, n: 1,
size: "1024x1024", size: '1024x1024',
}); });
// Télécharger l'image depuis l'URL OpenAI
const imageUrl = imageResponse.data[0].url; const imageUrl = imageResponse.data[0].url;
const response = await fetch(imageUrl); const response = await fetch(imageUrl);
if (!response.ok) throw new Error(`Téléchargement image: ${response.statusText}`);
const imageBuffer = Buffer.from(await response.arrayBuffer()); const imageBuffer = Buffer.from(await response.arrayBuffer());
// Préparer le fichier pour Minio const sanitizedTitle = title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
const sanitizedTitle = recipeData.titre.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
const fileName = `${sanitizedTitle}-${Date.now()}.jpg`; const fileName = `${sanitizedTitle}-${Date.now()}.jpg`;
const folderPath = 'recipes';
// Créer un objet file compatible avec uploadFile try {
const file = { const filePath = await uploadFile(
filename: fileName, { filename: fileName, file: bufferToStream(imageBuffer) },
file: bufferToStream(imageBuffer) 'recipes'
}; );
return await getFileUrl(filePath);
// Uploader vers Minio } catch (storageErr) {
const filePath = await uploadFile(file, folderPath); fastify.log.warn(`Upload image vers MinIO échoué: ${storageErr.message}`);
const minioUrl = await getFileUrl(filePath); // Fallback: on retourne null plutôt que de faire échouer toute la génération
return null;
// 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;
} }
} 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' },
}); });
// Gestion du téléchargement de fichiers audio let recipeData;
fastify.decorate('saveAudioFile', async (file) => {
try { try {
// Vérifier que le fichier est valide recipeData = JSON.parse(completion.choices[0].message.content);
if (!file || !file.filename) { } catch (err) {
throw new Error("Fichier audio invalide"); 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;
});
// --- Sauvegarde fichier audio ---
fastify.decorate('saveAudioFile', async (file) => {
if (!file || !file.filename) {
throw new Error('Fichier audio invalide');
} }
// Préparer le nom de fichier
const fileName = `${Date.now()}-${file.filename}`; const fileName = `${Date.now()}-${file.filename}`;
const folderPath = 'audio';
// Si le fichier est déjà un stream, l'utiliser directement // Tenter MinIO
// 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 { 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'; const uploadDir = './uploads';
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
} }
const filepath = `${uploadDir}/${fileName}`;
const filename = `${Date.now()}-${file.filename}`;
const filepath = `${uploadDir}/${filename}`;
// Gérer différents types d'entrée
if (Buffer.isBuffer(file.file)) { if (Buffer.isBuffer(file.file)) {
fs.writeFileSync(filepath, file.file); fs.writeFileSync(filepath, file.file);
} else if (file.file && typeof file.file.pipe === 'function') { } else if (file.file && typeof file.file.pipe === 'function') {
await pump(file.file, fs.createWriteStream(filepath)); await pipeline(file.file, fs.createWriteStream(filepath));
} else if (Buffer.isBuffer(file)) { } else if (Buffer.isBuffer(file)) {
fs.writeFileSync(filepath, file); fs.writeFileSync(filepath, file);
} else { } else {
throw new Error("Format de fichier non pris en charge"); throw new Error('Format de fichier non pris en charge');
} }
return { return { success: true, localPath: filepath, isLocal: true };
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}`
};
}
}
}); });
}); });

View File

@ -1,13 +1,17 @@
const fs = require('fs'); const fs = require('fs');
const util = require('util');
const { pipeline } = require('stream');
const pump = util.promisify(pipeline);
const multipart = require('@fastify/multipart'); const multipart = require('@fastify/multipart');
const { deleteFile } = require('../utils/storage');
const FREE_PLAN_LIMIT = 5;
module.exports = async function (fastify, opts) { 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); fastify.addHook('preHandler', fastify.authenticate);
// Créer une recette // Créer une recette
@ -19,65 +23,62 @@ module.exports = async function (fastify, opts) {
return reply.code(400).send({ error: 'Fichier audio requis' }); return reply.code(400).send({ error: 'Fichier audio requis' });
} }
// Vérifier le plan de l'utilisateur // Vérifier le plan de l'utilisateur (et compter atomiquement)
const user = await fastify.prisma.user.findUnique({ const [user, recipeCount] = await Promise.all([
where: { id: request.user.id } fastify.prisma.user.findUnique({ where: { id: request.user.id } }),
}); fastify.prisma.recipe.count({ where: { userId: request.user.id } }),
]);
if (user.subscription === 'free') { if (!user) {
// Vérifier le nombre de recettes pour les utilisateurs gratuits return reply.code(404).send({ error: 'Utilisateur non trouvé' });
const recipeCount = await fastify.prisma.recipe.count({ }
where: { userId: user.id }
});
if (recipeCount >= 5) { const isFree = !user.subscription || user.subscription === 'free';
if (isFree && recipeCount >= FREE_PLAN_LIMIT) {
return reply.code(403).send({ return reply.code(403).send({
error: 'Limite de recettes atteinte pour le plan gratuit. Passez au plan premium pour créer plus de recettes.' error: `Limite de ${FREE_PLAN_LIMIT} recettes atteinte pour le plan gratuit. Passez au plan premium pour en créer davantage.`,
}); });
} }
}
// Sauvegarder le fichier audio // Sauvegarder le fichier audio (Minio si dispo, sinon fallback local)
const audioResult = await fastify.saveAudioFile(data); const audioResult = await fastify.saveAudioFile(data);
const audioUrl = audioResult.url || audioResult.localPath || null;
// Extraire l'URL audio (chaîne de caractères) pour Prisma const audioStoragePath = audioResult.path || null; // chemin Minio si applicable
const audioUrl = audioResult.url || (audioResult.localPath || null);
// Transcrire l'audio // Transcrire l'audio
const transcription = await fastify.transcribeAudio(audioResult); const transcription = await fastify.transcribeAudio(audioResult);
// Extraire les ingrédients du texte transcrit // Générer la recette (image gérée en best-effort, cf. plugin ai)
const ingredients = transcription; const recipeData = await fastify.generateRecipe(
transcription,
'Crée une recette délicieuse et détaillée'
);
// Générer la recette // Normaliser les tableaux en chaînes
const recipeData = await fastify.generateRecipe(ingredients, "Crée une recette délicieuse et détaillée");
// Convertir les tableaux en chaînes de caractères pour la base de données
const ingredientsString = Array.isArray(recipeData.ingredients) const ingredientsString = Array.isArray(recipeData.ingredients)
? recipeData.ingredients.join('\n') ? recipeData.ingredients.join('\n')
: recipeData.ingredients; : recipeData.ingredients || '';
const stepsString = Array.isArray(recipeData.etapes) const stepsString = Array.isArray(recipeData.etapes)
? recipeData.etapes.join('\n') ? recipeData.etapes.join('\n')
: recipeData.etapes; : recipeData.etapes || '';
// Créer la recette en base de données
const recipe = await fastify.prisma.recipe.create({ const recipe = await fastify.prisma.recipe.create({
data: { data: {
title: recipeData.titre, title: recipeData.titre || 'Recette sans titre',
ingredients: ingredientsString, ingredients: ingredientsString,
userPrompt: transcription, userPrompt: transcription,
generatedRecipe: JSON.stringify(recipeData), generatedRecipe: JSON.stringify(recipeData),
imageUrl: recipeData.image_url, imageUrl: recipeData.image_url || null,
preparationTime: recipeData.temps_preparation, preparationTime: recipeData.temps_preparation || null,
cookingTime: recipeData.temps_cuisson, cookingTime: recipeData.temps_cuisson || null,
servings: recipeData.portions, servings: recipeData.portions || null,
difficulty: recipeData.difficulte, difficulty: recipeData.difficulte || null,
steps: stepsString, steps: stepsString,
tips: recipeData.conseils, tips: recipeData.conseils || null,
audioUrl: audioUrl, audioUrl: audioStoragePath || audioUrl,
userId: request.user.id userId: request.user.id,
} },
}); });
return { return {
@ -93,8 +94,8 @@ module.exports = async function (fastify, opts) {
tips: recipe.tips, tips: recipe.tips,
imageUrl: recipe.imageUrl, imageUrl: recipe.imageUrl,
audioUrl: audioUrl, audioUrl: audioUrl,
createdAt: recipe.createdAt createdAt: recipe.createdAt,
} },
}; };
} catch (error) { } catch (error) {
fastify.log.error(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) => { fastify.get('/list', async (request, reply) => {
try { try {
const recipes = await fastify.prisma.recipe.findMany({ const recipes = await fastify.prisma.recipe.findMany({
@ -117,8 +118,8 @@ module.exports = async function (fastify, opts) {
servings: true, servings: true,
difficulty: true, difficulty: true,
imageUrl: true, imageUrl: true,
createdAt: true createdAt: true,
} },
}); });
return { recipes }; return { recipes };
@ -131,24 +132,28 @@ module.exports = async function (fastify, opts) {
// Récupérer une recette par ID // Récupérer une recette par ID
fastify.get('/:id', async (request, reply) => { fastify.get('/:id', async (request, reply) => {
try { 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: { where: {
id: request.params.id, id: request.params.id,
userId: request.user.id userId: request.user.id,
} },
}); });
if (!recipe) { if (!recipe) {
return reply.code(404).send({ error: 'Recette non trouvée' }); return reply.code(404).send({ error: 'Recette non trouvée' });
} }
// Vous pouvez choisir de parser le JSON ici ou le laisser tel quel let parsed = null;
const recipeData = { if (recipe.generatedRecipe) {
...recipe, try {
generatedRecipe: JSON.parse(recipe.generatedRecipe) 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) { } catch (error) {
fastify.log.error(error); fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la récupération de la recette' }); 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) => { fastify.delete('/:id', async (request, reply) => {
try { try {
const recipe = await fastify.prisma.recipe.findUnique({ const recipe = await fastify.prisma.recipe.findUnique({
where: { id: request.params.id } where: { id: request.params.id },
}); });
if (!recipe) { if (!recipe) {
@ -170,14 +175,23 @@ module.exports = async function (fastify, opts) {
return reply.code(403).send({ error: 'Non autorisé' }); return reply.code(403).send({ error: 'Non autorisé' });
} }
await fastify.prisma.recipe.delete({ await fastify.prisma.recipe.delete({ where: { id: request.params.id } });
where: { id: request.params.id }
});
// Supprimer le fichier audio si existant // Best-effort: supprimer le fichier audio associé
if (recipe.audioUrl && fs.existsSync(recipe.audioUrl)) { 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); fs.unlinkSync(recipe.audioUrl);
} }
} catch (cleanupErr) {
fastify.log.warn(`Nettoyage audio échoué: ${cleanupErr.message}`);
}
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@ -1,5 +1,4 @@
const crypto = require('crypto'); const crypto = require('node:crypto');
const bcrypt = require('bcrypt');
const { sendEmail } = require('../utils/email'); const { sendEmail } = require('../utils/email');
module.exports = async function (fastify, opts) { 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' }); 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) { if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe actuel incorrect' }); return reply.code(400).send({ error: 'Mot de passe actuel incorrect' });
} }
// Hasher et mettre à jour le nouveau mot de passe // 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({ await fastify.prisma.user.update({
where: { id: request.user.id }, where: { id: request.user.id },
data: { password: hashedPassword } 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' }); 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) { if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' }); 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 // 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({ await fastify.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
@ -296,7 +295,7 @@ module.exports = async function (fastify, opts) {
return reply.code(400).send({ error: 'Mot de passe requis' }); 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) { if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' }); return reply.code(400).send({ error: 'Mot de passe incorrect' });
} }

View File

@ -1,41 +1,84 @@
const fastify = require('fastify')({ logger: true });
const { PrismaClient } = require('@prisma/client');
require('dotenv').config(); require('dotenv').config();
// Création de l'instance Prisma const { validateEnv } = require('./utils/env');
const prisma = new PrismaClient(); 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'), { fastify.register(require('@fastify/cors'), {
origin: true, // Autoriser toutes les origines en développement origin: (origin, cb) => {
// Ou spécifier correctement les origines autorisées : // Autoriser les requêtes sans origine (curl, health checks)
// origin: ['http://localhost:5173', 'http://127.0.0.1:5173'], 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'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'], allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true credentials: true,
}); });
// --- Plugins applicatifs ---
fastify.register(require('./plugins/auth')); fastify.register(require('./plugins/auth'));
fastify.register(require('./plugins/stripe')); fastify.register(require('./plugins/stripe'));
fastify.register(require('./plugins/ai')); fastify.register(require('./plugins/ai'));
fastify.register(require('./plugins/google-auth')); fastify.register(require('./plugins/google-auth'));
// Routes // --- Routes ---
fastify.register(require('./routes/auth'), { prefix: '/auth' }); fastify.register(require('./routes/auth'), { prefix: '/auth' });
fastify.register(require('./routes/recipes'), { prefix: '/recipes' }); fastify.register(require('./routes/recipes'), { prefix: '/recipes' });
fastify.register(require('./routes/users'), { prefix: '/users' }); 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) => { fastify.addHook('onClose', async (instance, done) => {
await prisma.$disconnect(); await prisma.$disconnect();
done(); done();
}); });
// Décoration pour rendre prisma disponible dans les routes const shutdown = async (signal) => {
fastify.decorate('prisma', prisma); 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 () => { const start = async () => {
try { 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) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
process.exit(1); process.exit(1);

34
backend/src/utils/env.js Normal file
View File

@ -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 };

View File

@ -1,46 +1,70 @@
const Minio = require('minio'); const Minio = require('minio');
const https = require('https'); const https = require('https');
const minioClient = new Minio.Client({ 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?:\/\//, ''), endPoint: process.env.MINIO_ENDPOINT.replace(/^https?:\/\//, ''),
port: parseInt(process.env.MINIO_PORT), port: parseInt(process.env.MINIO_PORT, 10),
useSSL: process.env.MINIO_USE_SSL === 'true', useSSL,
accessKey: process.env.MINIO_ACCESS_KEY, accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY, secretKey: process.env.MINIO_SECRET_KEY,
pathStyle: true, pathStyle: true,
transport: { };
agent: new https.Agent({
rejectUnauthorized: false // 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 uploadFile = async (file, folderPath) => {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
const fileName = `${Date.now()}-${file.filename}`; const fileName = `${Date.now()}-${file.filename}`;
const filePath = `${folderPath}/${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; return filePath;
}; };
const deleteFile = async (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 getFile = async (filePath) => {
const file = await minioClient.getObject(process.env.MINIO_BUCKET, filePath); const client = getClient();
return file; if (!client) throw new Error('MinIO non configuré');
return client.getObject(process.env.MINIO_BUCKET, filePath);
}; };
const listFiles = async (folderPath) => { const listFiles = async (folderPath) => {
const files = await minioClient.listObjects(process.env.MINIO_BUCKET, folderPath); const client = getClient();
return files; if (!client) throw new Error('MinIO non configuré');
return client.listObjects(process.env.MINIO_BUCKET, folderPath);
}; };
const getFileUrl = async (filePath) => { const getFileUrl = async (filePath) => {
const url = await minioClient.presignedUrl('GET', process.env.MINIO_BUCKET, filePath); const client = getClient();
return url; if (!client) throw new Error('MinIO non configuré');
return client.presignedUrl('GET', process.env.MINIO_BUCKET, filePath);
}; };
module.exports = { uploadFile, deleteFile, getFile, listFiles, getFileUrl }; module.exports = { uploadFile, deleteFile, getFile, listFiles, getFileUrl };

0
backend/uploads/.gitkeep Normal file
View File

View File

@ -27,7 +27,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.4.11", "framer-motion": "^12.4.11",
"ky": "^1.7.5",
"lucide-react": "^0.478.0", "lucide-react": "^0.478.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@ -4,46 +4,47 @@ import Register from './pages/Auth/Register'
import Login from './pages/Auth/Login' import Login from './pages/Auth/Login'
import RecipeList from './pages/Recipes/RecipeList' import RecipeList from './pages/Recipes/RecipeList'
import RecipeDetail from './pages/Recipes/RecipeDetail' import RecipeDetail from './pages/Recipes/RecipeDetail'
// import Favorites from './pages/Recipes/Favorites' import RecipeForm from '@/pages/Recipes/RecipeForm'
import Profile from './pages/Profile' import Profile from './pages/Profile'
import Home from './pages/Home' import Home from './pages/Home'
import ResetPassword from '@/pages/ResetPassword'
import { MainLayout } from './layouts/MainLayout' import { MainLayout } from './layouts/MainLayout'
import RecipeForm from "@/pages/Recipes/RecipeForm"
import useAuth from '@/hooks/useAuth' import useAuth from '@/hooks/useAuth'
import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards' import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards'
import ResetPassword from "@/pages/ResetPassword"
function App() { function App() {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth()
if (isLoading) { if (isLoading) {
return <div>Chargement de l'application...</div>; return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
Chargement de l'application...
</div>
)
} }
return ( return (
<BrowserRouter> <BrowserRouter>
<MainLayout> <MainLayout>
<Routes> <Routes>
{/* Routes d'authentification */} {/* Auth */}
<Route path="/auth/register" element={<AuthRoute><Register /></AuthRoute>} /> <Route path="/auth/register" element={<AuthRoute><Register /></AuthRoute>} />
<Route path="/auth/login" element={<AuthRoute><Login /></AuthRoute>} /> <Route path="/auth/login" element={<AuthRoute><Login /></AuthRoute>} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Routes publiques */} {/* Recettes (protégées) */}
<Route path="/recipes" element={<ProtectedRoute><RecipeList /></ProtectedRoute>} /> <Route path="/recipes" element={<ProtectedRoute><RecipeList /></ProtectedRoute>} />
<Route path="/recipes/new" element={<ProtectedRoute><RecipeForm /></ProtectedRoute>} />
<Route path="/recipes/:id" element={<ProtectedRoute><RecipeDetail /></ProtectedRoute>} /> <Route path="/recipes/:id" element={<ProtectedRoute><RecipeDetail /></ProtectedRoute>} />
{/* Routes protégées */} {/* Profil */}
<Route path="/recipes/new" element={<ProtectedRoute><RecipeForm /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
{/* Route racine avec redirection conditionnelle */} {/* Racine */}
<Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} /> <Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} />
{/* Route de fallback pour les URLs non trouvées */} {/* Fallback — DOIT être la dernière route */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
{/* Nouvelle route pour la réinitialisation du mot de passe */}
<Route path="/reset-password" element={<ResetPassword />} />
</Routes> </Routes>
</MainLayout> </MainLayout>
</BrowserRouter> </BrowserRouter>

View File

@ -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<string | null>(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 (
<div className="login-form">
<h2>Connexion</h2>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Mot de passe:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Connexion en cours...' : 'Se connecter'}
</button>
</form>
</div>
);
};
export default LoginForm;

View File

@ -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 (
<nav className="navbar">
<div className="navbar-brand">
<Link to="/">App Recettes</Link>
</div>
<div className="navbar-menu">
{authenticated ? (
<>
<Link to="/recipes" className="navbar-item">Mes recettes</Link>
<Link to="/recipes/new" className="navbar-item">Nouvelle recette</Link>
<div className="navbar-item user-info">
{user?.name || user?.email}
<span className="badge">{user?.subscription}</span>
</div>
<button onClick={handleLogout} className="navbar-item logout-btn">
Déconnexion
</button>
</>
) : (
<>
<Link to="/login" className="navbar-item">Connexion</Link>
<Link to="/register" className="navbar-item">Inscription</Link>
</>
)}
</div>
</nav>
);
};
export default Navbar;

View File

@ -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<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div>Chargement de la recette...</div>;
if (error) return <div className="error-message">{error}</div>;
if (!recipe) return <div>Recette non trouvée</div>;
return (
<div className="recipe-detail">
<h2>{recipe.title}</h2>
<div className="recipe-section">
<h3>Ingrédients</h3>
<p>{recipe.ingredients}</p>
</div>
<div className="recipe-section">
<h3>Recette</h3>
<div className="recipe-content">
{recipe.generatedRecipe.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
</div>
<div className="recipe-actions">
<button onClick={() => navigate('/recipes')} className="btn">
Retour à la liste
</button>
<button onClick={handleDelete} className="btn delete-btn">
Supprimer cette recette
</button>
</div>
</div>
);
};
export default RecipeDetail;

View File

@ -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<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="recipe-form">
<h2>Créer une nouvelle recette</h2>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="audio-file">Fichier audio des ingrédients:</label>
<input
type="file"
id="audio-file"
accept="audio/*"
onChange={handleFileChange}
disabled={loading}
/>
<small>Enregistrez-vous en listant les ingrédients disponibles</small>
</div>
<button type="submit" disabled={loading || !file}>
{loading ? 'Création en cours...' : 'Créer la recette'}
</button>
</form>
</div>
);
};
export default RecipeForm;

View File

@ -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<Recipe[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div>Chargement des recettes...</div>;
if (error) return <div className="error-message">{error}</div>;
return (
<div className="recipe-list">
<h2>Mes recettes</h2>
{recipes.length === 0 ? (
<p>Vous n'avez pas encore de recettes. Créez-en une!</p>
) : (
<ul>
{recipes.map(recipe => (
<li key={recipe.id} className="recipe-item">
<h3>{recipe.title}</h3>
<p><strong>Ingrédients:</strong> {recipe.ingredients}</p>
<div className="recipe-actions">
<Link to={`/recipes/${recipe.id}`} className="btn">
Voir les détails
</Link>
<button onClick={() => handleDelete(recipe.id)} className="btn delete-btn">
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
};
export default RecipeList;

View File

@ -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 (
<>
<Card
className="overflow-hidden transition-all duration-200 hover:shadow-md cursor-pointer h-full flex flex-col"
onClick={() => setIsModalOpen(true)}
>
<div className="relative h-48 overflow-hidden">
<img
src={recipe.imageUrl || "/placeholder.svg"}
alt={recipe.title}
className="w-full h-full object-cover transition-transform duration-300 hover:scale-105"
/>
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="icon"
className="rounded-full bg-background/80 backdrop-blur-sm hover:bg-background/90"
onClick={toggleFavorite}
>
<Heart
className={`h-5 w-5 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-slate-600'}`}
/>
</Button>
</div>
</div>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<h3 className="font-semibold text-lg line-clamp-1">{recipe.title}</h3>
</div>
<div className="flex items-center text-muted-foreground text-sm">
<Clock className="h-3.5 w-3.5 mr-1" />
<span>{recipe.cookingTime} mins</span>
<span className="mx-2"></span>
<span className="capitalize">{recipe.difficulty}</span>
</div>
</CardHeader>
<CardContent className="pb-2 flex-grow">
<p className="text-muted-foreground text-sm line-clamp-2 mb-3">
{recipe.description}
</p>
<div className="flex flex-wrap gap-1.5">
{recipe.tags.slice(0, 3).map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{recipe.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{recipe.tags.length - 3} more
</Badge>
)}
</div>
</CardContent>
<CardFooter className="pt-2">
<div className="flex justify-between w-full">
<Button variant="outline" size="sm">View Recipe</Button>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={shareRecipe}
>
<Share2 className="h-4 w-4" />
</Button>
</div>
</CardFooter>
</Card>
<RecipeModal
recipe={recipe}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onShare={shareRecipe}
/>
</>
)
}

View File

@ -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 (
<div>
<SearchFilters />
<Tabs defaultValue="grid" className="mb-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">
{recipes.length} Recipes
</h2>
<TabsList>
<TabsTrigger value="grid" onClick={() => setViewMode("grid")}>
<LayoutGrid className="h-4 w-4 mr-2" />
Grid
</TabsTrigger>
<TabsTrigger value="list" onClick={() => setViewMode("list")}>
<List className="h-4 w-4 mr-2" />
List
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="grid" className="mt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{recipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
</TabsContent>
<TabsContent value="list" className="mt-6">
<div className="space-y-4">
{recipes.map(recipe => (
<div key={recipe.id} className="border rounded-lg overflow-hidden">
<div className="flex flex-col sm:flex-row">
<div className="sm:w-1/3 h-48 sm:h-auto">
<img
src={recipe.imageUrl || "/placeholder.svg"}
alt={recipe.title}
className="w-full h-full object-cover"
/>
</div>
<div className="p-4 sm:w-2/3 flex flex-col">
<h3 className="font-semibold text-lg">{recipe.title}</h3>
<div className="flex items-center text-muted-foreground text-sm mt-1">
<span>{recipe.cookingTime} mins</span>
<span className="mx-2"></span>
<span className="capitalize">{recipe.difficulty}</span>
</div>
<p className="text-muted-foreground mt-2 flex-grow">
{recipe.description}
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{recipe.tags.map(tag => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
<div className="flex justify-between items-center mt-4">
<Button>View Recipe</Button>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={(e) => e.stopPropagation()}
>
<Heart
className={`h-5 w-5 ${recipe.isFavorite ? 'fill-red-500 text-red-500' : ''}`}
/>
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => e.stopPropagation()}
>
<Share2 className="h-5 w-5" />
</Button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="flex flex-row items-start justify-between">
<DialogTitle className="text-2xl">{recipe.title}</DialogTitle>
<Button variant="ghost" size="icon" onClick={onClose} className="mt-0">
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="rounded-md overflow-hidden mb-4">
<img
src={recipe.imageUrl || "/placeholder.svg"}
alt={recipe.title}
className="w-full h-64 object-cover"
/>
</div>
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center">
<Clock className="h-4 w-4 mr-1.5 text-muted-foreground" />
<span>{recipe.cookingTime} mins</span>
</div>
<Separator orientation="vertical" className="h-4" />
<span className="capitalize">{recipe.difficulty}</span>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="icon" onClick={onToggleFavorite}>
<Heart className={`h-4 w-4 ${isFavorite ? "fill-red-500 text-red-500" : ""}`} />
</Button>
<Button variant="outline" size="icon" onClick={onShare}>
<Share2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-4">
{recipe.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
<p className="text-muted-foreground mb-6">{recipe.description}</p>
</div>
<div>
<h3 className="font-medium text-lg mb-3">Ingredients</h3>
<ul className="space-y-2 mb-6">
{recipe.ingredients.map((ingredient, index) => (
<li key={index} className="flex items-start">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary mt-1.5 mr-2"></span>
{ingredient}
</li>
))}
</ul>
<h3 className="font-medium text-lg mb-3">Instructions</h3>
<ol className="space-y-3">
{recipe.instructions.map((instruction, index) => (
<li key={index} className="flex items-start">
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/10 text-primary text-xs font-medium mr-2 mt-0.5">
{index + 1}
</span>
<span>{instruction}</span>
</li>
))}
</ol>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,30 +0,0 @@
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
export function RecipeSkeleton() {
return (
<Card className="overflow-hidden">
<Skeleton className="h-48 w-full" />
<CardHeader className="pb-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2 mt-2" />
</CardHeader>
<CardContent className="pb-2">
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-5/6 mb-4" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
</div>
</CardContent>
<CardFooter className="pt-2">
<div className="flex justify-between w-full">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-9 rounded-full" />
</div>
</CardFooter>
</Card>
)
}

View File

@ -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<RecipeTag[]>([])
const toggleTag = (tag: RecipeTag) => {
if (selectedTags.includes(tag)) {
setSelectedTags(selectedTags.filter((t) => t !== tag))
} else {
setSelectedTags([...selectedTags, tag])
}
}
return (
<div className="space-y-4 mb-8">
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-grow">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search recipes..." className="pl-8" />
</div>
<div className="flex gap-2">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Cooking time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any time</SelectItem>
<SelectItem value="under15">Under 15 mins</SelectItem>
<SelectItem value="under30">Under 30 mins</SelectItem>
<SelectItem value="under60">Under 1 hour</SelectItem>
<SelectItem value="over60">Over 1 hour</SelectItem>
</SelectContent>
</Select>
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any difficulty</SelectItem>
{difficultyOptions.map((difficulty) => (
<SelectItem key={difficulty} value={difficulty}>
{difficulty.charAt(0).toUpperCase() + difficulty.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" className="hidden md:flex">
View: Grid
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{tagOptions.map((tag) => (
<Badge
key={tag}
variant={selectedTags.includes(tag) ? "default" : "outline"}
className="cursor-pointer"
onClick={() => toggleTag(tag)}
>
{tag}
</Badge>
))}
</div>
</div>
)
}

View File

@ -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,
},
]

View File

@ -1,15 +0,0 @@
import RecipeList from "@/components/recipe-list"
import { SearchFilters } from "@/components/search-filters"
export default function Home() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2">My Recipes</h1>
<p className="text-muted-foreground mb-8">Browse your collection of delicious recipes</p>
<SearchFilters />
<RecipeList />
</div>
)
}

383
package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -1,70 +0,0 @@
import React, { useState } from 'react';
import RecipeService from '../services/RecipeService';
const RecipeForm: React.FC = () => {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="recipe-form">
<h2>Créer une nouvelle recette</h2>
{error && <div className="error-message">{error}</div>}
{success && <div className="success-message">Recette créée avec succès!</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="audio-file">Fichier audio des ingrédients:</label>
<input
type="file"
id="audio-file"
accept="audio/*"
onChange={handleFileChange}
disabled={loading}
/>
<small>Enregistrez-vous en listant les ingrédients disponibles</small>
</div>
<button type="submit" disabled={loading || !file}>
{loading ? 'Création en cours...' : 'Créer la recette'}
</button>
</form>
</div>
);
};
export default RecipeForm;

View File

@ -1,66 +0,0 @@
import React, { useEffect, useState } from 'react';
import RecipeService, { Recipe } from '../services/RecipeService';
const RecipeList: React.FC = () => {
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div>Chargement des recettes...</div>;
if (error) return <div className="error-message">{error}</div>;
return (
<div className="recipe-list">
<h2>Mes recettes</h2>
{recipes.length === 0 ? (
<p>Vous n'avez pas encore de recettes. Créez-en une!</p>
) : (
<ul>
{recipes.map(recipe => (
<li key={recipe.id} className="recipe-item">
<h3>{recipe.title}</h3>
<p><strong>Ingrédients:</strong> {recipe.ingredients}</p>
<div className="recipe-actions">
<button onClick={() => window.location.href = `/recipes/${recipe.id}`}>
Voir les détails
</button>
<button onClick={() => handleDelete(recipe.id)} className="delete-btn">
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
};
export default RecipeList;

View File

@ -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<AuthResponse> {
try {
const response = await axios.post<AuthResponse>(`${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<AuthResponse> {
try {
const response = await axios.post<AuthResponse>(`${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();

View File

@ -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<Recipe> {
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<RecipeResponse>('/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<Recipe[]> {
try {
const axiosInstance = this.getAxiosInstance();
const response = await axiosInstance.get<RecipesResponse>('/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<Recipe> {
try {
const axiosInstance = this.getAxiosInstance();
const response = await axiosInstance.get<RecipeResponse>(`/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<boolean> {
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();