chore: initialize React + Vite project with Docker deployment and apartment listing application

This commit is contained in:
2026-02-13 16:45:55 +01:00
commit 18f1f91c9f
22 changed files with 4656 additions and 0 deletions

372
src/App.jsx Normal file
View File

@@ -0,0 +1,372 @@
import { useState, useEffect, useMemo } from "react";
import {
Plus,
RefreshCw,
Search,
SlidersHorizontal,
Home,
ArrowUpDown,
Loader2,
MapPin,
} from "lucide-react";
import AppartCard from "./components/AppartCard";
import AddModal from "./components/AddModal";
import { fetchAnnonces, addAnnonce, deleteAnnonce } from "./api";
const SORT_OPTIONS = [
{ value: "prix_asc", label: "Prix croissant" },
{ value: "prix_desc", label: "Prix decroissant" },
{ value: "surface_desc", label: "Surface decroissante" },
{ value: "note_desc", label: "Meilleure note" },
{ value: "recent", label: "Plus recent" },
];
function App() {
const [annonces, setAnnonces] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [search, setSearch] = useState("");
const [sort, setSort] = useState("recent");
const [filters, setFilters] = useState({
meuble: null,
parking: null,
balcon: null,
maxPrix: "",
minSurface: "",
});
const [showFilters, setShowFilters] = useState(false);
const load = async () => {
setLoading(true);
try {
const data = await fetchAnnonces();
setAnnonces(data);
} catch (err) {
console.error("Erreur chargement:", err);
}
setLoading(false);
};
useEffect(() => {
load();
}, []);
const handleAdd = async (form) => {
await addAnnonce(form);
await load();
};
const handleDelete = async (id) => {
await deleteAnnonce(id);
await load();
};
const existingUrls = useMemo(
() => annonces.map((a) => a.URL),
[annonces]
);
const filtered = useMemo(() => {
let list = [...annonces];
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(a) =>
a.ADDRESS?.toLowerCase().includes(q) ||
a.URL?.toLowerCase().includes(q) ||
a.CHARGES?.toLowerCase().includes(q)
);
}
if (filters.meuble !== null) {
list = list.filter((a) => a.MEUBLE === filters.meuble);
}
if (filters.parking !== null) {
list = list.filter((a) => a.PARKING === filters.parking);
}
if (filters.balcon !== null) {
list = list.filter((a) => a.BALCON === filters.balcon);
}
if (filters.maxPrix) {
list = list.filter((a) => a.PRIX <= parseFloat(filters.maxPrix));
}
if (filters.minSurface) {
list = list.filter((a) => a.SURFACE >= parseInt(filters.minSurface));
}
switch (sort) {
case "prix_asc":
list.sort((a, b) => a.PRIX - b.PRIX);
break;
case "prix_desc":
list.sort((a, b) => b.PRIX - a.PRIX);
break;
case "surface_desc":
list.sort((a, b) => b.SURFACE - a.SURFACE);
break;
case "note_desc":
list.sort(
(a, b) =>
(b.NOTE_P || 0) + (b.NOTE_A || 0) - ((a.NOTE_P || 0) + (a.NOTE_A || 0))
);
break;
case "recent":
default:
list.sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
);
}
return list;
}, [annonces, search, sort, filters]);
const activeFilterCount = [
filters.meuble !== null,
filters.parking !== null,
filters.balcon !== null,
!!filters.maxPrix,
!!filters.minSurface,
].filter(Boolean).length;
const stats = useMemo(() => {
if (!annonces.length) return null;
const prices = annonces.map((a) => a.PRIX ?? 0).filter((p) => p > 0);
if (!prices.length) return { count: annonces.length, avgPrix: 0, minPrix: 0, maxPrix: 0 };
return {
count: annonces.length,
avgPrix: (prices.reduce((s, p) => s + p, 0) / prices.length).toFixed(0),
minPrix: Math.min(...prices).toFixed(0),
maxPrix: Math.max(...prices).toFixed(0),
};
}, [annonces]);
return (
<div className="min-h-screen bg-sand-50">
{/* Header */}
<header className="sticky top-0 z-40 bg-warm-50/90 backdrop-blur-md border-b border-warm-200/50">
<div className="max-w-6xl mx-auto px-5 py-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-warm-400 rounded-2xl flex items-center justify-center shadow-sm">
<Home size={18} className="text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-sand-900 tracking-tight">
Appart Nantes
</h1>
<p className="text-xs text-sand-500">
Paulin & Agathe
{stats && (
<span className="ml-1.5 text-sand-400">
-- {stats.count} annonce{stats.count > 1 ? "s" : ""} | moy.{" "}
{stats.avgPrix} EUR | {stats.minPrix} - {stats.maxPrix} EUR
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={load}
disabled={loading}
className="p-2.5 rounded-xl border border-sand-200 hover:bg-sand-100 transition-colors text-sand-500 disabled:opacity-50"
title="Rafraichir"
>
<RefreshCw
size={16}
className={loading ? "animate-spin" : ""}
/>
</button>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-4 py-2.5 bg-warm-500 text-white rounded-xl text-sm font-semibold hover:bg-warm-600 transition-colors shadow-sm"
>
<Plus size={16} />
<span className="hidden sm:inline">Ajouter</span>
</button>
</div>
</div>
{/* Search + Sort + Filters toggle */}
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<Search
size={15}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-sand-400"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher par adresse..."
className="w-full pl-10 pr-4 py-2.5 text-sm border border-sand-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-warm-300/50 focus:border-warm-300 bg-white/80 text-sand-800 placeholder:text-sand-400"
/>
</div>
<div className="relative">
<ArrowUpDown
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-sand-400 pointer-events-none"
/>
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
className="pl-9 pr-4 py-2.5 text-sm border border-sand-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-warm-300/50 focus:border-warm-300 bg-white/80 text-sand-700 appearance-none cursor-pointer"
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`relative p-2.5 rounded-xl border transition-colors ${
showFilters || activeFilterCount
? "border-warm-400 bg-warm-50 text-warm-600"
: "border-sand-200 hover:bg-sand-100 text-sand-500"
}`}
>
<SlidersHorizontal size={16} />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-warm-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{activeFilterCount}
</span>
)}
</button>
</div>
{/* Filters panel */}
{showFilters && (
<div className="mt-3 p-3.5 bg-white/60 rounded-xl border border-sand-200 flex flex-wrap items-center gap-3">
{[
{ key: "meuble", label: "Meuble" },
{ key: "parking", label: "Parking" },
{ key: "balcon", label: "Balcon" },
].map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<span className="text-xs text-sand-600 font-medium">{label}</span>
<select
value={
filters[key] === null
? ""
: filters[key]
? "true"
: "false"
}
onChange={(e) =>
setFilters((f) => ({
...f,
[key]:
e.target.value === ""
? null
: e.target.value === "true",
}))
}
className="px-2 py-1 text-xs border border-sand-200 rounded-lg bg-white text-sand-700 focus:outline-none focus:ring-1 focus:ring-warm-300"
>
<option value="">Tous</option>
<option value="true">Oui</option>
<option value="false">Non</option>
</select>
</div>
))}
<div className="flex items-center gap-1.5">
<span className="text-xs text-sand-600 font-medium">Prix max</span>
<input
type="number"
value={filters.maxPrix}
onChange={(e) =>
setFilters((f) => ({ ...f, maxPrix: e.target.value }))
}
placeholder="EUR"
className="w-20 px-2 py-1 text-xs border border-sand-200 rounded-lg bg-white text-sand-700 focus:outline-none focus:ring-1 focus:ring-warm-300"
/>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-sand-600 font-medium">Surface min</span>
<input
type="number"
value={filters.minSurface}
onChange={(e) =>
setFilters((f) => ({ ...f, minSurface: e.target.value }))
}
placeholder="m2"
className="w-20 px-2 py-1 text-xs border border-sand-200 rounded-lg bg-white text-sand-700 focus:outline-none focus:ring-1 focus:ring-warm-300"
/>
</div>
{activeFilterCount > 0 && (
<button
onClick={() =>
setFilters({
meuble: null,
parking: null,
balcon: null,
maxPrix: "",
minSurface: "",
})
}
className="text-xs text-warm-600 hover:text-warm-800 font-semibold"
>
Reinitialiser
</button>
)}
</div>
)}
</div>
</header>
{/* Content */}
<main className="max-w-6xl mx-auto px-5 py-8">
{loading && !annonces.length ? (
<div className="flex flex-col items-center justify-center py-24 text-sand-400">
<Loader2 size={28} className="animate-spin mb-3 text-warm-400" />
<p className="text-sm text-sand-500">Chargement des annonces...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="w-16 h-16 bg-warm-100 rounded-full flex items-center justify-center mb-4">
<MapPin size={28} className="text-warm-400" />
</div>
<p className="text-sm font-semibold text-sand-700">
Aucune annonce trouvee
</p>
<p className="text-xs mt-1 text-sand-400">
{annonces.length
? "Essaie de modifier tes filtres"
: "Ajoute ta premiere annonce !"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{filtered.map((a, idx) => (
<AppartCard
key={a.id ?? idx}
annonce={a}
onDelete={handleDelete}
onUpdated={load}
/>
))}
</div>
)}
</main>
{/* Add modal */}
<AddModal
open={showAdd}
onClose={() => setShowAdd(false)}
onSubmit={handleAdd}
existingUrls={existingUrls}
/>
</div>
);
}
export default App;