Init
This commit is contained in:
333
app/page.tsx
Normal file
333
app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user