feat: first commit

This commit is contained in:
2026-07-08 18:09:09 +02:00
parent a7cd7739ca
commit 82abc317f1
16 changed files with 1582 additions and 69 deletions
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
type CopyAddressButtonProps = {
value: string;
className?: string;
};
export default function CopyAddressButton({
value,
className = "",
}: CopyAddressButtonProps) {
const [copied, setCopied] = useState(false);
async function copyAddress() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
}
return (
<button
type="button"
className={`copy-address-button ${copied ? "is-copied" : ""} ${className}`}
onClick={copyAddress}
aria-live="polite"
>
<CopyIcon />
<span>{copied ? "Adresse copiée" : "Copier l'adresse"}</span>
</button>
);
}
function CopyIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M8 7.5A2.5 2.5 0 0 1 10.5 5h6A2.5 2.5 0 0 1 19 7.5v6A2.5 2.5 0 0 1 16.5 16h-6A2.5 2.5 0 0 1 8 13.5z" />
<path d="M5 10.5A2.5 2.5 0 0 1 7.5 8H8v5.5A2.5 2.5 0 0 0 10.5 16H16v.5a2.5 2.5 0 0 1-2.5 2.5h-6A2.5 2.5 0 0 1 5 16.5z" />
</svg>
);
}
+104
View File
@@ -0,0 +1,104 @@
import Link from "next/link";
import type { ReactNode } from "react";
type HomeElementProps = {
title: string;
description: string;
href: string;
actionLabel: string;
variant: "jellyfin" | "minecraft";
external?: boolean;
children: ReactNode;
};
export default function HomeElement({
title,
description,
href,
actionLabel,
variant,
external = false,
children,
}: HomeElementProps) {
const content = (
<>
<span className="service-status" aria-label="Service en ligne" />
<span className="service-icon-shell">{children}</span>
<span className="service-copy">
<strong>{title}</strong>
<span>{description}</span>
</span>
<span className="service-action">
{actionLabel}
<ArrowIcon />
</span>
</>
);
const className = `service-card service-card--${variant}`;
if (external) {
return (
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
{content}
</a>
);
}
return (
<Link href={href} className={className}>
{content}
</Link>
);
}
export function JellyfinIcon() {
return (
<svg viewBox="0 0 96 96" aria-hidden="true" focusable="false">
<defs>
<linearGradient id="jellyfin-gradient" x1="16" y1="84" x2="80" y2="12">
<stop stopColor="#7c3cff" />
<stop offset="0.52" stopColor="#00d8ff" />
<stop offset="1" stopColor="#c833ff" />
</linearGradient>
</defs>
<path
d="M48 11 88 80H8z"
fill="none"
stroke="url(#jellyfin-gradient)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="9"
/>
<path
d="M48 35 66 67H30z"
fill="none"
stroke="url(#jellyfin-gradient)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="8"
/>
</svg>
);
}
export function MinecraftIcon() {
return (
<svg viewBox="0 0 96 96" aria-hidden="true" focusable="false">
<path d="M48 8 84 28 48 48 12 28z" fill="#7bd452" />
<path d="M48 48 84 28v40L48 88z" fill="#6b4a2c" />
<path d="M48 48 12 28v40l36 20z" fill="#8a6238" />
<path d="M24 34h10v10H24zm19 9h11v11H43zm18-9h11v10H61z" fill="#5aa543" />
<path d="M56 58h9v10h-9zm-25 0h10v11H31zm14 14h9v10h-9z" fill="#4b3525" opacity=".7" />
</svg>
);
}
function ArrowIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 12h12" />
<path d="m13 6 6 6-6 6" />
</svg>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
type ServerStatus = {
online: boolean;
playersOnline: number | null;
playersMax: number | null;
version: string | null;
checkedAt: string;
error?: string;
};
const loadingStatus: ServerStatus = {
online: false,
playersOnline: null,
playersMax: null,
version: null,
checkedAt: "",
};
export default function MinecraftStatus() {
const [status, setStatus] = useState<ServerStatus>(loadingStatus);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
async function refreshStatus() {
try {
const response = await fetch("/api/minecraft/status", {
cache: "no-store",
});
const payload = (await response.json()) as ServerStatus;
if (alive) {
setStatus(payload);
setLoading(false);
}
} catch {
if (alive) {
setStatus({
...loadingStatus,
checkedAt: new Date().toISOString(),
error: "Impossible de récupérer le statut.",
});
setLoading(false);
}
}
}
refreshStatus();
const interval = window.setInterval(refreshStatus, 60_000);
return () => {
alive = false;
window.clearInterval(interval);
};
}, []);
const playerCount =
status.playersOnline === null
? "-"
: `${status.playersOnline}${status.playersMax ? ` / ${status.playersMax}` : ""}`;
return (
<section id="statut" className="minecraft-section">
<div className="section-heading pixel-heading">
<h2>Statut du serveur</h2>
<p>Mis à jour automatiquement toutes les 60 secondes.</p>
</div>
<div className="status-panel block-panel">
<StatusItem
label="État"
value={loading ? "Vérification..." : status.online ? "En ligne" : "Hors ligne"}
tone={loading ? undefined : status.online ? "online" : "offline"}
/>
<StatusItem label="Joueurs en ligne" value={playerCount} />
<StatusItem label="Version" value={status.version ?? "-"} />
<StatusItem
label="Dernier check"
value={formatCheckedAt(status.checkedAt)}
/>
</div>
{status.error ? (
<p className="status-note">{status.error}</p>
) : null}
</section>
);
}
type StatusItemProps = {
label: string;
value: string;
tone?: "online" | "offline";
};
function StatusItem({ label, value, tone }: StatusItemProps) {
return (
<div className="status-item">
<span className="status-label">{label}</span>
<strong className={tone ? `status-value status-value--${tone}` : "status-value"}>
{tone ? <span className="status-light" aria-hidden="true" /> : null}
{value}
</strong>
</div>
);
}
function formatCheckedAt(value: string) {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}