This commit is contained in:
Julien Froidefond
2025-12-19 09:59:22 +01:00
commit 165928d64e
24 changed files with 5099 additions and 0 deletions

333
app/page.tsx Normal file
View File

@@ -0,0 +1,333 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { parseCSV, Person } from '@/lib/csv-parser';
export default function Home() {
const [people, setPeople] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCount, setSelectedCount] = useState<number>(5);
const [selectedPostes, setSelectedPostes] = useState<Set<string>>(new Set());
const [randomPeople, setRandomPeople] = useState<Person[]>([]);
const [showResults, setShowResults] = useState(false);
const [searchPoste, setSearchPoste] = useState<string>('');
useEffect(() => {
async function loadData() {
try {
const response = await fetch('/api/people');
const data = await response.json();
setPeople(data);
} catch (error) {
console.error('Error loading data:', error);
} finally {
setLoading(false);
}
}
loadData();
}, []);
// Compter le nombre de personnes par poste
const posteCounts = useMemo(() => {
const counts: Record<string, number> = {};
people.forEach(person => {
if (person.description) {
counts[person.description] = (counts[person.description] || 0) + 1;
}
});
return counts;
}, [people]);
// Extraire tous les postes uniques triés par nombre décroissant
const allPostes = useMemo(() => {
const postes = new Set<string>();
people.forEach(person => {
if (person.description) {
postes.add(person.description);
}
});
return Array.from(postes).sort((a, b) => {
const countA = posteCounts[a] || 0;
const countB = posteCounts[b] || 0;
// Tri décroissant par nombre, puis alphabétique si égal
if (countA !== countB) {
return countB - countA;
}
return a.localeCompare(b);
});
}, [people, posteCounts]);
// Filtrer les postes selon la recherche
const filteredPostes = useMemo(() => {
if (!searchPoste.trim()) {
return allPostes;
}
const searchLower = searchPoste.toLowerCase();
return allPostes.filter(poste =>
poste.toLowerCase().includes(searchLower)
);
}, [allPostes, searchPoste]);
// Filtrer les personnes selon les postes sélectionnés
const filteredPeople = useMemo(() => {
if (selectedPostes.size === 0) {
return people;
}
return people.filter(person =>
person.description && selectedPostes.has(person.description)
);
}, [people, selectedPostes]);
const handlePosteToggle = (poste: string) => {
const newSelected = new Set(selectedPostes);
if (newSelected.has(poste)) {
newSelected.delete(poste);
} else {
newSelected.add(poste);
}
setSelectedPostes(newSelected);
setShowResults(false);
};
const handleSelectAll = () => {
if (selectedPostes.size === allPostes.length) {
setSelectedPostes(new Set());
} else {
setSelectedPostes(new Set(allPostes));
}
setShowResults(false);
};
const handleRandomize = () => {
if (selectedCount <= 0 || selectedCount > filteredPeople.length) {
alert(`Veuillez entrer un nombre entre 1 et ${filteredPeople.length}`);
return;
}
const shuffled = [...filteredPeople].sort(() => Math.random() - 0.5);
const selected = shuffled.slice(0, selectedCount);
setRandomPeople(selected);
setShowResults(true);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="relative">
<div className="w-16 h-16 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
<div className="absolute inset-0 w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
</div>
</div>
);
}
return (
<div className="min-h-screen py-8 px-4 relative overflow-hidden">
{/* Effets de fond animés */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
</div>
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-12">
<h1 className="text-6xl font-black mb-4 text-gradient tracking-tight">
People Randomizr
</h1>
<p className="text-cyan-300 text-lg font-light">Sélection intelligente Extraction aléatoire</p>
</div>
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-cyan-500/20">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
Filtres par poste
</h2>
<div className="flex items-center gap-4">
<div className="px-4 py-2 glass rounded-lg border border-cyan-500/30">
<span className="text-sm text-cyan-300">
<span className="font-bold text-cyan-400 text-lg">{selectedPostes.size}</span>
<span className="text-purple-300"> / </span>
<span className="text-white">{allPostes.length}</span>
<span className="text-gray-400 ml-2">sélectionné{selectedPostes.size > 1 ? 's' : ''}</span>
</span>
</div>
<button
onClick={handleSelectAll}
className="px-5 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-cyan-600 to-purple-600 rounded-lg hover:from-cyan-500 hover:to-purple-500 transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 border border-cyan-400/30"
>
{selectedPostes.size === allPostes.length ? 'Tout désélectionner' : 'Tout sélectionner'}
</button>
</div>
</div>
{/* Barre de recherche */}
<div className="mb-6">
<div className="relative">
<input
type="text"
placeholder="🔍 Rechercher un poste..."
value={searchPoste}
onChange={(e) => setSearchPoste(e.target.value)}
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white placeholder-gray-400 border border-cyan-500/20 transition-all duration-300"
/>
{searchPoste && (
<button
onClick={() => setSearchPoste('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
>
</button>
)}
</div>
</div>
{/* Badges de filtres */}
<div className="max-h-64 overflow-y-auto p-3 mb-6 custom-scrollbar">
<div className="flex flex-wrap gap-3">
{filteredPostes.length === 0 ? (
<div className="w-full text-center py-8">
<p className="text-gray-400 text-sm">Aucun poste trouvé</p>
</div>
) : (
filteredPostes.map((poste) => {
const isSelected = selectedPostes.has(poste);
const count = posteCounts[poste] || 0;
return (
<button
key={poste}
onClick={() => handlePosteToggle(poste)}
className={`
px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300
flex items-center gap-2 border-2 relative overflow-hidden group
${isSelected
? 'bg-gradient-to-r from-cyan-600 to-purple-600 text-white border-cyan-400 shadow-lg glow-cyan transform scale-105'
: 'glass text-gray-300 border-cyan-500/20 hover:border-cyan-400/50 hover:bg-cyan-500/10 hover:text-white'
}
`}
>
{isSelected && (
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 animate-pulse"></div>
)}
<span className="relative z-10">{poste}</span>
<span className={`
px-2.5 py-1 rounded-lg text-xs font-bold relative z-10
${isSelected
? 'bg-white/20 text-white backdrop-blur-sm'
: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
}
`}>
{count}
</span>
</button>
);
})
)}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-6 items-end">
<div className="flex-1">
<label htmlFor="count" className="block text-sm font-semibold text-cyan-300 mb-3">
Nombre de personnes à extraire
</label>
<input
id="count"
type="number"
min="1"
max={filteredPeople.length}
value={selectedCount}
onChange={(e) => setSelectedCount(parseInt(e.target.value) || 1)}
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white bg-white/5 border border-cyan-500/20 transition-all duration-300"
/>
<p className="text-sm text-gray-400 mt-2 font-medium">
{selectedPostes.size === 0 ? (
<>📊 Total disponible: <span className="text-cyan-400 font-bold">{people.length}</span> personnes</>
) : (
<>🎯 Total filtré: <span className="text-cyan-400 font-bold">{filteredPeople.length}</span> personne{filteredPeople.length > 1 ? 's' : ''} <span className="text-gray-500">(sur {people.length})</span></>
)}
</p>
</div>
<button
onClick={handleRandomize}
disabled={filteredPeople.length === 0}
className="px-8 py-4 bg-gradient-to-r from-cyan-600 via-purple-600 to-pink-600 text-white font-bold rounded-xl hover:from-cyan-500 hover:via-purple-500 hover:to-pink-500 focus:outline-none transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-cyan-600 disabled:hover:via-purple-600 disabled:hover:to-pink-600 border border-cyan-400/30 relative overflow-hidden group"
>
<span className="relative z-10 flex items-center gap-2">
<span>🎲</span>
<span>Extraire aléatoirement</span>
</span>
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</button>
</div>
</div>
{showResults && randomPeople.length > 0 && (
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-purple-500/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-8 bg-gradient-to-b from-cyan-400 to-purple-400 rounded-full"></div>
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
Résultats sélectionnés
</h2>
<span className="px-3 py-1 glass rounded-lg text-cyan-300 font-semibold border border-cyan-500/30">
{randomPeople.length} personne{randomPeople.length > 1 ? 's' : ''}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{randomPeople.map((person, index) => (
<div
key={`${person.nom}-${index}`}
className="glass rounded-xl p-5 hover:bg-white/10 border border-cyan-500/20 hover:border-cyan-400/50 transition-all duration-300 hover:shadow-lg hover:shadow-cyan-500/20 group"
style={{ animationDelay: `${index * 50}ms` }}
>
<h3 className="font-bold text-lg text-white mb-2 group-hover:text-cyan-300 transition-colors">
{person.nom}
</h3>
{person.description && (
<p className="text-sm text-gray-300 mb-3">{person.description}</p>
)}
<span className="inline-block px-3 py-1 text-xs rounded-lg bg-gradient-to-r from-cyan-600/30 to-purple-600/30 text-cyan-300 border border-cyan-500/30 font-semibold">
{person.type}
</span>
</div>
))}
</div>
</div>
)}
<div className="glass-strong rounded-2xl p-8 shadow-2xl border border-purple-500/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-8 bg-gradient-to-b from-purple-400 to-pink-400 rounded-full"></div>
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
{selectedPostes.size === 0
? `Toutes les personnes`
: `Personnes filtrées`
}
</h2>
<span className="px-3 py-1 glass rounded-lg text-purple-300 font-semibold border border-purple-500/30">
{filteredPeople.length} / {people.length}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-96 overflow-y-auto custom-scrollbar p-2">
{filteredPeople.map((person, index) => (
<div
key={`${person.nom}-${index}`}
className={`glass rounded-xl p-4 hover:bg-white/10 border transition-all duration-300 hover:shadow-lg ${
selectedPostes.size > 0 && selectedPostes.has(person.description)
? 'border-cyan-400/50 bg-cyan-500/10 hover:border-cyan-400 hover:shadow-cyan-500/20'
: 'border-purple-500/20 hover:border-purple-400/50'
}`}
>
<h3 className="font-semibold text-white mb-1">{person.nom}</h3>
{person.description && (
<p className="text-sm text-gray-400 mt-1">{person.description}</p>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}