From 563e7b7d9a09939fc98bb0af2aaf472933ed21eb Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 23 Aug 2026 09:06:32 +0000 Subject: [PATCH] Initial commit: CSV Vie Locale --- .gitignore | 5 + README.md | 87 ++++++++++++ backend/Dockerfile | 7 + backend/package.json | 18 +++ backend/src/config/db.js | 14 ++ backend/src/index.js | 154 ++++++++++++++++++++ backend/src/middleware/auth.js | 31 +++++ backend/src/routes/articles.js | 58 ++++++++ backend/src/routes/auth.js | 83 +++++++++++ backend/src/routes/businesses.js | 85 ++++++++++++ backend/src/routes/events.js | 57 ++++++++ backend/src/routes/helpRequests.js | 83 +++++++++++ backend/src/routes/listings.js | 105 ++++++++++++++ docker-compose.yml | 24 ++++ frontend/Dockerfile | 12 ++ frontend/index.html | 13 ++ frontend/nginx.conf | 18 +++ frontend/package.json | 23 +++ frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 4 + frontend/src/App.jsx | 37 +++++ frontend/src/api/index.js | 25 ++++ frontend/src/components/Footer.jsx | 35 +++++ frontend/src/components/Navbar.jsx | 86 ++++++++++++ frontend/src/contexts/AuthContext.jsx | 48 +++++++ frontend/src/index.css | 7 + frontend/src/main.jsx | 16 +++ frontend/src/pages/Admin.jsx | 193 ++++++++++++++++++++++++++ frontend/src/pages/Articles.jsx | 49 +++++++ frontend/src/pages/Businesses.jsx | 102 ++++++++++++++ frontend/src/pages/Events.jsx | 56 ++++++++ frontend/src/pages/HelpRequests.jsx | 136 ++++++++++++++++++ frontend/src/pages/Home.jsx | 89 ++++++++++++ frontend/src/pages/Listings.jsx | 159 +++++++++++++++++++++ frontend/src/pages/Login.jsx | 45 ++++++ frontend/src/pages/Profile.jsx | 107 ++++++++++++++ frontend/src/pages/Register.jsx | 60 ++++++++ frontend/tailwind.config.js | 16 +++ frontend/vite.config.js | 6 + 39 files changed, 2159 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/config/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/articles.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/businesses.js create mode 100644 backend/src/routes/events.js create mode 100644 backend/src/routes/helpRequests.js create mode 100644 backend/src/routes/listings.js create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/components/Footer.jsx create mode 100644 frontend/src/components/Navbar.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/Admin.jsx create mode 100644 frontend/src/pages/Articles.jsx create mode 100644 frontend/src/pages/Businesses.jsx create mode 100644 frontend/src/pages/Events.jsx create mode 100644 frontend/src/pages/HelpRequests.jsx create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/Listings.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Profile.jsx create mode 100644 frontend/src/pages/Register.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03dc2dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +uploads/ +.env +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3666a30 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# CSV - Plateforme de Vie Locale + +Application communautaire : articles, événements, petites annonces (listings), demandes d'aide, annuaire d'entreprises locales, avec comptes utilisateurs et panneau d'administration. + +## Stack technique + +| Composant | Technologie | +|-----------|------------| +| Backend | Node.js + Express (ESM) | +| Frontend | React 18 + Vite 5 + Tailwind 3 | +| Base de données | MariaDB (instance distante, LXC 102) | +| Authentification | JWT (jsonwebtoken) + bcryptjs | +| Uploads | Multer | + +## Déploiement + +```bash +cd /opt/csv +docker compose up -d +``` + +- **Backend** : http://localhost:3002 +- **Frontend** : http://localhost:3003 + +La BDD est auto-créée au démarrage du backend (tables `users`, `articles`, `events`, `listings`, `help_requests`, `businesses`, `categories`). + +## Structure + +``` +/opt/csv/ +├── backend/ +│ ├── src/ +│ │ ├── index.js # Point d'entrée Express + auto-create tables +│ │ ├── config/db.js # Pool MySQL +│ │ ├── routes/ +│ │ │ ├── auth.js # Inscription, connexion, profil +│ │ │ ├── articles.js # CRUD articles +│ │ │ ├── events.js # CRUD événements +│ │ │ ├── listings.js # Petites annonces +│ │ │ ├── helpRequests.js # Demandes d'aide +│ │ │ └── businesses.js # Annuaire entreprises +│ │ └── uploads/ # Fichiers uploadés +│ └── package.json +├── frontend/ +│ ├── src/ +│ │ ├── App.jsx # Routeur React +│ │ ├── components/ # Composants UI +│ │ └── pages/ # Pages +│ └── package.json +├── docker-compose.yml +└── nginx.conf +``` + +## API Routes + +| Méthode | Route | Description | +|---------|-------|-------------| +| POST | `/api/auth/register` | Inscription | +| POST | `/api/auth/login` | Connexion | +| GET/POST | `/api/articles` | Liste/Création articles | +| GET/POST | `/api/events` | Liste/Création événements | +| GET/POST | `/api/listings` | Liste/Création petites annonces | +| GET/POST | `/api/help-requests` | Liste/Création demandes d'aide | +| GET/POST | `/api/businesses` | Liste/Création entreprises | + +## Variables d'environnement + +| Variable | Description | Valeur | +|----------|-------------|--------| +| `PORT` | Port backend | `3002` | +| `DB_HOST` | Host MariaDB | `10.10.10.11` | +| `DB_PORT` | Port MariaDB | `3306` | +| `DB_USER` | Utilisateur BDD | `csvusr` | +| `DB_PASSWORD` | Mot de passe BDD | `Fdp1989Dor@` | +| `DB_NAME` | Nom de la BDD | `csv` | +| `JWT_SECRET` | Secret JWT | `csv-secret-key-change-in-production` | + +> **Sécurité** : Le JWT_SECRET est une valeur par défaut. Il doit être changé en production. + +## Domaine de production + +- **URL** : https://csv.secourit.fr +- **LXC** : 106 (IP 10.10.10.23) +- **Backend** : port 3002 +- **Frontend** : port 3003 +- **SSL** : Let's Encrypt via Nginx Proxy Manager +- **BDD** : MariaDB sur LXC 102 (10.10.10.11), base `csv` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0423c3e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json . +RUN npm install --production +COPY . . +EXPOSE 3002 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..9052dcf --- /dev/null +++ b/backend/package.json @@ -0,0 +1,18 @@ +{ + "name": "csv-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.11.0" + } +} \ No newline at end of file diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..e05be48 --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,14 @@ +import mysql from 'mysql2/promise'; + +const pool = mysql.createPool({ + host: process.env.DB_HOST || '127.0.0.1', + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER || 'csvusr', + password: process.env.DB_PASSWORD || 'Fdp1989Dor@', + database: process.env.DB_NAME || 'csv', + waitForConnections: true, + connectionLimit: 10, + charset: 'utf8mb4' +}); + +export default pool; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..eec592d --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,154 @@ +import express from 'express'; +import multer from 'multer'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import pool from './config/db.js'; +import authRoutes from './routes/auth.js'; +import articleRoutes from './routes/articles.js'; +import eventRoutes from './routes/events.js'; +import listingRoutes from './routes/listings.js'; +import helpRoutes from './routes/helpRequests.js'; +import businessRoutes from './routes/businesses.js'; + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3002; + +app.use(cors()); +app.use(express.json({ limit: '10mb' })); +app.use('/uploads', express.static('uploads')); +app.use('/api/uploads', express.static('uploads')); +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, 'uploads/'), + filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname) +}); +const upload = multer({ storage }); +app.use('/api/uploads', express.static('uploads')); + +const initDB = async () => { + const conn = await pool.getConnection(); + await conn.query(` + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + phone VARCHAR(20), + address VARCHAR(255), + role ENUM('user','admin') DEFAULT 'user', + avatar VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + await conn.query(` + CREATE TABLE IF NOT EXISTS articles ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + image VARCHAR(255), + author_id INT, + published TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await conn.query(` + CREATE TABLE IF NOT EXISTS events ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + date DATE NOT NULL, + time TIME, + location VARCHAR(255), + image VARCHAR(255), + category VARCHAR(50) DEFAULT 'autre', + author_id INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await conn.query(` + CREATE TABLE IF NOT EXISTS categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + type ENUM('listing','event') DEFAULT 'listing' + ) + `); + await conn.query(` + CREATE TABLE IF NOT EXISTS listings ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) DEFAULT 0, + type ENUM('don','vente','troc') DEFAULT 'don', + category_id INT, + images TEXT, + status ENUM('disponible','archive','vendu') DEFAULT 'disponible', + approved TINYINT(1) DEFAULT 1, + author_id INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL + ) + `); + await conn.query(` + CREATE TABLE IF NOT EXISTS help_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + category ENUM('course','bricolage','jardinage','transport','numerique','autre') DEFAULT 'autre', + status ENUM('ouvert','en_cours','resolu') DEFAULT 'ouvert', + approved TINYINT(1) DEFAULT 1, + urgent TINYINT(1) DEFAULT 0, + author_id INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + await conn.query(` + CREATE TABLE IF NOT EXISTS businesses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + category VARCHAR(100), + address VARCHAR(255), + phone VARCHAR(20), + website VARCHAR(255), + email VARCHAR(255), + image VARCHAR(255), + owner_id INT, + approved TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + await conn.query(` + INSERT IGNORE INTO categories (name, type) VALUES + ('Meuble','listing'), ('Electromenager','listing'), ('Vetement','listing'), + ('Livres','listing'), ('Jouets','listing'), ('Bricolage','listing'), + ('Jardinage','listing'), ('Animaux','listing'), ('Autre','listing') + `); + conn.release(); + console.log('DB initialized'); +}; + +initDB().catch(console.error); + +app.use('/api/auth', authRoutes); +app.use('/api/articles', articleRoutes); +app.use('/api/events', eventRoutes); +app.use('/api/listings', listingRoutes); +app.use('/api/help', helpRoutes); +app.use('/api/businesses', businessRoutes); + +app.get('/api/health', (_, res) => res.json({ status: 'ok' })); +app.post('/api/upload', upload.single('file'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Aucun fichier' }); + res.json({ url: '/api/uploads/' + req.file.filename }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log('CSV API running on port', PORT); +}); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..bd88a8c --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,31 @@ +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'csv-secret-key-change-in-production'; + +export const authenticate = (req, res, next) => { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) return res.status(401).json({ error: 'Authentification requise' }); + try { + req.user = jwt.verify(token, JWT_SECRET); + next(); + } catch { + res.status(401).json({ error: 'Token invalide' }); + } +}; + +export const optionalAuth = (req, res, next) => { + const token = req.headers.authorization?.split(' ')[1]; + if (token) { + try { req.user = jwt.verify(token, JWT_SECRET); } catch {} + } + next(); +}; + +export const requireAdmin = (req, res, next) => { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ error: 'Accès réservé aux administrateurs' }); + } + next(); +}; + +export { JWT_SECRET }; diff --git a/backend/src/routes/articles.js b/backend/src/routes/articles.js new file mode 100644 index 0000000..ac7b8d1 --- /dev/null +++ b/backend/src/routes/articles.js @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import pool from '../config/db.js'; +import { authenticate, requireAdmin, optionalAuth } from '../middleware/auth.js'; + +const router = Router(); + +router.get('/', optionalAuth, async (req, res) => { + try { + const [rows] = await pool.execute( + 'SELECT a.*, u.name as author_name FROM articles a LEFT JOIN users u ON a.author_id = u.id WHERE a.published = 1 OR (a.author_id = ? AND a.published = 0) ORDER BY a.created_at DESC', + [req.user?.id || 0] + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get('/:id', optionalAuth, async (req, res) => { + try { + const [rows] = await pool.execute( + 'SELECT a.*, u.name as author_name FROM articles a LEFT JOIN users u ON a.author_id = u.id WHERE a.id = ? AND (a.published = 1 OR a.author_id = ?)', + [req.params.id, req.user?.id || 0] + ); + if (!rows.length) return res.status(404).json({ error: 'Article introuvable' }); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.post('/', authenticate, requireAdmin, async (req, res) => { + try { + const { title, content, image } = req.body; + if (!title || !content) return res.status(400).json({ error: 'Titre et contenu requis' }); + const [result] = await pool.execute( + 'INSERT INTO articles (title, content, image, author_id) VALUES (?, ?, ?, ?)', + [title, content, image || null, req.user.id] + ); + res.status(201).json({ id: result.insertId, message: 'Article créé' }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put('/:id', authenticate, requireAdmin, async (req, res) => { + try { + const { title, content, image, published } = req.body; + await pool.execute( + 'UPDATE articles SET title = ?, content = ?, image = ?, published = ? WHERE id = ?', + [title, content, image || null, published ?? 1, req.params.id] + ); + res.json({ message: 'Article mis à jour' }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.delete('/:id', authenticate, requireAdmin, async (req, res) => { + try { + await pool.execute('DELETE FROM articles WHERE id = ?', [req.params.id]); + res.json({ message: 'Article supprimé' }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +export default router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..283099e --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,83 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import pool from '../config/db.js'; +import { authenticate, JWT_SECRET } from '../middleware/auth.js'; + +const router = Router(); + +router.post('/register', async (req, res) => { + try { + const { name, email, password, phone, address } = req.body; + if (!name || !email || !password) { + return res.status(400).json({ error: 'Nom, email et mot de passe requis' }); + } + if (password.length < 6) { + return res.status(400).json({ error: 'Mot de passe trop court (6 car. min)' }); + } + const [existing] = await pool.execute('SELECT id FROM users WHERE email = ?', [email]); + if (existing.length) { + return res.status(409).json({ error: 'Cet email est déjà utilisé' }); + } + const hash = await bcrypt.hash(password, 10); + const [result] = await pool.execute( + 'INSERT INTO users (name, email, password, phone, address) VALUES (?, ?, ?, ?, ?)', + [name, email, hash, phone || null, address || null] + ); + const token = jwt.sign({ id: result.insertId, name, email, role: 'user' }, JWT_SECRET, { expiresIn: '30d' }); + res.status(201).json({ token, user: { id: result.insertId, name, email, role: 'user' } }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ error: 'Email et mot de passe requis' }); + } + const [users] = await pool.execute('SELECT * FROM users WHERE email = ?', [email]); + if (!users.length) { + return res.status(401).json({ error: 'Email ou mot de passe incorrect' }); + } + const user = users[0]; + const valid = await bcrypt.compare(password, user.password); + if (!valid) { + return res.status(401).json({ error: 'Email ou mot de passe incorrect' }); + } + const token = jwt.sign( + { id: user.id, name: user.name, email: user.email, role: user.role }, + JWT_SECRET, + { expiresIn: '30d' } + ); + res.json({ + token, + user: { id: user.id, name: user.name, email: user.email, role: user.role, phone: user.phone, address: user.address } + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +router.get('/me', authenticate, async (req, res) => { + try { + const [users] = await pool.execute('SELECT id, name, email, role, phone, address, avatar, created_at FROM users WHERE id = ?', [req.user.id]); + if (!users.length) return res.status(404).json({ error: 'Utilisateur introuvable' }); + res.json(users[0]); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +router.put('/profile', authenticate, async (req, res) => { + try { + const { name, phone, address } = req.body; + await pool.execute('UPDATE users SET name = ?, phone = ?, address = ? WHERE id = ?', [name, phone, address, req.user.id]); + res.json({ message: 'Profil mis à jour' }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +export default router; diff --git a/backend/src/routes/businesses.js b/backend/src/routes/businesses.js new file mode 100644 index 0000000..0d2179c --- /dev/null +++ b/backend/src/routes/businesses.js @@ -0,0 +1,85 @@ +import { Router } from "express"; +import pool from "../config/db.js"; +import { authenticate, requireAdmin, optionalAuth } from "../middleware/auth.js"; + +const router = Router(); + +router.get("/", async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT * FROM businesses WHERE approved = 1 ORDER BY name" + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/pending", authenticate, requireAdmin, async (req, res) => { + try { + const [rows] = await pool.execute("SELECT * FROM businesses WHERE approved = 0 ORDER BY created_at DESC"); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/mine", authenticate, async (req, res) => { + try { + const [rows] = await pool.execute("SELECT * FROM businesses WHERE owner_id = ? ORDER BY created_at DESC", [req.user.id]); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/:id", async (req, res) => { + try { + const [rows] = await pool.execute("SELECT * FROM businesses WHERE id = ?", [req.params.id]); + if (!rows.length) return res.status(404).json({ error: "Commerce introuvable" }); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.post("/", authenticate, async (req, res) => { + try { + const { name, description, category, address, phone, website, email, image } = req.body; + if (!name) return res.status(400).json({ error: "Nom requis" }); + const [result] = await pool.execute( + "INSERT INTO businesses (name, description, category, address, phone, website, email, image, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [name, description || null, category || null, address || null, phone || null, website || null, email || null, image || null, req.user.id] + ); + res.status(201).json({ id: result.insertId, message: "Commerce soumis, en attente d'approbation" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put("/:id", authenticate, async (req, res) => { + try { + const [businesses] = await pool.execute("SELECT * FROM businesses WHERE id = ?", [req.params.id]); + if (!businesses.length) return res.status(404).json({ error: "Commerce introuvable" }); + if (businesses[0].owner_id !== req.user.id && req.user.role !== "admin") { + return res.status(403).json({ error: "Action non autorisee" }); + } + const { name, description, category, address, phone, website, email, image } = req.body; + await pool.execute( + "UPDATE businesses SET name=?, description=?, category=?, address=?, phone=?, website=?, email=?, image=? WHERE id=?", + [name, description, category, address, phone, website, email, image, req.params.id] + ); + res.json({ message: "Commerce mis a jour" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put("/:id/approve", authenticate, requireAdmin, async (req, res) => { + try { + await pool.execute("UPDATE businesses SET approved = 1 WHERE id = ?", [req.params.id]); + res.json({ message: "Commerce approuve" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.delete("/:id", authenticate, async (req, res) => { + try { + const [businesses] = await pool.execute("SELECT * FROM businesses WHERE id = ?", [req.params.id]); + if (!businesses.length) return res.status(404).json({ error: "Commerce introuvable" }); + if (businesses[0].owner_id !== req.user.id && req.user.role !== "admin") { + return res.status(403).json({ error: "Action non autorisee" }); + } + await pool.execute("DELETE FROM businesses WHERE id = ?", [req.params.id]); + res.json({ message: "Commerce supprime" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +export default router; diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js new file mode 100644 index 0000000..97b7a59 --- /dev/null +++ b/backend/src/routes/events.js @@ -0,0 +1,57 @@ +import { Router } from "express"; +import pool from "../config/db.js"; +import { authenticate, requireAdmin } from "../middleware/auth.js"; + +const router = Router(); + +router.get("/", async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT e.*, u.name as author_name FROM events e LEFT JOIN users u ON e.author_id = u.id ORDER BY e.date DESC" + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/:id", async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT e.*, u.name as author_name FROM events e LEFT JOIN users u ON e.author_id = u.id WHERE e.id = ?", + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: "Evenement introuvable" }); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.post("/", authenticate, requireAdmin, async (req, res) => { + try { + const { title, description, date, time, location, image, category } = req.body; + if (!title || !date) return res.status(400).json({ error: "Titre et date requis" }); + const [result] = await pool.execute( + "INSERT INTO events (title, description, date, time, location, image, category, author_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [title, description || null, date, time || null, location || null, image || null, category || "autre", req.user.id] + ); + res.status(201).json({ id: result.insertId, message: "Evenement cree" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put("/:id", authenticate, requireAdmin, async (req, res) => { + try { + const { title, description, date, time, location, image, category } = req.body; + await pool.execute( + "UPDATE events SET title=?, description=?, date=?, time=?, location=?, image=?, category=? WHERE id=?", + [title, description, date, time, location, image, category, req.params.id] + ); + res.json({ message: "Evenement mis a jour" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.delete("/:id", authenticate, requireAdmin, async (req, res) => { + try { + await pool.execute("DELETE FROM events WHERE id = ?", [req.params.id]); + res.json({ message: "Evenement supprime" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +export default router; diff --git a/backend/src/routes/helpRequests.js b/backend/src/routes/helpRequests.js new file mode 100644 index 0000000..abaa2f1 --- /dev/null +++ b/backend/src/routes/helpRequests.js @@ -0,0 +1,83 @@ +import { Router } from "express"; +import pool from "../config/db.js"; +import { authenticate, requireAdmin } from "../middleware/auth.js"; + +const router = Router(); + +router.get("/", async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT h.*, u.name as author_name, u.phone as author_phone FROM help_requests h LEFT JOIN users u ON h.author_id = u.id WHERE h.status != 'resolu' AND h.approved = 1 ORDER BY h.urgent DESC, h.created_at DESC" + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/pending", authenticate, requireAdmin, async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT h.*, u.name as author_name FROM help_requests h LEFT JOIN users u ON h.author_id = u.id WHERE h.approved = 0 ORDER BY h.created_at DESC" + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/:id", async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT h.*, u.name as author_name, u.phone as author_phone FROM help_requests h LEFT JOIN users u ON h.author_id = u.id WHERE h.id = ?", + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: "Demande introuvable" }); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.post("/", authenticate, async (req, res) => { + try { + const { title, description, category, urgent, image } = req.body; + if (!title || !description) return res.status(400).json({ error: "Titre et description requis" }); + const [result] = await pool.execute( + "INSERT INTO help_requests (title, description, category, urgent, image, author_id) VALUES (?, ?, ?, ?, ?, ?)", + [title, description, category || "autre", urgent ? 1 : 0, image || null, req.user.id] + ); + res.status(201).json({ id: result.insertId, message: "Demande soumise, en attente d'approbation" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put("/:id", authenticate, async (req, res) => { + try { + const [requests] = await pool.execute("SELECT * FROM help_requests WHERE id = ?", [req.params.id]); + if (!requests.length) return res.status(404).json({ error: "Demande introuvable" }); + if (requests[0].author_id !== req.user.id && req.user.role !== "admin") { + return res.status(403).json({ error: "Action non autorisee" }); + } + const { title, description, category, urgent, status, image } = req.body; + await pool.execute( + "UPDATE help_requests SET title=?, description=?, category=?, urgent=?, status=?, image=? WHERE id=?", + [title, description, category, urgent ? 1 : 0, status || "ouvert", image || null, req.params.id] + ); + res.json({ message: "Demande mise a jour" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put("/:id/approve", authenticate, requireAdmin, async (req, res) => { + try { + await pool.execute("UPDATE help_requests SET approved = 1 WHERE id = ?", [req.params.id]); + res.json({ message: "Demande approuvee" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.delete("/:id", authenticate, async (req, res) => { + try { + const [requests] = await pool.execute("SELECT * FROM help_requests WHERE id = ?", [req.params.id]); + if (!requests.length) return res.status(404).json({ error: "Demande introuvable" }); + if (requests[0].author_id !== req.user.id && req.user.role !== "admin") { + return res.status(403).json({ error: "Action non autorisee" }); + } + await pool.execute("DELETE FROM help_requests WHERE id = ?", [req.params.id]); + res.json({ message: "Demande supprimee" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +export default router; diff --git a/backend/src/routes/listings.js b/backend/src/routes/listings.js new file mode 100644 index 0000000..b77c987 --- /dev/null +++ b/backend/src/routes/listings.js @@ -0,0 +1,105 @@ +import { Router } from "express"; +import pool from "../config/db.js"; +import { authenticate, requireAdmin, optionalAuth } from "../middleware/auth.js"; + +const router = Router(); + +router.get("/", optionalAuth, async (req, res) => { + try { + const { type, category, search } = req.query; + let sql = "SELECT l.*, u.name as author_name, u.phone as author_phone, c.name as category_name FROM listings l LEFT JOIN users u ON l.author_id = u.id LEFT JOIN categories c ON l.category_id = c.id WHERE l.status = 'disponible' AND l.approved = 1"; + const params = []; + if (type && type !== "all") { sql += " AND l.type = ?"; params.push(type); } + if (category) { sql += " AND l.category_id = ?"; params.push(category); } + if (search) { sql += " AND (l.title LIKE ? OR l.description LIKE ?)"; params.push("%" + search + "%", "%" + search + "%"); } + sql += " ORDER BY l.created_at DESC"; + const [rows] = await pool.execute(sql, params); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/mine", authenticate, async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT l.*, c.name as category_name FROM listings l LEFT JOIN categories c ON l.category_id = c.id WHERE l.author_id = ? ORDER BY l.created_at DESC", + [req.user.id] + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/pending", authenticate, requireAdmin, async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT l.*, u.name as author_name FROM listings l LEFT JOIN users u ON l.author_id = u.id WHERE l.approved = 0 ORDER BY l.created_at DESC" + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/:id", async (req, res) => { + try { + const [rows] = await pool.execute( + "SELECT l.*, u.name as author_name, u.phone as author_phone, c.name as category_name FROM listings l LEFT JOIN users u ON l.author_id = u.id LEFT JOIN categories c ON l.category_id = c.id WHERE l.id = ?", + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: "Annonce introuvable" }); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.post("/", authenticate, async (req, res) => { + try { + const { title, description, price, type, category_id, image } = req.body; + if (!title) return res.status(400).json({ error: "Titre requis" }); + const [result] = await pool.execute( + "INSERT INTO listings (title, description, price, type, category_id, image, author_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + [title, description || null, price || 0, type || "don", category_id || null, image || null, req.user.id] + ); + res.status(201).json({ id: result.insertId, message: "Annonce soumise, en attente d'approbation" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put("/:id", authenticate, async (req, res) => { + try { + const [listings] = await pool.execute("SELECT * FROM listings WHERE id = ?", [req.params.id]); + if (!listings.length) return res.status(404).json({ error: "Annonce introuvable" }); + if (listings[0].author_id !== req.user.id && req.user.role !== "admin") { + return res.status(403).json({ error: "Action non autorisee" }); + } + const { title, description, price, type, category_id, image, status } = req.body; + await pool.execute( + "UPDATE listings SET title=?, description=?, price=?, type=?, category_id=?, image=?, status=? WHERE id=?", + [title, description, price, type, category_id, image, status || "disponible", req.params.id] + ); + res.json({ message: "Annonce mise a jour" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.put("/:id/approve", authenticate, requireAdmin, async (req, res) => { + try { + await pool.execute("UPDATE listings SET approved = 1 WHERE id = ?", [req.params.id]); + res.json({ message: "Annonce approuvee" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.delete("/:id", authenticate, async (req, res) => { + try { + const [listings] = await pool.execute("SELECT * FROM listings WHERE id = ?", [req.params.id]); + if (!listings.length) return res.status(404).json({ error: "Annonce introuvable" }); + if (listings[0].author_id !== req.user.id && req.user.role !== "admin") { + return res.status(403).json({ error: "Action non autorisee" }); + } + await pool.execute("DELETE FROM listings WHERE id = ?", [req.params.id]); + res.json({ message: "Annonce supprimee" }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +router.get("/categories/all", async (req, res) => { + try { + const [rows] = await pool.execute("SELECT * FROM categories WHERE type = 'listing' ORDER BY name"); + res.json(rows); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +export default router; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..05bc7a7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + backend: + build: ./backend + container_name: csv-backend + restart: unless-stopped + ports: + - 3002:3002 + environment: + - PORT=3002 + - DB_HOST=10.10.10.11 + - DB_PORT=3306 + - DB_USER=csvusr + - DB_PASSWORD=Fdp1989Dor@ + - DB_NAME=csv + - JWT_SECRET=csv-secret-key-change-in-production + + frontend: + build: ./frontend + container_name: csv-frontend + restart: unless-stopped + ports: + - 3003:80 + depends_on: + - backend diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..40debb2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..87e318f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + CSV - Condé Sur Vesgre + + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d45283f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + resolver 127.0.0.11 valid=10s; + location / { + try_files $uri $uri/ /index.html; + } + location /api/ { + set $upstream_backend http://backend:3002; + proxy_pass $upstream_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..24c786b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "csv-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.12", + "vite": "^5.4.7" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..fbe14a4 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; \ No newline at end of file diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..bf25102 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + CSV + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..e7e7cc8 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,37 @@ +import { Routes, Route } from "react-router-dom"; +import Navbar from "./components/Navbar"; +import Footer from "./components/Footer"; +import Home from "./pages/Home"; +import Login from "./pages/Login"; +import Register from "./pages/Register"; +import Articles from "./pages/Articles"; +import Events from "./pages/Events"; +import Listings from "./pages/Listings"; +import HelpRequests from "./pages/HelpRequests"; +import Businesses from "./pages/Businesses"; +import Admin from "./pages/Admin"; +import Profile from "./pages/Profile"; + +export default function App() { + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..3f6522d --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,25 @@ +import axios from "axios"; + +const api = axios.create({ + baseURL: "/api", + headers: { "Content-Type": "application/json" }, +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem("token"); + if (token) config.headers.Authorization = "Bearer " + token; + return config; +}); + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + } + return Promise.reject(err); + } +); + +export default api; \ No newline at end of file diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx new file mode 100644 index 0000000..e58e94f --- /dev/null +++ b/frontend/src/components/Footer.jsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; + +export default function Footer() { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 0000000..dd4b82f --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -0,0 +1,86 @@ +import { Link, useLocation } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; +import { useState } from "react"; + +export default function Navbar() { + const { user, logout } = useAuth(); + const location = useLocation(); + const [open, setOpen] = useState(false); + + const links = [ + { to: "/", label: "Accueil" }, + { to: "/articles", label: "Actualités" }, + { to: "/events", label: "Agenda" }, + { to: "/listings", label: "Annonces" }, + { to: "/help", label: "Entraide" }, + { to: "/businesses", label: "Commerces" }, + ]; + + const isActive = (path) => + location.pathname === path ? "text-csv-600 font-semibold" : "text-gray-600 hover:text-csv-600"; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..b19868a --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useState, useEffect } from "react"; +import api from "../api"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + api.get("/auth/me") + .then((res) => setUser(res.data)) + .catch(() => { localStorage.removeItem("token"); }) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + + const login = async (email, password) => { + const res = await api.post("/auth/login", { email, password }); + localStorage.setItem("token", res.data.token); + setUser(res.data.user); + return res.data; + }; + + const register = async (data) => { + const res = await api.post("/auth/register", data); + localStorage.setItem("token", res.data.token); + setUser(res.data.user); + return res.data; + }; + + const logout = () => { + localStorage.removeItem("token"); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a5f27ec --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-50 text-gray-900 antialiased; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..cd582e6 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import { AuthProvider } from "./contexts/AuthContext"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + + + +); \ No newline at end of file diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..f3d7515 --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,193 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import api from "../api"; +import { useAuth } from "../contexts/AuthContext"; + +export default function Admin() { + const { user } = useAuth(); + const navigate = useNavigate(); + const [tab, setTab] = useState("articles"); + const [articles, setArticles] = useState([]); + const [events, setEvents] = useState([]); + const [pendingBiz, setPendingBiz] = useState([]); + const [pendingListings, setPendingListings] = useState([]); + const [pendingHelp, setPendingHelp] = useState([]); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ title: "", content: "", image: "" }); + + useEffect(() => { + if (!user || user.role !== "admin") { navigate("/"); return; } + api.get("/articles").then((r) => setArticles(r.data)).catch(() => {}); + api.get("/events").then((r) => setEvents(r.data)).catch(() => {}); + api.get("/businesses/pending").then((r) => setPendingBiz(r.data)).catch(() => {}); + api.get("/listings/pending").then((r) => setPendingListings(r.data)).catch(() => {}); + api.get("/help/pending").then((r) => setPendingHelp(r.data)).catch(() => {}); + }, []); + + const createArticle = async (e) => { + e.preventDefault(); + try { + await api.post("/articles", form); + setForm({ title: "", content: "", image: "" }); + const r = await api.get("/articles"); + setArticles(r.data); + } catch (err) { alert(err.response?.data?.error || "Erreur"); } + }; + + const deleteArticle = async (id) => { + if (!confirm("Supprimer cet article ?")) return; + await api.delete("/articles/" + id); + setArticles(articles.filter((a) => a.id !== id)); + }; + + const approveListing = async (id) => { + await api.put("/listings/" + id + "/approve"); + setPendingListings(pendingListings.filter((l) => l.id !== id)); + }; + + const approveHelp = async (id) => { + await api.put("/help/" + id + "/approve"); + setPendingHelp(pendingHelp.filter((h) => h.id !== id)); + }; + + const approveBiz = async (id) => { + try { + await api.put("/businesses/" + id + "/approve"); + setPendingBiz(pendingBiz.filter((b) => b.id !== id)); + } catch (err) { + alert(err.response?.data?.error || "Erreur lors de l'approbation"); + } + }; + + const deleteEvent = async (id) => { + if (!confirm("Supprimer cet événement ?")) return; + await api.delete("/events/" + id); + setEvents(events.filter((e) => e.id !== id)); + }; + + return ( +
+

Administration

+ +
+ {["articles", "events", "listings", "help", "businesses"].map((t) => ( + + ))} +
+ + {tab === "articles" && ( +
+
+

Nouvel article

+ setForm({ ...form, title: e.target.value })} + className="w-full border rounded px-3 py-2 text-sm outline-none" /> +