init a fonctional prototype

This commit is contained in:
Arthur Barre 2025-03-10 00:24:26 +01:00
commit 958a778f85
84 changed files with 13277 additions and 0 deletions

30
.gitignore vendored Normal file
View 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
View 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
View File

@ -0,0 +1 @@
node_modules/

2274
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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");

View 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"

View 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
View 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;
});
});

View 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);
});
});

View 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
View 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' });
}
});
};

View 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
View 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();

View File

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1
frontend/public/vite.svg Normal file
View 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
View File

37
frontend/src/App.tsx Normal file
View 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
View 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
View 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)
};

View 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,
};

View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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&apos;t have an account?{" "}
<a href="/register" className="underline underline-offset-4">
Sign up
</a>
</div>
</form>
)
}

View 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}
/>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;t have an account?{" "}
<a href="/login" className="underline underline-offset-4">
Se connecter
</a>
</div>
</form>
)
}

View 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>
)
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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>
)
}

View 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 => {

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

View 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">
&copy; {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>
);
}

View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View File

@ -0,0 +1,19 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

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

View 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;

View 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
View 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();

View 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();