init a fonctional prototype
This commit is contained in:
commit
958a778f85
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Build files
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
123
README.md
Normal file
123
README.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React.js + TailwindCSS + ShadCN
|
||||
- **Backend**: Fastify + Prisma + SQLite
|
||||
- **AI**: ChatGPT API for recipe generation
|
||||
- **Payments**: Stripe (subscriptions)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
freedge/
|
||||
├── frontend/ # React frontend application
|
||||
│ ├── public/ # Static assets
|
||||
│ └── src/ # Source code
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── pages/ # Application pages
|
||||
│ ├── services/ # API service integrations
|
||||
│ └── utils/ # Utility functions
|
||||
│
|
||||
├── backend/ # Fastify API server
|
||||
│ ├── prisma/ # Prisma schema and migrations
|
||||
│ └── src/ # Source code
|
||||
│ ├── routes/ # API route definitions
|
||||
│ ├── controllers/ # Request handlers
|
||||
│ ├── services/ # Business logic
|
||||
│ └── models/ # Data models
|
||||
│
|
||||
└── README.md # Project documentation
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v16+)
|
||||
- npm or yarn
|
||||
- SQLite
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository
|
||||
```
|
||||
git clone https://github.com/yourusername/freedge.git
|
||||
cd freedge
|
||||
```
|
||||
|
||||
2. Install backend dependencies
|
||||
```
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Set up environment variables
|
||||
- Create a `.env` file in the backend directory (or modify the existing one)
|
||||
- Add your OpenAI API key and Stripe keys
|
||||
|
||||
4. Set up the database
|
||||
```
|
||||
npx prisma migrate dev --name init
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
5. Install frontend dependencies
|
||||
```
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
6. Start the development servers
|
||||
|
||||
In the backend directory:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In the frontend directory:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
7. Open your browser and navigate to `http://localhost:5173`
|
||||
|
||||
## Features
|
||||
|
||||
- User authentication with JWT
|
||||
- Ingredient management
|
||||
- AI-powered recipe generation
|
||||
- Subscription management with Stripe
|
||||
- Recipe history
|
||||
|
||||
## API Routes
|
||||
|
||||
All routes are prefixed with `/api`.
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/register` - Create a new user account
|
||||
- `POST /auth/login` - Login and get JWT token
|
||||
|
||||
### Profile Management
|
||||
- `GET /profile` - Get user profile
|
||||
- `PUT /profile` - Update user profile
|
||||
|
||||
### Ingredients
|
||||
- `GET /ingredients` - Get user's ingredients
|
||||
- `POST /ingredients` - Add a new ingredient
|
||||
- `DELETE /ingredients/:id` - Delete an ingredient
|
||||
|
||||
### Recipes
|
||||
- `POST /recipes/generate` - Generate a recipe based on ingredients
|
||||
- `GET /recipes/history` - Get recipe history
|
||||
- `GET /recipes/:id` - Get a specific recipe
|
||||
|
||||
### Subscriptions
|
||||
- `POST /subscriptions/create-checkout-session` - Create a Stripe checkout session
|
||||
- `GET /subscriptions/status` - Get subscription status
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
||||
2274
backend/package-lock.json
generated
Normal file
2274
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/package.json
Normal file
28
backend/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "recipe-app-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend pour application de recettes",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js",
|
||||
"migrate": "prisma migrate dev",
|
||||
"studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/jwt": "^7.0.0",
|
||||
"@fastify/multipart": "^8.0.0",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"fastify": "^4.19.0",
|
||||
"fastify-plugin": "^4.5.0",
|
||||
"openai": "^4.0.0",
|
||||
"stripe": "^12.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.0.0"
|
||||
}
|
||||
}
|
||||
1606
backend/pnpm-lock.yaml
generated
Normal file
1606
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/prisma/migrations/20250309204458_init/migration.sql
Normal file
28
backend/prisma/migrations/20250309204458_init/migration.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"subscription" TEXT NOT NULL DEFAULT 'free',
|
||||
"stripeId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Recipe" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"ingredients" TEXT NOT NULL,
|
||||
"userPrompt" TEXT NOT NULL,
|
||||
"generatedRecipe" TEXT NOT NULL,
|
||||
"audioUrl" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Recipe_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
33
backend/prisma/schema.prisma
Normal file
33
backend/prisma/schema.prisma
Normal file
@ -0,0 +1,33 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String
|
||||
name String?
|
||||
subscription String @default("free") // "free" ou "premium"
|
||||
stripeId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
recipes Recipe[]
|
||||
}
|
||||
|
||||
model Recipe {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
ingredients String
|
||||
userPrompt String
|
||||
generatedRecipe String
|
||||
audioUrl String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
56
backend/src/plugins/ai.js
Normal file
56
backend/src/plugins/ai.js
Normal file
@ -0,0 +1,56 @@
|
||||
const fp = require('fastify-plugin');
|
||||
const { OpenAI } = require('openai');
|
||||
const fs = require('fs');
|
||||
const util = require('util');
|
||||
const { pipeline } = require('stream');
|
||||
const pump = util.promisify(pipeline);
|
||||
|
||||
module.exports = fp(async function (fastify, opts) {
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
});
|
||||
|
||||
fastify.decorate('openai', openai);
|
||||
|
||||
// Transcription audio avec Whisper
|
||||
fastify.decorate('transcribeAudio', async (audioPath) => {
|
||||
const transcription = await openai.audio.transcriptions.create({
|
||||
file: fs.createReadStream(audioPath),
|
||||
model: 'whisper-1',
|
||||
});
|
||||
return transcription.text;
|
||||
});
|
||||
|
||||
// Génération de recette avec 01-mini
|
||||
fastify.decorate('generateRecipe', async (ingredients, prompt) => {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-3.5-turbo", // Remplacer par 01-mini quand disponible
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Tu es un chef cuisinier expert qui crée des recettes délicieuses et faciles à réaliser."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Voici les ingrédients disponibles: ${ingredients}. ${prompt || 'Propose une recette avec ces ingrédients.'}`
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
return completion.choices[0].message.content;
|
||||
});
|
||||
|
||||
// Gestion du téléchargement de fichiers audio
|
||||
fastify.decorate('saveAudioFile', async (file) => {
|
||||
const uploadDir = './uploads';
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir);
|
||||
}
|
||||
|
||||
const filename = `${Date.now()}-${file.filename}`;
|
||||
const filepath = `${uploadDir}/${filename}`;
|
||||
|
||||
await pump(file.file, fs.createWriteStream(filepath));
|
||||
return filepath;
|
||||
});
|
||||
});
|
||||
26
backend/src/plugins/auth.js
Normal file
26
backend/src/plugins/auth.js
Normal file
@ -0,0 +1,26 @@
|
||||
const fp = require('fastify-plugin');
|
||||
const jwt = require('@fastify/jwt');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
module.exports = fp(async function (fastify, opts) {
|
||||
fastify.register(jwt, {
|
||||
secret: process.env.JWT_SECRET
|
||||
});
|
||||
|
||||
fastify.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Non authentifié' });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.decorate('hashPassword', async (password) => {
|
||||
const saltRounds = 10;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
});
|
||||
|
||||
fastify.decorate('comparePassword', async (password, hash) => {
|
||||
return bcrypt.compare(password, hash);
|
||||
});
|
||||
});
|
||||
24
backend/src/plugins/stripe.js
Normal file
24
backend/src/plugins/stripe.js
Normal file
@ -0,0 +1,24 @@
|
||||
const fp = require('fastify-plugin');
|
||||
const Stripe = require('stripe');
|
||||
|
||||
module.exports = fp(async function (fastify, opts) {
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
fastify.decorate('stripe', stripe);
|
||||
|
||||
fastify.decorate('createCustomer', async (email, name) => {
|
||||
return stripe.customers.create({
|
||||
email,
|
||||
name
|
||||
});
|
||||
});
|
||||
|
||||
fastify.decorate('createSubscription', async (customerId, priceId) => {
|
||||
return stripe.subscriptions.create({
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
payment_behavior: 'default_incomplete',
|
||||
expand: ['latest_invoice.payment_intent'],
|
||||
});
|
||||
});
|
||||
});
|
||||
111
backend/src/routes/auth.js
Normal file
111
backend/src/routes/auth.js
Normal file
@ -0,0 +1,111 @@
|
||||
module.exports = async function (fastify, opts) {
|
||||
// Inscription
|
||||
fastify.post('/register', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password', 'name'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string', minLength: 6 },
|
||||
name: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { email, password, name } = request.body;
|
||||
|
||||
try {
|
||||
// Vérifier si l'utilisateur existe déjà
|
||||
const existingUser = await fastify.prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return reply.code(400).send({ error: 'Cet email est déjà utilisé' });
|
||||
}
|
||||
|
||||
// Créer un client Stripe
|
||||
const customer = await fastify.createCustomer(email, name);
|
||||
|
||||
// Hacher le mot de passe
|
||||
const hashedPassword = await fastify.hashPassword(password);
|
||||
|
||||
// Créer l'utilisateur
|
||||
const user = await fastify.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
stripeId: customer.id
|
||||
}
|
||||
});
|
||||
|
||||
// Générer un token JWT
|
||||
const token = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
subscription: user.subscription
|
||||
},
|
||||
token
|
||||
};
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.code(500).send({ error: 'Erreur lors de l\'inscription' });
|
||||
}
|
||||
});
|
||||
|
||||
// Connexion
|
||||
fastify.post('/login', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { email, password } = request.body;
|
||||
|
||||
try {
|
||||
// Trouver l'utilisateur
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Email ou mot de passe incorrect' });
|
||||
}
|
||||
|
||||
// Vérifier le mot de passe
|
||||
const passwordMatch = await fastify.comparePassword(password, user.password);
|
||||
|
||||
if (!passwordMatch) {
|
||||
return reply.code(401).send({ error: 'Email ou mot de passe incorrect' });
|
||||
}
|
||||
|
||||
// Générer un token JWT
|
||||
const token = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
subscription: user.subscription
|
||||
},
|
||||
token
|
||||
};
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.code(500).send({ error: 'Erreur lors de la connexion' });
|
||||
}
|
||||
});
|
||||
};
|
||||
152
backend/src/routes/recipes.js
Normal file
152
backend/src/routes/recipes.js
Normal file
@ -0,0 +1,152 @@
|
||||
const fs = require('fs');
|
||||
const util = require('util');
|
||||
const { pipeline } = require('stream');
|
||||
const pump = util.promisify(pipeline);
|
||||
const multipart = require('@fastify/multipart');
|
||||
|
||||
module.exports = async function (fastify, opts) {
|
||||
fastify.register(multipart);
|
||||
|
||||
// Middleware d'authentification
|
||||
fastify.addHook('preHandler', fastify.authenticate);
|
||||
|
||||
// Créer une recette
|
||||
fastify.post('/create', async (request, reply) => {
|
||||
try {
|
||||
const data = await request.file();
|
||||
|
||||
if (!data) {
|
||||
return reply.code(400).send({ error: 'Fichier audio requis' });
|
||||
}
|
||||
|
||||
// Vérifier le plan de l'utilisateur
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: request.user.id }
|
||||
});
|
||||
|
||||
if (user.subscription === 'free') {
|
||||
// Vérifier le nombre de recettes pour les utilisateurs gratuits
|
||||
const recipeCount = await fastify.prisma.recipe.count({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
if (recipeCount >= 5) {
|
||||
return reply.code(403).send({
|
||||
error: 'Limite de recettes atteinte pour le plan gratuit. Passez au plan premium pour créer plus de recettes.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder le fichier audio
|
||||
const audioPath = await fastify.saveAudioFile(data);
|
||||
|
||||
// Transcrire l'audio
|
||||
const transcription = await fastify.transcribeAudio(audioPath);
|
||||
|
||||
// Extraire les ingrédients du texte transcrit
|
||||
const ingredients = transcription;
|
||||
|
||||
// Générer la recette
|
||||
const generatedRecipe = await fastify.generateRecipe(ingredients, "Crée une recette délicieuse et détaillée");
|
||||
|
||||
// Créer la recette en base de données
|
||||
const recipe = await fastify.prisma.recipe.create({
|
||||
data: {
|
||||
title: `Recette du ${new Date().toLocaleDateString()}`,
|
||||
ingredients,
|
||||
userPrompt: transcription,
|
||||
generatedRecipe,
|
||||
audioUrl: audioPath,
|
||||
userId: request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
recipe: {
|
||||
id: recipe.id,
|
||||
title: recipe.title,
|
||||
ingredients: recipe.ingredients,
|
||||
generatedRecipe: recipe.generatedRecipe,
|
||||
createdAt: recipe.createdAt
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.code(500).send({ error: 'Erreur lors de la création de la recette' });
|
||||
}
|
||||
});
|
||||
|
||||
// Récupérer la liste des recettes
|
||||
fastify.get('/list', async (request, reply) => {
|
||||
try {
|
||||
const recipes = await fastify.prisma.recipe.findMany({
|
||||
where: { userId: request.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
ingredients: true,
|
||||
generatedRecipe: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
return { recipes };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.code(500).send({ error: 'Erreur lors de la récupération des recettes' });
|
||||
}
|
||||
});
|
||||
|
||||
// Récupérer une recette par ID
|
||||
fastify.get('/:id', async (request, reply) => {
|
||||
try {
|
||||
const recipe = await fastify.prisma.recipe.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
userId: request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return reply.code(404).send({ error: 'Recette non trouvée' });
|
||||
}
|
||||
|
||||
return { recipe };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.code(500).send({ error: 'Erreur lors de la récupération de la recette' });
|
||||
}
|
||||
});
|
||||
|
||||
// Supprimer une recette
|
||||
fastify.delete('/:id', async (request, reply) => {
|
||||
try {
|
||||
const recipe = await fastify.prisma.recipe.findUnique({
|
||||
where: { id: request.params.id }
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return reply.code(404).send({ error: 'Recette non trouvée' });
|
||||
}
|
||||
|
||||
if (recipe.userId !== request.user.id) {
|
||||
return reply.code(403).send({ error: 'Non autorisé' });
|
||||
}
|
||||
|
||||
await fastify.prisma.recipe.delete({
|
||||
where: { id: request.params.id }
|
||||
});
|
||||
|
||||
// Supprimer le fichier audio si existant
|
||||
if (recipe.audioUrl && fs.existsSync(recipe.audioUrl)) {
|
||||
fs.unlinkSync(recipe.audioUrl);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.code(500).send({ error: 'Erreur lors de la suppression de la recette' });
|
||||
}
|
||||
});
|
||||
};
|
||||
45
backend/src/server.js
Normal file
45
backend/src/server.js
Normal file
@ -0,0 +1,45 @@
|
||||
const fastify = require('fastify')({ logger: true });
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
require('dotenv').config();
|
||||
|
||||
// Création de l'instance Prisma
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Plugins
|
||||
fastify.register(require('@fastify/cors'), {
|
||||
origin: true, // Autoriser toutes les origines en développement
|
||||
// Ou spécifier correctement les origines autorisées :
|
||||
// origin: ['http://localhost:5173', 'http://127.0.0.1:5173'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true
|
||||
});
|
||||
|
||||
fastify.register(require('./plugins/auth'));
|
||||
fastify.register(require('./plugins/stripe'));
|
||||
fastify.register(require('./plugins/ai'));
|
||||
|
||||
// Routes
|
||||
fastify.register(require('./routes/auth'), { prefix: '/auth' });
|
||||
fastify.register(require('./routes/recipes'), { prefix: '/recipes' });
|
||||
|
||||
// Hook pour fermer la connexion Prisma à l'arrêt du serveur
|
||||
fastify.addHook('onClose', async (instance, done) => {
|
||||
await prisma.$disconnect();
|
||||
done();
|
||||
});
|
||||
|
||||
// Décoration pour rendre prisma disponible dans les routes
|
||||
fastify.decorate('prisma', prisma);
|
||||
|
||||
// Démarrage du serveur
|
||||
const start = async () => {
|
||||
try {
|
||||
await fastify.listen({ port: process.env.PORT || 3000, host: '0.0.0.0' });
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
0
backend/src/utils/errors.js
Normal file
0
backend/src/utils/errors.js
Normal file
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
48
frontend/package.json
Normal file
48
frontend/package.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"axios": "^1.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^1.7.5",
|
||||
"lucide-react": "^0.478.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
3451
frontend/pnpm-lock.yaml
generated
Normal file
3451
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
0
frontend/src/App.css
Normal file
0
frontend/src/App.css
Normal file
37
frontend/src/App.tsx
Normal file
37
frontend/src/App.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import './App.css'
|
||||
import Register from './pages/Auth/Register'
|
||||
import Login from './pages/Auth/Login'
|
||||
import RecipeList from './pages/Recipes/RecipeList'
|
||||
import RecipeDetail from './pages/Recipes/RecipeDetail'
|
||||
// import Favorites from './pages/Recipes/Favorites'
|
||||
import Profile from './pages/Profile'
|
||||
import Home from './pages/Home'
|
||||
import { MainLayout } from './layouts/MainLayout'
|
||||
import RecipeForm from "@/pages/Recipes/RecipeForm"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
{/* Pages d'authentification */}
|
||||
<Route path="/auth/register" element={<Register />} />
|
||||
<Route path="/auth/login" element={<Login />} />
|
||||
|
||||
{/* Pages de recettes */}
|
||||
<Route path="/recipes" element={<RecipeList />} />
|
||||
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
||||
<Route path="/recipes/new" element={<RecipeForm />} />
|
||||
{/* <Route path="/favorites" element={<Favorites />} /> */}
|
||||
|
||||
{/* Autres pages */}
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
80
frontend/src/api/auth.ts
Normal file
80
frontend/src/api/auth.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { base } from './base';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Inscription d'un nouvel utilisateur
|
||||
export const register = async (data: RegisterData): Promise<AuthResponse> => {
|
||||
const response = await base.post<AuthResponse>('/auth/register', data);
|
||||
|
||||
if (response.data.token) {
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Connexion d'un utilisateur existant
|
||||
export const login = async (data: LoginData): Promise<AuthResponse> => {
|
||||
console.log("Tentative de connexion avec:", data.email);
|
||||
try {
|
||||
const response = await base.post<AuthResponse>('/auth/login', data);
|
||||
console.log("Réponse du serveur:", response.data);
|
||||
|
||||
if (response.data.token) {
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Erreur de connexion:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Déconnexion
|
||||
export const logout = (): void => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
export const getCurrentUser = (): User | null => {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
return JSON.parse(userStr);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Vérifier si l'utilisateur est connecté
|
||||
export const isAuthenticated = (): boolean => {
|
||||
return !!localStorage.getItem('token');
|
||||
};
|
||||
|
||||
// Récupérer le token
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem('token');
|
||||
};
|
||||
56
frontend/src/api/base.ts
Normal file
56
frontend/src/api/base.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Récupérer l'URL de base de l'API depuis les variables d'environnement
|
||||
const API_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
// Création de l'instance axios de base
|
||||
export const base = axios.create({
|
||||
baseURL: API_URL
|
||||
});
|
||||
|
||||
// Intercepteur pour ajouter le token d'authentification à chaque requête
|
||||
base.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Intercepteur pour gérer les erreurs de réponse
|
||||
base.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Gérer les erreurs 401 (non authentifié)
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
// Rediriger vers la page de connexion si nécessaire
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Fonctions d'aide pour les requêtes API
|
||||
export const apiService = {
|
||||
get: <T>(endpoint: string, options = {}) =>
|
||||
base.get<T>(endpoint, options).then(response => response.data),
|
||||
|
||||
post: <T>(endpoint: string, data: any, options = {}) =>
|
||||
base.post<T>(endpoint, data, options).then(response => response.data),
|
||||
|
||||
put: <T>(endpoint: string, data: any, options = {}) =>
|
||||
base.put<T>(endpoint, data, options).then(response => response.data),
|
||||
|
||||
patch: <T>(endpoint: string, data: any, options = {}) =>
|
||||
base.patch<T>(endpoint, data, options).then(response => response.data),
|
||||
|
||||
delete: <T>(endpoint: string, options = {}) =>
|
||||
base.delete<T>(endpoint, options).then(response => response.data)
|
||||
};
|
||||
86
frontend/src/api/recipe.ts
Normal file
86
frontend/src/api/recipe.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { base } from './base';
|
||||
import axios from 'axios';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
// Créer une nouvelle recette à partir d'un fichier audio
|
||||
export const createRecipe = async (audioFile: File): Promise<Recipe> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioFile);
|
||||
|
||||
const response = await base.post<RecipeResponse>('/recipes/create', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
export const getRecipes = async (): Promise<Recipe[]> => {
|
||||
try {
|
||||
const response = await base.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
|
||||
export const getRecipeById = async (id: string): Promise<Recipe> => {
|
||||
try {
|
||||
const response = await base.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
|
||||
export const deleteRecipe = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
await base.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 const recipeService = {
|
||||
createRecipe,
|
||||
getRecipes,
|
||||
getRecipeById,
|
||||
deleteRecipe,
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
67
frontend/src/components/LoginForm.tsx
Normal file
67
frontend/src/components/LoginForm.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
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;
|
||||
45
frontend/src/components/Navbar.tsx
Normal file
45
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
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;
|
||||
85
frontend/src/components/RecipeDetail.tsx
Normal file
85
frontend/src/components/RecipeDetail.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
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;
|
||||
70
frontend/src/components/RecipeForm.tsx
Normal file
70
frontend/src/components/RecipeForm.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
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;
|
||||
76
frontend/src/components/RecipeList.tsx
Normal file
76
frontend/src/components/RecipeList.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
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;
|
||||
155
frontend/src/components/header.tsx
Normal file
155
frontend/src/components/header.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GalleryVerticalEnd, Menu, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Header() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si l'utilisateur est authentifié
|
||||
const token = localStorage.getItem("token");
|
||||
setIsAuthenticated(!!token);
|
||||
}, [location]); // Re-vérifier à chaque changement de route
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ name: "Accueil", path: "/", public: true },
|
||||
{ name: "Recettes", path: "/recipes", public: true },
|
||||
{ name: "Mes recettes", path: "/my-recipes", public: false },
|
||||
{ name: "Favoris", path: "/favorites", public: false },
|
||||
{ name: "Profil", path: "/profile", public: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
<span className="hidden sm:inline-block">Freedge</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation desktop */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{navItems
|
||||
.filter(item => item.public || isAuthenticated)
|
||||
.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-primary",
|
||||
location.pathname === item.path
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Boutons d'authentification */}
|
||||
<div className="hidden md:flex items-center gap-4">
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
localStorage.removeItem("token");
|
||||
setIsAuthenticated(false);
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
>
|
||||
Déconnexion
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/auth/login">
|
||||
<Button variant="ghost">Connexion</Button>
|
||||
</Link>
|
||||
<Link to="/auth/register">
|
||||
<Button>S'inscrire</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton menu mobile */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={toggleMobileMenu}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu mobile */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden border-b">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4 pb-6">
|
||||
<nav className="flex flex-col space-y-4">
|
||||
{navItems
|
||||
.filter(item => item.public || isAuthenticated)
|
||||
.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-primary",
|
||||
location.pathname === item.path
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
localStorage.removeItem("token");
|
||||
setIsAuthenticated(false);
|
||||
setIsMobileMenuOpen(false);
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
>
|
||||
Déconnexion
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Link to="/auth/login" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" className="w-full">Connexion</Button>
|
||||
</Link>
|
||||
<Link to="/auth/register" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button className="w-full">S'inscrire</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/login-form.tsx
Normal file
109
frontend/src/components/login-form.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { login } from "@/api/auth"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const response = await login({ email, password })
|
||||
|
||||
if (!response.user) {
|
||||
throw new Error("Échec de la connexion")
|
||||
}
|
||||
|
||||
navigate("/")
|
||||
} catch (err) {
|
||||
console.error("Erreur de connexion:", err);
|
||||
setError(err instanceof Error ? err.message : "Une erreur est survenue")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Login to your account</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email below to login to your account
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md dark:bg-red-950/50">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Connexion en cours..." : "Login"}
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/register" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
111
frontend/src/components/recipe-card.tsx
Normal file
111
frontend/src/components/recipe-card.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
104
frontend/src/components/recipe-list.tsx
Normal file
104
frontend/src/components/recipe-list.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/recipe-modal.tsx
Normal file
98
frontend/src/components/recipe-modal.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
30
frontend/src/components/recipe-skeleton.tsx
Normal file
30
frontend/src/components/recipe-skeleton.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
122
frontend/src/components/register-form.tsx
Normal file
122
frontend/src/components/register-form.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { register } from "@/api/auth"
|
||||
|
||||
export function RegisterForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const response = await register({ email, password, name })
|
||||
|
||||
// Stocker le token si votre API en renvoie un
|
||||
if (response.token) {
|
||||
localStorage.setItem("token", response.token)
|
||||
}
|
||||
|
||||
// Rediriger vers la page d'accueil après connexion réussie
|
||||
navigate("/auth/login")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Une erreur est survenue")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Créer un compte</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Entrez votre email ci-dessous pour créer un compte
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md dark:bg-red-950/50">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="name">Nom</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Votre nom"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Mot de passe oublié ?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Création en cours..." : "Créer un compte"}
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||
Ou continuez avec
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Créer un compte avec Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/login" className="underline underline-offset-4">
|
||||
Se connecter
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
93
frontend/src/components/search-filters.tsx
Normal file
93
frontend/src/components/search-filters.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
66
frontend/src/components/ui/alert.tsx
Normal file
66
frontend/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
51
frontend/src/components/ui/avatar.tsx
Normal file
51
frontend/src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
frontend/src/components/ui/badge.tsx
Normal file
46
frontend/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
68
frontend/src/components/ui/card.tsx
Normal file
68
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
133
frontend/src/components/ui/dialog.tsx
Normal file
133
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
22
frontend/src/components/ui/label.tsx
Normal file
22
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
179
frontend/src/components/ui/select.tsx
Normal file
179
frontend/src/components/ui/select.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
26
frontend/src/components/ui/separator.tsx
Normal file
26
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
13
frontend/src/components/ui/skeleton.tsx
Normal file
13
frontend/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
64
frontend/src/components/ui/tabs.tsx
Normal file
64
frontend/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
24
frontend/src/components/ui/textarea.tsx
Normal file
24
frontend/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
127
frontend/src/components/ui/toast.tsx
Normal file
127
frontend/src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
frontend/src/components/ui/toaster.tsx
Normal file
33
frontend/src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/ui/use-toast.tsx
Normal file
62
frontend/src/components/ui/use-toast.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
// Importez le composant Toast de shadcn/ui
|
||||
// https://ui.shadcn.com/docs/components/toast
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 5
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: string
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
|
||||
176
frontend/src/data/recipes.ts
Normal file
176
frontend/src/data/recipes.ts
Normal file
@ -0,0 +1,176 @@
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
124
frontend/src/index.css
Normal file
124
frontend/src/index.css
Normal file
@ -0,0 +1,124 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
43
frontend/src/layouts/MainLayout.tsx
Normal file
43
frontend/src/layouts/MainLayout.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Header } from "@/components/header";
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MainLayout({ children }: MainLayoutProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// Exclure le layout pour les pages d'authentification
|
||||
const authPages = ["/auth/login", "/auth/register"];
|
||||
|
||||
if (authPages.includes(location.pathname)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<footer className="border-t">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6 md:h-16 md:py-0">
|
||||
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||
<p className="text-center text-sm text-muted-foreground md:text-left">
|
||||
© {new Date().getFullYear()} Freedge. Tous droits réservés.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<a href="#" className="hover:underline">Mentions légales</a>
|
||||
<a href="#" className="hover:underline">Confidentialité</a>
|
||||
<a href="#" className="hover:underline">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
31
frontend/src/pages/Auth/Login.tsx
Normal file
31
frontend/src/pages/Auth/Login.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { GalleryVerticalEnd } from "lucide-react"
|
||||
import { LoginForm } from "@/components/login-form"
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<a href="#" className="flex items-center gap-2 font-medium">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
Freedge
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-xs">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden bg-muted lg:block">
|
||||
<img
|
||||
src="/placeholder.svg"
|
||||
alt="Image"
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
frontend/src/pages/Auth/Register.tsx
Normal file
31
frontend/src/pages/Auth/Register.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { GalleryVerticalEnd } from "lucide-react"
|
||||
import { RegisterForm } from "@/components/register-form"
|
||||
|
||||
export default function Register() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<a href="#" className="flex items-center gap-2 font-medium">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
Freedge
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-xs">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden bg-muted lg:block">
|
||||
<img
|
||||
src="/placeholder.svg"
|
||||
alt="Image"
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
frontend/src/pages/Home.tsx
Normal file
90
frontend/src/pages/Home.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, ChefHat, Heart, Search } from "lucide-react";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Hero section */}
|
||||
<section className="py-12 md:py-20">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
|
||||
Découvrez et partagez des recettes délicieuses
|
||||
</h1>
|
||||
<p className="max-w-[600px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
|
||||
Freedge vous aide à trouver des recettes adaptées à vos ingrédients disponibles et à partager vos créations culinaires avec la communauté.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 min-[400px]:flex-row">
|
||||
<Link to="/recipes">
|
||||
<Button className="flex gap-1">
|
||||
Explorer les recettes
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/auth/register">
|
||||
<Button variant="outline">Créer un compte</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
alt="Hero Image"
|
||||
className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full"
|
||||
height="310"
|
||||
src="/images/hero-image.jpg"
|
||||
width="550"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features section */}
|
||||
<section className="py-12 md:py-20">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
|
||||
Fonctionnalités principales
|
||||
</h2>
|
||||
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
|
||||
Découvrez tout ce que Freedge peut faire pour vous
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-6 py-12 md:grid-cols-3 lg:gap-12">
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Search className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Recherche intelligente</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Trouvez des recettes en fonction des ingrédients que vous avez déjà chez vous.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<ChefHat className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Créez et partagez</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Ajoutez vos propres recettes et partagez-les avec la communauté.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Heart className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Favoris personnalisés</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Enregistrez vos recettes préférées pour y accéder rapidement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
551
frontend/src/pages/Profile.tsx
Normal file
551
frontend/src/pages/Profile.tsx
Normal file
@ -0,0 +1,551 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle, Save, User, Lock, LogOut, Trash2 } from "lucide-react";
|
||||
import { apiService } from "@/api/base";
|
||||
import { recipeService, Recipe } from "@/api/recipe";
|
||||
|
||||
// Types pour les données utilisateur
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Service utilisateur
|
||||
const userService = {
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
return apiService.get<User>('users/me');
|
||||
},
|
||||
|
||||
updateProfile: async (data: Partial<User>): Promise<User> => {
|
||||
return apiService.put<User>('users/me', data);
|
||||
},
|
||||
|
||||
changePassword: async (data: { currentPassword: string; newPassword: string }): Promise<void> => {
|
||||
return apiService.post('users/change-password', data);
|
||||
},
|
||||
|
||||
deleteAccount: async (): Promise<void> => {
|
||||
return apiService.delete('users/me');
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
return apiService.post('auth/logout', {});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [userRecipes, setUserRecipes] = useState<Recipe[]>([]);
|
||||
const [favoriteRecipes, setFavoriteRecipes] = useState<Recipe[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
// États pour le formulaire de profil
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
username: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
bio: ""
|
||||
});
|
||||
|
||||
// États pour le formulaire de mot de passe
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Récupérer les données de l'utilisateur
|
||||
const userData = await userService.getCurrentUser();
|
||||
setUser(userData);
|
||||
setProfileForm({
|
||||
username: userData.username || "",
|
||||
firstName: userData.firstName || "",
|
||||
lastName: userData.lastName || "",
|
||||
bio: userData.bio || ""
|
||||
});
|
||||
|
||||
// Récupérer les recettes de l'utilisateur
|
||||
const recipes = await recipeService.getUserRecipes();
|
||||
setUserRecipes(recipes);
|
||||
|
||||
// Récupérer les recettes favorites
|
||||
const favorites = await recipeService.getFavoriteRecipes();
|
||||
setFavoriteRecipes(favorites);
|
||||
} catch (err) {
|
||||
console.error("Erreur lors du chargement du profil:", err);
|
||||
setError("Impossible de charger les données du profil");
|
||||
|
||||
// Rediriger vers la page de connexion si non authentifié
|
||||
if (err instanceof Error && err.message.includes("401")) {
|
||||
localStorage.removeItem("token");
|
||||
navigate("/auth/login");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [navigate]);
|
||||
|
||||
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setProfileForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setPasswordForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleProfileSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedUser = await userService.updateProfile(profileForm);
|
||||
setUser(updatedUser);
|
||||
setSuccess("Profil mis à jour avec succès");
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de la mise à jour du profil:", err);
|
||||
setError("Impossible de mettre à jour le profil");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
// Vérifier que les mots de passe correspondent
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
setError("Les mots de passe ne correspondent pas");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await userService.changePassword({
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
newPassword: passwordForm.newPassword
|
||||
});
|
||||
setSuccess("Mot de passe modifié avec succès");
|
||||
setPasswordForm({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: ""
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Erreur lors du changement de mot de passe:", err);
|
||||
setError("Impossible de changer le mot de passe");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userService.logout();
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de la déconnexion:", err);
|
||||
} finally {
|
||||
localStorage.removeItem("token");
|
||||
navigate("/auth/login");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!window.confirm("Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.deleteAccount();
|
||||
localStorage.removeItem("token");
|
||||
navigate("/auth/login");
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de la suppression du compte:", err);
|
||||
setError("Impossible de supprimer le compte");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 p-6 text-center text-red-700 dark:bg-red-900/50 dark:text-red-200">
|
||||
<h2 className="text-xl font-bold">Erreur</h2>
|
||||
<p>Utilisateur non trouvé ou non connecté</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/auth/login")}
|
||||
>
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Profil</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez vos informations personnelles et vos préférences
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Déconnexion
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Erreur</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="bg-green-50 text-green-700 dark:bg-green-900/50 dark:text-green-200">
|
||||
<AlertTitle>Succès</AlertTitle>
|
||||
<AlertDescription>{success}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-8 md:flex-row">
|
||||
<div className="md:w-1/3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informations</CardTitle>
|
||||
<CardDescription>Vos informations de compte</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src={user.avatarUrl} alt={user.username || user.email} />
|
||||
<AvatarFallback className="text-2xl">
|
||||
{user.firstName?.[0]}{user.lastName?.[0] || user.email[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold">{user.username || "Utilisateur"}</h3>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
{(user.firstName || user.lastName) && (
|
||||
<p className="text-sm">{user.firstName} {user.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-muted-foreground">Membre depuis</p>
|
||||
<p>{new Date(user.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{user.bio && (
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-muted-foreground">Bio</p>
|
||||
<p className="text-sm">{user.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={handleDeleteAccount}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Supprimer mon compte
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Tabs defaultValue="profile">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profil
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Sécurité
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informations du profil</CardTitle>
|
||||
<CardDescription>
|
||||
Mettez à jour vos informations personnelles
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Prénom</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={profileForm.firstName}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Nom</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={profileForm.lastName}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Nom d'utilisateur</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
value={profileForm.username}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<textarea
|
||||
id="bio"
|
||||
name="bio"
|
||||
value={profileForm.bio}
|
||||
onChange={handleProfileChange}
|
||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||
Enregistrement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Enregistrer les modifications
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sécurité du compte</CardTitle>
|
||||
<CardDescription>
|
||||
Mettez à jour votre mot de passe
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Mot de passe actuel</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">Nouveau mot de passe</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||
Enregistrement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Changer le mot de passe
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Mes recettes</h2>
|
||||
<Separator className="my-4" />
|
||||
|
||||
{userRecipes.length === 0 ? (
|
||||
<div className="rounded-lg border p-6 text-center">
|
||||
<p className="text-muted-foreground">Vous n'avez pas encore créé de recettes</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/recipes/create")}
|
||||
>
|
||||
Créer une recette
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{userRecipes.slice(0, 3).map(recipe => (
|
||||
<Card key={recipe.id} className="overflow-hidden">
|
||||
<div className="aspect-video w-full overflow-hidden">
|
||||
<img
|
||||
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
|
||||
alt={recipe.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg">{recipe.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
||||
>
|
||||
Voir la recette
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{userRecipes.length > 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/my-recipes")}
|
||||
>
|
||||
Voir toutes mes recettes ({userRecipes.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Mes favoris</h2>
|
||||
<Separator className="my-4" />
|
||||
|
||||
{favoriteRecipes.length === 0 ? (
|
||||
<div className="rounded-lg border p-6 text-center">
|
||||
<p className="text-muted-foreground">Vous n'avez pas encore de recettes favorites</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/recipes")}
|
||||
>
|
||||
Explorer les recettes
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{favoriteRecipes.slice(0, 3).map(recipe => (
|
||||
<Card key={recipe.id} className="overflow-hidden">
|
||||
<div className="aspect-video w-full overflow-hidden">
|
||||
<img
|
||||
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
|
||||
alt={recipe.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg">{recipe.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
||||
>
|
||||
Voir la recette
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{favoriteRecipes.length > 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/favorites")}
|
||||
>
|
||||
Voir tous mes favoris ({favoriteRecipes.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
338
frontend/src/pages/Recipes/RecipeDetail.tsx
Normal file
338
frontend/src/pages/Recipes/RecipeDetail.tsx
Normal file
@ -0,0 +1,338 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { recipeService, Recipe } from "@/api/recipe";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
ChefHat,
|
||||
Heart,
|
||||
Share2,
|
||||
ArrowLeft,
|
||||
Trash2,
|
||||
Edit,
|
||||
HeartOff
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export default function RecipeDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [addingToFavorites, setAddingToFavorites] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecipeDetails = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// ✅ GET RECIPE DETAILS
|
||||
const recipeData = await recipeService.getRecipeById(id);
|
||||
|
||||
// Optionnel : conversion ingrédients / instructions si nécessaire
|
||||
let ingredients = recipeData.ingredients;
|
||||
if (typeof ingredients === "string") {
|
||||
ingredients = ingredients.split("\n").filter(item => item.trim() !== "");
|
||||
}
|
||||
|
||||
// Correction: déclarer la variable instructions
|
||||
let instructions: string[] = [];
|
||||
if (recipeData.generatedRecipe) {
|
||||
instructions = recipeData.generatedRecipe
|
||||
.split("\n")
|
||||
.filter(item => item.trim() !== "");
|
||||
}
|
||||
|
||||
setRecipe({
|
||||
...recipeData,
|
||||
ingredients,
|
||||
instructions, // Ajouter instructions au recipe
|
||||
});
|
||||
|
||||
// ✅ GET FAVORITE RECIPES & CHECK IF FAVORITE
|
||||
try {
|
||||
const favorites = await recipeService.getFavoriteRecipes();
|
||||
setIsFavorite(favorites.some(fav => fav.id === id));
|
||||
} catch (favError) {
|
||||
// Ignorer les erreurs de favoris pour ne pas bloquer l'affichage
|
||||
console.log("Impossible de vérifier les favoris:", favError);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Impossible de charger les détails de la recette");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecipeDetails();
|
||||
}, [id]);
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!id || !recipe) return;
|
||||
|
||||
try {
|
||||
setAddingToFavorites(true);
|
||||
|
||||
if (isFavorite) {
|
||||
// ✅ REMOVE FROM FAVORITES
|
||||
await recipeService.removeFromFavorites(id);
|
||||
setIsFavorite(false);
|
||||
} else {
|
||||
// ✅ ADD TO FAVORITES
|
||||
await recipeService.addToFavorites(id);
|
||||
setIsFavorite(true);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de la modification des favoris:", err);
|
||||
// Ne pas afficher d'erreur à l'utilisateur pour cette fonctionnalité
|
||||
} finally {
|
||||
setAddingToFavorites(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRecipe = async () => {
|
||||
if (!id) return;
|
||||
if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette recette ?")) return;
|
||||
|
||||
try {
|
||||
// ✅ DELETE RECIPE
|
||||
await recipeService.deleteRecipe(id);
|
||||
navigate("/recipes");
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de la suppression:", err);
|
||||
// Optionnel: afficher un message d'erreur à l'utilisateur
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
if (!recipe) return;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: recipe.title,
|
||||
text: recipe.description || "Découvrez cette recette !",
|
||||
url: window.location.href,
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert("Lien copié dans le presse-papier !");
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// === LOADING & ERROR STATES ============
|
||||
// ========================================
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 p-6 text-center text-red-700 dark:bg-red-900/50 dark:text-red-200">
|
||||
<h2 className="text-xl font-bold">Erreur</h2>
|
||||
<p>{error || "Recette introuvable"}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/recipes")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Retour aux recettes
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// === MAIN COMPONENT =====================
|
||||
// ========================================
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-fit"
|
||||
onClick={() => navigate("/recipes")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Retour aux recettes
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
Partager
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isFavorite ? "default" : "outline"}
|
||||
onClick={handleToggleFavorite}
|
||||
disabled={addingToFavorites}
|
||||
>
|
||||
{isFavorite ? (
|
||||
<>
|
||||
<HeartOff className="mr-2 h-4 w-4" />
|
||||
Retirer des favoris
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heart className="mr-2 h-4 w-4" />
|
||||
Ajouter aux favoris
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/recipes/edit/${id}`)}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Modifier
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteRecipe}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
|
||||
alt={recipe.title}
|
||||
className="h-[300px] w-full object-cover sm:h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<h1 className="text-3xl font-bold">{recipe.title}</h1>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{recipe.description || "Aucune description disponible"}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recipe.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
{recipe.preparationTime && (
|
||||
<div className="flex flex-col items-center rounded-lg border p-3">
|
||||
<Clock className="mb-1 h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Préparation</span>
|
||||
<span className="text-lg font-bold">{recipe.preparationTime} min</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.cookingTime && (
|
||||
<div className="flex flex-col items-center rounded-lg border p-3">
|
||||
<Clock className="mb-1 h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Cuisson</span>
|
||||
<span className="text-lg font-bold">{recipe.cookingTime} min</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.servings && (
|
||||
<div className="flex flex-col items-center rounded-lg border p-3">
|
||||
<Users className="mb-1 h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Portions</span>
|
||||
<span className="text-lg font-bold">{recipe.servings}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.difficulty && (
|
||||
<div className="flex flex-col items-center rounded-lg border p-3">
|
||||
<ChefHat className="mb-1 h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Difficulté</span>
|
||||
<span className="text-lg font-bold capitalize">{recipe.difficulty}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="ingredients" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="ingredients">Ingrédients</TabsTrigger>
|
||||
<TabsTrigger value="instructions">Instructions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ingredients" className="mt-6">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-bold">Ingrédients</h2>
|
||||
<ul className="space-y-2">
|
||||
{Array.isArray(recipe.ingredients) ? (
|
||||
recipe.ingredients.map((ingredient, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span>
|
||||
<span>{ingredient}</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span>
|
||||
<span>{recipe.ingredients}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="instructions" className="mt-6">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-bold">Instructions</h2>
|
||||
<ol className="space-y-4">
|
||||
{recipe.instructions && recipe.instructions.length > 0 ? (
|
||||
recipe.instructions.map((instruction, index) => (
|
||||
<li key={index} className="flex">
|
||||
<span className="mr-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="pt-1">{instruction}</p>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="flex">
|
||||
<span className="mr-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||||
1
|
||||
</span>
|
||||
<p className="pt-1">{recipe.generatedRecipe}</p>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
frontend/src/pages/Recipes/RecipeForm.tsx
Normal file
225
frontend/src/pages/Recipes/RecipeForm.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { recipeService } from "@/api/recipe";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Mic, Upload, ArrowLeft, Loader2 } from "lucide-react";
|
||||
|
||||
export default function RecipeForm() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
||||
const [recordingStatus, setRecordingStatus] = useState("idle");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Gérer l'upload de fichier audio
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setAudioFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Démarrer l'enregistrement audio
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
setMediaRecorder(recorder);
|
||||
|
||||
const chunks: BlobPart[] = [];
|
||||
recorder.ondataavailable = (e) => {
|
||||
chunks.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||
const file = new File([blob], "recording.webm", { type: 'audio/webm' });
|
||||
setAudioFile(file);
|
||||
setRecordingStatus("idle");
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordingStatus("recording");
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de l'accès au microphone:", err);
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: "Erreur de microphone",
|
||||
// description: "Impossible d'accéder au microphone. Vérifiez les permissions."
|
||||
// });
|
||||
}
|
||||
};
|
||||
|
||||
// Arrêter l'enregistrement audio
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
setIsRecording(false);
|
||||
setRecordingStatus("processing");
|
||||
|
||||
// Arrêter toutes les pistes audio
|
||||
mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
// Soumettre le formulaire
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!audioFile) {
|
||||
setError("Veuillez fournir un enregistrement audio des ingrédients");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const recipe = await recipeService.createRecipe(audioFile);
|
||||
|
||||
// toast({
|
||||
// title: "Recette créée !",
|
||||
// description: "Votre recette a été générée avec succès."
|
||||
// });
|
||||
|
||||
// Rediriger vers la page de détails de la recette
|
||||
navigate(`/recipes/${recipe.id}`);
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de la création de la recette:", err);
|
||||
setError(err instanceof Error ? err.message : "Une erreur est survenue lors de la création de la recette");
|
||||
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: "Erreur",
|
||||
// description: "Impossible de créer la recette. Veuillez réessayer."
|
||||
// });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-3xl py-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => navigate("/recipes")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Retour aux recettes
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Créer une nouvelle recette</CardTitle>
|
||||
<CardDescription>
|
||||
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles,
|
||||
et nous générerons une recette pour vous.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audio-file">Fichier audio</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="audio-file"
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={loading || isRecording}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isRecording ? "destructive" : "outline"}
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
disabled={loading}
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Arrêter
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="mr-2 h-4 w-4" />
|
||||
Enregistrer
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recordingStatus === "processing" && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Traitement de l'enregistrement...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioFile && (
|
||||
<div className="mt-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/50 dark:text-green-200">
|
||||
<p className="font-medium">Fichier audio prêt :</p>
|
||||
<p>{audioFile.name} ({(audioFile.size / 1024).toFixed(2)} KB)</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<audio controls className="w-full">
|
||||
<source src={URL.createObjectURL(audioFile)} type={audioFile.type} />
|
||||
Votre navigateur ne supporte pas la lecture audio.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Enregistrez-vous en listant les ingrédients que vous avez à disposition.
|
||||
Notre IA générera une recette adaptée à ces ingrédients.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/recipes")}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !audioFile}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Création en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Créer la recette
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
frontend/src/pages/Recipes/RecipeList.tsx
Normal file
159
frontend/src/pages/Recipes/RecipeList.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { recipeService, Recipe } from "@/api/recipe";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, Filter, Plus } from "lucide-react";
|
||||
|
||||
export default function RecipeList() {
|
||||
const navigate = useNavigate();
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecipes = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await recipeService.getRecipes();
|
||||
setRecipes(data);
|
||||
} catch (err) {
|
||||
setError("Impossible de charger les recettes");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecipes();
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
const data = await recipeService.getRecipes();
|
||||
setRecipes(data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const results = await recipeService.getRecipes();
|
||||
setRecipes(results);
|
||||
} catch (err) {
|
||||
setError("Erreur lors de la recherche");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRecipe = () => {
|
||||
navigate("/recipes/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Recettes</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Découvrez notre collection de recettes délicieuses
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<form onSubmit={handleSearch} className="flex w-full max-w-sm items-center space-x-2">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Rechercher des recettes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" size="icon">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
onClick={handleCreateRecipe}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Créer une recette
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-700 dark:bg-red-900/50 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{recipes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-lg font-medium">Aucune recette trouvée</p>
|
||||
<p className="text-muted-foreground">
|
||||
Essayez de modifier vos critères de recherche ou
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleCreateRecipe}
|
||||
className="px-1 py-0 h-auto"
|
||||
>
|
||||
créez une nouvelle recette
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{recipes.map((recipe) => (
|
||||
<Link
|
||||
key={recipe.id}
|
||||
to={`/recipes/${recipe.id}`}
|
||||
className="group overflow-hidden rounded-lg border bg-card shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="aspect-video w-full overflow-hidden">
|
||||
<img
|
||||
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
|
||||
alt={recipe.title}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold">{recipe.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{recipe.description || "Aucune description disponible"}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{recipe.tags?.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{recipe.preparationTime ? `${recipe.preparationTime} min` : ""}
|
||||
</span>
|
||||
<span>{recipe.difficulty || ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/pages/recipe/Recipes.tsx
Normal file
15
frontend/src/pages/recipe/Recipes.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
27
frontend/src/types/recipe.ts
Normal file
27
frontend/src/types/recipe.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export type RecipeTag =
|
||||
| "vegetarian"
|
||||
| "vegan"
|
||||
| "gluten-free"
|
||||
| "dairy-free"
|
||||
| "quick meal"
|
||||
| "dessert"
|
||||
| "breakfast"
|
||||
| "lunch"
|
||||
| "dinner"
|
||||
| "snack"
|
||||
|
||||
export type DifficultyLevel = "easy" | "medium" | "hard"
|
||||
|
||||
export interface Recipe {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
cookingTime: number // in minutes
|
||||
difficulty: DifficultyLevel
|
||||
tags: RecipeTag[]
|
||||
ingredients: string[]
|
||||
instructions: string[]
|
||||
isFavorite: boolean
|
||||
}
|
||||
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
36
frontend/tsconfig.app.json
Normal file
36
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
383
package-lock.json
generated
Normal file
383
package-lock.json
generated
Normal file
@ -0,0 +1,383 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "freedge",
|
||||
"version": "1.0.0",
|
||||
"description": "Recipe generator based on available ingredients",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"frontend": "cd frontend && npm run dev",
|
||||
"backend": "cd backend && npm run dev",
|
||||
"dev": "concurrently \"npm run backend\" \"npm run frontend\"",
|
||||
"install:all": "npm install && cd backend && npm install && cd ../frontend && npm install",
|
||||
"setup:db": "cd backend && npx prisma migrate dev --name init && npx prisma generate"
|
||||
},
|
||||
"keywords": [
|
||||
"recipe",
|
||||
"ai",
|
||||
"food"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.0.1"
|
||||
}
|
||||
}
|
||||
70
src/components/RecipeForm.tsx
Normal file
70
src/components/RecipeForm.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
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;
|
||||
66
src/components/RecipeList.tsx
Normal file
66
src/components/RecipeList.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
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;
|
||||
114
src/services/AuthService.ts
Normal file
114
src/services/AuthService.ts
Normal file
@ -0,0 +1,114 @@
|
||||
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();
|
||||
103
src/services/RecipeService.ts
Normal file
103
src/services/RecipeService.ts
Normal file
@ -0,0 +1,103 @@
|
||||
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