feat: Michess – Initiales Setup auf Basis von chessu

- Rebranding: chessu → Michess (Header, Footer, Metadata, Session-Cookie)
- Stockfish KI-Integration: 6 Schwierigkeitsgrade (Anfänger bis Meister)
- Admin-Panel: Nutzerverwaltung, Sperren/Entsperren, Git-Pull-Update
- Freundessystem: Anfragen, Freundesliste, Nutzersuche
- DB-Schema: role, banned, friend_request, friendship Tabellen
- Docker: NAS-optimiertes docker-compose.yml mit PostgreSQL Healthcheck
- Stockfish binary via apk im Alpine-Image installiert
- .env.example für einfaches Deployment
- scripts/update.sh für manuelles NAS-Update
- Brain.md + Plan.md als Projekt-Gedächtnis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-13 10:02:52 +02:00
parent 29968f023d
commit 7dac042d73
28 changed files with 1760 additions and 89 deletions

View File

@@ -0,0 +1,234 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
interface UserRow {
id: number;
name: string;
email: string;
wins: number;
losses: number;
draws: number;
role: string;
banned: boolean;
created_at: string;
}
interface Stats {
totalUsers: number;
totalGames: number;
newUsersThisWeek: number;
}
export default function AdminPage() {
const { user } = useSession();
const router = useRouter();
const [stats, setStats] = useState<Stats | null>(null);
const [users, setUsers] = useState<UserRow[]>([]);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [updateLog, setUpdateLog] = useState("");
const [updating, setUpdating] = useState(false);
const limit = 20;
useEffect(() => {
if (user === null) router.push("/");
if (user && user.role !== "admin") router.push("/");
}, [user, router]);
useEffect(() => {
fetchStats();
fetchUsers();
}, [page, search]);
const fetchStats = async () => {
const res = await fetch(`${API_URL}/v1/admin/stats`, { credentials: "include" });
if (res.ok) setStats(await res.json());
};
const fetchUsers = async () => {
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
if (search) params.set("search", search);
const res = await fetch(`${API_URL}/v1/admin/users?${params}`, { credentials: "include" });
if (res.ok) {
const data = await res.json();
setUsers(data.users);
setTotal(data.total);
}
};
const toggleBan = async (id: number, banned: boolean) => {
await fetch(`${API_URL}/v1/admin/users/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ banned: !banned })
});
fetchUsers();
};
const toggleAdmin = async (id: number, role: string) => {
const newRole = role === "admin" ? "user" : "admin";
await fetch(`${API_URL}/v1/admin/users/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole })
});
fetchUsers();
};
const deleteUser = async (id: number, name: string) => {
if (!confirm(`Nutzer "${name}" wirklich löschen?`)) return;
await fetch(`${API_URL}/v1/admin/users/${id}`, { method: "DELETE", credentials: "include" });
fetchUsers();
fetchStats();
};
const triggerUpdate = async () => {
if (!confirm("Website jetzt aus Git aktualisieren?")) return;
setUpdating(true);
setUpdateLog("Update wird ausgeführt...");
const res = await fetch(`${API_URL}/v1/admin/update`, { method: "POST", credentials: "include" });
const data = await res.json();
setUpdateLog(data.output || data.message || "Fertig.");
setUpdating(false);
};
if (!user || user.role !== "admin") {
return <div className="flex items-center justify-center w-full py-20 text-xl">Kein Zugriff.</div>;
}
return (
<div className="w-full max-w-6xl py-8 px-4 flex flex-col gap-8">
<h1 className="text-3xl font-bold">Admin-Panel</h1>
{/* Stats */}
{stats && (
<div className="stats stats-horizontal shadow w-full">
<div className="stat">
<div className="stat-title">Nutzer gesamt</div>
<div className="stat-value">{stats.totalUsers}</div>
</div>
<div className="stat">
<div className="stat-title">Spiele gesamt</div>
<div className="stat-value">{stats.totalGames}</div>
</div>
<div className="stat">
<div className="stat-title">Neue Nutzer (7 Tage)</div>
<div className="stat-value">{stats.newUsersThisWeek}</div>
</div>
</div>
)}
{/* Update Button */}
<div className="card bg-base-200 shadow p-6 flex flex-col gap-3">
<h2 className="text-xl font-semibold">Website aktualisieren</h2>
<p className="text-sm opacity-70">Führt <code>git pull</code> im App-Verzeichnis aus. Container muss danach neugestartet werden.</p>
<button
className="btn btn-primary w-fit"
onClick={triggerUpdate}
disabled={updating}
>
{updating ? <span className="loading loading-spinner loading-sm" /> : null}
Jetzt aktualisieren
</button>
{updateLog && (
<pre className="bg-base-300 rounded p-3 text-xs overflow-auto max-h-40">{updateLog}</pre>
)}
</div>
{/* User Management */}
<div className="card bg-base-200 shadow p-6 flex flex-col gap-4">
<h2 className="text-xl font-semibold">Nutzerverwaltung</h2>
<input
type="text"
placeholder="Nutzer suchen..."
className="input input-bordered w-full max-w-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
<div className="overflow-x-auto">
<table className="table table-zebra w-full text-sm">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>E-Mail</th>
<th>W/L/D</th>
<th>Rolle</th>
<th>Status</th>
<th>Registriert</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={u.banned ? "opacity-50" : ""}>
<td>{u.id}</td>
<td className="font-medium">{u.name}</td>
<td>{u.email}</td>
<td>{u.wins}/{u.losses}/{u.draws}</td>
<td>
<span className={`badge badge-sm ${u.role === "admin" ? "badge-warning" : "badge-ghost"}`}>
{u.role}
</span>
</td>
<td>
<span className={`badge badge-sm ${u.banned ? "badge-error" : "badge-success"}`}>
{u.banned ? "Gesperrt" : "Aktiv"}
</span>
</td>
<td>{new Date(u.created_at).toLocaleDateString("de-DE")}</td>
<td className="flex gap-1 flex-wrap">
<button
className="btn btn-xs btn-outline"
onClick={() => toggleBan(u.id, u.banned)}
>
{u.banned ? "Entsperren" : "Sperren"}
</button>
<button
className="btn btn-xs btn-outline btn-warning"
onClick={() => toggleAdmin(u.id, u.role)}
disabled={u.id === (user.id as number)}
>
{u.role === "admin" ? "Admin entfernen" : "Admin machen"}
</button>
<button
className="btn btn-xs btn-outline btn-error"
onClick={() => deleteUser(u.id, u.name)}
disabled={u.id === (user.id as number)}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex gap-2 items-center">
<button className="btn btn-sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
&larr; Zurück
</button>
<span className="text-sm">Seite {page + 1} &mdash; {total} Nutzer gesamt</span>
<button
className="btn btn-sm"
disabled={(page + 1) * limit >= total}
onClick={() => setPage(p => p + 1)}
>
Weiter &rarr;
</button>
</div>
</div>
</div>
);
}

187
client/src/app/ai/page.tsx Normal file
View File

@@ -0,0 +1,187 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Chessboard } from "react-chessboard";
import { Chess } from "chess.js";
import { useRouter } from "next/navigation";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
const AI_LEVELS = [
{ level: 1, name: "Anfänger" },
{ level: 2, name: "Leicht" },
{ level: 3, name: "Mittel" },
{ level: 4, name: "Fortgeschritten" },
{ level: 5, name: "Experte" },
{ level: 6, name: "Meister" },
];
export default function AiGamePage() {
const { user } = useSession();
const router = useRouter();
const [selectedLevel, setSelectedLevel] = useState(3);
const [gameStarted, setGameStarted] = useState(false);
const [game, setGame] = useState(new Chess());
const [playerColor, setPlayerColor] = useState<"white" | "black">("white");
const [status, setStatus] = useState("");
const [thinking, setThinking] = useState(false);
const [lastMove, setLastMove] = useState<{ from: string; to: string } | null>(null);
const getStatus = useCallback((g: Chess) => {
if (g.isCheckmate()) return g.turn() === "w" ? "Schwarz gewinnt! Schachmatt." : "Weiß gewinnt! Schachmatt.";
if (g.isDraw()) return "Unentschieden!";
if (g.isCheck()) return g.turn() === "w" ? "Weiß ist im Schach!" : "Schwarz ist im Schach!";
return g.turn() === "w" ? "Weiß am Zug" : "Schwarz am Zug";
}, []);
const requestAiMove = useCallback(async (fen: string, level: number) => {
setThinking(true);
try {
const res = await fetch(`${API_URL}/v1/ai/move`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, level })
});
if (res.ok) {
const { move } = await res.json();
if (move) {
setGame((prev) => {
const newGame = new Chess(prev.fen());
const result = newGame.move({ from: move.slice(0, 2), to: move.slice(2, 4), promotion: move[4] || "q" });
if (result) setLastMove({ from: result.from, to: result.to });
setStatus(getStatus(newGame));
return newGame;
});
}
}
} catch (e) {
console.error("AI move error:", e);
}
setThinking(false);
}, [getStatus]);
const onDrop = useCallback((sourceSquare: string, targetSquare: string) => {
if (thinking) return false;
const currentTurn = game.turn() === "w" ? "white" : "black";
if (currentTurn !== playerColor) return false;
const newGame = new Chess(game.fen());
let move = null;
try {
move = newGame.move({ from: sourceSquare, to: targetSquare, promotion: "q" });
} catch {
return false;
}
if (!move) return false;
setLastMove({ from: move.from, to: move.to });
setGame(newGame);
setStatus(getStatus(newGame));
if (!newGame.isGameOver()) {
requestAiMove(newGame.fen(), selectedLevel);
}
return true;
}, [game, playerColor, thinking, selectedLevel, getStatus, requestAiMove]);
const startGame = (color: "white" | "black") => {
const newGame = new Chess();
setGame(newGame);
setPlayerColor(color);
setGameStarted(true);
setLastMove(null);
setStatus(getStatus(newGame));
if (color === "black") {
requestAiMove(newGame.fen(), selectedLevel);
}
};
const resetGame = () => {
setGameStarted(false);
setGame(new Chess());
setLastMove(null);
setStatus("");
};
const customSquareStyles: Record<string, React.CSSProperties> = {};
if (lastMove) {
customSquareStyles[lastMove.from] = { backgroundColor: "rgba(255, 255, 0, 0.4)" };
customSquareStyles[lastMove.to] = { backgroundColor: "rgba(255, 255, 0, 0.4)" };
}
if (!gameStarted) {
return (
<div className="flex flex-col items-center gap-8 py-10 w-full max-w-xl px-4">
<h1 className="text-3xl font-bold">Gegen KI spielen</h1>
<div className="card bg-base-200 shadow p-6 w-full flex flex-col gap-5">
<div>
<h2 className="text-lg font-semibold mb-3">Schwierigkeitsgrad</h2>
<div className="flex flex-wrap gap-2">
{AI_LEVELS.map((l) => (
<button
key={l.level}
className={`btn ${selectedLevel === l.level ? "btn-primary" : "btn-outline"}`}
onClick={() => setSelectedLevel(l.level)}
>
{l.level}. {l.name}
</button>
))}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Farbe wählen</h2>
<div className="flex gap-4">
<button className="btn btn-outline flex-1" onClick={() => startGame("white")}>
Als Weiß spielen
</button>
<button className="btn btn-outline flex-1" onClick={() => startGame("black")}>
Als Schwarz spielen
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col lg:flex-row gap-6 py-6 w-full max-w-5xl px-4 items-start justify-center">
<div className="w-full max-w-[560px]">
<Chessboard
position={game.fen()}
onPieceDrop={onDrop}
boardOrientation={playerColor}
customSquareStyles={customSquareStyles}
arePiecesDraggable={!game.isGameOver() && !thinking}
/>
</div>
<div className="flex flex-col gap-4 min-w-[200px]">
<div className="card bg-base-200 shadow p-4">
<p className="font-semibold text-sm opacity-70">Niveau: {AI_LEVELS.find(l => l.level === selectedLevel)?.name}</p>
<p className="font-semibold text-sm opacity-70">Du spielst: {playerColor === "white" ? "Weiß ♔" : "Schwarz ♚"}</p>
</div>
<div className={`card shadow p-4 ${game.isGameOver() ? "bg-warning text-warning-content" : "bg-base-200"}`}>
<p className="font-semibold">{status}</p>
{thinking && <p className="text-sm opacity-70 mt-1">KI denkt nach...</p>}
</div>
<button className="btn btn-outline" onClick={resetGame}>Neues Spiel</button>
<div className="card bg-base-200 shadow p-3 max-h-64 overflow-y-auto">
<h3 className="text-sm font-semibold mb-2">Züge</h3>
<div className="text-xs font-mono">
{game.history().map((move, i) => (
<span key={i}>{i % 2 === 0 ? `${Math.floor(i / 2) + 1}. ` : ""}{move} </span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
interface Friend {
id: number;
friend_id: number;
friend_name: string;
wins: number;
losses: number;
draws: number;
created_at: string;
}
interface FriendRequest {
id: number;
from_id: number;
to_id: number;
from_name?: string;
to_name?: string;
status: string;
created_at: string;
}
export default function FriendsPage() {
const { user } = useSession();
const router = useRouter();
const [friends, setFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<{ incoming: FriendRequest[]; outgoing: FriendRequest[] }>({ incoming: [], outgoing: [] });
const [addUsername, setAddUsername] = useState("");
const [addMsg, setAddMsg] = useState("");
useEffect(() => {
if (user === null) router.push("/");
if (user && user.id) {
fetchFriends();
fetchRequests();
}
}, [user, router]);
const fetchFriends = async () => {
const res = await fetch(`${API_URL}/v1/friends`, { credentials: "include" });
if (res.ok) setFriends(await res.json());
};
const fetchRequests = async () => {
const res = await fetch(`${API_URL}/v1/friends/requests`, { credentials: "include" });
if (res.ok) setRequests(await res.json());
};
const sendRequest = async () => {
setAddMsg("");
const res = await fetch(`${API_URL}/v1/friends/request`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: addUsername })
});
const data = await res.json();
setAddMsg(res.ok ? `Anfrage an "${data.toName}" gesendet!` : data.message || "Fehler.");
if (res.ok) { setAddUsername(""); fetchRequests(); }
};
const respond = async (id: number, action: "accept" | "reject") => {
await fetch(`${API_URL}/v1/friends/requests/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action })
});
fetchFriends();
fetchRequests();
};
const removeFriend = async (friendId: number, name: string) => {
if (!confirm(`"${name}" als Freund entfernen?`)) return;
await fetch(`${API_URL}/v1/friends/${friendId}`, { method: "DELETE", credentials: "include" });
fetchFriends();
};
if (!user?.id || typeof user.id === "string") {
return <div className="flex items-center justify-center w-full py-20">Bitte einloggen.</div>;
}
return (
<div className="w-full max-w-3xl py-8 px-4 flex flex-col gap-8">
<h1 className="text-3xl font-bold">Freunde</h1>
{/* Add friend */}
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Freund hinzufügen</h2>
<div className="flex gap-2">
<input
type="text"
placeholder="Nutzername"
className="input input-bordered flex-1"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendRequest()}
/>
<button className="btn btn-primary" onClick={sendRequest} disabled={!addUsername.trim()}>
Anfrage senden
</button>
</div>
{addMsg && <p className="text-sm">{addMsg}</p>}
</div>
{/* Incoming requests */}
{requests.incoming.length > 0 && (
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Eingehende Anfragen ({requests.incoming.length})</h2>
{requests.incoming.map((r) => (
<div key={r.id} className="flex items-center justify-between gap-2">
<Link href={`/user/${r.from_name}`} className="font-medium link link-hover">
{r.from_name}
</Link>
<div className="flex gap-2">
<button className="btn btn-sm btn-success" onClick={() => respond(r.id, "accept")}>Annehmen</button>
<button className="btn btn-sm btn-outline btn-error" onClick={() => respond(r.id, "reject")}>Ablehnen</button>
</div>
</div>
))}
</div>
)}
{/* Outgoing requests */}
{requests.outgoing.length > 0 && (
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Gesendete Anfragen</h2>
{requests.outgoing.map((r) => (
<div key={r.id} className="flex items-center gap-2 opacity-70">
<span></span>
<Link href={`/user/${r.to_name}`} className="link link-hover">{r.to_name}</Link>
<span className="badge badge-sm">ausstehend</span>
</div>
))}
</div>
)}
{/* Friends list */}
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Meine Freunde ({friends.length})</h2>
{friends.length === 0 && <p className="opacity-60">Noch keine Freunde. Füge jemanden hinzu!</p>}
{friends.map((f) => (
<div key={f.id} className="flex items-center justify-between gap-2">
<Link href={`/user/${f.friend_name}`} className="font-medium link link-hover">
{f.friend_name}
</Link>
<span className="text-sm opacity-60">{f.wins}W / {f.losses}L / {f.draws}D</span>
<button className="btn btn-xs btn-outline btn-error" onClick={() => removeFriend(f.friend_id, f.friend_name)}>
Entfernen
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -9,18 +9,17 @@ import AuthModal from "@/components/auth/AuthModal";
import ContextProvider from "@/context/ContextProvider";
export const metadata = {
title: "chessu",
description: "Play Chess online.",
title: "Michess",
description: "Schach spielen lokal, privat, kostenlos.",
openGraph: {
title: "chessu",
description: "Play Chess online.",
url: "https://ches.su",
siteName: "chessu",
locale: "en_US",
title: "Michess",
description: "Schach spielen lokal, privat, kostenlos.",
siteName: "Michess",
locale: "de_DE",
type: "website"
},
robots: {
index: true,
index: false,
follow: false,
nocache: true,
noarchive: true
@@ -33,12 +32,12 @@ export const metadata = {
apple: { url: "/apple-touch-icon.png", sizes: "180x180" }
},
manifest: "/site.webmanifest",
metadataBase: new URL(process.env.VERCEL ? "https://ches.su" : "http://localhost:3000")
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000")
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className="overflow-x-hidden">
<html lang="de" className="overflow-x-hidden">
<body className="overflow-x-hidden">
<ContextProvider>
<Header />
@@ -52,7 +51,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<Footer />
{/* next/script issue: https://github.com/vercel/next.js/issues/43402 */}
<script
id="load-theme"
dangerouslySetInnerHTML={{

View File

@@ -1,24 +1,54 @@
"use client";
import CreateGame from "@/components/home/CreateGame";
import JoinGame from "@/components/home/JoinGame";
import PublicGames from "@/components/home/PublicGames/PublicGames";
import Link from "next/link";
import { useSession } from "@/context/session";
export const revalidate = 0;
export default function Home() {
const { user } = useSession();
return (
<div className="flex w-full flex-wrap items-center justify-center gap-8 px-4 py-10 lg:gap-16 ">
<div className="flex w-full flex-wrap items-start justify-center gap-8 px-4 py-10 lg:gap-16">
<PublicGames />
<div className="flex flex-col items-center gap-4">
{/* Against AI */}
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Join from invite</h2>
<h2 className="mb-4 text-xl font-bold leading-tight">Gegen KI spielen</h2>
<Link href="/ai" className="btn btn-primary w-full max-w-xs">
Gegen Stockfish spielen
</Link>
</div>
<div className="divider divider-vertical">oder</div>
{/* Friends */}
{user?.id && typeof user.id === "number" && (
<>
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Freunde</h2>
<Link href="/friends" className="btn btn-outline w-full max-w-xs">
👥 Freunde verwalten
</Link>
</div>
<div className="divider divider-vertical">oder</div>
</>
)}
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Per Einladung beitreten</h2>
<JoinGame />
</div>
<div className="divider divider-vertical">or</div>
<div className="divider divider-vertical">oder</div>
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Create game</h2>
<h2 className="mb-4 text-xl font-bold leading-tight">Spiel erstellen</h2>
<CreateGame />
</div>
</div>

View File

@@ -1,30 +1,17 @@
import { IconBrandGithub } from "@tabler/icons-react";
export default function Footer() {
return (
<footer className="footer border-base-300 dark:border-neutral text-base-content mx-1 mt-4 w-auto grid-flow-col items-center justify-between border-t-2 p-4 md:mx-16 lg:mx-40">
<div className="items-center">
<p>
&copy; 2023{" "}
<a
href="https://n9ze.com"
target="_blank"
rel="noopener noreferrer"
className="link-hover"
>
nize
</a>
</p>
<p>&copy; {new Date().getFullYear()} Michess &mdash; Dein privates Schachbrett</p>
</div>
<div className="items-center">
<a
href="https://github.com/dotnize/chessu"
href="https://git.mischlabs.de/MrDiderot/Michess"
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost btn-sm gap-1 normal-case"
>
<IconBrandGithub className="inline-block" size={16} />
GitHub
Gitea
</a>
</div>
</footer>

View File

@@ -1,8 +1,13 @@
import { IconExternalLink, IconUser } from "@tabler/icons-react";
"use client";
import { IconUser, IconShield } from "@tabler/icons-react";
import Link from "next/link";
import ThemeToggle from "./ThemeToggle";
import { useSession } from "@/context/session";
export default function Header() {
const { user } = useSession();
return (
<header className="navbar border-base-300 dark:border-neutral mx-1 w-auto justify-center border-b-2 md:mx-16 lg:mx-40">
<div className="flex flex-1 items-center gap-2">
@@ -20,20 +25,16 @@ export default function Header() {
>
<path d="M237.84 383.74c13.61 23.9 35.9 48.09 32.29 80.27H17.83c-3.62-32.18 21.67-56.37 35.28-80.27h184.73zM69.68 120.41h148.64c7.51 0 13.66 6.18 13.66 13.66s-6.18 13.66-13.66 13.66H69.68c-7.48 0-13.66-6.15-13.66-13.66s6.15-13.66 13.66-13.66zM120.55 0h48.95v56.29c0 3.87 3.18 7.04 7.04 7.04h24.32c3.86 0 6.32-3.23 7.04-7.04L218.59 0h43.56L244.7 85.8c-4.01 12.59-13.6 18.96-28.35 19.63H66.56c-12.93-.28-20.33-6.82-22.54-19.63L25.85 0h45.62l10.69 56.29c.71 3.81 3.17 7.04 7.03 7.04h24.32c3.86 0 7.04-3.17 7.04-7.04V0zM70.94 162.75c-1.5 60.45-7.75 119.42-22.87 158.47h191.86c-17.59-44.3-24.65-102.49-26.68-158.47H70.94zM46.28 336.2h195.44c8.94 0 16.26 7.36 16.26 16.26v.01c0 8.9-7.36 16.26-16.26 16.26H46.28c-8.9 0-16.26-7.32-16.26-16.26v-.01c0-8.94 7.32-16.26 16.26-16.26zM16.82 479.03h254.36c9.25 0 16.82 7.57 16.82 16.81v.01c0 9.25-7.57 16.81-16.82 16.81H16.82C7.57 512.66 0 505.1 0 495.85v-.01c0-9.24 7.57-16.81 16.82-16.81z" />
</svg>
chessu
Michess
</Link>
<a
title="Project roadmap"
className="badge badge-sm badge-secondary gap-0.5"
href="https://github.com/users/dotnize/projects/2"
target="_blank"
rel="noopener noreferrer"
>
pre-alpha
<IconExternalLink size={12} />
</a>
</div>
<div className="flex-none">
<div className="flex-none gap-1">
{user?.role === "admin" && (
<Link href="/admin" className="btn btn-ghost btn-sm gap-1 normal-case" title="Admin-Panel">
<IconShield size={16} />
Admin
</Link>
)}
<ThemeToggle />
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
<div className="w-10 rounded-full">

View File

@@ -1,7 +1,13 @@
import type { User } from "@chessu/types";
import { createContext, Dispatch, SetStateAction } from "react";
import { createContext, Dispatch, SetStateAction, useContext } from "react";
export const SessionContext = createContext<{
user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
setUser: Dispatch<SetStateAction<User | null>>;
} | null>(null);
export const useSession = () => {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSession must be used within ContextProvider");
return ctx;
};