feat: mise à jour sécurité CVE-2025-55182 (Next.js 15.5.7, React 19.1.2)

This commit is contained in:
jessy-david-dev
2026-02-08 17:24:48 +01:00
commit b9a9d687b6
64 changed files with 15638 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
// Mapping des textes associés à chaque visuel
const ADS_COPY: Record<string, { title: string; subtitle: string }> = {
"riseofkinkdom.jpeg": {
title: "Atteignez 50 Millions dImpuissance !",
subtitle: "Rejoignez « Rise of Kink Doms » et dominez vos adversaires.",
},
"voat.png": {
title: "Aymeric Pierre vend votre voiture !",
subtitle: "Offre exclusive : 69,42 BTC, garantie World PvP Champion.",
},
};
export default function AdsGalleryClient({ files }: { files: string[] }) {
// \u2714 État qui détermine l'image agrandie actuellement affichée
const [selected, setSelected] = useState<string | null>(null);
/**
* Gestion du clavier : ␛ pour fermer la light-box
* On utilise le « KeyboardEvent » natif du DOM, plus compatible
*/
const handleKey = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
}, []);
useEffect(() => {
if (!selected) return; // rien à faire si aucune image n'est ouverte
// ➜ attache l'écouteur
window.addEventListener("keydown", handleKey);
document.body.style.overflow = "hidden"; // bloque le scroll arrière-plan
// ➜ cleanup à la fermeture / unmount
return () => {
window.removeEventListener("keydown", handleKey);
document.body.style.overflow = "";
};
}, [selected, handleKey]);
return (
<main className="flex min-h-screen flex-col bg-[#0e0e10]">
<section className="mx-auto w-full max-w-6xl flex-1 px-4 py-12">
{/* Titre */}
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="mb-10 text-3xl font-extrabold text-red-600"
>
Galerie des publicités
</motion.h1>
{/* Message si aucun fichier */}
{files.length === 0 ? (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-gray-400"
>
Aucune bannière trouvée dans <code className="text-sm">/public/ads</code>.
</motion.p>
) : (
<motion.ul
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.1 } },
}}
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
{files.map((file) => {
const copy = ADS_COPY[file];
return (
<motion.li
key={file}
variants={{ hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 } }}
className="cursor-pointer overflow-hidden rounded-xl border border-[#1c2536] bg-[#0b1320] shadow"
onClick={() => setSelected(file)}
>
{/* Vignette */}
<div className="relative h-42 w-full">
<Image
src={`/ads/${file}`}
alt={file}
fill
className="object-cover transition-transform duration-200 hover:scale-105"
sizes="(min-width:1024px) 33vw, (min-width:640px) 50vw, 100vw"
priority
/>
</div>
{/* Texte associé */}
{copy && (
<div className="px-4 py-3 text-center">
<h2 className="text-[15px] font-bold leading-snug text-amber-100">
{copy.title.split(/(50 Millions)/).map((part, i) =>
part === "50 Millions" ? (
<span key={i} className="text-red-500">
{part}
</span>
) : (
part
)
)}
</h2>
<p className="mt-1 text-sm text-gray-300">{copy.subtitle}</p>
</div>
)}
{/* Nom de fichier */}
<p className="truncate bg-[#0b1320] px-3 py-2 text-center text-xs text-gray-500">
{file}
</p>
</motion.li>
);
})}
</motion.ul>
)}
</section>
{/* ---- Light-box plein écran ---- */}
<AnimatePresence>
{selected && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md"
onClick={(e) => {
// Ferme si on clique en dehors de l'image
if (e.target === e.currentTarget) setSelected(null);
}}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative max-h-[90vh] max-w-[90vw]"
>
<Image
src={`/ads/${selected}`}
alt={selected}
width={1200}
height={800}
className="h-auto w-auto rounded-lg shadow-xl"
priority
/>
{/* Bouton de fermeture */}
<button
aria-label="Fermer"
className="absolute right-2 top-2 rounded-full bg-black/60 p-1 text-white hover:bg-black/80 focus:outline-none"
onClick={() => setSelected(null)}
>
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</main>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { X } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
const ADS_IMAGES = [
'/ads/riseofkinkdom.jpeg',
'/ads/voat.png'
];
export default function PopupAd() {
const [visible, setVisible] = useState(false);
const [src, setSrc] = useState<string>(ADS_IMAGES[0]);
/* choix aléatoire + délai dapparition */
useEffect(() => {
setSrc(ADS_IMAGES[Math.floor(Math.random() * ADS_IMAGES.length)]);
const id = setTimeout(() => setVisible(true), 5000); // 5 s
return () => clearTimeout(id);
}, []);
if (!visible) return null;
return (
<div className="fixed bottom-6 right-6 hidden md:block z-50 animate-in fade-in slide-in-from-bottom-4">
<div className="relative w-[420px] rounded-lg overflow-hidden shadow-2xl ring-1 ring-black/20">
{/* bouton fermer */}
<button
onClick={() => setVisible(false)}
aria-label="Fermer la pub"
className="absolute right-2 top-2 z-10 rounded-full bg-black/40 p-1 hover:bg-black/60"
>
<X className="h-4 w-4 text-amber-50" />
</button>
{/* image de la pub */}
<Image
src={src}
alt="Publicité fictive"
width={420}
height={1080}
priority
className="object-cover select-none"
unoptimized
/>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
export default function Banner() {
return (
<section className="w-full bg-[#0e0e10] flex justify-center py-4">
<motion.div
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="relative w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg"
>
<div className="transition-transform duration-300 hover:scale-105">
<Image
src="/aystone-saison2.png"
alt="Aystone Saison 2"
priority
className="w-full h-auto select-none"
sizes="(max-width: 640px) 90vw, (max-width: 768px) 384px, (max-width: 1024px) 448px, 512px"
width={512}
height={200}
/>
</div>
</motion.div>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { motion } from "framer-motion";
import { Heart } from "lucide-react";
import Link from "next/link";
export default function Footer() {
return (
<motion.footer
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="w-full bg-[#18181b] py-6 text-center text-sm text-amber-50/80"
>
<div className="mx-auto flex max-w-7xl flex-col items-center gap-2 px-4">
<p>
© 2025 Aystone Instance Cobble Projet non affilié à Mojang/Microsoft
</p>
<p className="flex items-center gap-1">
Réalisé avec
<Heart size={14} className="text-red-500" aria-label="coeur" />
par
<Link href="https://ultralion.xyz" className="font-semibold text-indigo-400 hover:underline">
UltraLion
</Link>
</p>
</div>
</motion.footer>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
export default function MapsClient() {
return (
<section className="mx-auto max-w-7xl px-4 py-10">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-3xl font-bold text-red-500"
>
Carte dynamique de linstance
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="mt-2 text-amber-50/80"
>
Explore la carte interactive ci-dessous ou ouvre-la dans un nouvel onglet.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-6 flex justify-end"
>
<Link
href="https://maps.aystone.fr/cobble"
target="_blank"
rel="noopener noreferrer"
className="inline-block rounded bg-indigo-600 px-4 py-2 text-sm font-medium hover:bg-indigo-700 transition"
>
Ouvrir la carte dans un nouvel onglet
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.6, duration: 0.4 }}
className="mt-4 relative w-full pb-[56.25%] overflow-hidden rounded-2xl border border-white/10"
>
<iframe
src="https://maps.aystone.fr/cobble"
className="absolute top-0 left-0 w-full h-full"
allowFullScreen
loading="lazy"
title="Carte Aystone Cobble"
/>
</motion.div>
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { motion } from "framer-motion";
export default function McSplash() {
return (
<motion.span
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: -15 }}
transition={{ type: "spring", stiffness: 220, damping: 12, delay: 0.3 }}
className="
pointer-events-none absolute left-4
top-1/2 -translate-y-1/2
-rotate-[3deg]
select-none whitespace-nowrap
text-[clamp(12px,3vw,18px)] font-extrabold italic
text-yellow-300 drop-shadow-[2px_2px_0_#000]
"
>
Instance&nbsp;Cobble
</motion.span>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import RedstoneParticles from "@/components/RedstoneParticles";
import { motion } from "framer-motion";
export default function MotionHeading() {
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut", delay: 0.2 }}
className="flex justify-center py-8"
>
<span className="relative inline-block">
<h2
className="text-4xl sm:text-5xl font-extrabold leading-snug pb-1
bg-gradient-to-r from-red-600 via-orange-500 to-red-600
bg-clip-text text-transparent"
>
Tableau des projets
</h2>
<RedstoneParticles className="absolute inset-0" density={15} maxRadius={2} />
</span>
</motion.section>
);
}
+186
View File
@@ -0,0 +1,186 @@
/* components/Navbar.tsx */
"use client";
import McSplash from "@/components/McSplash";
import RedstoneParticles from "@/components/RedstoneParticles";
import { AnimatePresence, motion } from "framer-motion";
import {
BookOpen,
Home,
Info,
MapPin,
Megaphone,
Menu,
X,
} from "lucide-react";
import { signIn, signOut, useSession } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
const navLinks = [
{ name: "Accueil", href: "/", Icon: Home },
{ name: "Wiki Aystone", href: "https://wiki.aystone.fr", Icon: BookOpen },
{ name: "BlueMap", href: "/maps", Icon: MapPin },
{ name: "Espace Publicitaire", href: "/ads", Icon: Megaphone },
{ name: "Outils", href: "/outils", Icon: Info },
{ name: "À Propos", href: "/a-propos", Icon: Info },
];
export default function Navbar() {
const { data: session } = useSession();
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > 10);
};
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header
className={`sticky top-0 z-50 text-amber-50 transition-all duration-300 ${
scrolled
? "bg-[#18181b]/80 backdrop-blur-md shadow-md"
: "bg-[#18181b]"
}`}
>
<nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
<div className="min-w-[130px]">
<McSplash />
</div>
<Link href="/" className="flex items-center gap-2 relative">
<Image
src="/aystone.png"
alt="Logo Aystone"
width={32}
height={32}
priority
/>
<span className="text-red-600 relative font-extrabold text-lg flex items-center gap-1">
Aystone
<RedstoneParticles
className="absolute -left-1 top-0 h-full w-full z-0"
density={15}
maxRadius={2}
/>
</span>
</Link>
<ul className="hidden md:flex items-center gap-6 text-sm">
{navLinks.map(({ name, href, Icon }) => (
<li key={name} className="flex items-center gap-1">
<Icon size={14} aria-hidden="true" />
<Link href={href} className="hover:text-red-400">
{name}
</Link>
</li>
))}
</ul>
{!session ? (
<button
onClick={() => signIn("discord", { callbackUrl: "/dashboard" })}
className="rounded-full bg-gradient-to-r from-indigo-500 to-violet-600 px-4 py-2 text-sm font-medium shadow hover:opacity-90"
>
Se connecter
</button>
) : (
<div className="hidden md:flex items-center gap-3">
{session.user?.image && (
<Image
src={session.user.image}
alt={session.user.name ?? "avatar"}
width={32}
height={32}
className="rounded-full"
/>
)}
<Link
href="/dashboard"
className="rounded-full bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Dashboard
</Link>
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded-full bg-red-600 px-3 py-2 text-sm font-medium hover:bg-red-700"
>
Déconnexion
</button>
</div>
)}
<button
className="md:hidden flex items-center"
onClick={() => setOpen((p) => !p)}
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
</nav>
<AnimatePresence>
{open && (
<motion.ul
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.25 }}
className="md:hidden flex flex-col gap-4 bg-[#0e0e10] px-6 pb-6"
>
{navLinks.map(({ name, href, Icon }) => (
<li
key={name}
className="flex items-center gap-2 border-b border-white/10 py-2"
>
<Icon size={16} aria-hidden="true" />
<Link href={href} onClick={() => setOpen(false)}>
{name}
</Link>
</li>
))}
{!session ? (
<li>
<button
onClick={() =>
signIn("discord", { callbackUrl: "/dashboard" })
}
className="w-full rounded-md bg-indigo-600 py-2 text-sm font-medium"
>
Se connecter
</button>
</li>
) : (
<>
<li>
<Link
href="/dashboard"
onClick={() => setOpen(false)}
className="block w-full rounded-md bg-green-600 py-2 text-center text-sm font-medium"
>
Dashboard
</Link>
</li>
<li>
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="w-full rounded-md bg-red-600 py-2 text-sm font-medium"
>
Déconnexion
</button>
</li>
</>
)}
</motion.ul>
)}
</AnimatePresence>
</header>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { motion } from "framer-motion";
import { BookText, ExternalLink, Map, Users, Wrench } from "lucide-react";
const outils = [
{
label: "Wiki Aystone",
href: "https://wiki.aystone.fr/",
icon: Map,
description: "Wiki du serveur Minecraft Aystone.",
},
{
label: "DynMap",
href: "/maps",
icon: Map,
description: "Carte dynamique de l'instance Cobble.",
},
{
label: "Liste Colorants Minecraft",
href: "/couleurs",
icon: BookText,
description: "Charte des couleurs Minecraft pour les colorants.",
},
{
label: "Dashboard",
href: "/dashboard",
icon: BookText,
description: "Tableau de bord pour gérer vos projets.",
},
{
label: "Discord d'Aypierre",
href: "https://discord.gg/aypierre",
icon: Users,
description: "Rejoignez la communauté d'aypierre pour discuter avec nous ! :)",
},
{
label: "Espace Publicitaire",
href: "/ads",
icon: Users,
description: "Espace des publicités (Merci Terrone1 pour les pubs :p)",
},
];
export default function OutilsPage() {
return (
<div className="min-h-screen bg-[#0e0e10] text-amber-50 px-4 sm:px-8 py-12">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h1 className="text-center text-4xl sm:text-5xl font-extrabold mb-14 flex items-center justify-center gap-3 text-amber-50">
<Wrench className="text-red-500" size={36} />
Outils & Liens Utiles
</h1>
</motion.div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{outils.map((outil, i) => (
<motion.a
key={outil.label}
href={outil.href}
target={outil.href.startsWith("http") ? "_blank" : "_self"}
rel="noopener noreferrer"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1, duration: 0.4 }}
className="bg-[#1a1c2a] hover:bg-[#252735] border border-white/10 hover:border-red-500 transition-colors rounded-2xl p-5 flex flex-col gap-3 shadow-lg group"
>
<div className="flex items-center gap-2 text-red-400">
<outil.icon size={22} className="group-hover:text-red-400 transition" />
<span className="text-lg font-semibold text-blue-400 group-hover:underline flex items-center gap-1">
{outil.label}
<ExternalLink size={14} />
</span>
</div>
<p className="text-sm text-amber-50/70">{outil.description}</p>
</motion.a>
))}
</div>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { motion } from "framer-motion";
import {
AlignLeft,
BadgeCheck,
Check,
Copy,
FileText,
Globe,
MapPin,
Tag,
User,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
export type Project = {
id?: string;
joueur: string;
etat: "En cours" | "Terminé" | "Pause";
monde: "Overworld" | "Nether" | "End";
projet: string;
description: string;
coords: string;
tags: string[];
};
export default function ProjectTable({
data,
hidePlayerFilter = false,
}: {
data: Project[];
hidePlayerFilter?: boolean;
}) {
const [copied, setCopied] = useState<number | null>(null);
const [search, setSearch] = useState("");
const [etat, setEtat] = useState("");
const [monde, setMonde] = useState("");
const [joueur, setJoueur] = useState("");
const filtered = useMemo(() => {
return data.filter((p) => {
const matchesSearch = p.projet.toLowerCase().includes(search.toLowerCase());
const matchesEtat = etat ? p.etat === etat : true;
const matchesMonde = monde ? p.monde === monde : true;
const matchesJoueur = hidePlayerFilter ? true : joueur ? p.joueur === joueur : true;
return matchesSearch && matchesEtat && matchesMonde && matchesJoueur;
});
}, [data, search, etat, monde, joueur, hidePlayerFilter]);
const joueurs = [...new Set(data.map((p) => p.joueur))];
const etats = ["En cours", "Terminé", "Pause"];
const mondes = ["Overworld", "Nether", "End"];
const headers = [
{ label: "Joueur", icon: User },
{ label: "État", icon: BadgeCheck },
{ label: "Monde", icon: Globe },
{ label: "Projet", icon: FileText },
{ label: "Description", icon: AlignLeft },
{ label: "Coordonnées", icon: MapPin },
{ label: "Tags", icon: Tag },
];
function resolveWorldId(monde: Project["monde"]): string {
switch (monde) {
case "Overworld":
return "world";
case "Nether":
return "world_nether";
case "End":
return "world_the_end";
default:
return "world"; // fallback
}
}
if (!data.length) {
return <p className="mt-8 text-center text-amber-50/60">Aucun projet enregistré pour le moment.</p>;
}
return (
<div className="w-full overflow-x-auto px-2 sm:px-4 py-8">
<div className="max-w-7xl mx-auto">
<div className="mb-4 flex flex-wrap gap-2 items-center">
<input
type="text"
placeholder="Rechercher un projet..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="rounded-md border border-white/20 bg-white/10 px-3 py-1 text-sm text-amber-50 placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
{!hidePlayerFilter && (
<select
value={joueur}
onChange={(e) => setJoueur(e.target.value)}
className="rounded-md bg-slate-800 text-amber-50 px-3 py-1 text-sm"
>
<option value="">Tous les joueurs</option>
{joueurs.map((j) => (
<option key={j} value={j}>{j}</option>
))}
</select>
)}
<select
value={etat}
onChange={(e) => setEtat(e.target.value)}
className="rounded-md bg-slate-800 text-amber-50 px-3 py-1 text-sm"
>
<option value="">Tous les états</option>
{etats.map((e) => (
<option key={e} value={e}>{e}</option>
))}
</select>
<select
value={monde}
onChange={(e) => setMonde(e.target.value)}
className="rounded-md bg-slate-800 text-amber-50 px-3 py-1 text-sm"
>
<option value="">Tous les mondes</option>
{mondes.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="overflow-auto rounded-xl border border-white/10">
<table className="min-w-[1100px] w-full text-sm text-amber-50">
<thead className="bg-slate-800 text-amber-50">
<tr>
{headers.map(({ label, icon: Icon }) => (
<th key={label} className="px-4 py-3 text-left font-semibold whitespace-nowrap">
<div className="flex items-center gap-1">
<Icon size={14} className="opacity-70" />
{label}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((p, i) => (
<tr key={p.id ?? i} className="border-t border-white/10 hover:bg-white/5">
<Td>
<div className="flex items-center gap-2">
<Image
src={`https://mc-heads.net/avatar/${p.joueur}/20`}
alt=""
width={20}
height={20}
unoptimized
className="rounded"
onError={(e) => ((e.target as HTMLImageElement).style.display = "none")}
/>
{hidePlayerFilter ? (
<span className="text-amber-50/80">{p.joueur}</span>
) : (
<Link
href={`/user/${p.joueur}`}
className="text-blue-400 hover:underline hover:text-blue-300 transition"
>
{p.joueur}
</Link>
)}
</div>
</Td>
<Td><EtatBadge v={p.etat} /></Td>
<Td><MondeBadge v={p.monde} /></Td>
<Td className="font-medium">{p.projet}</Td>
<Td>
<div className="max-h-[100px] overflow-auto whitespace-pre-line text-amber-50/80 break-words">
{p.description}
</div>
</Td>
<Td>
<div className="flex justify-between items-start gap-2 whitespace-nowrap">
<div className="flex flex-col gap-1">
<span className="font-mono tabular-nums">{p.coords}</span>
{p.coords && (
<a
href={`https://maps.aystone.fr/cobble/#${resolveWorldId(p.monde)}:${p.coords.replace(/ /g, ":")}:1500:0:0:0:0:free`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex justify-center items-center w-[70px] text-[11px] font-medium rounded-full px-2 py-0.5 border border-blue-500 text-blue-400 hover:bg-blue-500/10 transition"
>
DynMap
</a>
)}
</div>
<motion.button
whileTap={{ scale: 0.9 }}
onClick={() => {
navigator.clipboard.writeText(p.coords);
setCopied(i);
setTimeout(() => setCopied(null), 1200);
}}
className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-white/10 hover:bg-white/20"
>
{copied === i ? (
<Check size={16} className="text-green-400" />
) : (
<Copy size={14} />
)}
</motion.button>
</div>
</Td>
<Td>
<div className="flex gap-1 overflow-x-auto max-w-xs whitespace-nowrap pr-1">
{p.tags.map((t, idx) => <TagBadge key={t + idx} v={t} />)}
</div>
</Td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function Td({ children }: { children: React.ReactNode }) {
return <td className="px-4 py-3 align-top">{children}</td>;
}
function EtatBadge({ v }: { v: Project["etat"] }) {
const cls = v === "En cours"
? "bg-yellow-400/90 text-black"
: v === "Terminé"
? "bg-green-600/90"
: "bg-red-600/90";
return <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${cls}`}>{v}</span>;
}
function MondeBadge({ v }: { v: Project["monde"] }) {
const map = {
Overworld: "bg-emerald-700/80",
Nether: "bg-red-700/80",
End: "bg-violet-800/90",
} as const;
return <span className={`rounded-full px-2 py-0.5 text-[11px] ${map[v]}`}>{v}</span>;
}
function TagBadge({ v }: { v: string }) {
const palette: Record<string, string> = {
spawn: "bg-fuchsia-700/90",
build: "bg-emerald-700/90",
usine: "bg-orange-600/90",
};
return <span className={`rounded-full px-2 py-0.5 text-[11px] ${palette[v] ?? "bg-slate-800/70"}`}>{v}</span>;
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useEffect, useRef } from "react";
interface RedstoneParticlesProps {
className?: string;
density?: number;
maxRadius?: number;
}
class Star {
context: CanvasRenderingContext2D;
areaWidth: number;
areaHeight: number;
position: { x: number; y: number };
speed: { x: number; y: number };
radius: number;
alpha: number;
alphaSpeed: number;
rgb: string[];
selectedColor: number;
color: string;
constructor(args: {
context: CanvasRenderingContext2D;
areaWidth: number;
areaHeight: number;
x?: number;
y?: number;
speedX?: number;
speedY?: number;
radius: number;
alpha?: number;
alphaSpeed?: number;
}) {
this.context = args.context;
this.areaWidth = args.areaWidth;
this.areaHeight = args.areaHeight;
this.position = {
x: args.x ?? Math.random() * this.areaWidth,
y: args.y ?? Math.random() * this.areaHeight,
};
this.speed = {
x: args.speedX ?? Math.random() * 0.003 - 0.0015,
y: args.speedY ?? Math.random() * 0.003 - 0.0015,
};
this.radius = Math.ceil(Math.random() * args.radius) || 2;
this.alpha = args.alpha ?? Math.random() * 0.5 + 0.5;
this.alphaSpeed = args.alphaSpeed ?? (Math.random() * 0.04 - 0.02);
this.rgb = ["255,0,0", "255,50,50", "200,0,0", "255,80,80"];
this.selectedColor = Math.floor(Math.random() * this.rgb.length);
this.color = `rgba(${this.rgb[this.selectedColor]},${this.alpha})`;
}
draw() {
this.updateCoords();
this.updateAlpha();
this.context.beginPath();
this.context.fillStyle = this.color;
this.context.rect(this.position.x, this.position.y, this.radius, this.radius);
this.context.fill();
this.context.closePath();
}
updateCoords() {
this.position.x += this.speed.x;
this.position.y -= this.speed.y;
if (this.position.x > this.areaWidth + this.radius) this.position.x = 0 - this.radius;
if (this.position.y > this.areaHeight + this.radius) this.position.y = 0 - this.radius;
if (this.position.x < 0 - this.radius) this.position.x = this.areaWidth + this.radius;
if (this.position.y < 0 - this.radius) this.position.y = this.areaHeight + this.radius;
}
updateAlpha() {
this.alpha += this.alphaSpeed;
if (this.alpha >= 1 || this.alpha <= 0) this.alphaSpeed = -this.alphaSpeed;
this.color = `rgba(${this.rgb[this.selectedColor]},${this.alpha})`;
}
}
class Starfield {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
width: number;
height: number;
stars: Star[];
totalStars: number;
maxRadius: number;
constructor(canvas: HTMLCanvasElement, totalStars = 100, maxRadius = 3) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d")!;
this.width = 0;
this.height = 0;
this.stars = [];
this.totalStars = totalStars;
this.maxRadius = maxRadius;
this.resize();
this.setStars();
}
resize() {
this.width = this.canvas.clientWidth;
this.height = this.canvas.clientHeight;
this.canvas.width = this.width * window.devicePixelRatio;
this.canvas.height = this.height * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
setStars() {
this.stars = [];
for (let i = 0; i < this.totalStars; i++) {
this.stars.push(
new Star({
context: this.ctx,
areaWidth: this.width,
areaHeight: this.height,
radius: this.maxRadius,
})
);
}
}
render() {
this.ctx.clearRect(0, 0, this.width, this.height);
this.stars.forEach((star) => star.draw());
}
}
export default function RedstoneParticles({ className = "", density = 60, maxRadius = 2 }: RedstoneParticlesProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const starfieldRef = useRef<Starfield | null>(null);
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
starfieldRef.current = new Starfield(canvasRef.current, density, maxRadius);
const handleResize = () => {
starfieldRef.current?.resize();
starfieldRef.current?.setStars();
};
window.addEventListener("resize", handleResize);
const animate = () => {
starfieldRef.current?.render();
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
window.removeEventListener("resize", handleResize);
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
};
}, [density, maxRadius]);
return (
<canvas
ref={canvasRef}
className={className}
style={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
left: 0,
pointerEvents: "none",
zIndex: 0,
}}
/>
);
}