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:
parent
9b98b3c0c6
commit
0134390f5e
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
4
.gitignore
vendored
@ -25,6 +25,10 @@ build/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Uploads (keep the folder, ignore contents)
|
||||
backend/uploads/*
|
||||
!backend/uploads/.gitkeep
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
178
README.md
178
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
|
||||
```
|
||||
# Base de données
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
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 migrate dev
|
||||
npx prisma generate
|
||||
|
||||
# Lancement
|
||||
npm run dev # backend sur :3000
|
||||
cd ../frontend && pnpm dev # frontend sur :5173
|
||||
```
|
||||
|
||||
5. Install frontend dependencies
|
||||
```
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
## Variables d'environnement backend
|
||||
|
||||
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:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
## Routes API (préfixe `/`)
|
||||
|
||||
In the frontend 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
|
||||
|
||||
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
|
||||
- Ingredient management
|
||||
- AI-powered recipe generation
|
||||
- Subscription management with Stripe
|
||||
- Recipe history
|
||||
### Divers
|
||||
- `GET /health` — Healthcheck
|
||||
|
||||
## API Routes
|
||||
🔒 = nécessite un JWT `Authorization: Bearer <token>`
|
||||
|
||||
All routes are prefixed with `/api`.
|
||||
## Sécurité
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/register` - Create a new user account
|
||||
- `POST /auth/login` - Login and get JWT token
|
||||
- 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
|
||||
|
||||
### 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
|
||||
|
||||
29
backend/.env.example
Normal file
29
backend/.env.example
Normal 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
6
backend/.gitignore
vendored
@ -1 +1,7 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
prisma/dev.db*
|
||||
|
||||
8
backend/.prettierrc.json
Normal file
8
backend/.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
19
backend/eslint.config.js
Normal file
19
backend/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
try {
|
||||
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' },
|
||||
});
|
||||
|
||||
// Gestion du téléchargement de fichiers audio
|
||||
fastify.decorate('saveAudioFile', async (file) => {
|
||||
let recipeData;
|
||||
try {
|
||||
// Vérifier que le fichier est valide
|
||||
if (!file || !file.filename) {
|
||||
throw new Error("Fichier audio invalide");
|
||||
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;
|
||||
});
|
||||
|
||||
// --- 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 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
|
||||
// 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}`;
|
||||
|
||||
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));
|
||||
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");
|
||||
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}`
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: true, localPath: filepath, isLocal: true };
|
||||
});
|
||||
});
|
||||
@ -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 (!user) {
|
||||
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
|
||||
}
|
||||
|
||||
if (recipeCount >= 5) {
|
||||
const isFree = !user.subscription || user.subscription === 'free';
|
||||
if (isFree && recipeCount >= FREE_PLAN_LIMIT) {
|
||||
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);
|
||||
|
||||
// Extraire l'URL audio (chaîne de caractères) pour Prisma
|
||||
const audioUrl = audioResult.url || (audioResult.localPath || null);
|
||||
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,14 +175,23 @@ 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)) {
|
||||
// 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 };
|
||||
} catch (error) {
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
|
||||
@ -1,41 +1,84 @@
|
||||
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);
|
||||
|
||||
34
backend/src/utils/env.js
Normal file
34
backend/src/utils/env.js
Normal 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 };
|
||||
@ -1,46 +1,70 @@
|
||||
const Minio = require('minio');
|
||||
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?:\/\//, ''),
|
||||
port: parseInt(process.env.MINIO_PORT),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
port: parseInt(process.env.MINIO_PORT, 10),
|
||||
useSSL,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY,
|
||||
secretKey: process.env.MINIO_SECRET_KEY,
|
||||
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 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 };
|
||||
|
||||
0
backend/uploads/.gitkeep
Normal file
0
backend/uploads/.gitkeep
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -27,7 +27,6 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.4.11",
|
||||
"ky": "^1.7.5",
|
||||
"lucide-react": "^0.478.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@ -4,46 +4,47 @@ import Register from './pages/Auth/Register'
|
||||
import Login from './pages/Auth/Login'
|
||||
import RecipeList from './pages/Recipes/RecipeList'
|
||||
import RecipeDetail from './pages/Recipes/RecipeDetail'
|
||||
// import Favorites from './pages/Recipes/Favorites'
|
||||
import RecipeForm from '@/pages/Recipes/RecipeForm'
|
||||
import Profile from './pages/Profile'
|
||||
import Home from './pages/Home'
|
||||
import ResetPassword from '@/pages/ResetPassword'
|
||||
import { MainLayout } from './layouts/MainLayout'
|
||||
import RecipeForm from "@/pages/Recipes/RecipeForm"
|
||||
import useAuth from '@/hooks/useAuth'
|
||||
import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards'
|
||||
import ResetPassword from "@/pages/ResetPassword"
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return <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 (
|
||||
<BrowserRouter>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
{/* Routes d'authentification */}
|
||||
{/* Auth */}
|
||||
<Route path="/auth/register" element={<AuthRoute><Register /></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/new" element={<ProtectedRoute><RecipeForm /></ProtectedRoute>} />
|
||||
<Route path="/recipes/:id" element={<ProtectedRoute><RecipeDetail /></ProtectedRoute>} />
|
||||
|
||||
{/* Routes protégées */}
|
||||
<Route path="/recipes/new" element={<ProtectedRoute><RecipeForm /></ProtectedRoute>} />
|
||||
{/* Profil */}
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
|
||||
{/* Route racine avec redirection conditionnelle */}
|
||||
{/* Racine */}
|
||||
<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 />} />
|
||||
|
||||
{/* Nouvelle route pour la réinitialisation du mot de passe */}
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@ -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
383
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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();
|
||||
@ -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();
|
||||
Loading…
x
Reference in New Issue
Block a user