From 7dac042d73a8764ef3287549a7893512111ebba2 Mon Sep 17 00:00:00 2001 From: Michess Date: Mon, 13 Apr 2026 10:02:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Michess=20=E2=80=93=20Initiales=20Setup?= =?UTF-8?q?=20auf=20Basis=20von=20chessu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 26 ++ Brain.md | 127 ++++++++++ Dockerfile | 20 +- Plan.md | 189 ++++++++++++++ client/src/app/admin/page.tsx | 234 ++++++++++++++++++ client/src/app/ai/page.tsx | 187 ++++++++++++++ client/src/app/friends/page.tsx | 162 ++++++++++++ client/src/app/layout.tsx | 20 +- client/src/app/page.tsx | 38 ++- client/src/components/Footer.tsx | 19 +- client/src/components/Header.tsx | 27 +- client/src/context/session.ts | 8 +- docker-compose.yml | 65 +++-- scripts/update.sh | 27 ++ server/src/controllers/admin.controller.ts | 113 +++++++++ server/src/controllers/friends.controller.ts | 192 ++++++++++++++ .../src/controllers/stockfish.controller.ts | 127 ++++++++++ server/src/db/index.ts | 25 +- server/src/db/models/user.model.ts | 89 ++++++- server/src/middleware/auth.ts | 21 ++ server/src/middleware/session.ts | 2 +- server/src/routes/admin.route.ts | 15 ++ server/src/routes/ai.route.ts | 31 +++ server/src/routes/friends.route.ts | 15 ++ server/src/routes/index.ts | 6 + server/src/routes/users.route.ts | 2 + server/src/server.ts | 38 ++- types/index.d.ts | 24 ++ 28 files changed, 1760 insertions(+), 89 deletions(-) create mode 100644 .env.example create mode 100644 Brain.md create mode 100644 Plan.md create mode 100644 client/src/app/admin/page.tsx create mode 100644 client/src/app/ai/page.tsx create mode 100644 client/src/app/friends/page.tsx create mode 100755 scripts/update.sh create mode 100644 server/src/controllers/admin.controller.ts create mode 100644 server/src/controllers/friends.controller.ts create mode 100644 server/src/controllers/stockfish.controller.ts create mode 100644 server/src/middleware/auth.ts create mode 100644 server/src/routes/admin.route.ts create mode 100644 server/src/routes/ai.route.ts create mode 100644 server/src/routes/friends.route.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1f3b1ce --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# ============================================ +# Michess – Konfigurationsdatei +# Kopiere diese Datei nach .env und passe sie an +# ============================================ + +# Datenbank +POSTGRES_DB=michess +POSTGRES_USER=michess +POSTGRES_PASSWORD=sicheres_passwort_hier + +# Session (mindestens 32 zufällige Zeichen) +SESSION_SECRET=aendere_mich_in_produktion_mindestens_32_zeichen + +# CORS: URL unter der das Frontend erreichbar ist +CORS_ORIGIN=http://deine-nas-ip:3000 + +# Admin: Diese E-Mail-Adresse bekommt automatisch Admin-Rechte +ADMIN_EMAIL=admin@example.com + +# Ports (optional, Standard: 3000 + 3001) +MICHESS_PORT=3000 +MICHESS_API_PORT=3001 + +# App-URL (für Next.js Metadaten) +NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000 +NEXT_PUBLIC_API_URL=http://deine-nas-ip:3001 diff --git a/Brain.md b/Brain.md new file mode 100644 index 0000000..8b1173b --- /dev/null +++ b/Brain.md @@ -0,0 +1,127 @@ +# Brain.md — Michess Project Memory + +> Dieses Dokument ist mein aktives Gedächtnis während der Entwicklung von Michess. +> Ich denke hier, notiere Entscheidungen, Probleme und Fortschritt. + +--- + +## Projektziel + +Michess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Docker, mit Stockfish-Engine. + +--- + +## Tech Stack (Entschieden) + +| Schicht | Technologie | Grund | +|---|---|---| +| Frontend | Next.js 14 + React + Tailwind + daisyUI | Von chessu geerbt, modern, SSR-fähig | +| Backend | Node.js + Express + Socket.io | Von chessu geerbt, real-time-ready | +| Datenbank | PostgreSQL | Von chessu geerbt, solide | +| Auth | express-session + argon2 | Bereits vorhanden, sicher | +| Schach-Logik | chess.js | Bereits vorhanden | +| Schachbrett UI | react-chessboard | Bereits vorhanden | +| Stockfish | stockfish npm-Paket | Einfache Integration im Backend | +| Container | Docker Compose | NAS-Deployment | +| Package Manager | pnpm Workspaces | Monorepo-Setup | + +--- + +## Basis-Projekt + +- **Geclont von:** `dotnize/chessu` (MIT-Lizenz) +- **Warum:** TypeScript, React/Next.js, Express, Socket.io, PostgreSQL, chess.js — perfekter Ausgangspunkt +- **Was chessu schon hat:** + - User-Accounts (name, email, password, wins/losses/draws) + - Session-Auth mit argon2-Passwort-Hashing + - Real-time Multiplayer via Socket.io + - Spiele mit PGN-Speicherung + - Public Games Liste + - Archiv (gespielte Partien) + - User-Profilseite + +--- + +## Was ich bauen muss (Delta zu chessu) + +1. **Stockfish AI-Gegner** — verschiedene Schwierigkeitsgrade (Level 1-8, ELO-basiert) +2. **Admin-Panel** — Nutzerverwaltung, Git-Pull-Button, Statistiken +3. **Freundessystem** — Freundschaftsanfragen, Freundesliste, Freunde herausfordern +4. **Branding** — Alles auf "Michess" umbenennen +5. **Docker NAS-Deployment** — docker-compose für NAS, Auto-Update-Script +6. **DB-Schema Erweiterungen** — admin-Flag, friends-Tabelle, friend_requests-Tabelle +7. **E-Mail-basierte Registrierung** — (email-Feld schon da, braucht UI-Flow: email → unique username wählen) + +--- + +## Architektur-Entscheidungen + +### Stockfish im Backend +- Stockfish läuft als Child-Process auf dem Server (NAS) +- Kommunikation über UCI-Protokoll +- Schwierigkeitsgrade via `Skill Level` (0-20) und `Depth` +- Pro aktiver AI-Partie: eigener Stockfish-Prozess (oder Pool) + +### Admin-Rolle +- `role` Spalte in `user`-Tabelle (default: 'user', admin: 'admin') +- Erster User mit email aus `ADMIN_EMAIL` env var wird automatisch Admin +- Admin-Middleware schützt `/v1/admin/*` Routen + +### Friends-System +- `friends`-Tabelle: user_id_1, user_id_2 (symmetrisch) +- `friend_requests`-Tabelle: from_id, to_id, status (pending/accepted/rejected) +- Nutzer suchen via Username + +### Git-Auto-Update (Admin-Feature) +- Admin-Route: `POST /v1/admin/update` +- Führt `git pull` im Container-Verzeichnis aus +- Triggert danach Neustart (via restart-policy des Containers) +- Update-Script wird ins Docker-Image eingebaut + +### Docker NAS-Setup +- `docker-compose.yml` mit Services: `michess-app` + `postgres` +- Watchtower oder einfaches update-script für automatische Updates +- Alle Credentials via `.env`-Datei + +--- + +## Aktueller Status + +- [x] Basis-Projekt geclont (chessu) +- [x] Brain.md erstellt +- [x] Plan.md erstellt +- [ ] Git remote auf `git.mischlabs.de` gesetzt +- [ ] Projekt auf "Michess" umbenannt +- [ ] DB-Schema erweitert (admin, friends, friend_requests) +- [ ] Stockfish integriert +- [ ] Admin-Panel gebaut +- [ ] Friends-System gebaut +- [ ] Docker NAS-Deployment finalisiert +- [ ] Alles committed & gepusht + +--- + +## Bekannte Probleme / Offene Fragen + +- chessu's Dockerfile nutzt `CMD ["start"]` — muss für Michess angepasst werden (Server + Client separat oder zusammen) +- Stockfish binary muss im Docker-Image vorhanden sein (Alpine Linux: `apk add stockfish` oder npm-Paket) +- Admin-Git-Pull: funktioniert nur wenn Container read-write Zugriff auf sein eigenes Verzeichnis hat — besser: Host-Script via webhook triggern + +--- + +## Lessons Learned + +*(wird im Verlauf befüllt)* + +--- + +## Nächste Schritte (immer aktuell) + +1. Git remote setzen → push +2. Umbenennen chessu → Michess +3. DB-Schema erweitern +4. Stockfish backend einbauen +5. Admin-Panel UI + Backend +6. Friends-System +7. Docker finalisieren +8. Issues aus Git bearbeiten diff --git a/Dockerfile b/Dockerfile index a46864a..6e634e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,23 @@ FROM node:lts-alpine3.20 +# Install stockfish and git +RUN apk update && apk add --no-cache stockfish git + ENV PNPM_HOME=/usr/local/bin -WORKDIR /opt/chessu/ +WORKDIR /opt/michess/ COPY . . -RUN apk update && \ - corepack enable && corepack prepare pnpm@latest --activate && \ - pnpm config set store-dir /opt/chessu/.pnpm-store && \ - pnpm install -g pnpm@latest && \ - pnpm install --unsafe-perm +RUN corepack enable && \ + corepack prepare pnpm@latest --activate && \ + pnpm config set store-dir /opt/michess/.pnpm-store && \ + pnpm install --frozen-lockfile + +# Build both client and server +RUN pnpm build:server && pnpm build:client + +EXPOSE 3000 3001 ENTRYPOINT ["pnpm"] - CMD ["start"] diff --git a/Plan.md b/Plan.md new file mode 100644 index 0000000..7f4aa29 --- /dev/null +++ b/Plan.md @@ -0,0 +1,189 @@ +# Plan.md — Michess Entwicklungsplan + +--- + +## Vision + +**Michess** ist eine selbst-gehostete Schachplattform für eine private Community, inspiriert von Lichess.org. +Läuft als Docker-Container auf einer Synology/QNAP NAS, komplett ohne Cloud-Abhängigkeit. + +--- + +## Phase 1: Foundation (Basis) + +### 1.1 Projekt-Setup +- [x] chessu (MIT) als Basis klonen +- [x] Brain.md & Plan.md erstellen +- [ ] Git remote auf `https://git.mischlabs.de/MrDiderot/Michess.git` setzen +- [ ] Initaler Push (chessu-Basis + eigene Dateien) + +### 1.2 Rebranding chessu → Michess +- [ ] `package.json` Namen anpassen +- [ ] README durch Michess-README ersetzen +- [ ] Alle Titel/Meta-Tags im Frontend ändern +- [ ] Logo-Platzhalter anpassen + +### 1.3 Docker NAS-Deployment +- [ ] `docker-compose.yml` überarbeiten (michess-app, postgres, eigene Secrets) +- [ ] `.env.example` erstellen mit allen benötigten Variablen +- [ ] Stockfish in Docker-Image einbauen (`apk add stockfish`) +- [ ] Update-Script: `scripts/update.sh` (git pull + docker restart) +- [ ] Deployment-README schreiben (einziger Befehl für NAS) + +--- + +## Phase 2: Datenbank-Erweiterungen + +### 2.1 Schema-Änderungen +```sql +-- User: Admin-Rolle hinzufügen +ALTER TABLE "user" ADD COLUMN role VARCHAR(16) DEFAULT 'user'; + +-- Freundschaftsanfragen +CREATE TABLE IF NOT EXISTS "friend_request" ( + id SERIAL PRIMARY KEY, + from_id INT REFERENCES "user"(id) ON DELETE CASCADE, + to_id INT REFERENCES "user"(id) ON DELETE CASCADE, + status VARCHAR(16) DEFAULT 'pending', -- pending | accepted | rejected + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(from_id, to_id) +); + +-- Freunde (bidirektional, normalisiert als kleinere_id + größere_id) +CREATE TABLE IF NOT EXISTS "friendship" ( + id SERIAL PRIMARY KEY, + user_id_1 INT REFERENCES "user"(id) ON DELETE CASCADE, + user_id_2 INT REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id_1, user_id_2) +); +``` + +--- + +## Phase 3: Stockfish AI-Integration + +### 3.1 Backend +- [ ] `stockfish` npm-Paket als Server-Dependency +- [ ] `server/src/controllers/stockfish.controller.ts` — Stockfish-Prozess-Manager +- [ ] Schwierigkeitsgrade: + | Level | Name | Stockfish Skill | Depth | + |---|---|---|---| + | 1 | Anfänger | 0 | 1 | + | 2 | Leicht | 3 | 3 | + | 3 | Mittel | 8 | 5 | + | 4 | Fortgeschritten | 14 | 10 | + | 5 | Experte | 18 | 15 | + | 6 | Meister | 20 | 20 | +- [ ] Socket-Events: `ai:move`, `ai:game_start`, `ai:game_end` + +### 3.2 Frontend +- [ ] "Gegen KI spielen" Button auf Startseite +- [ ] Schwierigkeitsgrad-Auswahl Modal +- [ ] KI-Spielpartie-UI (keine zweite Socket-Session nötig, nur lokal) + +--- + +## Phase 4: Admin-Panel + +### 4.1 Backend +- [ ] `isAdmin` Middleware für `/v1/admin/*` +- [ ] `GET /v1/admin/users` — alle Nutzer auflisten (paginiert) +- [ ] `PATCH /v1/admin/users/:id` — Nutzer sperren/entsperren, Rolle ändern +- [ ] `DELETE /v1/admin/users/:id` — Nutzer löschen +- [ ] `POST /v1/admin/update` — git pull + trigger restart +- [ ] `GET /v1/admin/stats` — Nutzerzahl, Spiele, etc. + +### 4.2 Frontend +- [ ] `/admin` Route (nur für Admins sichtbar) +- [ ] Dashboard mit Statistiken +- [ ] Nutzertabelle mit Such-/Filterfunktion +- [ ] "Website aktualisieren" Button mit Bestätigung +- [ ] Nutzer sperren/entsperren/löschen + +### 4.3 Erster Admin +- [ ] `ADMIN_EMAIL` env-Variable +- [ ] Beim Server-Start: wenn User mit dieser E-Mail existiert → Role = 'admin' +- [ ] Alternativ: `ADMIN_SETUP_TOKEN` für initiale Admin-Einrichtung + +--- + +## Phase 5: Freundessystem + +### 5.1 Backend +- [ ] `POST /v1/friends/request` — Anfrage senden (via Username) +- [ ] `GET /v1/friends/requests` — eigene Anfragen (eingehend + ausgehend) +- [ ] `PATCH /v1/friends/requests/:id` — annehmen/ablehnen +- [ ] `GET /v1/friends` — eigene Freundesliste +- [ ] `DELETE /v1/friends/:id` — Freundschaft beenden +- [ ] `GET /v1/users/search?q=` — Nutzer suchen + +### 5.2 Frontend +- [ ] Freundesliste im User-Profil +- [ ] "Freund hinzufügen" Button auf fremden Profilseiten +- [ ] Benachrichtigungs-Badge bei ausstehenden Anfragen +- [ ] "Freund herausfordern" → direktes Spiel erstellen + +--- + +## Phase 6: Registrierungs-Flow + +### Aktueller chessu-Flow: +`name + email + password` → Account erstellt + +### Gewünschter Michess-Flow: +1. `email + password` → Account erstellt (noch kein Nutzername) +2. Weitergeleitet zu "Nutzername wählen" (einmalig, nicht änderbar) +3. Nutzername prüfen: verfügbar? gültig? → bestätigen +4. Account aktiv + +- [ ] `name` Feld nullable machen in DB (bis Schritt 2) +- [ ] `POST /v1/auth/choose-username` Endpoint +- [ ] "Username wählen"-Seite im Frontend + +--- + +## Phase 7: Sonstige Verbesserungen + +- [ ] Zeitkontrolle für Partien (Bullet/Blitz/Rapid/Classical) +- [ ] Rating-System (Elo) für Nutzer-vs-Nutzer +- [ ] Rematch-Funktion +- [ ] Spectator-Verbesserungen (Live-Zuschauer) +- [ ] Mobile-Optimierung + +--- + +## Deployment-Anleitung (Ziel-Zustand) + +```bash +# Einmalige Erstinstallation auf der NAS: +git clone https://git.mischlabs.de/MrDiderot/Michess.git michess +cd michess +cp .env.example .env +# .env anpassen (Passwörter, Admin-Email, etc.) +docker compose up -d + +# Update (manuell oder per Admin-Panel): +./scripts/update.sh +# oder via Admin-Panel "Website aktualisieren" Button +``` + +--- + +## Git-Strategie + +- `main` — Production-Branch (immer deploybar) +- Features werden direkt auf main entwickelt (kleines Team) +- Issues aus `git.mischlabs.de` als Feature-Tracker +- Commit-Messages: `feat:`, `fix:`, `admin:`, `docker:`, `ui:` Prefixe + +--- + +## Prioritäten (Reihenfolge) + +1. **P0**: Foundation + Docker + Git (läuft auf NAS) +2. **P0**: Stockfish AI (Kernfeature) +3. **P1**: Admin-Panel (Verwaltbarkeit) +4. **P1**: Freundessystem (Social) +5. **P2**: Registrierungs-Flow verfeinern +6. **P3**: Zeitkontrolle + Rating diff --git a/client/src/app/admin/page.tsx b/client/src/app/admin/page.tsx new file mode 100644 index 0000000..d06f13c --- /dev/null +++ b/client/src/app/admin/page.tsx @@ -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(null); + const [users, setUsers] = useState([]); + 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
Kein Zugriff.
; + } + + return ( +
+

Admin-Panel

+ + {/* Stats */} + {stats && ( +
+
+
Nutzer gesamt
+
{stats.totalUsers}
+
+
+
Spiele gesamt
+
{stats.totalGames}
+
+
+
Neue Nutzer (7 Tage)
+
{stats.newUsersThisWeek}
+
+
+ )} + + {/* Update Button */} +
+

Website aktualisieren

+

Führt git pull im App-Verzeichnis aus. Container muss danach neugestartet werden.

+ + {updateLog && ( +
{updateLog}
+ )} +
+ + {/* User Management */} +
+

Nutzerverwaltung

+ + { setSearch(e.target.value); setPage(0); }} + /> + +
+ + + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + + ))} + +
IDNameE-MailW/L/DRolleStatusRegistriertAktionen
{u.id}{u.name}{u.email}{u.wins}/{u.losses}/{u.draws} + + {u.role} + + + + {u.banned ? "Gesperrt" : "Aktiv"} + + {new Date(u.created_at).toLocaleDateString("de-DE")} + + + +
+
+ + {/* Pagination */} +
+ + Seite {page + 1} — {total} Nutzer gesamt + +
+
+
+ ); +} diff --git a/client/src/app/ai/page.tsx b/client/src/app/ai/page.tsx new file mode 100644 index 0000000..291969d --- /dev/null +++ b/client/src/app/ai/page.tsx @@ -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 = {}; + 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 ( +
+

Gegen KI spielen

+ +
+
+

Schwierigkeitsgrad

+
+ {AI_LEVELS.map((l) => ( + + ))} +
+
+ +
+

Farbe wählen

+
+ + +
+
+
+
+ ); + } + + return ( +
+
+ +
+ +
+
+

Niveau: {AI_LEVELS.find(l => l.level === selectedLevel)?.name}

+

Du spielst: {playerColor === "white" ? "Weiß ♔" : "Schwarz ♚"}

+
+ +
+

{status}

+ {thinking &&

KI denkt nach...

} +
+ + + +
+

Züge

+
+ {game.history().map((move, i) => ( + {i % 2 === 0 ? `${Math.floor(i / 2) + 1}. ` : ""}{move} + ))} +
+
+
+
+ ); +} diff --git a/client/src/app/friends/page.tsx b/client/src/app/friends/page.tsx new file mode 100644 index 0000000..90c6ce6 --- /dev/null +++ b/client/src/app/friends/page.tsx @@ -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([]); + 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
Bitte einloggen.
; + } + + return ( +
+

Freunde

+ + {/* Add friend */} +
+

Freund hinzufügen

+
+ setAddUsername(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendRequest()} + /> + +
+ {addMsg &&

{addMsg}

} +
+ + {/* Incoming requests */} + {requests.incoming.length > 0 && ( +
+

Eingehende Anfragen ({requests.incoming.length})

+ {requests.incoming.map((r) => ( +
+ + {r.from_name} + +
+ + +
+
+ ))} +
+ )} + + {/* Outgoing requests */} + {requests.outgoing.length > 0 && ( +
+

Gesendete Anfragen

+ {requests.outgoing.map((r) => ( +
+ + {r.to_name} + ausstehend +
+ ))} +
+ )} + + {/* Friends list */} +
+

Meine Freunde ({friends.length})

+ {friends.length === 0 &&

Noch keine Freunde. Füge jemanden hinzu!

} + {friends.map((f) => ( +
+ + {f.friend_name} + + {f.wins}W / {f.losses}L / {f.draws}D + +
+ ))} +
+
+ ); +} diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 22ae904..2dbbb1b 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -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 ( - +
@@ -52,7 +51,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {