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