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:
234
client/src/app/admin/page.tsx
Normal file
234
client/src/app/admin/page.tsx
Normal 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)}>
|
||||
← Zurück
|
||||
</button>
|
||||
<span className="text-sm">Seite {page + 1} — {total} Nutzer gesamt</span>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={(page + 1) * limit >= total}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
client/src/app/ai/page.tsx
Normal file
187
client/src/app/ai/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
client/src/app/friends/page.tsx
Normal file
162
client/src/app/friends/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
© 2023{" "}
|
||||
<a
|
||||
href="https://n9ze.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-hover"
|
||||
>
|
||||
nize
|
||||
</a>
|
||||
</p>
|
||||
<p>© {new Date().getFullYear()} Michess — 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user