Initial commit: CSV Vie Locale
This commit is contained in:
@@ -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;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CSV - Condé Sur Vesgre</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns=http://www.w3.org/2000/svg viewBox=0 0 100 100>
|
||||
<rect width=100 height=100 rx=20 fill=#16a34a/>
|
||||
<text x=50 y=65 font-size=40 font-weight=bold fill=white text-anchor=middle font-family=Arial>CSV</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 223 B |
@@ -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 (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/articles" element={<Articles />} />
|
||||
<Route path="/articles/:id" element={<Articles />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/help" element={<HelpRequests />} />
|
||||
<Route path="/businesses" element={<Businesses />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-white border-t mt-12">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="font-bold text-csv-700 text-lg mb-2">CSV</h3>
|
||||
<p className="text-sm text-gray-500">Condé Sur Vesgre - L'application citoyenne du village.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">Liens</h4>
|
||||
<div className="space-y-1 text-sm text-gray-500">
|
||||
<Link to="/articles" className="block hover:text-csv-600">Actualités</Link>
|
||||
<Link to="/events" className="block hover:text-csv-600">Agenda</Link>
|
||||
<Link to="/listings" className="block hover:text-csv-600">Annonces</Link>
|
||||
<Link to="/help" className="block hover:text-csv-600">Entraide</Link>
|
||||
<Link to="/businesses" className="block hover:text-csv-600">Commerces</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">À propos</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
Application citoyenne libre et gratuite pour les habitants de Condé Sur Vesgre.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t mt-6 pt-4 text-center text-xs text-gray-400">
|
||||
© {new Date().getFullYear()} CSV - Condé Sur Vesgre
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className="bg-white shadow-sm border-b sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="flex justify-between h-16">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src="/api/uploads/logo.png" alt="Condé sur Vesgre" className="h-10 w-10 rounded-full object-cover" />
|
||||
<span className="text-lg font-bold text-csv-700 leading-tight">
|
||||
Condé<br /><span className="text-sm font-normal">sur Vesgre</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-6 text-sm">
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={isActive(l.to)}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/profile" className="text-sm text-gray-600 hover:text-csv-600">
|
||||
{user.name}
|
||||
</Link>
|
||||
{user.role === "admin" && (
|
||||
<Link to="/admin" className="text-xs bg-csv-100 text-csv-700 px-2 py-1 rounded">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={logout} className="text-sm text-red-500 hover:text-red-700">
|
||||
Quitter
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="text-sm text-csv-600 hover:text-csv-700">Connexion</Link>
|
||||
<Link to="/register" className="text-sm bg-csv-600 text-white px-3 py-1.5 rounded hover:bg-csv-700">Inscription</Link>
|
||||
</>
|
||||
)}
|
||||
<button className="md:hidden p-2" onClick={() => setOpen(!open)}>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{open ? (
|
||||
<path strokeLinecap="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="md:hidden pb-4 space-y-2">
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={`block px-3 py-2 rounded text-sm ${isActive(l.to)}`} onClick={() => setOpen(false)}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Administration</h1>
|
||||
|
||||
<div className="flex gap-2 mb-6 border-b pb-2">
|
||||
{["articles", "events", "listings", "help", "businesses"].map((t) => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={"px-4 py-2 text-sm rounded-t " + (tab === t ? "bg-csv-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200")}>
|
||||
{t === "articles" ? "Actualités" : t === "events" ? "Événements" : t === "listings" ? "Annonces" : t === "help" ? "Entraide" : "Commerces"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "articles" && (
|
||||
<div>
|
||||
<form onSubmit={createArticle} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<h2 className="font-semibold">Nouvel article</h2>
|
||||
<input placeholder="Titre *" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<textarea placeholder="Contenu *" required rows={6} value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none font-mono" />
|
||||
<input placeholder="URL image (optionnelle)" value={form.image} onChange={(e) => setForm({ ...form, image: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm">Publier</button>
|
||||
</form>
|
||||
<div className="space-y-2">
|
||||
{articles.map((a) => (
|
||||
<div key={a.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{a.title}</span>
|
||||
<span className={"ml-2 text-xs px-2 py-0.5 rounded " + (a.published ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500")}>
|
||||
{a.published ? "Publié" : "Brouillon"}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => deleteArticle(a.id)} className="text-red-500 text-sm hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "events" && (
|
||||
<div className="space-y-2">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{e.title}</span>
|
||||
<span className="ml-2 text-xs text-gray-400">{new Date(e.date).toLocaleDateString("fr-FR")}</span>
|
||||
</div>
|
||||
<button onClick={() => deleteEvent(e.id)} className="text-red-500 text-sm hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "listings" && (
|
||||
<div>
|
||||
{pendingListings.length > 0 ? (
|
||||
<div>
|
||||
<h2 className="font-semibold mb-3">Annonces en attente d'approbation</h2>
|
||||
<div className="space-y-2">
|
||||
{pendingListings.map((l) => (
|
||||
<div key={l.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{l.title}</span>
|
||||
<p className="text-sm text-gray-500">par {l.author_name}</p>
|
||||
</div>
|
||||
<button onClick={() => approveListing(l.id)} className="bg-csv-600 text-white px-3 py-1 rounded text-sm">Approuver</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400">Aucune annonce en attente.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "help" && (
|
||||
<div>
|
||||
{pendingHelp.length > 0 ? (
|
||||
<div>
|
||||
<h2 className="font-semibold mb-3">Demandes d'entraide en attente d'approbation</h2>
|
||||
<div className="space-y-2">
|
||||
{pendingHelp.map((h) => (
|
||||
<div key={h.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{h.title}</span>
|
||||
<p className="text-sm text-gray-500">par {h.author_name}</p>
|
||||
</div>
|
||||
<button onClick={() => approveHelp(h.id)} className="bg-csv-600 text-white px-3 py-1 rounded text-sm">Approuver</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400">Aucune demande en attente.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "businesses" && (
|
||||
<div>
|
||||
{pendingBiz.length > 0 ? (
|
||||
<div>
|
||||
<h2 className="font-semibold mb-3">En attente d'approbation</h2>
|
||||
<div className="space-y-2">
|
||||
{pendingBiz.map((b) => (
|
||||
<div key={b.id} className="flex justify-between items-center border rounded p-3 bg-white">
|
||||
<div>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
<p className="text-sm text-gray-500">{b.description}</p>
|
||||
</div>
|
||||
<button onClick={() => approveBiz(b.id)} className="bg-csv-600 text-white px-3 py-1 rounded text-sm">Approuver</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400">Aucun commerce en attente.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
export default function Articles() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [article, setArticle] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
api.get("/articles/" + id).then((r) => setArticle(r.data)).catch(() => {});
|
||||
} else {
|
||||
setArticle(null);
|
||||
api.get("/articles").then((r) => setArticles(r.data)).catch(() => {});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (article) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<button onClick={() => navigate("/articles")} className="text-sm text-csv-600 hover:underline mb-4 inline-block">← Retour aux actualités</button>
|
||||
<h1 className="text-2xl font-bold mb-2">{article.title}</h1>
|
||||
<p className="text-sm text-gray-400 mb-4">{new Date(article.created_at).toLocaleDateString("fr-FR")} par {article.author_name}</p>
|
||||
<div className="prose max-w-none whitespace-pre-wrap text-gray-700">{article.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Actualités du village</h1>
|
||||
{articles.length === 0 ? (
|
||||
<p className="text-gray-400">Aucune actualité pour le moment.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{articles.map((a) => (
|
||||
<Link key={a.id} to={"/articles/" + a.id} className="block border rounded-lg p-5 hover:shadow-md">
|
||||
<p className="text-xs text-gray-400 mb-1">{new Date(a.created_at).toLocaleDateString("fr-FR")}</p>
|
||||
<h2 className="text-lg font-semibold mb-1">{a.title}</h2>
|
||||
<p className="text-sm text-gray-500 line-clamp-2">{a.content}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Businesses() {
|
||||
const { user } = useAuth();
|
||||
const [businesses, setBusinesses] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", description: "", category: "", address: "", phone: "", website: "", email: "", image: "" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
api.get("/businesses").then((r) => setBusinesses(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post("/businesses", form);
|
||||
setShowForm(false);
|
||||
setForm({ name: "", description: "", category: "", address: "", phone: "", website: "", email: "", image: "" });
|
||||
alert("Commerce soumis ! En attente d'approbation par un administrateur.");
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
const res = await api.post("/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm({ ...form, image: res.data.url });
|
||||
} catch (err) { alert("Erreur upload"); }
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Commerces & Artisans</h1>
|
||||
<p className="text-sm text-gray-500">Découvrez les commerces et artisans de Condé Sur Vesgre</p>
|
||||
</div>
|
||||
{user && (
|
||||
<button onClick={() => setShowForm(!showForm)} className="bg-csv-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-csv-700">
|
||||
{showForm ? "Annuler" : "Ajouter mon commerce"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<input placeholder="Nom du commerce *" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<textarea placeholder="Description" rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input placeholder="Catégorie" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Adresse" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Téléphone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Site web" value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Email" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none col-span-2" />
|
||||
</div>
|
||||
{form.image && <img src={form.image} alt="" className="h-20 rounded" />}
|
||||
<input type="file" accept="image/*" onChange={handleImageUpload} className="text-sm" />
|
||||
{uploading && <span className="text-xs text-gray-400">Upload...</span>}
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm hover:bg-csv-700">Soumettre</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{businesses.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">Aucun commerce référencé pour le moment.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{businesses.map((b) => (
|
||||
<div key={b.id} className="border rounded-lg p-4 bg-white">
|
||||
{b.image && <img src={b.image} alt={b.name} className="w-full h-36 object-cover rounded mb-2" />}
|
||||
<h3 className="font-semibold text-lg">{b.name}</h3>
|
||||
{b.category && <span className="text-xs bg-csv-100 text-csv-700 px-2 py-0.5 rounded-full">{b.category}</span>}
|
||||
{b.description && <p className="text-sm text-gray-600 mt-2">{b.description}</p>}
|
||||
<div className="mt-3 space-y-1 text-sm text-gray-500">
|
||||
{b.address && <p>📍 {b.address}</p>}
|
||||
{b.phone && <p>📞 {b.phone}</p>}
|
||||
{b.website && <a href={b.website} target="_blank" rel="noopener noreferrer" className="text-csv-600 hover:underline block">🌐 {b.website}</a>}
|
||||
{b.email && <p>✉ {b.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
|
||||
export default function Events() {
|
||||
const [events, setEvents] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/events").then((r) => setEvents(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const categoryColors = {
|
||||
brocante: "bg-orange-100 text-orange-700",
|
||||
marche: "bg-green-100 text-green-700",
|
||||
culture: "bg-purple-100 text-purple-700",
|
||||
sport: "bg-blue-100 text-blue-700",
|
||||
reunion: "bg-yellow-100 text-yellow-700",
|
||||
autre: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Agenda du village</h1>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-gray-400">Aucun événement à venir.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="border rounded-lg p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-center min-w-[60px]">
|
||||
<p className="text-2xl font-bold text-csv-600">{new Date(e.date).getDate()}</p>
|
||||
<p className="text-xs text-gray-500 uppercase">{new Date(e.date).toLocaleDateString("fr-FR", { month: "short" })}</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-lg">{e.title}</h2>
|
||||
{e.category && (
|
||||
<span className={"inline-block text-xs px-2 py-0.5 rounded-full mt-1 " + (categoryColors[e.category] || categoryColors.autre)}>
|
||||
{e.category}
|
||||
</span>
|
||||
)}
|
||||
{e.description && <p className="text-sm text-gray-600 mt-2">{e.description}</p>}
|
||||
{(e.location || e.time) && (
|
||||
<div className="flex gap-4 mt-2 text-xs text-gray-400">
|
||||
{e.location && <span>📍 {e.location}</span>}
|
||||
{e.time && <span>🕔 {e.time.slice(0, 5)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function HelpRequests() {
|
||||
const { user } = useAuth();
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ title: "", description: "", category: "autre", urgent: false, image: "" });
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
api.get("/help").then((r) => setRequests(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Supprimer cette demande ?")) return;
|
||||
try {
|
||||
await api.delete("/help/" + id);
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.put("/help/" + editingId, form);
|
||||
} else {
|
||||
await api.post("/help", form);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setForm({ title: "", description: "", category: "autre", urgent: false, image: "" });
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleEdit = (r) => {
|
||||
setForm({ title: r.title, description: r.description, category: r.category, urgent: r.urgent === 1, image: r.image || "" });
|
||||
setEditingId(r.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
const res = await api.post("/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm({ ...form, image: res.data.url });
|
||||
} catch (err) { alert("Erreur upload"); }
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const categoryLabels = { course: "Courses", bricolage: "Bricolage", jardinage: "Jardinage", transport: "Transport", numerique: "Numérique", autre: "Autre" };
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Entraide</h1>
|
||||
<p className="text-sm text-gray-500">Demander ou proposer de l'aide aux habitants</p>
|
||||
</div>
|
||||
{user && (
|
||||
<button onClick={() => { setShowForm(!showForm); setEditingId(null); setForm({ title: "", description: "", category: "autre", urgent: false, image: "" }); }} className="bg-csv-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-csv-700">
|
||||
{showForm ? "Annuler" : "Demander de l'aide"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<input placeholder="Titre *" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<textarea placeholder="Description *" required rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<div className="flex gap-3 items-center">
|
||||
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none">
|
||||
{Object.entries(categoryLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.urgent} onChange={(e) => setForm({ ...form, urgent: e.target.checked })} />
|
||||
Urgent
|
||||
</label>
|
||||
</div>
|
||||
{form.image && <img src={form.image} alt="" className="h-20 rounded" />}
|
||||
<div>
|
||||
<input type="file" accept="image/*" onChange={handleImageUpload} className="text-sm" />
|
||||
{uploading && <span className="text-xs text-gray-400 ml-2">Upload...</span>}
|
||||
</div>
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm hover:bg-csv-700">
|
||||
{editingId ? "Mettre a jour" : "Publier"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">Aucune demande d'aide pour le moment.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{requests.map((r) => (
|
||||
<div key={r.id} className={"border rounded-lg p-4 bg-white " + (r.urgent ? "border-red-300" : "")}>
|
||||
{r.image && <img src={r.image} alt={r.title} className="w-full h-36 object-cover rounded mb-2" />}
|
||||
<div className="flex items-start gap-3">
|
||||
{r.urgent && <span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full mt-0.5">Urgent</span>}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{r.title}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{r.description}</p>
|
||||
<div className="flex gap-3 mt-2 text-xs text-gray-400">
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded">{categoryLabels[r.category] || r.category}</span>
|
||||
<span>par {r.author_name}</span>
|
||||
{r.author_phone && <span>📞 {r.author_phone}</span>}
|
||||
{user && r.author_id === user.id && (
|
||||
<>
|
||||
<button onClick={() => handleEdit(r)} className="text-blue-500 hover:text-blue-700 ml-auto mr-2">Modifier</button>
|
||||
<button onClick={() => handleDelete(r.id)} className="text-red-500 hover:text-red-700">Supprimer</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
export default function Home() {
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [listings, setListings] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/articles").then((r) => setArticles(r.data.slice(0, 3))).catch(() => {});
|
||||
api.get("/events").then((r) => setEvents(r.data.slice(0, 3))).catch(() => {});
|
||||
api.get("/listings").then((r) => setListings(r.data.slice(0, 4))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="bg-gradient-to-br from-csv-600 to-csv-800 text-white">
|
||||
<div className="max-w-6xl mx-auto px-4 py-16 md:py-24">
|
||||
<h1 className="text-3xl md:text-5xl font-bold mb-4">Condé Sur Vesgre</h1>
|
||||
<p className="text-lg md:text-xl text-csv-100 mb-8 max-w-2xl">
|
||||
L'application citoyenne pour tout savoir sur le village : actualités, événements, annonces, entraide et commerces.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/register" className="bg-white text-csv-700 px-6 py-3 rounded-lg font-semibold hover:bg-csv-50">
|
||||
Rejoindre la communauté
|
||||
</Link>
|
||||
<Link to="/articles" className="border border-white/30 text-white px-6 py-3 rounded-lg font-semibold hover:bg-white/10">
|
||||
Voir les actualités
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-6xl mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
{[
|
||||
{ title: "Actualités", desc: "Infos et annonces du village", to: "/articles", color: "bg-blue-50 text-blue-700" },
|
||||
{ title: "Agenda", desc: "Tous les événements à venir", to: "/events", color: "bg-orange-50 text-orange-700" },
|
||||
{ title: "Annonces", desc: "Don, vente, troc entre habitants", to: "/listings", color: "bg-green-50 text-green-700" },
|
||||
{ title: "Entraide", desc: "Demander ou proposer de l'aide", to: "/help", color: "bg-red-50 text-red-700" },
|
||||
{ title: "Commerces", desc: "Artisans et commerces du village", to: "/businesses", color: "bg-purple-50 text-purple-700" },
|
||||
].map((item) => (
|
||||
<Link key={item.title} to={item.to} className="p-6 rounded-xl border hover:shadow-md transition-shadow">
|
||||
<h3 className={`inline-block px-3 py-1 rounded-full text-sm font-semibold mb-3 ${item.color}`}>{item.title}</h3>
|
||||
<p className="text-gray-600 text-sm">{item.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{articles.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Dernières actualités</h2>
|
||||
<Link to="/articles" className="text-sm text-csv-600 hover:underline">Tout voir</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{articles.map((a) => (
|
||||
<Link key={a.id} to={"/articles/" + a.id} className="border rounded-lg p-4 hover:shadow-md">
|
||||
<p className="text-xs text-gray-400 mb-1">{new Date(a.created_at).toLocaleDateString("fr-FR")}</p>
|
||||
<h3 className="font-semibold mb-1">{a.title}</h3>
|
||||
<p className="text-sm text-gray-500 line-clamp-2">{a.content}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{listings.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Dernières annonces</h2>
|
||||
<Link to="/listings" className="text-sm text-csv-600 hover:underline">Tout voir</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{listings.map((l) => (
|
||||
<div key={l.id} className="border rounded-lg p-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-csv-100 text-csv-700">{l.type}</span>
|
||||
<h3 className="font-medium text-sm mt-1">{l.title}</h3>
|
||||
{l.price > 0 && <p className="text-csv-600 font-semibold text-sm">{l.price}€</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Listings() {
|
||||
const { user } = useAuth();
|
||||
const [listings, setListings] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [filter, setFilter] = useState({ type: "all", category: "", search: "" });
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ title: "", description: "", price: "", type: "don", category_id: "", image: "" });
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.type && filter.type !== "all") params.set("type", filter.type);
|
||||
if (filter.category) params.set("category", filter.category);
|
||||
if (filter.search) params.set("search", filter.search);
|
||||
api.get("/listings?" + params.toString()).then((r) => setListings(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filter]);
|
||||
useEffect(() => { api.get("/listings/categories/all").then((r) => setCategories(r.data)).catch(() => {}); }, []);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Supprimer cette annonce ?")) return;
|
||||
try {
|
||||
await api.delete("/listings/" + id);
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.put("/listings/" + editingId, form);
|
||||
} else {
|
||||
await api.post("/listings", form);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setForm({ title: "", description: "", price: "", type: "don", category_id: "", image: "" });
|
||||
load();
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const handleEdit = (l) => {
|
||||
setForm({ title: l.title, description: l.description, price: l.price || "", type: l.type, category_id: l.category_id || "", image: l.image || "" });
|
||||
setEditingId(l.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
const res = await api.post("/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm({ ...form, image: res.data.url });
|
||||
} catch (err) { alert("Erreur upload"); }
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Annonces entre habitants</h1>
|
||||
{user && (
|
||||
<button onClick={() => { setShowForm(!showForm); setEditingId(null); setForm({ title: "", description: "", price: "", type: "don", category_id: "", image: "" }); }} className="bg-csv-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-csv-700">
|
||||
{showForm ? "Annuler" : "Publier une annonce"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="border rounded-lg p-4 mb-6 space-y-3 bg-white">
|
||||
<input placeholder="Titre *" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<textarea placeholder="Description" rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-csv-500" />
|
||||
<div className="flex gap-3">
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none">
|
||||
<option value="don">Don</option>
|
||||
<option value="vente">Vente</option>
|
||||
<option value="troc">Troc</option>
|
||||
</select>
|
||||
<select value={form.category_id} onChange={(e) => setForm({ ...form, category_id: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm outline-none">
|
||||
<option value="">Catégorie</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
{form.type === "vente" && (
|
||||
<input type="number" step="0.01" placeholder="Prix" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })}
|
||||
className="border rounded px-3 py-2 text-sm w-24 outline-none" />
|
||||
)}
|
||||
</div>
|
||||
{form.image && <img src={form.image} alt="" className="h-20 rounded" />}
|
||||
<input type="file" accept="image/*" onChange={handleImageUpload} className="text-sm" />
|
||||
{uploading && <span className="text-xs text-gray-400">Upload...</span>}
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm hover:bg-csv-700">
|
||||
{editingId ? "Mettre a jour" : "Publier"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}
|
||||
className="border rounded px-3 py-1.5 text-sm outline-none">
|
||||
<option value="all">Tous</option>
|
||||
<option value="don">Dons</option>
|
||||
<option value="vente">Ventes</option>
|
||||
<option value="troc">Troc</option>
|
||||
</select>
|
||||
<select value={filter.category} onChange={(e) => setFilter({ ...filter, category: e.target.value })}
|
||||
className="border rounded px-3 py-1.5 text-sm outline-none">
|
||||
<option value="">Toutes catégories</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<input placeholder="Rechercher..." value={filter.search} onChange={(e) => setFilter({ ...filter, search: e.target.value })}
|
||||
className="border rounded px-3 py-1.5 text-sm outline-none flex-1 min-w-[200px]" />
|
||||
</div>
|
||||
|
||||
{listings.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">Aucune annonce pour le moment.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{listings.map((l) => (
|
||||
<div key={l.id} className="border rounded-lg p-4 bg-white">
|
||||
{l.image && <img src={l.image} alt={l.title} className="w-full h-36 object-cover rounded mb-2" />}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={"text-xs px-2 py-0.5 rounded-full " + (l.type === "don" ? "bg-green-100 text-green-700" : l.type === "vente" ? "bg-blue-100 text-blue-700" : "bg-purple-100 text-purple-700")}>
|
||||
{l.type === "don" ? "Don" : l.type === "vente" ? "Vente" : "Troc"}
|
||||
</span>
|
||||
{l.category_name && <span className="text-xs text-gray-400">{l.category_name}</span>}
|
||||
</div>
|
||||
<h3 className="font-semibold">{l.title}</h3>
|
||||
{l.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{l.description}</p>}
|
||||
{l.price > 0 && <p className="text-csv-600 font-bold mt-2">{l.price} €</p>}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-xs text-gray-400">par {l.author_name}</p>
|
||||
{user && l.author_id === user.id && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleEdit(l)} className="text-xs text-blue-500 hover:text-blue-700">Modifier</button>
|
||||
<button onClick={() => handleDelete(l.id)} className="text-xs text-red-500 hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ email: "", password: "" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(form.email, form.password);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Erreur de connexion");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-bold mb-6">Connexion</h1>
|
||||
{error && <p className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" required value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 focus:border-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
|
||||
<input type="password" required value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 focus:border-csv-500 outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-csv-600 text-white py-2 rounded-lg hover:bg-csv-700 font-medium">
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
Pas encore inscrit ? <Link to="/register" className="text-csv-600 hover:underline">Créer un compte</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Profile() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [listings, setListings] = useState([]);
|
||||
const [myBiz, setMyBiz] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", phone: "", address: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) { navigate("/login"); return; }
|
||||
setForm({ name: user.name || "", phone: user.phone || "", address: user.address || "" });
|
||||
api.get("/listings/mine").then((r) => setListings(r.data)).catch(() => {});
|
||||
api.get("/businesses/mine").then((r) => setMyBiz(r.data)).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
const updateProfile = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.put("/auth/profile", form);
|
||||
alert("Profil mis à jour");
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const deleteBiz = async (id) => {
|
||||
if (!confirm("Supprimer ce commerce ?")) return;
|
||||
try {
|
||||
await api.delete("/businesses/" + id);
|
||||
setMyBiz(myBiz.filter((b) => b.id !== id));
|
||||
} catch (err) { alert(err.response?.data?.error || "Erreur"); }
|
||||
};
|
||||
|
||||
const archiveListing = async (id) => {
|
||||
await api.put("/listings/" + id, { status: "archive" });
|
||||
setListings(listings.map((l) => l.id === id ? { ...l, status: "archive" } : l));
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Mon profil</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<form onSubmit={updateProfile} className="border rounded-lg p-4 space-y-3 bg-white">
|
||||
<h2 className="font-semibold">Informations</h2>
|
||||
<input placeholder="Nom" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Téléphone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<input placeholder="Adresse" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm outline-none" />
|
||||
<button type="submit" className="bg-csv-600 text-white px-4 py-2 rounded text-sm">Enregistrer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border rounded-lg p-4 bg-white">
|
||||
<h2 className="font-semibold mb-3">Mes annonces</h2>
|
||||
{listings.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Aucune annonce</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{listings.map((l) => (
|
||||
<div key={l.id} className="flex justify-between items-center border-b pb-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{l.title}</span>
|
||||
<span className={"ml-2 text-xs px-2 py-0.5 rounded " + (l.status === "disponible" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500")}>
|
||||
{l.status}
|
||||
</span>
|
||||
</div>
|
||||
{l.status === "disponible" && (
|
||||
<button onClick={() => archiveListing(l.id)} className="text-xs text-gray-500 hover:text-red-600">Archiver</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{myBiz.length > 0 && (
|
||||
<div className="mt-6 border rounded-lg p-4 bg-white">
|
||||
<h2 className="font-semibold mb-3">Mes commerces</h2>
|
||||
<div className="space-y-2">
|
||||
{myBiz.map((b) => (
|
||||
<div key={b.id} className="flex justify-between items-center border-b pb-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{b.name}</span>
|
||||
<span className={"ml-2 text-xs px-2 py-0.5 rounded " + (b.approved ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700")}>
|
||||
{b.approved ? "Approuvé" : "En attente"}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => deleteBiz(b.id)} className="text-xs text-red-500 hover:text-red-700">Supprimer</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Register() {
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", phone: "", address: "" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await register(form);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Erreur d'inscription");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-bold mb-6">Inscription</h1>
|
||||
{error && <p className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nom complet *</label>
|
||||
<input type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input type="email" required value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe * (6 car. min)</label>
|
||||
<input type="password" required minLength={6} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Téléphone</label>
|
||||
<input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse (Condé sur Vesgre)</label>
|
||||
<input type="text" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-csv-500 outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-csv-600 text-white py-2 rounded-lg hover:bg-csv-700 font-medium">
|
||||
Créer mon compte
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
Déjà inscrit ? <Link to="/login" className="text-csv-600 hover:underline">Se connecter</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
csv: {
|
||||
50: "#f0fdf4", 100: "#dcfce7", 200: "#bbf7d0", 300: "#86efac",
|
||||
400: "#4ade80", 500: "#22c55e", 600: "#16a34a", 700: "#15803d",
|
||||
800: "#166534", 900: "#14532d",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: "0.0.0.0", port: 5173 },
|
||||
});
|
||||
Reference in New Issue
Block a user