Compare commits

...

55 Commits

Author SHA1 Message Date
Kroonk
37fc530b74 Document Michess SSO deployment fix
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m39s
2026-05-21 12:13:38 +02:00
Kroonk
77e30e268b docs: update Brain.md with SSO integration details and troubleshooting hypotheses
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m39s
2026-05-21 11:42:28 +02:00
Kroonk
85f50c5530 fix: make auth dropdown controlled in React to prevent password manager focus-loss from closing it
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m41s
2026-05-21 11:35:21 +02:00
Kroonk
a21e2df6c4 fix: allow SSO with only SSO_CLIENT_SECRET configured, since other env vars have defaults
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m35s
2026-05-21 11:23:05 +02:00
Kroonk
f31e4e0800 ux: completely replace auth modal with a sleek inline auth dropdown in header
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m34s
2026-05-21 11:18:34 +02:00
Kroonk
5ac768d75f ux: disable auto-open AuthModal, make Abbrechen always visible, translate tabs, and show SSO option on guest tab
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m37s
2026-05-21 11:12:43 +02:00
Kroonk
d47e87ef82 feat: implement Keycloak SSO integration with JIT provisioning and Next.js login button
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m31s
2026-05-21 10:45:30 +02:00
Kroonk
ca64163899 Switch theme palette to mint green
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m31s
2026-05-20 17:48:10 +02:00
Kroonk
e8c1e3539d Slim Docker runtime image
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m44s
2026-05-20 17:42:52 +02:00
Kroonk
c9919479bf Use registry PAT for Gitea Docker login
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Failing after 6m15s
2026-05-20 17:28:17 +02:00
Kroonk
37e2495e3d Use Gitea job token for registry login
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Failing after 7s
2026-05-20 17:27:14 +02:00
Kroonk
5e996f791e Fix auth modal feedback and production deploy config
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Failing after 6s
2026-05-20 17:25:15 +02:00
Kroonk
6f96b26e4c Migrate workflow to Gitea registry and use pre-built image
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Has been cancelled
2026-05-20 16:58:05 +02:00
Michess
d60899fdbf feat(ui): simplified homepage, /play subpage, and fix bot-game PGN bug
Homepage now shows only a "Spielen" card (links to /play), public games,
last 3 games as mini-board thumbnails for logged-in users, and live games.

New /play page contains all game-mode cards (create, vs AI, friends,
correspondence, tournament).

Bug fix: /ai page was constructing Chess instances from FEN on every move,
discarding the full PGN history. Switching to loadPgn() preserves all moves
so the saved game can be fully reviewed in the archive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:22:38 +02:00
Michess
2707efe115 feat(ui): move admin panel and join-game into profile dropdown
- Replace header admin link + profile modal with a dropdown menu
- Dropdown contains: profile, settings, admin (admins only), join-game form, logout
- Remove join-game card from homepage for logged-in users (now in dropdown)
- AuthModal now only handles guest/login/register flows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:14:15 +02:00
Michess
bbf26826c6 fix: call leaveLobby before setting connected=true in joinLobby
Previously leaveLobby was called after game.black/white.connected was set
to true. Because every logged-in socket is in a personal user:{id} room,
this.rooms.size was always >=2, so leaveLobby fired on every join and
immediately found the *new* game via the connected-flag check, resetting
connected=false. This prevented the bot-trigger and clock-start checks from
passing, so bot-as-white tournament games never made their first move.

Fix: move leaveLobby call to before the connection-state assignment so it
can only find a genuinely *previous* game (where connected was already true
from an earlier join).

Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:54:08 +02:00
Michess
0b3228c9d7 feat: live game tiles on homepage + bot spectating
- GET /v1/games/live returns all active games with both players assigned,
  including current FEN (computed server-side via chess.js)
- LiveGames component on homepage: mini chessboard tiles (160px) that
  auto-refresh every 3s, clickable to join as spectator
- Tournament page: 'Zuschauen' button for non-players on ongoing rounds
  (bot vs bot, human vs human, human vs bot all spectatable)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:03:26 +02:00
Michess
86fbdd39ed fix: resolve all 3 open Gitea issues
Issue #1 - Bot vs Bot Turnierspiele:
- triggerBotMove ruft sich rekursiv selbst auf wenn der nächste Spieler
  auch ein Bot ist (bot vs bot läuft jetzt vollautomatisch durch)
- startNextRoundGames erstellt aktive Spiele für die nächste Runde wenn
  eine Runde endet; bot vs bot Spiele starten sofort ohne Spectator
- updateRoundResult gibt jetzt die nächste Runden-Daten zurück statt void
- Alle Handler (sendMove, claimAbandoned, claimTimeout, triggerBotMove)
  starten die nächste Runde automatisch

Issue #2 - Bot-Schwierigkeit zu hoch:
- randomChance zu StockfishLevel hinzugefügt: Level 1=80%, 2=50%,
  3=15%, 4=3%, 5-6=0% zufällige Züge statt Stockfish-Best-Move
- Gilt sowohl für Socket-Bot-Züge als auch /v1/ai/move REST-Endpoint

Issue #3 - Turnier beenden + Zurück-Button:
- POST /:code/cancel Endpoint (nur Host), setzt status='finished'
- Turnier-Seite: 'Turnier beenden' Button für Host (rot, outline)
- tournamentCode Feld im Game-Typ; wird beim Erstellen von Turnierspielen
  gesetzt (inkl. Folgerunden)
- GamePage zeigt '← Zur Turnierübersicht' Link wenn tournamentCode gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 07:46:40 +02:00
Michess
536160bba6 feat(elo): add ELO rating system
- DB: elo column on user table (default 1200, added via ALTER TABLE IF NOT EXISTS)
- ELO calculated after every game save (K=32, min 100)
- Bots use their fixed ELO values; only human players' ELO changes
- AI page games save with bot name (e.g. 'Holzpferd Heinz') instead of 'Stockfish'
- User profile shows ELO badge below name
- Header shows current ELO for logged-in users (links to profile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 07:32:57 +02:00
Michess
157c14c11e feat(bots): add named bot profiles with ELO-based difficulty
- 5 bot profiles (Holzpferd Heinz to Meister Magnus, ELO 200-1400) in
  server/bots.ts and client/bots.ts with emoji + descriptions
- Bot users auto-created in DB on server startup (role='bot')
- AI page replaced difficulty buttons with bot profile cards
- Tournament host can add bots via dropdown (POST /:code/add-bot)
- Bot auto-moves triggered on joinLobby and after each human move
- Fixed clock start order so bot-as-white games initialize correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:32:43 +02:00
Michess
b29c467766 feat: add friend invite modal to all game modes
- New InviteFriendsModal component: fetches friends list, sends game
  invite via notification socket with per-friend sent confirmation
- Multiplayer (GamePage): invite button below copy link, hidden after game ends
- Correspondence: invite button in waiting-for-opponent card
- Tournament: invite button for joined players while status is waiting
- Invite links use mode-prefixed codes (correspondence/XYZ, tournament/XYZ)
  so notification "Beitreten" button navigates to the correct page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:16:00 +02:00
Michess
50ef567a1a fix(eslint): ignore unused function parameter names in type definitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:01:30 +02:00
Michess
4825a57a54 feat: add real-time notification system and fix admin login bug
- Fix login: role field was missing from session (admin page inaccessible)
- Add onlineUsers map in socket/state.ts to track connected users
- Join personal user:${id} socket room on connect for targeted notifications
- Emit friendRequestReceived/friendRequestAccepted socket events from friends controller
- Add sendGameInvite socket event handler on server
- New NotificationContext: global socket provider for registered users
- New NotificationPanel: bell icon with unread badge and dropdown in header
- Friends page: inline game invite input per friend with sendGameInvite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:35:09 +02:00
Michess
1501b859b2 fix: suppress unused parameter ESLint error in socketEvents interface type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:25:19 +02:00
Michess
dc7f718117 feat: new add time controls, correspondence games, and tournament mode
**Zeitmodi (Blitz/Rapid):**
- 5, 10, 30 Min Zeitkontrolle bei Spielerstellung
- Server-seitige Uhren: Zeit wird bei jedem Zug abgezogen
- Timeout-Erkennung via claimTimeout Socket-Event
- Schachuhr-Anzeige in GamePage (rot bei < 30s)
- Turnierspiele erhalten automatisch Zeitkontrolle

**Tagespartien (Correspondence):**
- Neue DB-Tabelle correspondence_game
- REST API: erstellen, beitreten, Zug machen, aufgeben
- Seite /correspondence: Liste aktiver Tagespartien
- Seite /correspondence/[code]: Spielen mit interaktivem Brett
- Einladungslink teilen, Gegner tritt via Link bei

**Turniermodus (Round-Robin):**
- Neue DB-Tabellen: tournament, tournament_player, tournament_round
- Round-Robin Paarungen mit Circle-Methode
- Turnier erstellen: /tournament/create
- Turnierliste: /tournament
- Turnierseite /tournament/[code]: Standings, Runden, Spiele
- Automatische Aktivisierung der nächsten Runde
- Turnierspiele werden als normale activeGames erstellt
- Ergebnisse aktualisieren Punkte automatisch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:54:33 +02:00
Michess
aeb5fe313e feat: add time controls, correspondence games, and tournament mode
**Zeitmodi (Blitz/Rapid):**
- 5, 10, 30 Min Zeitkontrolle bei Spielerstellung
- Server-seitige Uhren: Zeit wird bei jedem Zug abgezogen
- Timeout-Erkennung via claimTimeout Socket-Event
- Schachuhr-Anzeige in GamePage (rot bei < 30s)
- Turnierspiele erhalten automatisch Zeitkontrolle

**Tagespartien (Correspondence):**
- Neue DB-Tabelle correspondence_game
- REST API: erstellen, beitreten, Zug machen, aufgeben
- Seite /correspondence: Liste aktiver Tagespartien
- Seite /correspondence/[code]: Spielen mit interaktivem Brett
- Einladungslink teilen, Gegner tritt via Link bei

**Turniermodus (Round-Robin):**
- Neue DB-Tabellen: tournament, tournament_player, tournament_round
- Round-Robin Paarungen mit Circle-Methode
- Turnier erstellen: /tournament/create
- Turnierliste: /tournament
- Turnierseite /tournament/[code]: Standings, Runden, Spiele
- Automatische Aktivisierung der nächsten Runde
- Turnierspiele werden als normale activeGames erstellt
- Ergebnisse aktualisieren Punkte automatisch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 09:44:35 +02:00
Michess
09e5271dfb feat: redesign homepage layout and fix invite links
- Homepage: Grid-Layout mit 3 Karten (Spiel erstellen, KI, Freunde/Beitreten)
  statt vertikalem Stack mit Dividers
- CreateGame: kompakteres Styling, auf Deutsch, in Primary-Card integriert
- Einladungslink: APP_URL statt hardcoded ches.su in GamePage und CopyLink
- JoinGame: URL-Parsing für beliebige Domains (nicht nur ches.su)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:49:59 +02:00
Michess
0cc5a8e99c fix(ai): fix database model mapping for AI matches and missing AI names 2026-04-13 15:06:45 +02:00
Michess
f7b87f2941 fix: use existsSync instead of require(fs) in ESM module 2026-04-13 14:25:31 +02:00
Michess
4bc6ea58e6 fix: handle stockfish process error, try /usr/games/stockfish path 2026-04-13 14:19:58 +02:00
Michess
157fcf8fa4 fix: RefreshButton handle async onRefresh outside startTransition 2026-04-13 14:01:38 +02:00
Michess
446fce247f debug: add error boundary to show actual error message 2026-04-13 13:52:44 +02:00
Michess
39592bd579 fix: convert PublicGames to client component to avoid async server component in client tree 2026-04-13 13:45:43 +02:00
Michess
ebbc8227fd fix: use native stockfish binary via debian image 2026-04-13 13:40:21 +02:00
Michess
6b676d3015 fix: use child_process to spawn stockfish via stdin/stdout 2026-04-13 13:33:26 +02:00
Michess
6d677348d3 fix: pass NEXT_PUBLIC_* build args when building locally 2026-04-13 13:24:16 +02:00
Michess
3508c664e0 fix: next start explicitly on port 3000 2026-04-13 13:12:06 +02:00
Michess
81f60c9846 docker: build locally on NAS, no registry needed 2026-04-13 13:01:31 +02:00
Michess
8a6d7ddffb ci: trigger build
Some checks failed
Build & Push Docker Image / build (push) Failing after 6s
2026-04-13 12:57:46 +02:00
Michess
0e7b9c61e1 ci: use localhost:3001 registry to bypass Cloudflare
Some checks failed
Build & Push Docker Image / build (push) Failing after 6s
2026-04-13 12:53:02 +02:00
Michess
3a9681348f ci: trigger build after registry fix
Some checks failed
Build & Push Docker Image / build (push) Failing after 6s
2026-04-13 12:46:51 +02:00
Michess
0edc3cc7fd ci: trigger build
Some checks failed
Build & Push Docker Image / build (push) Failing after 6s
2026-04-13 12:29:21 +02:00
Michess
dfdf508064 ci: install bash+docker-cli+git via sh shell in setup step
Some checks failed
Build & Push Docker Image / build (push) Failing after 5s
2026-04-13 12:22:43 +02:00
Michess
46baf29eff ci: use node:20-alpine with docker-cli for runner compatibility
Some checks failed
Build & Push Docker Image / build (push) Failing after 3s
2026-04-13 12:19:57 +02:00
Michess
c1ea929753 ci: trigger build
Some checks failed
Build & Push Docker Image / build (push) Failing after 9s
2026-04-13 12:15:14 +02:00
Michess
b562839bd6 ci: trigger first Gitea Actions build
Some checks failed
Build & Push Docker Image / build (push) Failing after 22s
2026-04-13 11:58:05 +02:00
Michess
7b1f898b9e feat: Gitea Actions CI/CD — baut Image und pushed in Registry
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-04-13 11:39:18 +02:00
Michess
47d9464ad4 fix: NEXT_PUBLIC_* URLs als Docker Build-Args weitergereicht 2026-04-13 11:36:47 +02:00
Michess
c056b93459 fix: provenance-Option entfernt (inkompatibel mit dieser Docker-Version) 2026-04-13 11:35:35 +02:00
Michess
641cdc7aad fix: BuildKit provenance deaktiviert (git-Warning entfernt) 2026-04-13 11:31:21 +02:00
Michess
e4c7aba178 fix: revalidate aus Client Component entfernt (Next.js Fehler) 2026-04-13 10:50:45 +02:00
Michess
694177c108 feat: Vollständiges Rebranding zu MiChess, Copyright Tom Misch
- Alle @chessu/ → @michess/ (Package-Namen und Imports)
- Theme-Namen chessuDark/Light → michessDark/Light
- Alle Display-Texte: chessu/Michess → MiChess
- Autor: dotnize → Tom Misch
- Copyright: Nathaniel Tampus → Tom Misch
- README komplett neu (Deutsch, MiChess-spezifisch)
- CONTRIBUTING.md neu
- FUNDING.yml + CODE_OF_CONDUCT.md entfernt
- Fork-Hinweis auf chessu bleibt im README erhalten (MIT-Pflicht)
- pnpm-lock.yaml entfernt (wird beim Build neu generiert)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:47:16 +02:00
Michess
4ab8cd3834 fix: ESLint-Fehler im Client behoben (unused vars, useCallback, CSSProperties) 2026-04-13 10:36:10 +02:00
Michess
b79a442b0c fix: TypeScript-Fehler behoben (stockfish types, null-Typ) 2026-04-13 10:30:54 +02:00
Michess
50464981c2 fix: Stockfish via npm-Paket statt Alpine apk
- stockfish als npm dependency (kein apk add nötig, portabel)
- Dockerfile: nur noch git via apk, kein stockfish
- docker-compose.yml: version-Zeile entfernt (veraltet)
- Controller auf npm-Package-API umgeschrieben (postMessage/onmessage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:23:33 +02:00
94 changed files with 5796 additions and 2368 deletions

View File

@@ -1,8 +1,21 @@
.git
.github
.gitea
.next
.pnpm-store
.vercel
**/.next
**/.turbo
**/dist
**/node_modules
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Dockerfile
LICENSE
README.md
docker-compose.yml
Dockerfile
LICENSE
logs
*.log
*.local
.env
.env.*

View File

@@ -24,3 +24,10 @@ 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
# Keycloak SSO (OIDC) Integration
SSO_AUTHORITY=https://auth.mischlabs.de/realms/mischlabs
SSO_CLIENT_ID=michess
SSO_CLIENT_SECRET=dein_client_secret_aus_keycloak
SSO_REDIRECT_URI=https://michess.mischlabs.de/v1/auth/sso/callback
APP_URL=https://michess.mischlabs.de

View File

@@ -0,0 +1,64 @@
name: Build & Push Docker Image to Gitea Registry
on:
push:
branches: [main]
env:
REGISTRY: git.mischlabs.de
REGISTRY_USER: MrDiderot
REGISTRY_IMAGE: git.mischlabs.de/mrdiderot/michess
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI
shell: sh
run: |
if ! command -v docker >/dev/null 2>&1; then
echo "Docker CLI not found. Installing static binary..."
if command -v curl >/dev/null 2>&1; then
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
else
echo "Neither curl nor wget found! Attempting package manager install..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y docker.io
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache docker-cli
else
echo "Cannot install Docker CLI!"
exit 1
fi
fi
else
echo "Docker CLI already installed."
fi
docker --version
- name: Log in to Gitea Package Registry
shell: sh
run: |
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "Missing REGISTRY_TOKEN secret."
echo "Create a Gitea personal access token for MrDiderot with package Read and Write permission."
exit 1
fi
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ env.REGISTRY_USER }}" --password-stdin
- name: Build and push Docker image
shell: sh
run: |
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://michess-api.mischlabs.de \
--build-arg NEXT_PUBLIC_APP_URL=https://michess.mischlabs.de \
-t ${{ env.REGISTRY_IMAGE }}:latest \
-t ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} \
.
docker push ${{ env.REGISTRY_IMAGE }}:latest
docker push ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: [dotnize]

152
Brain.md
View File

@@ -1,13 +1,13 @@
# Brain.md — Michess Project Memory
# Brain.md — MiChess Project Memory
> Dieses Dokument ist mein aktives Gedächtnis während der Entwicklung von Michess.
> 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.
MiChess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Docker, mit Stockfish-Engine.
---
@@ -47,7 +47,7 @@ Michess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Doc
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
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)
@@ -91,7 +91,7 @@ Michess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Doc
- [x] Brain.md erstellt
- [x] Plan.md erstellt
- [ ] Git remote auf `git.mischlabs.de` gesetzt
- [ ] Projekt auf "Michess" umbenannt
- [ ] Projekt auf "MiChess" umbenannt
- [ ] DB-Schema erweitert (admin, friends, friend_requests)
- [ ] Stockfish integriert
- [ ] Admin-Panel gebaut
@@ -103,7 +103,7 @@ Michess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Doc
## Bekannte Probleme / Offene Fragen
- chessu's Dockerfile nutzt `CMD ["start"]` — muss für Michess angepasst werden (Server + Client separat oder zusammen)
- 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
@@ -111,17 +111,137 @@ Michess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Doc
## Lessons Learned
*(wird im Verlauf befüllt)*
- **CSS-only Dropdowns vs. Autofill Extensions:** Rein CSS-basierte DaisyUI Dropdowns (`dropdown-content` getriggert über `:focus`/`tabIndex`) schließen sich unkontrolliert, wenn Passwort-Manager (z.B. Vaultwarden) Fokus-Events abfangen oder Overlays einblenden. Lösung: Vollständig React-gesteuerter Dropdown-Zustand (`useState`, `useRef`, `click-outside` via `mousedown` auf Dokumentenebene) ohne `tabIndex={0}`.
- **Docker Env Mapping Empty Strings:** Im `docker-compose.yml` sorgt die Syntax `SSO_CLIENT_ID: ${SSO_CLIENT_ID:-}` dafür, dass bei Auslassung der Variablen in der `.env`-Datei ein leerer String (`""`) in die Node.js-Umgebung gereicht wird. Node.js interpretiert `""` als *falsy*, wodurch Abfragen wie `process.env.SSO_CLIENT_ID` fehlschlagen.
---
## Nächste Schritte (immer aktuell)
## 🔑 Keycloak SSO Integration & UX Overhaul
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
### 1. Backend-Erweiterungen (Server)
- **Paket:** `openid-client` für OIDC-Standard-Interaktionen.
- **Endpunkte (`/v1/auth/sso/*`):**
- `GET /v1/auth/sso/config`: Gibt `{ enabled: boolean }` zurück. Erkennt SSO als aktiv, sobald `SSO_CLIENT_SECRET` gesetzt ist (die optionalen Variablen `SSO_CLIENT_ID` und `SSO_AUTHORITY` haben Code-Fallbacks).
- `GET /v1/auth/sso/login`: Initiiert den OIDC-Flow und leitet zur Keycloak Realm `mischlabs` weiter.
- `GET /v1/auth/sso/callback`: Verarbeitet das authorization code grant, erfragt User-Infos und führt **Just-In-Time (JIT) Provisionierung** durch.
- **Option A Permission Mapping:** JIT-provisionierte Benutzer werden automatisch zu `admin` befördert, wenn ihr Benutzername `MrDiderot` lautet oder wenn die Benutzer-Datenbank leer ist (andernfalls Rolle `user`).
### 2. Frontend-Umbau (Client)
- **Popup-Entfernung:** `AuthModal.tsx` wurde komplett gelöscht. Es gibt beim Laden der Seite kein störendes Popup mehr.
- **Avatar-Dropdown:** Einbettung von `AuthDropdownContent.tsx` in `Header.tsx` für unangekündigte Gäste. Bietet Reiter für *Gast*, *Anmelden*, *Registrieren* sowie die Keycloak-Schaltfläche (wenn aktiviert).
- **Passwort-Manager-Immunisierung:** Vollständig React-kontrolliertes Dropdown gegen vorzeitiges Schließen beim Autofill.
---
## 🔍 Fehleranalyse: SSO-Schaltfläche fehlt weiterhin
Obwohl das Frontend-Update aktiv ist (Vaultwarden-Anmeldung schließt das Fenster nicht mehr), wird die Schaltfläche `"Anmelden mit Keycloak"` nicht angezeigt. Das bedeutet, dass der Client `ssoEnabled === false` setzt, weil der API-Call `${API_URL}/v1/auth/sso/config` nicht `{ enabled: true }` zurückgibt.
### 📋 Vermutungen und Diagnose-Ansätze (für Zweit-KI / Debugging)
#### Vermutung A: Die Umgebungsvariable `SSO_CLIENT_SECRET` ist im Backend-Container nicht geladen.
- **Grund:** Die Variable wurde in `/volume2/docker/mischlabs/.env` eingetragen, aber Docker Compose hat den Container nicht vollständig mit der neuen Umgebung neu generiert.
- **Diagnose:** SSH-Zugriff auf das NAS und Ausführen von:
```bash
docker exec -it michess env | grep SSO
```
Wenn hier `SSO_CLIENT_SECRET` fehlt oder leer ist, lädt der Container die Variable nicht.
- **Behebung:** Den Container zwingend neu erstellen lassen, nicht nur updaten:
```bash
docker compose down && docker compose up -d
```
#### Vermutung B: Abweichende `.env`-Pfade oder fehlendes Volume-Mapping.
- **Grund:** Im Docker-Compose-Setup wird `SSO_CLIENT_SECRET` über `${SSO_CLIENT_SECRET:-}` zugewiesen. Wenn die `.env`-Datei nicht im selben Verzeichnis wie `docker-compose.yml` liegt oder Docker Compose sie nicht standardmäßig lädt, bleibt der Wert leer.
- **Diagnose:** Prüfen, ob die Variable auf dem Docker-Host in der `.env` existiert:
```bash
cat /volume2/docker/mischlabs/.env | grep SSO
```
#### Vermutung C: Netzwerkanfrage / CORS / SSL-Fehler beim Config-Call.
- **Grund:** Die Web-App (`https://michess.mischlabs.de`) versucht den Status von `https://michess-api.mischlabs.de/v1/auth/sso/config` abzufragen. Es könnte sein, dass dieser API-Call fehlschlägt (z.B. durch CORS-Sicherheitsrichtlinien, Cloudflare-Tunnel blockiert Requests mit Credentials, oder Mixed-Content-Fehler).
- **Diagnose:**
1. Öffne die Entwicklerkonsole des Browsers (F12) -> **Netzwerk-Tab (Network)**.
2. Lade die Seite neu und filtere nach `config` oder `sso`.
3. Prüfe den HTTP-Statuscode und die Antwort von `https://michess-api.mischlabs.de/v1/auth/sso/config`.
4. Kommt dort `{ "enabled": false }` zurück (dann ist es **Vermutung A/B**), oder scheitert der Request komplett (CORS/Netzwerkfehler)?
---
## Nächste Schritte
1. Prüfe die Browser-Konsole im Netzwerk-Tab, um zu sehen, ob die API-Antwort `{ enabled: false }` liefert oder blockiert wird.
2. Führe auf dem NAS `docker exec -it michess env | grep SSO` aus, um die Live-Umgebungsvariablen zu validieren.
3. Führe einen sauberen Docker-Neustart aus (`docker compose down && docker compose up -d`).
---
## SSO-Fix live geloest am 2026-05-21
### Final funktionierende NAS-Umgebung
In `/volume2/docker/mischlabs/.env`:
```env
SSO_AUTHORITY=https://auth.mischlabs.de/realms/mischlabs
SSO_CLIENT_ID=michess
SSO_CLIENT_SECRET=<keycloak_client_secret>
SSO_REDIRECT_URI=https://michess-api.mischlabs.de/v1/auth/sso/callback
APP_URL=https://michess.mischlabs.de
```
In `docker-compose.yml` im Service `michess` unter `environment:`:
```yaml
SSO_AUTHORITY: ${SSO_AUTHORITY:-https://auth.mischlabs.de/realms/mischlabs}
SSO_CLIENT_ID: ${SSO_CLIENT_ID:-michess}
SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:-}
SSO_REDIRECT_URI: ${SSO_REDIRECT_URI:-https://michess-api.mischlabs.de/v1/auth/sso/callback}
APP_URL: ${APP_URL:-https://michess.mischlabs.de}
```
Nach Aenderungen:
```bash
cd /volume2/docker/mischlabs
docker compose up -d --force-recreate michess
docker exec michess env | grep SSO
curl https://michess-api.mischlabs.de/v1/auth/sso/config
```
Erwartete API-Antwort:
```json
{"enabled":true}
```
### Keycloak Client `michess`
```text
Client ID: michess
Valid redirect URI: https://michess-api.mischlabs.de/v1/auth/sso/callback
Web origin: https://michess.mischlabs.de
Client scopes: email Default, profile Default
```
### Fehlerverlauf
1. `docker exec michess env | grep SSO` war leer, obwohl `.env` SSO-Werte enthielt.
- Ursache: NAS-Compose reichte die Variablen nicht an den Container weiter.
- Fix: `SSO_*` und `APP_URL` in `docker-compose.yml` unter `environment:` ergaenzt.
2. `docker compose config` meldete YAML-Fehler.
- Ursache: falsche Einrueckung und ein abgeschnittener langer Wert in `nano`.
- Fix: Alle Environment-Zeilen mit korrekter Einrueckung im Mapping-Stil (`KEY: value`) gesetzt.
3. Compose warnte: `The "CMUe..." variable is not set`.
- Ursache: Das Secret war in Compose als `${ECHTES_SECRET}` eingetragen.
- Fix: `SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:-}` verwenden. Das echte Secret gehoert in `.env`.
4. Button erschien, aber Callback landete auf `https://michess.mischlabs.de/v1/auth/sso/callback` und gab 404.
- Ursache: `SSO_REDIRECT_URI` zeigte auf die Frontend-Domain.
- Fix: Redirect URI auf API-Domain umgestellt.
5. `invalid_scope` wurde durch Default Scopes `email` und `profile` im Keycloak-Client geloest.
Kurzform: `SSO_REDIRECT_URI` zeigt auf `michess-api`, `APP_URL` zeigt auf `michess`.

View File

@@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
admin@ches.su.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -1,60 +1,14 @@
# Contributing
# Beitragen zu MiChess
Welcome! We're glad you're interested in contributing to the project.
Issues und Pull Requests sind willkommen auf [git.mischlabs.de/MrDiderot/MiChess](https://git.mischlabs.de/MrDiderot/MiChess).
We welcome contributions of any kind; however, for **feature changes or additions**, please open an issue first for discussion.
## Entwicklungsumgebung
## Getting Started
1. Node.js 20+ und pnpm installieren
2. Repository klonen und `pnpm install` ausführen
3. `.env.example` nach `.env` kopieren und anpassen
4. `pnpm dev` starten
1. [Fork this repository](https://github.com/dotnize/chessu/fork) to your GitHub account. You can then clone the repository to your local machine and create a new branch for your changes.
```sh
git clone https://github.com/[your-username]/chessu.git
cd chessu
git checkout -b my-feature-branch
```
2. Follow the [setup guide](./README.md#getting-started) from the README to install the necessary dependencies and run the development servers.
3. You may now make your changes and commit them to your branch.
## Commit-Stil
When adding new dependencies or running other commands from the root directory, you can specify the workspace with the `--filter` flag before the command. For example, `pnpm --filter client lint` or `pnpm --filter server add express`.
### Formatting and linting
We use ESLint and Prettier to enforce code style and formatting. Please make sure to run `pnpm lint:fix` and `pnpm format` before committing your changes.
### Environment variables
You may create a `.env` file in each package directory to set their environment variables.
<details>
<summary>client</summary>
```env
NEXT_PUBLIC_API_URL=http://localhost:3001 # replace with backend URL
```
</details>
<details>
<summary>server</summary>
```env
CORS_ORIGIN=http://localhost:3000 # replace with frontend URL
PORT=3001
SESSION_SECRET=randomstring # replace for security
# PostgreSQL connection info (required)
PGHOST=db.example.com
PGUSER=exampleuser
PGPASSWORD=examplepassword
PGDATABASE=chessu
```
</details>
## Guidelines
- Follow the [Code of Conduct](CODE_OF_CONDUCT.md).
- Make sure your changes are thoroughly tested.
- Keep your commits atomic and descriptive.
- Ensure that your code is formatted and linted using `pnpm lint:fix` and `pnpm format`.
- Make your pull requests as descriptive as possible.
Prefixe: `feat:`, `fix:`, `ui:`, `admin:`, `docker:`, `docs:`

View File

@@ -1,7 +1,4 @@
FROM node:lts-alpine3.20
# Install stockfish and git
RUN apk update && apk add --no-cache stockfish git
FROM node:lts-bookworm-slim AS build
ENV PNPM_HOME=/usr/local/bin
@@ -9,15 +6,46 @@ WORKDIR /opt/michess/
COPY . .
RUN corepack enable && \
corepack prepare pnpm@latest --activate && \
pnpm config set store-dir /opt/michess/.pnpm-store && \
pnpm install --frozen-lockfile
ARG NEXT_PUBLIC_API_URL=http://localhost:3001
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
# Build both client and server
RUN pnpm build:server && pnpm build:client
RUN corepack enable && \
corepack prepare pnpm@9.15.9 --activate && \
pnpm config set store-dir /opt/michess/.pnpm-store && \
pnpm install --no-frozen-lockfile && \
pnpm build:server && \
pnpm build:client && \
rm -rf node_modules client/node_modules server/node_modules types/node_modules && \
pnpm install --prod --no-frozen-lockfile && \
pnpm store prune && \
rm -rf \
/opt/michess/.pnpm-store \
/root/.cache \
/tmp/* \
client/.next/cache \
client/src \
server/src \
.git \
.github \
.gitea
FROM node:lts-bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends stockfish && \
rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV PNPM_HOME=/usr/local/bin
WORKDIR /opt/michess/
COPY --from=build /opt/michess/ ./
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
EXPOSE 3000 3001
ENTRYPOINT ["pnpm"]
CMD ["start"]
CMD ["node", "scripts/start-production.mjs"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023-present, Nathaniel Tampus
Copyright (c) 2024-present, Tom Misch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

14
Plan.md
View File

@@ -1,10 +1,10 @@
# Plan.md — Michess Entwicklungsplan
# Plan.md — MiChess Entwicklungsplan
---
## Vision
**Michess** ist eine selbst-gehostete Schachplattform für eine private Community, inspiriert von Lichess.org.
**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.
---
@@ -14,12 +14,12 @@ Läuft als Docker-Container auf einer Synology/QNAP NAS, komplett ohne Cloud-Abh
### 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
- [ ] Git remote auf `https://git.mischlabs.de/MrDiderot/MiChess.git` setzen
- [ ] Initaler Push (chessu-Basis + eigene Dateien)
### 1.2 Rebranding chessu → Michess
### 1.2 Rebranding chessu → MiChess
- [ ] `package.json` Namen anpassen
- [ ] README durch Michess-README ersetzen
- [ ] README durch MiChess-README ersetzen
- [ ] Alle Titel/Meta-Tags im Frontend ändern
- [ ] Logo-Platzhalter anpassen
@@ -131,7 +131,7 @@ CREATE TABLE IF NOT EXISTS "friendship" (
### Aktueller chessu-Flow:
`name + email + password` → Account erstellt
### Gewünschter Michess-Flow:
### 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
@@ -157,7 +157,7 @@ CREATE TABLE IF NOT EXISTS "friendship" (
```bash
# Einmalige Erstinstallation auf der NAS:
git clone https://git.mischlabs.de/MrDiderot/Michess.git michess
git clone https://git.mischlabs.de/MrDiderot/MiChess.git michess
cd michess
cp .env.example .env
# .env anpassen (Passwörter, Admin-Email, etc.)

108
README.md
View File

@@ -1,83 +1,59 @@
<h1 align="center">
<img src="./assets/chessu.png" alt="chessu" height="128" />
</h1>
<p align="center">
<a href="https://ches.su">
<img src="https://img.shields.io/github/deployments/dotnize/chessu/Production?label=deployment&style=for-the-badge&color=blue" alt="ches.su" />
</a>
<img src="https://img.shields.io/github/last-commit/dotnize/chessu?style=for-the-badge" alt="Last commit" />
</p>
# MiChess
<p align="center">Yet another Chess web app.
Eine selbst-gehostete Schachplattform, inspiriert von Lichess.org.
Läuft als Docker-Container auf einer NAS — lokal, privat, ohne Cloud!
<p align="center">
<img src="./assets/demo.jpg" alt="chessu" width="640" />
</p>
> Basiert auf [chessu](https://github.com/dotnize/chessu) (MIT) von dotnize — vielen Dank für die solide Basis.
- play against other users in real-time
- spectate and chat in ongoing games with other users
- _optional_ user accounts for tracking stats and game history
- ~~play solo against Stockfish~~ (wip)
- mobile-friendly
- ... and more ([view roadmap](https://github.com/users/dotnize/projects/2))
## Features
Built with Next.js 14, Tailwind CSS + daisyUI, react-chessboard, chess.js, Express.js, socket.io and PostgreSQL.
- Echtzeit-Multiplayer (Spieler vs. Spieler)
- Stockfish KI in 6 Schwierigkeitsgraden (Anfänger bis Meister)
- Nutzeraccounts mit E-Mail & Passwort
- Freundessystem mit Anfragen und Freundesliste
- Admin-Panel: Nutzerverwaltung, Sperren/Entsperren, Git-Update per Knopfdruck
- Spielarchiv & Statistiken
- Zuschauer & Chat
- Mobil-optimiert
## Development
## Tech Stack
> Node.js 20 or newer is recommended.
Next.js 14 · Tailwind CSS · daisyUI · react-chessboard · chess.js · Express.js · Socket.io · PostgreSQL · TypeScript · Docker
This project is structured as a monorepo using **pnpm** workspaces, separated into three packages:
## Deployment (NAS / Docker)
- `client` - Next.js application for the front-end, ~~deployed to ches.su via Vercel~~.
- `server` - Node/Express.js application for the back-end, ~~deployed to server.ches.su via Railway~~.
- `types` - Shared type definitions required by the client and server.
```bash
# 1. Repository klonen
git clone https://git.mischlabs.de/MrDiderot/MiChess.git michess
cd michess
### Getting started
# 2. Konfiguration anpassen
cp .env.example .env
nano .env # Passwörter, ADMIN_EMAIL etc. setzen
1. Install [pnpm](https://pnpm.io/installation).
2. Install the necessary dependencies by running `pnpm install` in the root directory of the project.
3. In the `server` directory, create a `.env` file for your PostgreSQL database. You can try [ElephantSQL](https://www.elephantsql.com/) or [Aiven](https://aiven.io/postgresql) for a free hosted database.
```env
PGHOST=db.example.com
PGUSER=exampleuser
PGPASSWORD=examplepassword
PGDATABASE=chessu
```
4. Run the development servers with `pnpm dev`.
- To run the frontend and backend servers separately, use `pnpm dev:client` and `pnpm dev:server`, respectively.
5. You can now access the frontend at http://localhost:3000 and the backend at http://localhost:3001.
## Running chessu with Docker
To build the project with Docker, you can use the provided `Dockerfile`.
```sh
docker build -t chessu .
# 3. Starten
docker compose up -d --build
```
This command will build the Docker image with the name `chessu`. You can then run the image with the following command:
```sh
docker run -p 3000:3000 -p 3001:3001 chessu
Frontend: `http://deine-nas-ip:3000`
Backend API: `http://deine-nas-ip:3001`
## Update
```bash
# Manuell via Script
./scripts/update.sh
# Oder im Admin-Panel → "Jetzt aktualisieren"
```
Once built, to start the project with POSTGRES, you can use the provided `docker-compose.yml` file.
```sh
docker-compose up
## Entwicklung (lokal)
```bash
pnpm install
pnpm dev
```
Please make sure to modify the values in the `server/.env` file to match the values in the `docker-compose.yml` file or vice versa.
The entrypoint for the Docker image is set to run pnpm.
The Dockerfile's `CMD` instruction is set to run the project in production mode.
If you want to run the project in development mode, you can override the `CMD` instruction by running the following command:
```sh
docker run -p 3000:3000 -p 3001:3001 chessu dev # runs both client and server in development mode
docker run -p 3000:3000 -p 3001:3001 chessu dev:client # runs only the client in development mode
docker run -p 3000:3000 -p 3001:3001 chessu dev:server # runs only the server in development mode
```
## Contributing
## Lizenz
Please read our [Contributing Guidelines](./CONTRIBUTING.md) before starting a pull request.
## License
[MIT](./LICENSE)
[MIT](./LICENSE) · Copyright © 2024-present Tom Misch

127
brain.old Normal file
View File

@@ -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

View File

@@ -5,5 +5,8 @@
"rootDir": "."
}
},
"rules": {
"no-unused-vars": ["error", { "args": "none" }]
},
"root": true
}

View File

@@ -1,12 +1,12 @@
{
"name": "@chessu/client",
"name": "@michess/client",
"version": "0.0.0",
"private": true,
"scripts": {
"isolated-install": "pnpm -w install:client",
"dev": "next dev",
"build": "next build",
"start": "next start",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
@@ -19,7 +19,7 @@
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@chessu/types": "workspace:*",
"@michess/types": "workspace:*",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

View File

@@ -18,10 +18,10 @@ export async function generateMetadata({ params }: { params: { code: string } })
return {
description: `Play or watch a game with ${game.host?.name}`,
openGraph: {
title: "chessu",
title: "MiChess",
description: `Play or watch a game with ${game.host?.name}`,
url: `https://ches.su/${game.code}`,
siteName: "chessu",
siteName: "MiChess",
locale: "en_US",
type: "website"
},

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
@@ -40,17 +40,12 @@ export default function AdminPage() {
if (user && user.role !== "admin") router.push("/");
}, [user, router]);
useEffect(() => {
fetchStats();
fetchUsers();
}, [page, search]);
const fetchStats = async () => {
const fetchStats = useCallback(async () => {
const res = await fetch(`${API_URL}/v1/admin/stats`, { credentials: "include" });
if (res.ok) setStats(await res.json());
};
}, []);
const fetchUsers = async () => {
const fetchUsers = useCallback(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" });
@@ -59,7 +54,12 @@ export default function AdminPage() {
setUsers(data.users);
setTotal(data.total);
}
};
}, [page, search, limit]);
useEffect(() => {
fetchStats();
fetchUsers();
}, [fetchStats, fetchUsers]);
const toggleBan = async (id: number, banned: boolean) => {
await fetch(`${API_URL}/v1/admin/users/${id}`, {
@@ -107,7 +107,6 @@ export default function AdminPage() {
<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">
@@ -125,15 +124,10 @@ export default function AdminPage() {
</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}
>
<button className="btn btn-primary w-fit" onClick={triggerUpdate} disabled={updating}>
{updating ? <span className="loading loading-spinner loading-sm" /> : null}
Jetzt aktualisieren
</button>
@@ -142,7 +136,6 @@ export default function AdminPage() {
)}
</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>
@@ -158,14 +151,7 @@ export default function AdminPage() {
<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>
<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>
@@ -176,9 +162,7 @@ export default function AdminPage() {
<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>
<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"}`}>
@@ -187,24 +171,13 @@ export default function AdminPage() {
</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)}
>
<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)}
>
<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)}
>
<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>
@@ -214,19 +187,10 @@ export default function AdminPage() {
</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>
<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>
<button className="btn btn-sm" disabled={(page + 1) * limit >= total} onClick={() => setPage(p => p + 1)}>Weiter &rarr;</button>
</div>
</div>
</div>

View File

@@ -1,31 +1,26 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useRef } 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" },
];
import { BOTS } from "@/bots";
import type { CSSProperties } from "react";
export default function AiGamePage() {
const { user } = useSession();
const router = useRouter();
const [selectedLevel, setSelectedLevel] = useState(3);
const [selectedBot, setSelectedBot] = useState(BOTS[2]);
const selectedLevel = selectedBot.level;
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 [savedGameId, setSavedGameId] = useState<number | null>(null);
const startedAtRef = useRef<number>(Date.now());
const gameSavedRef = useRef(false);
const getStatus = useCallback((g: Chess) => {
if (g.isCheckmate()) return g.turn() === "w" ? "Schwarz gewinnt! Schachmatt." : "Weiß gewinnt! Schachmatt.";
@@ -34,7 +29,31 @@ export default function AiGamePage() {
return g.turn() === "w" ? "Weiß am Zug" : "Schwarz am Zug";
}, []);
const requestAiMove = useCallback(async (fen: string, level: number) => {
const saveGame = useCallback(async (g: Chess, color: "white" | "black", level: number) => {
if (!user?.id || typeof user.id !== "number") return;
if (gameSavedRef.current) return;
gameSavedRef.current = true;
const winner = g.isCheckmate() ? (g.turn() === "w" ? "black" : "white") : "draw";
const endReason = g.isCheckmate() ? "checkmate" : g.isStalemate() ? "stalemate" : g.isThreefoldRepetition() ? "repetition" : g.isInsufficientMaterial() ? "insufficient" : "draw";
try {
const res = await fetch(`${API_URL}/v1/ai/save`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pgn: g.pgn(), winner, endReason, playerColor: color, level, botName: selectedBot.name, startedAt: startedAtRef.current })
});
if (res.ok) {
const { id } = await res.json();
setSavedGameId(id);
}
} catch (e) {
console.error("Save game error:", e);
}
}, [user]);
const requestAiMove = useCallback(async (fen: string, level: number, color: "white" | "black") => {
setThinking(true);
try {
const res = await fetch(`${API_URL}/v1/ai/move`, {
@@ -47,10 +66,14 @@ export default function AiGamePage() {
const { move } = await res.json();
if (move) {
setGame((prev) => {
const newGame = new Chess(prev.fen());
// Load from PGN (not FEN) to preserve the full move history
const newGame = new Chess();
const prevPgn = prev.pgn();
if (prevPgn) newGame.loadPgn(prevPgn);
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));
if (newGame.isGameOver()) saveGame(newGame, color, level);
return newGame;
});
}
@@ -59,14 +82,17 @@ export default function AiGamePage() {
console.error("AI move error:", e);
}
setThinking(false);
}, [getStatus]);
}, [getStatus, saveGame]);
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());
// Load from PGN (not FEN) to preserve the full move history
const newGame = new Chess();
const currentPgn = game.pgn();
if (currentPgn) newGame.loadPgn(currentPgn);
let move = null;
try {
move = newGame.move({ from: sourceSquare, to: targetSquare, promotion: "q" });
@@ -79,11 +105,13 @@ export default function AiGamePage() {
setGame(newGame);
setStatus(getStatus(newGame));
if (!newGame.isGameOver()) {
requestAiMove(newGame.fen(), selectedLevel);
if (newGame.isGameOver()) {
saveGame(newGame, playerColor, selectedLevel);
} else {
requestAiMove(newGame.fen(), selectedLevel, playerColor);
}
return true;
}, [game, playerColor, thinking, selectedLevel, getStatus, requestAiMove]);
}, [game, playerColor, thinking, selectedLevel, getStatus, requestAiMove, saveGame]);
const startGame = (color: "white" | "black") => {
const newGame = new Chess();
@@ -91,10 +119,13 @@ export default function AiGamePage() {
setPlayerColor(color);
setGameStarted(true);
setLastMove(null);
setSavedGameId(null);
gameSavedRef.current = false;
startedAtRef.current = Date.now();
setStatus(getStatus(newGame));
if (color === "black") {
requestAiMove(newGame.fen(), selectedLevel);
requestAiMove(newGame.fen(), selectedLevel, color);
}
};
@@ -102,10 +133,12 @@ export default function AiGamePage() {
setGameStarted(false);
setGame(new Chess());
setLastMove(null);
setSavedGameId(null);
gameSavedRef.current = false;
setStatus("");
};
const customSquareStyles: Record<string, React.CSSProperties> = {};
const customSquareStyles: Record<string, CSSProperties> = {};
if (lastMove) {
customSquareStyles[lastMove.from] = { backgroundColor: "rgba(255, 255, 0, 0.4)" };
customSquareStyles[lastMove.to] = { backgroundColor: "rgba(255, 255, 0, 0.4)" };
@@ -118,15 +151,20 @@ export default function AiGamePage() {
<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) => (
<h2 className="text-lg font-semibold mb-3">Gegner wählen</h2>
<div className="grid grid-cols-1 gap-2">
{BOTS.map((b) => (
<button
key={l.level}
className={`btn ${selectedLevel === l.level ? "btn-primary" : "btn-outline"}`}
onClick={() => setSelectedLevel(l.level)}
key={b.name}
className={`flex items-center gap-3 p-3 rounded-lg border-2 text-left transition-colors ${selectedBot.name === b.name ? "border-primary bg-primary/10" : "border-base-300 hover:border-primary/50"}`}
onClick={() => setSelectedBot(b)}
>
{l.level}. {l.name}
<span className="text-2xl">{b.emoji}</span>
<div className="flex-1">
<div className="font-semibold">{b.name}</div>
<div className="text-xs opacity-60">{b.description}</div>
</div>
<span className="badge badge-ghost badge-sm font-mono">{b.elo}</span>
</button>
))}
</div>
@@ -162,13 +200,18 @@ export default function AiGamePage() {
<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">Gegner: {selectedBot.emoji} {selectedBot.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>}
{savedGameId && (
<a className="btn btn-sm btn-ghost mt-2" href={`/archive/${savedGameId}`} target="_blank" rel="noopener noreferrer">
Spiel überprüfen
</a>
)}
</div>
<button className="btn btn-outline" onClick={resetGame}>Neues Spiel</button>

View File

@@ -1,6 +1,6 @@
import ArchivedGame from "@/components/archive/ArchivedGame";
import { fetchArchivedGame } from "@/lib/game";
import type { Game } from "@chessu/types";
import type { Game } from "@michess/types";
import { notFound } from "next/navigation";
export async function generateMetadata({ params }: { params: { id: number } }) {
@@ -19,10 +19,10 @@ export async function generateMetadata({ params }: { params: { id: number } }) {
return {
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
openGraph: {
title: "chessu",
title: "MiChess",
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
url: `https://ches.su/archive/${game.id}`,
siteName: "chessu",
siteName: "MiChess",
locale: "en_US",
type: "website"
},

View File

@@ -0,0 +1,251 @@
"use client";
import { useSession } from "@/context/session";
import {
fetchCorrespondenceGame,
joinCorrespondenceGame,
makeCorrespondenceMove,
resignCorrespondenceGame,
} from "@/lib/correspondence";
import type { CorrespondenceGame } from "@michess/types";
import { Chess } from "chess.js";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Chessboard } from "react-chessboard";
import type { Square } from "chess.js";
import { APP_URL } from "@/config";
import InviteFriendsModal from "@/components/InviteFriendsModal";
export default function CorrespondenceGamePage() {
const { user } = useSession();
const params = useParams();
const code = params.code as string;
const [game, setGame] = useState<CorrespondenceGame | null>(null);
const [chess] = useState(() => new Chess());
const [fen, setFen] = useState("start");
const [loading, setLoading] = useState(true);
const [moveFrom, setMoveFrom] = useState<string | null>(null);
const [joining, setJoining] = useState(false);
const [resigning, setResigning] = useState(false);
const [copied, setCopied] = useState(false);
const loadGame = useCallback(async () => {
const g = await fetchCorrespondenceGame(code);
if (!g) return;
setGame(g);
chess.reset();
if (g.pgn) chess.loadPgn(g.pgn);
setFen(chess.fen());
}, [code, chess]);
useEffect(() => {
loadGame().then(() => setLoading(false));
}, [loadGame]);
const isWhite = game?.white?.id === user?.id;
const isBlack = game?.black?.id === user?.id;
const isPlayer = isWhite || isBlack;
const myTurn = isPlayer && !game?.winner && (
(chess.turn() === "w" && isWhite) || (chess.turn() === "b" && isBlack)
);
async function handleSquareClick(square: Square) {
if (!myTurn || game?.winner) return;
if (moveFrom === null) {
const piece = chess.get(square);
if (!piece) return;
const isMyPiece = (chess.turn() === "w" && piece.color === "w") || (chess.turn() === "b" && piece.color === "b");
if (!isMyPiece) return;
setMoveFrom(square);
return;
}
if (moveFrom === square) {
setMoveFrom(null);
return;
}
const updated = await makeCorrespondenceMove(code, moveFrom, square);
setMoveFrom(null);
if (updated) {
setGame(updated);
chess.reset();
if (updated.pgn) chess.loadPgn(updated.pgn);
setFen(chess.fen());
} else {
// Try as from-square instead
const piece = chess.get(square);
if (piece) setMoveFrom(square);
}
}
function handleDrop(from: Square, to: Square) {
if (!myTurn || game?.winner) return false;
makeCorrespondenceMove(code, from, to).then((updated) => {
if (updated) {
setGame(updated);
chess.reset();
if (updated.pgn) chess.loadPgn(updated.pgn);
setFen(chess.fen());
}
});
return true;
}
async function handleJoin() {
if (!user?.id) return;
setJoining(true);
const updated = await joinCorrespondenceGame(code);
if (updated) setGame(updated);
setJoining(false);
}
async function handleResign() {
if (!isPlayer || game?.winner) return;
setResigning(true);
const updated = await resignCorrespondenceGame(code);
if (updated) setGame(updated);
setResigning(false);
}
function copyInvite() {
navigator.clipboard.writeText(`${APP_URL}/correspondence/${code}`).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}
function getMoveList() {
const history = chess.history({ verbose: true });
const pairs: { w: string; b?: string }[] = [];
for (let i = 0; i < history.length; i += 2) {
pairs.push({ w: history[i].san, b: history[i + 1]?.san });
}
return pairs;
}
if (loading) {
return (
<div className="flex justify-center py-16">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!game) {
return <div className="flex justify-center py-16 text-error">Partie nicht gefunden.</div>;
}
const boardOrientation = isBlack ? "black" : "white";
const winnerName = game.winner === "white" ? game.white?.name : game.winner === "black" ? game.black?.name : null;
return (
<div className="flex flex-wrap justify-center gap-6 px-4 py-8">
<div>
<Chessboard
boardWidth={480}
position={fen}
boardOrientation={boardOrientation}
customDarkSquareStyle={{ backgroundColor: "#2F8F72" }}
customLightSquareStyle={{ backgroundColor: "#DFF8E8" }}
isDraggablePiece={({ piece }) =>
isPlayer && !game.winner &&
((chess.turn() === "w" && piece.startsWith("w") && isWhite) ||
(chess.turn() === "b" && piece.startsWith("b") && isBlack))
}
onSquareClick={handleSquareClick}
onPieceDrop={handleDrop}
customSquareStyles={moveFrom ? { [moveFrom]: { background: "rgba(255,255,0,0.4)" } } : {}}
/>
</div>
<div className="flex flex-col gap-4 max-w-sm w-full">
{/* Players */}
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4 gap-1">
<div className="flex justify-between items-center">
<span className="font-bold">{game.white?.name ?? "?"}</span>
<span className="text-xs opacity-60">Weiß</span>
</div>
<div className="text-center text-sm opacity-50">vs</div>
<div className="flex justify-between items-center">
<span className="font-bold">{game.black?.name ?? "Wartet auf Gegner"}</span>
<span className="text-xs opacity-60">Schwarz</span>
</div>
</div>
</div>
{/* Status / winner banner */}
{game.winner ? (
<div className="alert alert-success">
<span>
{game.winner === "draw"
? "Remis!"
: `${winnerName} gewinnt (${game.endReason === "checkmate" ? "Matt" : game.endReason === "resign" ? "Aufgabe" : game.endReason})!`}
</span>
</div>
) : !game.black?.id ? (
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4 gap-2">
<p className="text-sm">Warte auf Gegner. Einladungslink:</p>
<button
className={"btn btn-sm btn-outline" + (copied ? " btn-success" : "")}
onClick={copyInvite}
>
{copied ? "Kopiert!" : `${APP_URL.replace(/^https?:\/\//, "")}/correspondence/${code}`}
</button>
<InviteFriendsModal gameCode={`correspondence/${code}`} label="Freunde einladen" />
{!isPlayer && user?.id && (
<button
className={"btn btn-primary btn-sm" + (joining ? " loading" : "")}
onClick={handleJoin}
disabled={joining}
>
Beitreten
</button>
)}
</div>
</div>
) : (
<div className="alert alert-info">
<span>
{myTurn ? "Du bist dran!" : `${chess.turn() === "w" ? game.white?.name : game.black?.name} ist dran.`}
</span>
</div>
)}
{/* Move list */}
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h3 className="font-semibold text-sm mb-1">Züge</h3>
<div className="max-h-48 overflow-y-auto">
<table className="table table-xs w-full">
<tbody>
{getMoveList().map((pair, i) => (
<tr key={i}>
<td className="opacity-50 w-6">{i + 1}.</td>
<td>{pair.w}</td>
<td>{pair.b ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Resign button */}
{isPlayer && !game.winner && game.black?.id && (
<button
className={"btn btn-error btn-outline btn-sm" + (resigning ? " loading" : "")}
onClick={handleResign}
disabled={resigning}
>
Aufgeben
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useSession } from "@/context/session";
import { createCorrespondenceGame, fetchMyCorrespondenceGames } from "@/lib/correspondence";
import type { CorrespondenceGame } from "@michess/types";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function CorrespondencePage() {
const { user } = useSession();
const [games, setGames] = useState<CorrespondenceGame[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [daysPerMove, setDaysPerMove] = useState(3);
useEffect(() => {
if (!user?.id) return;
fetchMyCorrespondenceGames().then((g) => {
setGames(g);
setLoading(false);
});
}, [user?.id]);
async function handleCreate() {
if (!user?.id) return;
setCreating(true);
const game = await createCorrespondenceGame(daysPerMove);
if (game) {
setGames((prev) => [game, ...prev]);
}
setCreating(false);
}
function getTurnLabel(game: CorrespondenceGame): string {
if (game.winner) {
if (game.winner === "draw") return "Remis";
return `${game.winner === "white" ? game.white?.name : game.black?.name} hat gewonnen`;
}
if (!game.black?.id) return "Warte auf Gegner...";
const chess = new (require("chess.js").Chess)();
if (game.pgn) chess.loadPgn(game.pgn);
const turn = chess.turn();
const turnName = turn === "w" ? game.white?.name : game.black?.name;
if (turn === "w" && game.white?.id === user?.id) return "Du bist dran";
if (turn === "b" && game.black?.id === user?.id) return "Du bist dran";
return `${turnName} ist dran`;
}
if (!user?.id) {
return (
<div className="flex flex-col items-center py-16 gap-4">
<p className="text-lg">Bitte einloggen um Tagespartien zu spielen.</p>
<Link href="/auth/login" className="btn btn-primary">Einloggen</Link>
</div>
);
}
return (
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Tagespartien</h1>
</div>
<div className="card bg-base-200 shadow">
<div className="card-body gap-3">
<h2 className="card-title text-lg">Neue Partie erstellen</h2>
<div className="flex items-center gap-3">
<label className="text-sm">Tage pro Zug:</label>
<select
className="select select-bordered select-sm"
value={daysPerMove}
onChange={(e) => setDaysPerMove(parseInt(e.target.value))}
>
<option value={1}>1 Tag</option>
<option value={2}>2 Tage</option>
<option value={3}>3 Tage</option>
<option value={5}>5 Tage</option>
<option value={7}>7 Tage</option>
</select>
<button
className={"btn btn-primary btn-sm" + (creating ? " loading" : "")}
onClick={handleCreate}
disabled={creating}
>
Erstellen
</button>
</div>
</div>
</div>
{loading ? (
<div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : games.length === 0 ? (
<div className="text-center opacity-60 py-8">Keine aktiven Tagespartien.</div>
) : (
<div className="flex flex-col gap-3">
{games.map((game) => (
<div key={game.code} className="card bg-base-200 shadow-sm">
<div className="card-body flex-row items-center justify-between py-3 px-4">
<div>
<div className="font-semibold">
{game.white?.name ?? "?"} vs {game.black?.name ?? "Warte auf Gegner"}
</div>
<div className="text-sm opacity-70">{getTurnLabel(game)}</div>
</div>
<Link href={`/correspondence/${game.code}`} className="btn btn-sm btn-primary">
Spielen
</Link>
</div>
</div>
))}
</div>
)}
</div>
);
}

17
client/src/app/error.tsx Normal file
View File

@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error("App error:", error);
}, [error]);
return (
<div className="flex flex-col items-center gap-4 py-20">
<h2 className="text-xl font-bold">Fehler aufgetreten</h2>
<pre className="bg-base-200 rounded p-4 text-sm max-w-xl overflow-auto">{error.message}</pre>
<button className="btn btn-primary" onClick={reset}>Neu laden</button>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { API_URL } from "@/config";
import { useNotifications } from "@/context/NotificationContext";
import { useSession } from "@/context/session";
interface Friend {
@@ -29,10 +30,13 @@ interface FriendRequest {
export default function FriendsPage() {
const { user } = useSession();
const router = useRouter();
const { sendGameInvite } = useNotifications();
const [friends, setFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<{ incoming: FriendRequest[]; outgoing: FriendRequest[] }>({ incoming: [], outgoing: [] });
const [addUsername, setAddUsername] = useState("");
const [addMsg, setAddMsg] = useState("");
const [inviteCodes, setInviteCodes] = useState<Record<number, string>>({});
const [inviteSent, setInviteSent] = useState<Record<number, boolean>>({});
useEffect(() => {
if (user === null) router.push("/");
@@ -146,7 +150,8 @@ export default function FriendsPage() {
<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">
<div key={f.id} className="flex flex-col gap-1 py-1 border-b border-base-300 last:border-0">
<div className="flex items-center justify-between gap-2">
<Link href={`/user/${f.friend_name}`} className="font-medium link link-hover">
{f.friend_name}
</Link>
@@ -155,6 +160,28 @@ export default function FriendsPage() {
Entfernen
</button>
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Spielcode eingeben…"
className="input input-bordered input-xs flex-1"
value={inviteCodes[f.friend_id] ?? ""}
onChange={(e) => setInviteCodes((prev) => ({ ...prev, [f.friend_id]: e.target.value }))}
/>
<button
className="btn btn-xs btn-secondary"
disabled={!inviteCodes[f.friend_id]?.trim()}
onClick={() => {
sendGameInvite(f.friend_id, inviteCodes[f.friend_id].trim());
setInviteSent((prev) => ({ ...prev, [f.friend_id]: true }));
setInviteCodes((prev) => ({ ...prev, [f.friend_id]: "" }));
setTimeout(() => setInviteSent((prev) => ({ ...prev, [f.friend_id]: false })), 3000);
}}
>
{inviteSent[f.friend_id] ? "✓ Gesendet" : "Einladen"}
</button>
</div>
</div>
))}
</div>
</div>

View File

@@ -4,17 +4,17 @@ import type { ReactNode } from "react";
import Footer from "@/components/Footer";
import Header from "@/components/Header";
import AuthModal from "@/components/auth/AuthModal";
import ContextProvider from "@/context/ContextProvider";
export const metadata = {
title: "Michess",
title: "MiChess",
description: "Schach spielen lokal, privat, kostenlos.",
openGraph: {
title: "Michess",
title: "MiChess",
description: "Schach spielen lokal, privat, kostenlos.",
siteName: "Michess",
siteName: "MiChess",
locale: "de_DE",
type: "website"
},
@@ -46,7 +46,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
{children}
</main>
<AuthModal />
</ContextProvider>
<Footer />
@@ -55,9 +55,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
id="load-theme"
dangerouslySetInnerHTML={{
__html: `if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.setAttribute("data-theme", "chessuDark");
document.documentElement.setAttribute("data-theme", "michessDark");
} else {
document.documentElement.setAttribute("data-theme", "chessuLight");
document.documentElement.setAttribute("data-theme", "michessLight");
}`
}}
></script>

View File

@@ -1,57 +1,47 @@
"use client";
import CreateGame from "@/components/home/CreateGame";
import JoinGame from "@/components/home/JoinGame";
import PublicGames from "@/components/home/PublicGames/PublicGames";
import LiveGames from "@/components/home/LiveGames";
import RecentGames from "@/components/home/RecentGames";
import Link from "next/link";
import { useSession } from "@/context/session";
export const revalidate = 0;
export default function Home() {
const { user } = useSession();
const isLoggedIn = user?.id && typeof user.id === "number";
return (
<div className="flex w-full flex-wrap items-start justify-center gap-8 px-4 py-10 lg:gap-16">
<div className="flex flex-col gap-8 w-full max-w-5xl px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Spielen-Karte */}
<Link
href="/play"
className="card bg-primary text-primary-content shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
>
<div className="card-body items-center justify-center gap-4 py-12">
<span className="text-6xl select-none"></span>
<h2 className="card-title text-2xl">Spielen</h2>
<p className="text-sm opacity-80 text-center">
Partie erstellen, gegen KI, Turnier & mehr
</p>
</div>
</Link>
{/* Öffentliche Spiele */}
<div className="md:col-span-2">
<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">Gegen KI spielen</h2>
<Link href="/ai" className="btn btn-primary w-full max-w-xs">
Gegen Stockfish spielen
</Link>
</div>
</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>
</>
{/* Letzte Spiele des eingeloggten Users */}
{isLoggedIn && user.id && typeof user.id === "number" && (
<RecentGames userId={user.id} />
)}
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Per Einladung beitreten</h2>
<JoinGame />
</div>
{/* Laufende Partien Live-Kacheln */}
<LiveGames />
<div className="divider divider-vertical">oder</div>
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Spiel erstellen</h2>
<CreateGame />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import CreateGame from "@/components/home/CreateGame";
import JoinGame from "@/components/home/JoinGame";
import Link from "next/link";
import { useSession } from "@/context/session";
export default function PlayPage() {
const { user } = useSession();
const isLoggedIn = user?.id && typeof user.id === "number";
return (
<div className="flex flex-col gap-8 w-full max-w-3xl px-4 py-8">
<h1 className="text-3xl font-bold">Spielen</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="card bg-base-200 border-2 border-primary shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title text-primary">Spiel erstellen</h2>
<p className="text-sm opacity-70">Erstelle eine Partie und teile den Einladungslink mit Freunden</p>
<div className="mt-2">
<CreateGame />
</div>
</div>
</div>
<div className="card bg-base-200 shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title">Gegen KI spielen</h2>
<p className="text-sm opacity-70">Fordere Stockfish auf verschiedenen Stufen heraus von Anfänger bis Meister</p>
<div className="mt-auto pt-4">
<Link href="/ai" className="btn btn-secondary w-full">Jetzt spielen</Link>
</div>
</div>
</div>
{isLoggedIn ? (
<div className="card bg-base-200 shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title">Freunde</h2>
<p className="text-sm opacity-70">Freunde hinzufügen, verwalten und zu einer Partie einladen</p>
<div className="mt-auto pt-4">
<Link href="/friends" className="btn btn-outline w-full">Freunde verwalten</Link>
</div>
</div>
</div>
) : (
<div className="card bg-base-200 shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title">Einladung annehmen</h2>
<p className="text-sm opacity-70 mb-2">Einladungslink oder Code eingeben um beizutreten</p>
<JoinGame />
</div>
</div>
)}
<div className="card bg-base-200 shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title">Tagespartien</h2>
<p className="text-sm opacity-70">Spiele in deinem eigenen Tempo Züge bis zu 3 Tage Zeit</p>
<div className="mt-auto pt-4">
<Link href="/correspondence" className="btn btn-outline w-full">Tagespartien</Link>
</div>
</div>
</div>
<div className="card bg-base-200 shadow-lg md:col-span-2">
<div className="card-body gap-3">
<h2 className="card-title">Turnier</h2>
<p className="text-sm opacity-70">Spiele Round-Robin Turniere gegen mehrere Gegner</p>
<div className="mt-auto pt-4">
<Link href="/tournament" className="btn btn-outline w-full">Turniere</Link>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
import InviteFriendsModal from "@/components/InviteFriendsModal";
import { useSession } from "@/context/session";
import { fetchTournament, joinTournament, startTournament, cancelTournament, addBotToTournament } from "@/lib/tournament";
import { BOTS } from "@/bots";
import type { Tournament, TournamentRound } from "@michess/types";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
export default function TournamentDetailPage() {
const { user } = useSession();
const params = useParams();
const code = params.code as string;
const [tournament, setTournament] = useState<Tournament | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [selectedBot, setSelectedBot] = useState(BOTS[0].name);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
async function load() {
const t = await fetchTournament(code);
setTournament(t);
}
useEffect(() => {
load().then(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code]);
// Auto-refresh every 10 seconds when active
useEffect(() => {
if (tournament?.status === "active") {
intervalRef.current = setInterval(load, 10000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournament?.status]);
async function handleJoin() {
if (!user?.id) return;
setActionLoading(true);
const updated = await joinTournament(code);
if (updated) setTournament(updated);
setActionLoading(false);
}
async function handleAddBot() {
setActionLoading(true);
const updated = await addBotToTournament(code, selectedBot);
if (updated) setTournament(updated);
setActionLoading(false);
}
async function handleCancel() {
if (!user?.id) return;
setActionLoading(true);
const updated = await cancelTournament(code);
if (updated) setTournament(updated);
setActionLoading(false);
}
async function handleStart() {
if (!user?.id) return;
setActionLoading(true);
const updated = await startTournament(code);
if (updated) setTournament(updated);
setActionLoading(false);
}
function statusBadge(status: Tournament["status"]) {
if (status === "waiting") return <span className="badge badge-warning">Wartet auf Spieler</span>;
if (status === "active") return <span className="badge badge-success">Aktiv</span>;
return <span className="badge badge-ghost">Beendet</span>;
}
function resultLabel(r: TournamentRound) {
if (!r.result) return <span className="opacity-40"></span>;
if (r.result === "draw") return <span className="badge badge-ghost badge-sm">Remis</span>;
return (
<span className="badge badge-success badge-sm">
{r.result === "white" ? r.whiteName : r.blackName}
</span>
);
}
if (loading) {
return (
<div className="flex justify-center py-16">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!tournament) {
return <div className="flex justify-center py-16 text-error">Turnier nicht gefunden.</div>;
}
const isHost = tournament.hostId === user?.id;
const isJoined = tournament.players?.some((p) => p.userId === user?.id);
const canStart = isHost && tournament.status === "waiting" && (tournament.players?.length ?? 0) >= 2;
// Group rounds
const rounds: Record<number, TournamentRound[]> = {};
for (const r of tournament.rounds ?? []) {
if (!rounds[r.round!]) rounds[r.round!] = [];
rounds[r.round!].push(r);
}
return (
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">{tournament.name}</h1>
<div className="flex items-center gap-2 mt-1">
{statusBadge(tournament.status)}
{tournament.timeControl
? <span className="text-sm opacity-60">{tournament.timeControl} Min/Spieler</span>
: <span className="text-sm opacity-60">Keine Uhr</span>}
{tournament.status === "active" && (
<span className="text-sm opacity-60">Runde {tournament.currentRound}/{tournament.totalRounds}</span>
)}
</div>
</div>
<div className="flex gap-2 flex-wrap">
{tournament.status === "waiting" && user?.id && !isJoined && (
<button
className={"btn btn-secondary btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleJoin}
disabled={actionLoading}
>
Beitreten
</button>
)}
{tournament.status === "waiting" && isJoined && (
<InviteFriendsModal gameCode={`tournament/${code}`} label="Freunde einladen" />
)}
{isHost && tournament.status === "waiting" && (
<div className="flex gap-2 items-center">
<select
className="select select-sm select-bordered"
value={selectedBot}
onChange={(e) => setSelectedBot(e.target.value)}
disabled={actionLoading}
>
{BOTS.map((b) => (
<option key={b.name} value={b.name}>{b.emoji} {b.name} ({b.elo})</option>
))}
</select>
<button
className={"btn btn-outline btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleAddBot}
disabled={actionLoading}
>
Bot hinzufügen
</button>
</div>
)}
{canStart && (
<button
className={"btn btn-primary btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleStart}
disabled={actionLoading}
>
Turnier starten
</button>
)}
{isHost && tournament.status !== "finished" && (
<button
className={"btn btn-error btn-sm btn-outline" + (actionLoading ? " loading" : "")}
onClick={handleCancel}
disabled={actionLoading}
>
Turnier beenden
</button>
)}
</div>
</div>
{/* Standings */}
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h2 className="font-bold text-lg mb-2">Tabelle</h2>
{!tournament.players?.length ? (
<p className="text-sm opacity-60">Noch keine Spieler.</p>
) : (
<table className="table table-sm w-full">
<thead>
<tr>
<th>#</th>
<th>Spieler</th>
<th className="text-right">Punkte</th>
<th className="text-right">Partien</th>
</tr>
</thead>
<tbody>
{tournament.players.map((p, i) => (
<tr key={p.userId} className={p.userId === user?.id ? "bg-base-300" : ""}>
<td>{i + 1}</td>
<td>{p.userName}</td>
<td className="text-right font-mono">{p.score}</td>
<td className="text-right opacity-60">{p.gamesPlayed}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Rounds */}
{Object.keys(rounds).length > 0 && (
<div className="flex flex-col gap-4">
{Object.entries(rounds).map(([roundNum, games]) => (
<div key={roundNum} className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h3 className="font-semibold mb-2">
Runde {roundNum}
{parseInt(roundNum) === tournament.currentRound && tournament.status === "active" && (
<span className="badge badge-success badge-sm ml-2">Aktuell</span>
)}
</h3>
<div className="flex flex-col gap-2">
{games.map((g) => {
const isMyGame = g.whiteId === user?.id || g.blackId === user?.id;
return (
<div key={g.id} className="flex items-center justify-between gap-2 text-sm">
<span>
<span className={g.whiteId === user?.id ? "font-bold" : ""}>{g.whiteName}</span>
<span className="opacity-40 mx-2">vs</span>
<span className={g.blackId === user?.id ? "font-bold" : ""}>{g.blackName}</span>
</span>
<div className="flex items-center gap-2">
{resultLabel(g)}
{isMyGame && g.gameCode && !g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-primary">
Spielen
</Link>
)}
{!isMyGame && g.gameCode && !g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-outline">
Zuschauen
</Link>
)}
{g.gameCode && g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-ghost">
Ansehen
</Link>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useSession } from "@/context/session";
import { createTournament } from "@/lib/tournament";
import { useRouter } from "next/navigation";
import type { FormEvent } from "react";
import { useState } from "react";
export default function CreateTournamentPage() {
const { user } = useSession();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!user?.id) return;
setLoading(true);
setError(null);
const form = e.target as HTMLFormElement;
const name = (form.elements.namedItem("name") as HTMLInputElement).value.trim();
const timeControlVal = parseInt((form.elements.namedItem("timeControl") as HTMLSelectElement).value);
const maxPlayers = parseInt((form.elements.namedItem("maxPlayers") as HTMLSelectElement).value);
if (!name) {
setError("Bitte einen Namen eingeben.");
setLoading(false);
return;
}
const tournament = await createTournament(name, timeControlVal > 0 ? timeControlVal : undefined, maxPlayers);
if (tournament?.code) {
router.push(`/tournament/${tournament.code}`);
} else {
setError("Turnier konnte nicht erstellt werden.");
setLoading(false);
}
}
if (!user?.id) {
return (
<div className="flex flex-col items-center py-16 gap-4">
<p className="text-lg">Bitte einloggen um ein Turnier zu erstellen.</p>
</div>
);
}
return (
<div className="flex flex-col gap-6 w-full max-w-md px-4 py-8">
<h1 className="text-2xl font-bold">Turnier erstellen</h1>
<form className="card bg-base-200 shadow" onSubmit={handleSubmit}>
<div className="card-body gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Name</span>
</label>
<input
type="text"
name="name"
className="input input-bordered"
placeholder="z.B. MiChess Open 2026"
maxLength={128}
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Bedenkzeit</span>
</label>
<select name="timeControl" className="select select-bordered">
<option value="0">Keine Uhr</option>
<option value="5">5 Min (Blitz)</option>
<option value="10">10 Min (Blitz)</option>
<option value="30">30 Min (Rapid)</option>
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Max. Spieler</span>
</label>
<select name="maxPlayers" className="select select-bordered" defaultValue="8">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="12">12</option>
<option value="16">16</option>
</select>
</div>
{error && <div className="alert alert-error text-sm">{error}</div>}
<button
type="submit"
className={"btn btn-primary" + (loading ? " loading" : "")}
disabled={loading}
>
Erstellen
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useSession } from "@/context/session";
import { fetchTournaments, joinTournament } from "@/lib/tournament";
import type { Tournament } from "@michess/types";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function TournamentPage() {
const { user } = useSession();
const [tournaments, setTournaments] = useState<Tournament[]>([]);
const [loading, setLoading] = useState(true);
const [joiningCode, setJoiningCode] = useState<string | null>(null);
useEffect(() => {
fetchTournaments().then((t) => {
setTournaments(t);
setLoading(false);
});
}, []);
async function handleJoin(code: string) {
if (!user?.id) return;
setJoiningCode(code);
const updated = await joinTournament(code);
if (updated) {
setTournaments((prev) => prev.map((t) => (t.code === code ? updated : t)));
}
setJoiningCode(null);
}
function statusBadge(status: Tournament["status"]) {
if (status === "waiting") return <span className="badge badge-warning badge-sm">Wartet</span>;
if (status === "active") return <span className="badge badge-success badge-sm">Aktiv</span>;
return <span className="badge badge-ghost badge-sm">Beendet</span>;
}
return (
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Turniere</h1>
{user?.id && (
<Link href="/tournament/create" className="btn btn-primary btn-sm">
Turnier erstellen
</Link>
)}
</div>
{loading ? (
<div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : tournaments.length === 0 ? (
<div className="text-center opacity-60 py-8">Keine aktiven Turniere.</div>
) : (
<div className="flex flex-col gap-3">
{tournaments.map((t) => {
const isJoined = t.players?.some((p) => p.userId === user?.id);
return (
<div key={t.code} className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-bold">{t.name}</span>
{statusBadge(t.status)}
</div>
<div className="text-xs opacity-60">
{t.players?.length ?? 0}/{t.maxPlayers} Spieler
{t.timeControl ? ` · ${t.timeControl} Min` : " · Keine Uhr"}
{t.status === "active" ? ` · Runde ${t.currentRound}/${t.totalRounds}` : ""}
</div>
</div>
<div className="flex gap-2">
{t.status === "waiting" && user?.id && !isJoined && (
<button
className={"btn btn-sm btn-secondary" + (joiningCode === t.code ? " loading" : "")}
onClick={() => handleJoin(t.code!)}
disabled={joiningCode === t.code}
>
Beitreten
</button>
)}
<Link href={`/tournament/${t.code}`} className="btn btn-sm btn-outline">
Details
</Link>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -16,13 +16,13 @@ export async function generateMetadata({ params }: { params: { name: string } })
};
}
return {
title: `${data.name} | chessu`,
title: `${data.name} | MiChess`,
description: `${data.name}'s profile`,
openGraph: {
title: `${data.name} | chessu`,
description: `${data.name}'s profile on chessu`,
title: `${data.name} | MiChess`,
description: `${data.name}'s profile on MiChess`,
url: `https://ches.su/user/${data.name}`,
siteName: "chessu",
siteName: "MiChess",
locale: "en_US",
type: "website"
},
@@ -43,7 +43,12 @@ export default async function Profile({ params }: { params: { name: string } })
return (
<div className="mt-8 flex w-full flex-col gap-8">
<div className="flex flex-wrap items-center justify-between gap-4 md:gap-8">
<div>
<h1 className="text-4xl font-bold">{data.name}</h1>
{data.elo != null && (
<span className="badge badge-primary badge-lg mt-1 font-mono">{data.elo} ELO</span>
)}
</div>
<div className="flex gap-6">
<div className="flex flex-col items-center">
<span className="text-sm">Wins</span>
@@ -62,7 +67,7 @@ export default async function Profile({ params }: { params: { name: string } })
</div>
<div>
<h2 className="mb-1 text-lg font-bold">Recent games</h2>
<h2 className="mb-1 text-lg font-bold">Letzte Spiele</h2>
<ul className="bg-base-300 flex h-[60vh] flex-col gap-1 overflow-y-scroll rounded-lg">
{data.recentGames.map((game) => {
let endReason = game.endReason as string;
@@ -77,6 +82,9 @@ export default async function Profile({ params }: { params: { name: string } })
key={game.id}
className="border-base-100 flex flex-wrap items-center justify-between gap-8 border-b-2 p-3"
>
{game.vsAi && (
<span className="badge badge-info badge-sm">vs KI Lv.{game.aiLevel}</span>
)}
<div className="flex w-72 justify-between">
<div className="flex flex-col">
<span className="flex items-center gap-1 text-sm">

8
client/src/bots.ts Normal file
View File

@@ -0,0 +1,8 @@
export const BOTS = [
{ name: "Holzpferd Heinz", elo: 200, level: 1, emoji: "🐴", description: "Perfekt für Einsteiger" },
{ name: "Bauernschubser Bert", elo: 500, level: 2, emoji: "♟", description: "Lernt die Grundzüge" },
{ name: "Taktiker Theo", elo: 800, level: 3, emoji: "🧩", description: "Kennt taktische Muster" },
{ name: "Kombinationskarl", elo: 1100, level: 4, emoji: "⚡", description: "Gefährlicher Angreifer" },
{ name: "Meister Magnus", elo: 1400, level: 5, emoji: "👑", description: "Fast unschlagbar" },
];
export type Bot = typeof BOTS[0];

View File

@@ -2,11 +2,11 @@ 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; {new Date().getFullYear()} Michess &mdash; Dein privates Schachbrett</p>
<p>&copy; {new Date().getFullYear()} MiChess &mdash; Dein privates Schachbrett</p>
</div>
<div className="items-center">
<a
href="https://git.mischlabs.de/MrDiderot/Michess"
href="https://git.mischlabs.de/MrDiderot/MiChess"
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost btn-sm gap-1 normal-case"

View File

@@ -1,12 +1,41 @@
"use client";
import { IconUser, IconShield } from "@tabler/icons-react";
import { IconUser, IconShield, IconUserCircle, IconSettings2, IconLogout } from "@tabler/icons-react";
import Link from "next/link";
import { useState, useEffect, useRef } from "react";
import NotificationPanel from "./NotificationPanel";
import ThemeToggle from "./ThemeToggle";
import { useSession } from "@/context/session";
import { logout } from "@/lib/auth";
import JoinGame from "./home/JoinGame";
import AuthDropdownContent from "./auth/AuthDropdownContent";
export default function Header() {
const { user } = useSession();
const { user, setUser } = useSession();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isAuthMenuOpen, setIsAuthMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const authMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setIsUserMenuOpen(false);
}
if (authMenuRef.current && !authMenuRef.current.contains(event.target as Node)) {
setIsAuthMenuOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
async function handleLogout() {
await logout();
setUser(null);
setIsUserMenuOpen(false);
}
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">
@@ -25,22 +54,79 @@ 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>
Michess
MiChess
</Link>
</div>
<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
{user?.id && typeof user.id === "number" && <NotificationPanel />}
{user?.elo != null && typeof user.id === "number" && (
<Link href={`/user/${user.name}`} className="btn btn-ghost btn-sm font-mono tabular-nums" title="Deine ELO-Wertung">
{user.elo}
</Link>
)}
<ThemeToggle />
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
{user?.id && typeof user.id === "number" ? (
<div ref={userMenuRef} className={`dropdown dropdown-end ${isUserMenuOpen ? "dropdown-open" : ""}`}>
<button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="btn btn-ghost btn-circle avatar"
>
<div className="w-10 rounded-full">
<IconUser className="m-auto block h-full" />
</div>
</label>
</button>
<div className="dropdown-content z-50 mt-3 w-80 rounded-box bg-base-100 p-3 shadow-lg border border-base-300 dark:border-neutral">
<div className="px-2 pb-2 font-semibold">{user.name}</div>
<ul className="menu menu-sm p-0">
<li>
<Link href={`/user/${user.name}`} onClick={() => setIsUserMenuOpen(false)}>
<IconUserCircle size={16} /> Profil
</Link>
</li>
<li>
<Link href="/settings" onClick={() => setIsUserMenuOpen(false)}>
<IconSettings2 size={16} /> Einstellungen
</Link>
</li>
{user.role === "admin" && (
<li>
<Link href="/admin" onClick={() => setIsUserMenuOpen(false)}>
<IconShield size={16} /> Admin-Panel
</Link>
</li>
)}
</ul>
<div className="divider my-2" />
<div className="px-2 pb-1 text-xs font-semibold uppercase opacity-50">Spiel beitreten</div>
<div className="px-2 pb-2">
<JoinGame />
</div>
<div className="divider my-2" />
<ul className="menu menu-sm p-0">
<li>
<button onClick={handleLogout} className="text-error">
<IconLogout size={16} /> Abmelden
</button>
</li>
</ul>
</div>
</div>
) : (
<div ref={authMenuRef} className={`dropdown dropdown-end ${isAuthMenuOpen ? "dropdown-open" : ""}`}>
<button
onClick={() => setIsAuthMenuOpen(!isAuthMenuOpen)}
className="btn btn-ghost btn-circle avatar"
>
<div className="w-10 rounded-full">
<IconUser className="m-auto block h-full" />
</div>
</button>
<div className="dropdown-content z-50 mt-3 w-80 rounded-box bg-base-100 p-4 shadow-lg border border-base-300 dark:border-neutral">
<AuthDropdownContent onClose={() => setIsAuthMenuOpen(false)} />
</div>
</div>
)}
</div>
</header>
);

View File

@@ -0,0 +1,90 @@
"use client";
import { API_URL } from "@/config";
import { useNotifications } from "@/context/NotificationContext";
import { useSession } from "@/context/session";
import { useState } from "react";
interface Friend {
id: number;
friend_id: number;
friend_name: string;
}
export default function InviteFriendsModal({
gameCode,
label = "Freunde einladen"
}: {
gameCode: string;
label?: string;
}) {
const { user } = useSession();
const { sendGameInvite } = useNotifications();
const [open, setOpen] = useState(false);
const [friends, setFriends] = useState<Friend[]>([]);
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState<Record<number, boolean>>({});
async function openModal() {
if (!user?.id || typeof user.id === "string") return;
setOpen(true);
setLoading(true);
const res = await fetch(`${API_URL}/v1/friends`, { credentials: "include" });
if (res.ok) setFriends(await res.json());
setLoading(false);
}
function invite(friendId: number) {
sendGameInvite(friendId, gameCode);
setSent((prev) => ({ ...prev, [friendId]: true }));
setTimeout(() => setSent((prev) => ({ ...prev, [friendId]: false })), 3000);
}
if (!user?.id || typeof user.id === "string") return null;
return (
<>
<button className="btn btn-sm btn-outline" onClick={openModal}>
{label}
</button>
{open && (
<dialog className="modal modal-open">
<div className="modal-box max-w-sm">
<button
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setOpen(false)}
>
</button>
<h3 className="font-bold text-lg mb-4">{label}</h3>
{loading ? (
<div className="flex justify-center py-4">
<span className="loading loading-spinner" />
</div>
) : friends.length === 0 ? (
<p className="text-sm opacity-60">
Keine Freunde gefunden. Füge zuerst Freunde hinzu.
</p>
) : (
<div className="flex flex-col gap-3">
{friends.map((f) => (
<div key={f.id} className="flex items-center justify-between gap-2">
<span className="font-medium">{f.friend_name}</span>
<button
className={"btn btn-sm btn-secondary" + (sent[f.friend_id] ? " btn-success" : "")}
onClick={() => invite(f.friend_id)}
>
{sent[f.friend_id] ? "✓ Gesendet" : "Einladen"}
</button>
</div>
))}
</div>
)}
</div>
<div className="modal-backdrop bg-black/40" onClick={() => setOpen(false)} />
</dialog>
)}
</>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import type { AppNotification } from "@michess/types";
import Link from "next/link";
import { useNotifications } from "@/context/NotificationContext";
function NotificationItem({ n, onDismiss }: { n: AppNotification; onDismiss: () => void }) {
let text = "";
if (n.type === "friendRequest") text = `${n.fromName} hat dir eine Freundschaftsanfrage gesendet.`;
if (n.type === "friendAccepted") text = `${n.fromName} hat deine Freundschaftsanfrage angenommen.`;
if (n.type === "gameInvite") text = `${n.fromName} lädt dich zu einem Spiel ein.`;
return (
<div className={`flex items-start gap-2 p-2 rounded-lg ${!n.read ? "bg-base-300" : ""}`}>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug">{text}</p>
</div>
{n.type === "gameInvite" && n.gameCode && (
<Link href={`/${n.gameCode}`} className="btn btn-xs btn-primary shrink-0" onClick={onDismiss}>
Beitreten
</Link>
)}
{n.type === "friendRequest" && (
<Link href="/friends" className="btn btn-xs btn-outline shrink-0" onClick={onDismiss}>
Ansehen
</Link>
)}
<button className="btn btn-xs btn-ghost shrink-0" onClick={onDismiss} aria-label="Schließen">
</button>
</div>
);
}
export default function NotificationPanel() {
const { notifications, unreadCount, markAllRead, dismiss } = useNotifications();
return (
<div className="dropdown dropdown-end">
<button
tabIndex={0}
className="btn btn-ghost btn-circle relative"
onClick={markAllRead}
aria-label="Benachrichtigungen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{unreadCount > 0 && (
<span className="badge badge-xs badge-primary absolute top-1 right-1">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<div
tabIndex={0}
className="dropdown-content shadow bg-base-200 rounded-box w-80 max-h-96 overflow-y-auto z-50 p-2 flex flex-col gap-1 mt-1"
>
<p className="text-xs font-semibold opacity-60 px-1 pb-1">Benachrichtigungen</p>
{notifications.length === 0 && (
<p className="text-sm opacity-50 px-2 py-3 text-center">Keine Benachrichtigungen</p>
)}
{notifications.map((n) => (
<NotificationItem key={n.id} n={n} onDismiss={() => dismiss(n.id)} />
))}
</div>
</div>
);
}

View File

@@ -7,7 +7,7 @@ export default function ThemeToggle() {
const [darkTheme, setDarkTheme] = useState(true);
useEffect(() => {
if (document.documentElement.getAttribute("data-theme") === "chessuDark") {
if (document.documentElement.getAttribute("data-theme") === "michessDark") {
setDarkTheme(true);
} else {
setDarkTheme(false);
@@ -16,14 +16,14 @@ export default function ThemeToggle() {
function toggleTheme() {
if (
document.documentElement.getAttribute("data-theme") === "chessuDark" ||
document.documentElement.getAttribute("data-theme") === "michessDark" ||
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.setAttribute("data-theme", "chessuLight");
document.documentElement.setAttribute("data-theme", "michessLight");
localStorage.theme = "light";
setDarkTheme(false);
} else {
document.documentElement.setAttribute("data-theme", "chessuDark");
document.documentElement.setAttribute("data-theme", "michessDark");
localStorage.theme = "dark";
setDarkTheme(true);
}

View File

@@ -1,7 +1,7 @@
"use client";
import type { CustomSquares } from "@/types";
import { Game } from "@chessu/types";
import { Game } from "@michess/types";
import {
IconChevronLeft,
IconChevronRight,
@@ -225,8 +225,8 @@ export default function ArchivedGame({ game }: { game: Game }) {
<div className="h-min">
<Chessboard
boardWidth={boardWidth}
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
customDarkSquareStyle={{ backgroundColor: "#2F8F72" }}
customLightSquareStyle={{ backgroundColor: "#DFF8E8" }}
position={navFen || actualGame.fen()}
boardOrientation={flipBoard ? "black" : "white"}
isDraggablePiece={() => false}

View File

@@ -0,0 +1,185 @@
"use client";
import { SessionContext } from "@/context/session";
import { login, register, setGuestSession } from "@/lib/auth";
import type { FormEvent } from "react";
import { useContext, useEffect, useState } from "react";
import { API_URL } from "@/config";
import Guest from "./Guest";
import Login from "./Login";
import Register from "./Register";
export default function AuthDropdownContent({ onClose }: { onClose?: () => void }) {
const session = useContext(SessionContext);
const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest");
const [serverMessage, setServerMessage] = useState<string | null>(null);
const [buttonLoading, setButtonLoading] = useState(false);
const [ssoEnabled, setSsoEnabled] = useState(false);
useEffect(() => {
fetch(`${API_URL}/v1/auth/sso/config`, { credentials: "include" })
.then((res) => res.json())
.then((data) => {
if (data && data.enabled) {
setSsoEnabled(true);
}
})
.catch((err) => console.error("Error loading SSO config:", err));
}, []);
function closeDropdown() {
if (onClose) {
onClose();
}
if (typeof document !== "undefined" && document.activeElement) {
(document.activeElement as HTMLElement).blur();
}
}
async function submitAuth(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const target = e.target as HTMLFormElement;
setServerMessage(null);
setButtonLoading(true);
try {
if (activeTab === "guest") {
const guestName = target.elements.namedItem("guestName") as HTMLInputElement;
if (!guestName || !guestName.value) return;
const user = await setGuestSession(guestName.value);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
closeDropdown();
guestName.value = "";
}
} else if (activeTab === "login") {
const loginName = target.elements.namedItem("loginName") as HTMLInputElement;
const loginPassword = target.elements.namedItem("loginPassword") as HTMLInputElement;
if (!loginName || !loginName.value || !loginPassword || !loginPassword.value) return;
const user = await login(loginName.value, loginPassword.value);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
closeDropdown();
}
} else if (activeTab === "register") {
const registerName = target.elements.namedItem("registerName") as HTMLInputElement;
const registerEmail = target.elements.namedItem("registerEmail") as HTMLInputElement;
const registerPassword = target.elements.namedItem("registerPassword") as HTMLInputElement;
if (!registerName || !registerName.value || !registerPassword || !registerPassword.value) {
return;
}
const user = await register(
registerName.value,
registerPassword.value,
registerEmail.value || undefined
);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
closeDropdown();
}
}
} finally {
setButtonLoading(false);
}
}
useEffect(() => {
setServerMessage(null);
}, [activeTab]);
return (
<div className="flex flex-col gap-4">
<div className="tabs flex-nowrap w-full">
<span
onClick={() => {
setActiveTab("guest");
}}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg cursor-pointer" +
(activeTab === "guest"
? " tab-active text-base-content"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Gast
</span>
<span
onClick={() => setActiveTab("login")}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg cursor-pointer" +
(activeTab === "login"
? " tab-active"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Anmelden
</span>
<span
onClick={() => setActiveTab("register")}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg cursor-pointer" +
(activeTab === "register"
? " tab-active"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Registrieren
</span>
</div>
<form className="flex flex-col px-1" onSubmit={submitAuth}>
{activeTab === "guest" && (
<Guest currentName={session?.user?.name || "Gast"} />
)}
{activeTab === "login" && <Login />}
{activeTab === "register" && <Register />}
{serverMessage && <div className="text-error mt-2 text-sm">{serverMessage}</div>}
<div className="mt-4 flex justify-end gap-2">
<button className={"btn btn-primary btn-sm flex-grow" + (buttonLoading ? " loading" : "")} type="submit">
{activeTab === "guest" && "Als Gast spielen"}
{activeTab === "login" && "Anmelden"}
{activeTab === "register" && "Registrieren"}
</button>
</div>
{ssoEnabled && (
<>
<div className="divider opacity-70 my-2 text-xs">oder</div>
<a
href={`${API_URL}/v1/auth/sso/login`}
className="btn btn-accent btn-outline btn-sm w-full gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
clipRule="evenodd"
/>
</svg>
Anmelden mit Keycloak
</a>
</>
)}
</form>
</div>
);
}

View File

@@ -1,208 +0,0 @@
"use client";
import { SessionContext } from "@/context/session";
import { login, logout, register, setGuestSession } from "@/lib/auth";
import Link from "next/link";
import type { FormEvent } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import { IconSettings2, IconUserCircle } from "@tabler/icons-react";
import Guest from "./Guest";
import Login from "./Login";
import Register from "./Register";
export default function AuthModal() {
const session = useContext(SessionContext);
const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest");
const [serverMessage, setServerMessage] = useState<string | null>(null);
const [buttonLoading, setButtonLoading] = useState(false);
const modalToggleRef = useRef<HTMLInputElement>(null);
async function clickLogout() {
if (serverMessage) {
setServerMessage(null);
}
setActiveTab("login");
await logout();
session?.setUser(null);
}
async function submitAuth(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const target = e.target as HTMLFormElement;
if (activeTab === "guest") {
const guestName = target.elements.namedItem("guestName") as HTMLInputElement;
if (!guestName || !guestName.value) return;
setButtonLoading(true);
const user = await setGuestSession(guestName.value);
if (user) {
session?.setUser(user);
if (modalToggleRef.current?.checked) {
modalToggleRef.current.checked = false;
}
}
guestName.value = "";
} else if (activeTab === "login") {
const loginName = target.elements.namedItem("loginName") as HTMLInputElement;
const loginPassword = target.elements.namedItem("loginPassword") as HTMLInputElement;
if (!loginName || !loginName.value || !loginPassword || !loginPassword.value) return;
setButtonLoading(true);
const user = await login(loginName.value, loginPassword.value);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
if (serverMessage) {
setServerMessage(null);
}
if (modalToggleRef.current?.checked) {
modalToggleRef.current.checked = false;
}
}
} else if (activeTab === "register") {
const registerName = target.elements.namedItem("registerName") as HTMLInputElement;
const registerEmail = target.elements.namedItem("registerEmail") as HTMLInputElement;
const registerPassword = target.elements.namedItem("registerPassword") as HTMLInputElement;
if (!registerName || !registerName.value || !registerPassword || !registerPassword.value) {
return;
}
setButtonLoading(true);
const user = await register(
registerName.value,
registerPassword.value,
registerEmail.value || undefined
);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
if (serverMessage) {
setServerMessage(null);
}
if (modalToggleRef.current?.checked) {
modalToggleRef.current.checked = false;
}
}
}
setButtonLoading(false);
}
useEffect(() => {
setServerMessage(null);
}, [activeTab]);
return (
<>
<input type="checkbox" id="auth-modal" className="modal-toggle" ref={modalToggleRef} />
<label
htmlFor="auth-modal"
className={"modal" + (session?.user === null ? " modal-open" : "")}
>
<label className="modal-box flex max-w-sm flex-col gap-4 pt-2">
{session?.user?.id && typeof session.user.id === "number" ? (
<div className="flex flex-col gap-2 pt-2">
<div className="flex w-full justify-between">
<div>
Logged in as <b>{session.user.name}</b>
</div>
<a className="link" onClick={clickLogout}>
Logout
</a>
</div>
<div className="flex w-full flex-col">
<Link
className="btn btn-ghost gap-1 normal-case"
href={`/user/${session.user.name}`}
onClick={() => ((modalToggleRef.current as HTMLInputElement).checked = false)}
>
<IconUserCircle /> View profile
</Link>
<Link
className="btn btn-ghost gap-1 normal-case"
href="/settings"
onClick={() => ((modalToggleRef.current as HTMLInputElement).checked = false)}
>
<IconSettings2 /> Account settings
</Link>
</div>
<div className="modal-action">
<label htmlFor="auth-modal" className="btn">
Close
</label>
</div>
</div>
) : (
<>
<div className="tabs flex-nowap w-full">
<span
onClick={() => {
setActiveTab("guest");
}}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
(activeTab === "guest"
? " tab-active text-base-content"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Guest
</span>
<span
onClick={() => setActiveTab("login")}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
(activeTab === "login"
? " tab-active"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Login
</span>
<span
onClick={() => setActiveTab("register")}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
(activeTab === "register"
? " tab-active"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Register
</span>
</div>
<form className="flex flex-col px-2" onSubmit={submitAuth}>
{activeTab === "guest" && (
<Guest currentName={session?.user?.name || "unknown user"} />
)}
{activeTab === "login" && <Login />}
{activeTab === "register" && <Register />}
{serverMessage && <div className="text-error mt-2">{serverMessage}</div>}
<div className="modal-action items-center">
{session?.user !== null && (
<label htmlFor="auth-modal" className="btn btn-ghost">
Close
</label>
)}
<button className={"btn" + (buttonLoading ? " loading" : "")} type="submit">
{activeTab === "guest" && "Confirm"}
{activeTab === "login" && "Login"}
{activeTab === "register" && "Register"}
</button>
</div>
</form>
</>
)}
</label>
</label>
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { SessionContext } from "@/context/session";
import type { Game } from "@chessu/types";
import type { Game } from "@michess/types";
import { useContext } from "react";
import GamePage from "./GamePage";

View File

@@ -15,14 +15,15 @@ import { SessionContext } from "@/context/session";
import { useContext, useEffect, useReducer, useRef, useState } from "react";
import type { Message } from "@/types";
import type { Game } from "@chessu/types";
import type { Game } from "@michess/types";
import type { Move, Square } from "chess.js";
import { Chess } from "chess.js";
import type { ClearPremoves } from "react-chessboard";
import { Chessboard } from "react-chessboard";
import { API_URL } from "@/config";
import { API_URL, APP_URL } from "@/config";
import InviteFriendsModal from "@/components/InviteFriendsModal";
import { io } from "socket.io-client";
import { lobbyReducer, squareReducer } from "./reducers";
@@ -54,6 +55,11 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
const [navFen, setNavFen] = useState<string | null>(null);
const [navIndex, setNavIndex] = useState<number | null>(null);
const [clockWhite, setClockWhite] = useState<number | null>(null);
const [clockBlack, setClockBlack] = useState<number | null>(null);
const clockRef = useRef<{ whiteTimeMs: number; blackTimeMs: number; clientLastMoveAt: number } | null>(null);
const lobbyRef = useRef(lobby);
const [playBtnLoading, setPlayBtnLoading] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [chatMessages, setChatMessages] = useState<Message[]>([
@@ -65,6 +71,30 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
const chatListRef = useRef<HTMLUListElement>(null);
const moveListRef = useRef<HTMLDivElement>(null);
// Keep lobbyRef in sync so clock interval can read latest lobby without re-registering
useEffect(() => { lobbyRef.current = lobby; }, [lobby]);
// Clock interval — only active when timeControl is set
useEffect(() => {
if (!initialLobby.timeControl) return;
const interval = setInterval(() => {
if (!clockRef.current) return;
const l = lobbyRef.current;
if (l.endReason || l.winner) return;
const elapsed = Date.now() - clockRef.current.clientLastMoveAt;
const turn = l.actualGame.turn();
const white = turn === "w" ? Math.max(0, clockRef.current.whiteTimeMs - elapsed) : clockRef.current.whiteTimeMs;
const black = turn === "b" ? Math.max(0, clockRef.current.blackTimeMs - elapsed) : clockRef.current.blackTimeMs;
setClockWhite(white);
setClockBlack(black);
if ((turn === "w" && white <= 0) || (turn === "b" && black <= 0)) {
socket.emit("claimTimeout");
}
}, 100);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialLobby.timeControl]);
const [abandonSeconds, setAbandonSeconds] = useState(60);
useEffect(() => {
if (
@@ -114,7 +144,14 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
updateCustomSquares,
makeMove,
setNavFen,
setNavIndex
setNavIndex,
onClockUpdate: ({ whiteTimeMs, blackTimeMs, lastMoveAt }) => {
clockRef.current = { whiteTimeMs, blackTimeMs, clientLastMoveAt: Date.now() };
setClockWhite(whiteTimeMs);
setClockBlack(blackTimeMs);
// suppress unused-warning on lastMoveAt — stored via clientLastMoveAt above
void lastMoveAt;
}
});
return () => {
@@ -149,9 +186,9 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
if (lobby.side === "s" || !lobby.white?.id || !lobby.black?.id) return;
if (!lobby.endReason && lobby.side === lobby.actualGame.turn()) {
document.title = "(your turn) chessu";
document.title = "(your turn) MiChess";
} else {
document.title = "chessu";
document.title = "MiChess";
}
}
@@ -408,12 +445,8 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
}
function copyInvite() {
const text = `https://ches.su/${lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}`;
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text);
} else {
document.execCommand("copy", true, text);
}
const text = `${APP_URL}/${lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}`;
navigator.clipboard.writeText(text).catch(() => {});
setCopiedLink(true);
setTimeout(() => {
setCopiedLink(false);
@@ -507,6 +540,12 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
};
}
function formatClock(ms: number | null): string {
if (ms === null) return "--:--";
const s = Math.max(0, Math.ceil(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function claimAbandoned(type: "win" | "draw") {
if (
lobby.side === "s" ||
@@ -543,8 +582,8 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
<Chessboard
boardWidth={boardWidth}
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
customDarkSquareStyle={{ backgroundColor: "#2F8F72" }}
customLightSquareStyle={{ backgroundColor: "#DFF8E8" }}
position={navFen || lobby.actualGame.fen()}
boardOrientation={lobby.side === "b" ? "black" : "white"}
isDraggablePiece={isDraggablePiece}
@@ -572,6 +611,29 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
{getPlayerHtml("bottom")}
</div>
{initialLobby.timeControl && (
<div className="flex flex-col items-end justify-between gap-1 pl-2">
{/* Top clock = opponent */}
<div className={
"font-mono text-lg font-bold px-2 py-1 rounded " +
(lobby.side === "b"
? (clockWhite !== null && clockWhite < 30000 ? "bg-error text-error-content" : "bg-base-300")
: (clockBlack !== null && clockBlack < 30000 ? "bg-error text-error-content" : "bg-base-300"))
}>
{lobby.side === "b" ? formatClock(clockWhite) : formatClock(clockBlack)}
</div>
{/* Bottom clock = self */}
<div className={
"font-mono text-lg font-bold px-2 py-1 rounded " +
(lobby.side === "b"
? (clockBlack !== null && clockBlack < 30000 ? "bg-error text-error-content" : "bg-base-300")
: (clockWhite !== null && clockWhite < 30000 ? "bg-error text-error-content" : "bg-base-300"))
}>
{lobby.side === "b" ? formatClock(clockBlack) : formatClock(clockWhite)}
</div>
</div>
)}
<div className="flex flex-1 flex-col gap-1">
<div className="mb-2 flex w-full flex-col items-end gap-1">
{lobby.endReason ? "Archived link:" : "Invite friends:"}
@@ -586,13 +648,25 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
onClick={copyInvite}
>
<IconCopy size={16} />
ches.su/{lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}
{APP_URL.replace(/^https?:\/\//, "")}/{lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}
</label>
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
copied to clipboard
</div>
</div>
</div>
{!lobby.endReason && (
<div className="mt-1 flex justify-end gap-2">
<InviteFriendsModal gameCode={initialLobby.code!} label="Freunde einladen" />
</div>
)}
{initialLobby.tournamentCode && (
<div className="mt-1 flex justify-end">
<a href={`/tournament/${initialLobby.tournamentCode}`} className="btn btn-ghost btn-xs">
Zur Turnierübersicht
</a>
</div>
)}
<div className="h-32 w-full overflow-y-scroll" ref={moveListRef}>
<table className="table-compact table w-full">
<tbody>{getMoveListHtml()}</tbody>

View File

@@ -1,5 +1,5 @@
import type { Action, CustomSquares, Lobby, Message } from "@/types";
import type { Game, User } from "@chessu/types";
import type { Game, User } from "@michess/types";
import type { Dispatch, SetStateAction } from "react";
import type { Socket } from "socket.io-client";
@@ -16,6 +16,7 @@ export function initSocket(
makeMove: Function;
setNavFen: Dispatch<SetStateAction<string | null>>;
setNavIndex: Dispatch<SetStateAction<number | null>>;
onClockUpdate?: (_data: { whiteTimeMs: number; blackTimeMs: number; lastMoveAt: number }) => void;
}
) {
socket.on("connect", () => {
@@ -34,6 +35,18 @@ export function initSocket(
actions.updateLobby({ type: "updateLobby", payload: latestGame });
syncSide(user, latestGame, lobby, actions);
if (latestGame.whiteTimeMs !== undefined && latestGame.lastMoveAt && actions.onClockUpdate) {
actions.onClockUpdate({
whiteTimeMs: latestGame.whiteTimeMs,
blackTimeMs: latestGame.blackTimeMs ?? 0,
lastMoveAt: latestGame.lastMoveAt
});
}
});
socket.on("clockUpdate", (data: { whiteTimeMs: number; blackTimeMs: number; lastMoveAt: number }) => {
actions.onClockUpdate?.(data);
});
socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => {
@@ -75,6 +88,8 @@ export function initSocket(
}
} else if (reason === "checkmate") {
m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
} else if (reason === "timeout") {
m.message = `Zeit abgelaufen! ${winnerSide === "white" ? "Weiß" : "Schwarz"} gewinnt.`;
} else {
let message = "The game has ended in a draw";
if (reason === "repetition") {

View File

@@ -1,5 +1,5 @@
import type { Action, CustomSquares, Lobby } from "@/types";
import type { Game, User } from "@chessu/types";
import type { Game, User } from "@michess/types";
import type { Dispatch, SetStateAction } from "react";
export const syncPgn = (

View File

@@ -19,10 +19,11 @@ export default function CreateGame() {
const target = e.target as HTMLFormElement;
const unlisted = target.elements.namedItem("createUnlisted") as HTMLInputElement;
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement)
.value;
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement).value;
const timeControlVal = parseInt((target.elements.namedItem("createTimeControl") as HTMLSelectElement).value);
const timeControl = timeControlVal > 0 ? timeControlVal : undefined;
const game = await createGame(startingSide, unlisted.checked);
const game = await createGame(startingSide, unlisted.checked, timeControl);
if (game) {
router.push(`/${game.code}`);
@@ -33,35 +34,45 @@ export default function CreateGame() {
}
return (
<form className="form-control" onSubmit={submitCreateGame}>
<label className="label cursor-pointer">
<span className="label-text">Unlisted/invite-only</span>
<input type="checkbox" className="checkbox" name="createUnlisted" id="createUnlisted" />
<form className="flex flex-col gap-3" onSubmit={submitCreateGame}>
<label className="flex items-center justify-between cursor-pointer">
<span className="label-text text-sm">Nur per Einladung</span>
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm" name="createUnlisted" id="createUnlisted" />
</label>
<label className="label" htmlFor="createStartingSide">
<span className="label-text">Select your side</span>
</label>
<div className="input-group">
<select
className="select select-bordered"
className="select select-bordered select-sm w-full"
name="createTimeControl"
id="createTimeControl"
>
<option value="0">Keine Uhr</option>
<option value="5">5 Min (Blitz)</option>
<option value="10">10 Min (Blitz)</option>
<option value="30">30 Min (Rapid)</option>
</select>
<div className="flex gap-2">
<select
className="select select-bordered select-sm flex-1"
name="createStartingSide"
id="createStartingSide"
>
<option value="random">Random</option>
<option value="white">White</option>
<option value="black">Black</option>
<option value="random">Zufällig</option>
<option value="white">Weiß</option>
<option value="black">Schwarz</option>
</select>
<button
className={
"btn" +
"btn btn-primary btn-sm" +
(buttonLoading ? " loading" : "") +
(!session?.user?.id ? " btn-disabled text-base-content" : "")
(!session?.user?.id ? " btn-disabled" : "")
}
type="submit"
>
Create
Erstellen
</button>
</div>
{!session?.user?.id && (
<p className="text-xs opacity-60">Bitte einloggen um ein Spiel zu erstellen</p>
)}
</form>
);
}

View File

@@ -25,8 +25,8 @@ export default function JoinGame() {
setButtonLoading(true);
if (code.startsWith("ches.su")) {
code = "http://" + code;
if (!code.startsWith("http") && code.includes("/")) {
code = "https://" + code;
}
if (code.startsWith("http")) {
code = new URL(code).pathname.split("/")[1];

View File

@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { Chessboard } from "react-chessboard";
import Link from "next/link";
import { fetchLiveGames, type LiveGameEntry } from "@/lib/game";
export default function LiveGames() {
const [games, setGames] = useState<LiveGameEntry[]>([]);
useEffect(() => {
let mounted = true;
const load = async () => {
const data = await fetchLiveGames();
if (mounted) setGames(data);
};
load();
const interval = setInterval(load, 3000);
return () => { mounted = false; clearInterval(interval); };
}, []);
if (!games.length) return null;
return (
<div>
<h2 className="text-xl font-bold mb-3">Laufende Partien</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{games.map((game) => (
<Link
key={game.code}
href={`/${game.code}`}
className="card bg-base-200 hover:bg-base-300 transition-colors overflow-hidden"
>
<Chessboard
position={game.fen}
boardWidth={160}
arePiecesDraggable={false}
customDarkSquareStyle={{ backgroundColor: "#2F8F72" }}
customLightSquareStyle={{ backgroundColor: "#DFF8E8" }}
/>
<div className="px-2 py-1.5 flex flex-col gap-0.5">
<span className="text-xs font-semibold truncate">{game.black?.name}</span>
<span className="text-xs opacity-40 text-center leading-none">vs</span>
<span className="text-xs font-semibold truncate">{game.white?.name}</span>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -1,14 +1,27 @@
"use client";
import { useEffect, useState } from "react";
import { fetchPublicGames } from "@/lib/game";
import type { Game } from "@michess/types";
import JoinButton from "./JoinButton";
import RefreshButton from "./RefreshButton";
export default async function PublicGames() {
const games = await fetchPublicGames();
export default function PublicGames() {
const [games, setGames] = useState<Game[]>([]);
async function load() {
const data = await fetchPublicGames();
setGames(data || []);
}
useEffect(() => {
load();
}, []);
return (
<div className="flex flex-col items-center">
<h2 className="mb-2 text-2xl font-bold leading-tight">
Public games <RefreshButton />
Public games <RefreshButton onRefresh={load} />
</h2>
<div className="bg-base-200 h-80 max-h-80 overflow-y-auto rounded-xl">
@@ -21,7 +34,7 @@ export default async function PublicGames() {
</tr>
</thead>
<tbody>
{games && games.length > 0 ? (
{games.length > 0 ? (
games.map((game) => (
<tr key={game.code} className="group">
<td className={typeof game.host?.id === "number" ? "text-primary" : ""}>

View File

@@ -2,25 +2,38 @@
import { IconRefresh } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useState, useTransition } from "react";
export default function RefreshButton() {
export default function RefreshButton({ onRefresh }: { onRefresh?: () => Promise<void> | void }) {
const router = useRouter();
const [isLoading, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
const [isLoading, setIsLoading] = useState(false);
function handleRefresh() {
async function handleRefresh() {
if (onRefresh) {
setIsLoading(true);
try {
await onRefresh();
} finally {
setIsLoading(false);
}
} else {
startTransition(() => {
router.refresh();
});
}
}
const loading = isLoading || isPending;
return (
<button
aria-label="Refresh public games"
className={"btn btn-sm btn-square btn-ghost" + (isLoading ? " loading" : "")}
className={"btn btn-sm btn-square btn-ghost" + (loading ? " loading" : "")}
onClick={handleRefresh}
disabled={loading}
>
<IconRefresh size={16} />
{!loading && <IconRefresh size={16} />}
</button>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import { Chessboard } from "react-chessboard";
import { Chess } from "chess.js";
import Link from "next/link";
import { API_URL } from "@/config";
import type { Game } from "@michess/types";
function getFen(pgn?: string): string {
if (!pgn) return "start";
try {
const c = new Chess();
c.loadPgn(pgn);
return c.fen();
} catch {
return "start";
}
}
const BOARD_SIZE = 160;
export default function RecentGames({ userId }: { userId: number }) {
const [games, setGames] = useState<Game[]>([]);
useEffect(() => {
fetch(`${API_URL}/v1/games?userid=${userId}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : []))
.then((data: Game[] | Game) => {
if (Array.isArray(data)) setGames(data.slice(0, 3));
})
.catch(() => {});
}, [userId]);
if (!games.length) return null;
return (
<div>
<h2 className="text-lg font-bold mb-3">Letzte Spiele</h2>
<div className="flex gap-4 flex-wrap">
{games.map((game) => {
const fen = getFen(game.pgn);
const myColor = game.white?.id === userId ? "white" : "black";
const opponent = myColor === "white" ? game.black?.name : game.white?.name;
const won = game.winner === myColor;
const drew = game.winner === "draw";
return (
<Link
key={game.id}
href={`/archive/${game.id}`}
className="card bg-base-200 shadow hover:shadow-lg transition-shadow overflow-hidden"
style={{ width: BOARD_SIZE }}
>
<div className="pointer-events-none">
<Chessboard
boardWidth={BOARD_SIZE}
position={fen}
boardOrientation={myColor}
isDraggablePiece={() => false}
customDarkSquareStyle={{ backgroundColor: "#2F8F72" }}
customLightSquareStyle={{ backgroundColor: "#DFF8E8" }}
areArrowsAllowed={false}
/>
</div>
<div className="px-2 py-2 text-xs">
<div className="font-semibold truncate">vs {opponent ?? "?"}</div>
<div
className={`mt-0.5 font-medium ${
won ? "text-success" : drew ? "text-warning" : "text-error"
}`}
>
{won ? "Gewonnen" : drew ? "Remis" : "Verloren"}
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -2,18 +2,14 @@
import { IconCopy } from "@tabler/icons-react";
import { useState } from "react";
import { APP_URL } from "@/config";
export default function CopyLink({ name }: { name: string }) {
const [copiedLink, setCopiedLink] = useState(false);
const host = APP_URL.replace(/^https?:\/\//, "");
function copyLink() {
const text = `https://ches.su/user/${name}`;
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text);
} else {
document.execCommand("copy", true, text);
}
navigator.clipboard.writeText(`${APP_URL}/user/${name}`).catch(() => {});
setCopiedLink(true);
setTimeout(() => {
setCopiedLink(false);
@@ -27,7 +23,7 @@ export default function CopyLink({ name }: { name: string }) {
onClick={copyLink}
>
<IconCopy size={16} />
ches.su/user/{name}
{host}/user/{name}
</label>
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
copied to clipboard

View File

@@ -1,2 +1,2 @@
// back-end server url
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";

View File

@@ -1,10 +1,11 @@
"use client";
import type { User } from "@chessu/types";
import type { User } from "@michess/types";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { fetchSession } from "@/lib/auth";
import NotificationProvider from "./NotificationContext";
import { SessionContext } from "./session";
export default function ContextProvider({ children }: { children: ReactNode }) {
@@ -19,5 +20,9 @@ export default function ContextProvider({ children }: { children: ReactNode }) {
getSession();
}, []);
return <SessionContext.Provider value={{ user, setUser }}>{children}</SessionContext.Provider>;
return (
<SessionContext.Provider value={{ user, setUser }}>
<NotificationProvider>{children}</NotificationProvider>
</SessionContext.Provider>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import type { AppNotification } from "@michess/types";
import type { ReactNode } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { io, type Socket } from "socket.io-client";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
interface NotificationContextValue {
notifications: AppNotification[];
unreadCount: number;
markAllRead: () => void;
dismiss: (id: string) => void;
sendGameInvite: (toId: number, gameCode: string) => void;
}
export const NotificationContext = createContext<NotificationContextValue | null>(null);
export const useNotifications = () => {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error("useNotifications must be used within NotificationProvider");
return ctx;
};
const notifSocket: Socket = io(API_URL, { withCredentials: true, autoConnect: false });
export default function NotificationProvider({ children }: { children: ReactNode }) {
const { user } = useSession();
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const unreadCount = notifications.filter((n) => !n.read).length;
useEffect(() => {
if (!user?.id || typeof user.id === "string") {
if (notifSocket.connected) notifSocket.disconnect();
return;
}
notifSocket.connect();
notifSocket.on("friendRequestReceived", ({ fromId, fromName }: { fromId: number; fromName: string }) => {
setNotifications((prev) => [
{ id: crypto.randomUUID(), type: "friendRequest", fromId, fromName, createdAt: Date.now(), read: false },
...prev
]);
});
notifSocket.on("friendRequestAccepted", ({ fromId, fromName }: { fromId: number; fromName: string }) => {
setNotifications((prev) => [
{ id: crypto.randomUUID(), type: "friendAccepted", fromId, fromName, createdAt: Date.now(), read: false },
...prev
]);
});
notifSocket.on("gameInviteReceived", ({ fromId, fromName, gameCode }: { fromId: number; fromName: string; gameCode: string }) => {
setNotifications((prev) => [
{ id: crypto.randomUUID(), type: "gameInvite", fromId, fromName, gameCode, createdAt: Date.now(), read: false },
...prev
]);
});
return () => {
notifSocket.off("friendRequestReceived");
notifSocket.off("friendRequestAccepted");
notifSocket.off("gameInviteReceived");
notifSocket.disconnect();
};
}, [user?.id]);
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
const dismiss = (id: string) => setNotifications((prev) => prev.filter((n) => n.id !== id));
const sendGameInvite = (toId: number, gameCode: string) => {
if (notifSocket.connected) {
notifSocket.emit("sendGameInvite", { toId, gameCode });
}
};
return (
<NotificationContext.Provider value={{ notifications, unreadCount, markAllRead, dismiss, sendGameInvite }}>
{children}
</NotificationContext.Provider>
);
}

View File

@@ -1,4 +1,4 @@
import type { User } from "@chessu/types";
import type { User } from "@michess/types";
import { createContext, Dispatch, SetStateAction, useContext } from "react";
export const SessionContext = createContext<{

View File

@@ -1,5 +1,18 @@
import { API_URL } from "@/config";
import type { User } from "@chessu/types";
import type { User } from "@michess/types";
const readErrorMessage = async (res: Response, fallback: string) => {
try {
const body = await res.json();
if (typeof body?.message === "string") {
return body.message;
}
} catch (err) {
// Response did not include a JSON error body.
}
return fallback;
};
export const fetchSession = async () => {
try {
@@ -30,8 +43,10 @@ export const setGuestSession = async (name: string) => {
const user: User = await res.json();
return user;
}
return await readErrorMessage(res, "Could not start guest session.");
} catch (err) {
console.error(err);
return "Could not reach the server. Please try again.";
}
};
@@ -48,12 +63,11 @@ export const register = async (name: string, password: string, email?: string) =
if (res.status === 201) {
const user: User = await res.json();
return user;
} else if (res.status === 409) {
const { message } = await res.json();
return message as string;
}
return await readErrorMessage(res, "Registration failed. Please try again.");
} catch (err) {
console.error(err);
return "Could not reach the server. Please try again.";
}
};
@@ -70,12 +84,11 @@ export const login = async (name: string, password: string) => {
if (res.status === 200) {
const user: User = await res.json();
return user;
} else if (res.status === 404 || res.status === 401) {
const { message } = await res.json();
return message as string;
}
return await readErrorMessage(res, "Login failed. Please try again.");
} catch (err) {
console.error(err);
return "Could not reach the server. Please try again.";
}
};
@@ -107,11 +120,10 @@ export const updateUser = async (name?: string, email?: string, password?: strin
if (res.status === 200) {
const user: User = await res.json();
return user;
} else if (res.status === 409) {
const { message } = await res.json();
return message as string;
}
return await readErrorMessage(res, "Could not update settings. Please try again.");
} catch (err) {
console.error(err);
return "Could not reach the server. Please try again.";
}
};

View File

@@ -0,0 +1,46 @@
import { API_URL } from "@/config";
import type { CorrespondenceGame } from "@michess/types";
export const fetchMyCorrespondenceGames = async (): Promise<CorrespondenceGame[]> => {
const res = await fetch(`${API_URL}/v1/correspondence`, { credentials: "include" });
if (!res.ok) return [];
return res.json();
};
export const fetchCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}`, { credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const joinCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}/join`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const makeCorrespondenceMove = async (code: string, from: string, to: string, promotion?: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}/move`, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from, to, promotion })
});
if (!res.ok) return null;
return res.json();
};
export const resignCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}/resign`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const createCorrespondenceGame = async (daysPerMove = 3): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence`, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ daysPerMove })
});
if (!res.ok) return null;
return res.json();
};

View File

@@ -1,7 +1,7 @@
import { API_URL } from "@/config";
import type { Game } from "@chessu/types";
import type { Game } from "@michess/types";
export const createGame = async (side: string, unlisted: boolean) => {
export const createGame = async (side: string, unlisted: boolean, timeControl?: number) => {
try {
const res = await fetch(`${API_URL}/v1/games`, {
method: "POST",
@@ -9,7 +9,7 @@ export const createGame = async (side: string, unlisted: boolean) => {
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ side, unlisted }),
body: JSON.stringify({ side, unlisted, timeControl: timeControl || null }),
cache: "no-store"
});
@@ -35,6 +35,25 @@ export const fetchActiveGame = async (code: string) => {
}
};
export type LiveGameEntry = {
code: string;
white: { name?: string };
black: { name?: string };
fen: string;
timeControl?: number;
tournamentCode?: string;
};
export const fetchLiveGames = async (): Promise<LiveGameEntry[]> => {
try {
const res = await fetch(`${API_URL}/v1/games/live`, { cache: "no-store" });
if (res.ok) return res.json();
} catch (err) {
console.error(err);
}
return [];
};
export const fetchPublicGames = async () => {
try {
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });

View File

@@ -0,0 +1,52 @@
import { API_URL } from "@/config";
import type { Tournament } from "@michess/types";
export const fetchTournaments = async (): Promise<Tournament[]> => {
const res = await fetch(`${API_URL}/v1/tournaments`, { cache: "no-store" });
if (!res.ok) return [];
return res.json();
};
export const fetchTournament = async (code: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}`, { cache: "no-store" });
if (!res.ok) return null;
return res.json();
};
export const createTournament = async (name: string, timeControl?: number, maxPlayers?: number): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments`, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, timeControl, maxPlayers })
});
if (!res.ok) return null;
return res.json();
};
export const joinTournament = async (code: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/join`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const startTournament = async (code: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/start`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const cancelTournament = async (code: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/cancel`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const addBotToTournament = async (code: string, botName: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/add-bot`, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botName })
});
if (!res.ok) return null;
return res.json();
};

View File

@@ -1,5 +1,5 @@
import { API_URL } from "@/config";
import type { Game, User } from "@chessu/types";
import type { Game, User } from "@michess/types";
export const fetchProfileData = async (name: string) => {
try {

View File

@@ -1,4 +1,4 @@
import type { Game, User } from "@chessu/types";
import type { Game, User } from "@michess/types";
import type { Chess } from "chess.js";
export interface Lobby extends Game {

View File

@@ -5,38 +5,47 @@ module.exports = {
extend: {}
},
plugins: [require("daisyui")],
darkMode: ["class", '[data-theme="chessuDark"]'],
darkMode: ["class", '[data-theme="michessDark"]'],
daisyui: {
// based on daisyUI night and winter themes
themes: [
{
chessuLight: {
primary: "#047AFF",
secondary: "#818CF8",
accent: "#C148AC",
neutral: "#9ab3d9",
"base-100": "#FFFFFF",
"base-200": "#F2F7FF",
"base-300": "#E3E9F4",
"base-content": "#394E6A",
info: "#93E7FB",
success: "#81CFD1",
warning: "#EFD7BB",
error: "#E58B8B"
michessLight: {
primary: "#10B981",
"primary-content": "#052E24",
secondary: "#2DD4BF",
"secondary-content": "#042F2E",
accent: "#84CC16",
"accent-content": "#1F2A05",
neutral: "#5F746B",
"base-100": "#F8FFFB",
"base-200": "#ECFDF5",
"base-300": "#D1FAE5",
"base-content": "#12352B",
info: "#5EEAD4",
success: "#22C55E",
warning: "#FBBF24",
error: "#EF7D7D"
},
chessuDark: {
primary: "#38BDF8",
secondary: "#818CF8",
accent: "#1d4ed8",
neutral: "#1E293B",
"base-100": "#0F172A",
info: "#0CA5E9",
success: "#2DD4BF",
michessDark: {
primary: "#34D399",
"primary-content": "#022C22",
secondary: "#2DD4BF",
"secondary-content": "#042F2E",
accent: "#A3E635",
"accent-content": "#1A2E05",
neutral: "#18362C",
"base-100": "#071A14",
"base-200": "#0D261F",
"base-300": "#12352B",
"base-content": "#DFFDF0",
info: "#5EEAD4",
success: "#4ADE80",
warning: "#F4BF50",
error: "#FB7085"
}
}
],
darkTheme: "chessuDark"
darkTheme: "michessDark"
}
};

View File

@@ -1,37 +1,31 @@
version: "3.8"
services:
michess:
build: .
image: michess:latest
image: git.mischlabs.de/mrdiderot/michess:latest
container_name: michess
restart: unless-stopped
environment:
NODE_ENV: production
# PostgreSQL connection (uses 'db' service below)
PGHOST: db
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB:-michess}
PGUSER: ${POSTGRES_USER:-michess}
PGPASSWORD: ${POSTGRES_PASSWORD:-changeme}
# App config
PORT: 3001
SESSION_SECRET: ${SESSION_SECRET:-change-this-secret-in-production}
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
# Admin setup: user with this email gets admin role on startup
CORS_ORIGIN: ${CORS_ORIGIN:-https://michess.mischlabs.de}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Stockfish binary path (stockfish is installed in the image)
STOCKFISH_PATH: stockfish
# Git update path (used by admin panel update button)
APP_DIR: /opt/michess
SSO_AUTHORITY: ${SSO_AUTHORITY:-}
SSO_CLIENT_ID: ${SSO_CLIENT_ID:-}
SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:-}
SSO_REDIRECT_URI: ${SSO_REDIRECT_URI:-}
APP_URL: ${APP_URL:-}
ports:
- "${MICHESS_PORT:-3000}:3000"
- "${MICHESS_API_PORT:-3001}:3001"
depends_on:
db:
condition: service_healthy
volumes:
- app_pnpm:/opt/michess/.pnpm-store
networks:
- michess_net
@@ -56,8 +50,6 @@ services:
volumes:
postgres_data:
driver: local
app_pnpm:
driver: local
networks:
michess_net:

View File

@@ -1,7 +1,7 @@
{
"name": "chessu",
"name": "michess",
"private": "true",
"author": "dotnize",
"author": "Tom Misch",
"license": "MIT",
"scripts": {
"install:client": "pnpm install --filter client",

3247
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
import { spawn } from "node:child_process";
const processes = [
spawn("node", ["server/dist/server.js"], { stdio: "inherit" }),
spawn("pnpm", ["--filter", "client", "start"], { stdio: "inherit" })
];
let stopping = false;
function stopAll(signal = "SIGTERM") {
stopping = true;
for (const child of processes) {
if (!child.killed) {
child.kill(signal);
}
}
}
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
stopAll(signal);
});
}
for (const child of processes) {
child.on("exit", (code, signal) => {
if (stopping) return;
stopping = true;
stopAll();
process.exit(code ?? (signal ? 1 : 0));
});
}

View File

@@ -1,27 +1,21 @@
#!/bin/sh
# Michess Update-Script
# Auf der NAS ausführen um die App auf den neuesten Stand zu bringen
# MiChess Update-Script
# Auf der NAS ausführen nach dem Synchronisieren der Dateien
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SCRIPT_DIR"
echo "=== Michess Update ==="
echo "Verzeichnis: $SCRIPT_DIR"
echo "=== MiChess Update ==="
echo ""
echo "1. Änderungen aus Git holen..."
git pull origin main
echo ""
echo "2. Docker Image neu bauen..."
echo "1. Image neu bauen..."
docker compose build michess
echo ""
echo "3. Container neu starten..."
echo "2. Container neu starten..."
docker compose up -d michess
echo ""
echo "Update abgeschlossen!"
echo " Container läuft: $(docker compose ps michess --format 'table {{.Status}}')"
echo "Update abgeschlossen!"
docker compose ps michess

View File

@@ -1,5 +1,5 @@
{
"name": "@chessu/server",
"name": "@michess/server",
"private": true,
"main": "./dist/server.js",
"type": "module",
@@ -20,10 +20,11 @@
"nanoid": "^5.0.7",
"pg": "^8.12.0",
"socket.io": "^4.7.5",
"xss": "^1.0.15"
"xss": "^1.0.15",
"openid-client": "^5.6.0"
},
"devDependencies": {
"@chessu/types": "workspace:*",
"@michess/types": "workspace:*",
"@types/connect-pg-simple": "^7.0.3",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",

8
server/src/bots.ts Normal file
View File

@@ -0,0 +1,8 @@
export const BOTS = [
{ name: "Holzpferd Heinz", elo: 200, level: 1 },
{ name: "Bauernschubser Bert", elo: 500, level: 2 },
{ name: "Taktiker Theo", elo: 800, level: 3 },
{ name: "Kombinationskarl", elo: 1100, level: 4 },
{ name: "Meister Magnus", elo: 1400, level: 5 },
] as const;
export const BOT_NAMES = new Set<string>(BOTS.map(b => b.name));

View File

@@ -1,8 +1,11 @@
import type { User } from "@chessu/types";
import type { User } from "@michess/types";
import { hash, verify } from "argon2";
import type { Request, Response } from "express";
import xss from "xss";
import crypto from "crypto";
import { Issuer } from "openid-client";
import { db } from "../db/index.js";
import { activeGames } from "../db/models/game.model.js";
import UserModel from "../db/models/user.model.js";
import { io } from "../server.js";
@@ -222,7 +225,9 @@ export const loginUser = async (req: Request, res: Response) => {
email: users[0].email,
wins: users[0].wins,
losses: users[0].losses,
draws: users[0].draws
draws: users[0].draws,
elo: users[0].elo,
role: users[0].role
};
req.session.save(() => {
res.status(200).json(req.session.user);
@@ -315,3 +320,123 @@ export const updateUser = async (req: Request, res: Response) => {
res.status(500).end();
}
};
// ==================== KEYCLOAK SSO API ====================
let oidcClient: any = null;
async function getOidcClient() {
if (oidcClient) return oidcClient;
const ssoAuthority = process.env.SSO_AUTHORITY || "https://auth.mischlabs.de/realms/mischlabs";
const ssoClientId = process.env.SSO_CLIENT_ID || "michess";
const ssoClientSecret = process.env.SSO_CLIENT_SECRET;
const ssoRedirectUri = process.env.SSO_REDIRECT_URI || "https://michess.mischlabs.de/v1/auth/sso/callback";
if (!ssoClientSecret) {
throw new Error("SSO_CLIENT_SECRET is not configured");
}
const issuer = await Issuer.discover(ssoAuthority);
oidcClient = new issuer.Client({
client_id: ssoClientId,
client_secret: ssoClientSecret,
redirect_uris: [ssoRedirectUri],
response_types: ["code"]
});
return oidcClient;
}
export const getSsoConfig = async (req: Request, res: Response) => {
res.json({
enabled: !!(process.env.SSO_CLIENT_SECRET && process.env.SSO_CLIENT_SECRET.trim() !== "")
});
};
export const ssoLogin = async (req: Request, res: Response) => {
try {
const client = await getOidcClient();
const authorizationUrl = client.authorizationUrl({
scope: "openid email profile",
state: "mischlabs-state"
});
res.redirect(authorizationUrl);
} catch (err: any) {
console.error("SSO Login Error:", err);
res.status(500).send("SSO Login Initialisierung fehlgeschlagen: " + err.message);
}
};
export const ssoCallback = async (req: Request, res: Response) => {
try {
const client = await getOidcClient();
const params = client.callbackParams(req);
const ssoRedirectUri = process.env.SSO_REDIRECT_URI || "https://michess.mischlabs.de/v1/auth/sso/callback";
const tokenSet = await client.callback(ssoRedirectUri, params, { state: "mischlabs-state" });
const userinfo = await client.userinfo(tokenSet.access_token);
const username = (userinfo.preferred_username || userinfo.name || userinfo.sub) as string;
const email = userinfo.email as string;
if (!username) {
return res.status(400).send("Kein Benutzername im OIDC Token gefunden.");
}
// Check if user exists in Michess database
const users = await UserModel.findByNameEmail({ name: username, email: email }, false, 1);
const handleUserLogin = (dbUser: User) => {
req.session.user = {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
wins: dbUser.wins,
losses: dbUser.losses,
draws: dbUser.draws,
elo: dbUser.elo,
role: dbUser.role
};
const appUrl = process.env.APP_URL || "https://michess.mischlabs.de";
req.session.save(() => {
res.redirect(appUrl);
});
};
if (users && users.length) {
// User exists, log them in
handleUserLogin(users[0]);
} else {
// User does not exist, provision them Just-In-Time (JIT)
const randomPassword = crypto.randomBytes(32).toString("hex");
const hashedPassword = await hash(randomPassword);
// Determine role: Option A (automatically make 'MrDiderot' an admin, or the first user in an empty DB)
let role = "user";
const countRes = await db.query('SELECT COUNT(*) as count FROM "user"');
const count = parseInt(countRes.rows[0].count, 10);
if (count === 0 || username.toLowerCase() === "mrdiderot") {
role = "admin";
}
// Create user
const newUser = await UserModel.create({ name: username, email: email || "" }, hashedPassword);
if (!newUser || !newUser.id) {
return res.status(500).send("JIT-Benutzererstellung fehlgeschlagen");
}
// If we assigned admin role, update it in DB
if (role === "admin") {
await UserModel.adminUpdate(newUser.id as number, { role: "admin" });
newUser.role = "admin";
}
handleUserLogin(newUser);
}
} catch (err: any) {
console.error("SSO Callback Error:", err);
res.status(500).send("SSO Authentifizierung fehlgeschlagen: " + err.message);
}
};

View File

@@ -0,0 +1,85 @@
import type { Request, Response } from "express";
import CorrespondenceModel from "../db/models/correspondence.model.js";
import { Chess } from "chess.js";
export const createCorrespondenceGame = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const game = await CorrespondenceModel.create(req.session.user.id as number, req.session.user.name!, req.body.daysPerMove || 3);
res.status(201).json(game);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const getMyCorrespondenceGames = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const games = await CorrespondenceModel.findByUserId(req.session.user.id as number);
res.status(200).json(games);
} catch (e) { res.status(500).end(); }
};
export const getCorrespondenceGame = async (req: Request, res: Response) => {
try {
const game = await CorrespondenceModel.findByCode(req.params.code);
if (!game) { res.status(404).end(); return; }
res.status(200).json(game);
} catch (e) { res.status(500).end(); }
};
export const joinCorrespondenceGame = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const game = await CorrespondenceModel.join(req.params.code, req.session.user.id as number, req.session.user.name!);
if (!game) { res.status(400).json({ message: "Spiel nicht verfügbar." }); return; }
res.status(200).json(game);
} catch (e) { res.status(500).end(); }
};
export const makeCorrespondenceMove = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const { from, to, promotion } = req.body;
const game = await CorrespondenceModel.findByCode(req.params.code);
if (!game || game.winner || game.endReason) { res.status(400).json({ message: "Spiel beendet." }); return; }
if (!game.black?.id) { res.status(400).json({ message: "Kein Gegner." }); return; }
const chess = new Chess();
if (game.pgn) chess.loadPgn(game.pgn);
const turn = chess.turn();
const userId = req.session.user.id as number;
const isWhite = game.white?.id === userId;
const isBlack = game.black?.id === userId;
if (!isWhite && !isBlack) { res.status(403).end(); return; }
if ((turn === "w" && !isWhite) || (turn === "b" && !isBlack)) {
res.status(403).json({ message: "Nicht dein Zug." }); return;
}
const move = chess.move({ from, to, promotion: promotion || "q" });
if (!move) { res.status(400).json({ message: "Ungültiger Zug." }); return; }
let winner: string | undefined, endReason: string | undefined;
if (chess.isGameOver()) {
if (chess.isCheckmate()) { endReason = "checkmate"; winner = turn === "w" ? "white" : "black"; }
else if (chess.isStalemate()) { endReason = "stalemate"; winner = "draw"; }
else if (chess.isThreefoldRepetition()) { endReason = "repetition"; winner = "draw"; }
else if (chess.isInsufficientMaterial()) { endReason = "insufficient"; winner = "draw"; }
else { endReason = "draw"; winner = "draw"; }
}
const updated = await CorrespondenceModel.applyMove(req.params.code, chess.pgn(), winner, endReason);
res.status(200).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const resignCorrespondenceGame = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const game = await CorrespondenceModel.findByCode(req.params.code);
if (!game || game.endReason) { res.status(400).end(); return; }
const userId = req.session.user.id as number;
if (game.white?.id !== userId && game.black?.id !== userId) { res.status(403).end(); return; }
const updated = await CorrespondenceModel.resign(req.params.code, userId);
res.status(200).json(updated);
} catch (e) { res.status(500).end(); }
};

View File

@@ -1,6 +1,8 @@
import type { Request, Response } from "express";
import { db } from "../db/index.js";
import UserModel from "../db/models/user.model.js";
import { io } from "../server.js";
import { onlineUsers } from "../socket/state.js";
export const sendRequest = async (req: Request, res: Response) => {
try {
@@ -49,6 +51,15 @@ export const sendRequest = async (req: Request, res: Response) => {
[fromId, toId]
);
// Notify recipient if online
const fromUser = req.session.user!;
if (onlineUsers.has(toId)) {
io.to(`user:${toId}`).emit("friendRequestReceived", {
fromId: fromUser.id,
fromName: fromUser.name
});
}
res.status(201).json({ id: result.rows[0].id, toName: targets[0].name });
} catch (err) {
console.log(err);
@@ -124,6 +135,13 @@ export const respondToRequest = async (req: Request, res: Response) => {
`INSERT INTO "friendship"(user_id_1, user_id_2) VALUES($1, $2) ON CONFLICT DO NOTHING`,
[a, b]
);
// Notify original sender that request was accepted
if (onlineUsers.has(from_id)) {
io.to(`user:${from_id}`).emit("friendRequestAccepted", {
fromId: userId,
fromName: req.session.user!.name
});
}
}
res.status(200).json({ message: action === "accept" ? "Friend added!" : "Request rejected." });

View File

@@ -1,9 +1,28 @@
import type { Game, User } from "@chessu/types";
import type { Game, User } from "@michess/types";
import type { Request, Response } from "express";
import { nanoid } from "nanoid";
import { Chess } from "chess.js";
import GameModel, { activeGames } from "../db/models/game.model.js";
export const getLiveGames = (_req: Request, res: Response) => {
const live = activeGames
.filter(g => g.white?.id && g.black?.id && !g.winner)
.map(g => {
const chess = new Chess();
if (g.pgn) chess.loadPgn(g.pgn);
return {
code: g.code,
white: { name: g.white?.name },
black: { name: g.black?.name },
fen: chess.fen(),
timeControl: g.timeControl,
tournamentCode: g.tournamentCode
};
});
res.status(200).json(live);
};
export const getGames = async (req: Request, res: Response) => {
try {
if (!req.query.id && !req.query.userid) {
@@ -78,11 +97,15 @@ export const createGame = async (req: Request, res: Response) => {
connected: false
};
const unlisted: boolean = req.body.unlisted ?? false;
const timeControl: number | undefined = req.body.timeControl ? parseInt(req.body.timeControl) : undefined;
const game: Game = {
code: nanoid(6),
unlisted,
host: user,
pgn: ""
pgn: "",
timeControl: timeControl || undefined,
whiteTimeMs: timeControl ? timeControl * 60 * 1000 : undefined,
blackTimeMs: timeControl ? timeControl * 60 * 1000 : undefined
};
if (req.body.side === "white") {
game.white = user;

View File

@@ -1,108 +1,117 @@
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
import { spawn, ChildProcess } from "child_process";
import { existsSync } from "fs";
export interface StockfishLevel {
level: number;
name: string;
skillLevel: number;
depth: number;
moveTime: number; // ms
moveTime: number;
randomChance: number; // 01 probability of playing a random legal move instead of Stockfish's best
}
export const AI_LEVELS: StockfishLevel[] = [
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 },
{ level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 },
{ level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 },
{ level: 4, name: "Fortgeschritten",skillLevel: 14, depth: 10, moveTime: 1000 },
{ level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 },
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 50, randomChance: 0.80 },
{ level: 2, name: "Leicht", skillLevel: 2, depth: 2, moveTime: 100, randomChance: 0.50 },
{ level: 3, name: "Mittel", skillLevel: 7, depth: 4, moveTime: 300, randomChance: 0.15 },
{ level: 4, name: "Fortgeschritten", skillLevel: 13, depth: 8, moveTime: 800, randomChance: 0.03 },
{ level: 5, name: "Experte", skillLevel: 18, depth: 14, moveTime: 1500, randomChance: 0 },
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000, randomChance: 0 },
];
export class StockfishEngine {
private process: ChildProcessWithoutNullStreams | null = null;
private buffer = "";
private resolvers: Map<string, (move: string) => void> = new Map();
private process: ChildProcess | null = null;
private ready = false;
private resolvers: ((move: string) => void)[] = [];
private initPromise: Promise<void>;
private buffer = "";
constructor() {
this.start();
this.initPromise = this.init();
}
private start() {
// Try system stockfish first, then fallback to npm stockfish
const binary = process.env.STOCKFISH_PATH || "stockfish";
private async init() {
try {
this.process = spawn(binary, [], { stdio: ["pipe", "pipe", "pipe"] });
} catch {
console.error("Failed to start stockfish binary, trying node-stockfish fallback");
return;
}
// Debian installs stockfish to /usr/games/stockfish
const sfBin = ["/usr/games/stockfish", "/usr/bin/stockfish", "stockfish"]
.find(p => existsSync(p)) ?? "stockfish";
this.process.stdout.on("data", (data: Buffer) => {
this.buffer += data.toString();
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
for (const line of lines) {
this.handleLine(line.trim());
}
this.process = spawn(sfBin, [], {
stdio: ["pipe", "pipe", "pipe"],
});
this.process.stderr.on("data", (data: Buffer) => {
console.error("Stockfish stderr:", data.toString());
});
this.process.on("exit", (code) => {
console.log("Stockfish exited with code", code);
this.process.on("error", (err) => {
console.error("Stockfish process error:", err.message);
this.process = null;
this.ready = false;
});
this.send("uci");
}
await new Promise<void>((resolve) => {
let readyOk = false;
private send(cmd: string) {
if (this.process?.stdin.writable) {
this.process.stdin.write(cmd + "\n");
}
}
this.process!.stdout!.on("data", (data: Buffer) => {
this.buffer += data.toString();
const lines = this.buffer.split("\n");
this.buffer = lines.pop() ?? "";
private handleLine(line: string) {
if (line === "uciok") {
for (const line of lines) {
const msg = line.trim();
if (msg === "uciok") {
this.process!.stdin!.write("isready\n");
}
if (msg === "readyok") {
readyOk = true;
this.ready = true;
this.send("isready");
resolve();
}
if (line === "readyok") {
// engine ready
if (msg.startsWith("bestmove ")) {
const move = msg.split(" ")[1];
const resolver = this.resolvers.shift();
if (resolver) resolver(move === "(none)" ? "" : move);
}
if (line.startsWith("bestmove ")) {
const parts = line.split(" ");
const move = parts[1];
// resolve any pending resolver
const [key] = this.resolvers.entries().next().value ?? [];
if (key) {
this.resolvers.get(key)?.(move === "(none)" ? "" : move);
this.resolvers.delete(key);
}
});
this.process!.stderr!.on("data", (data: Buffer) => {
console.error("Stockfish stderr:", data.toString());
});
this.process!.stdin!.write("uci\n");
setTimeout(() => {
if (!readyOk) {
console.warn("Stockfish init timeout");
resolve();
}
}, 5000);
});
if (this.ready) console.log("Stockfish engine ready");
} catch (err) {
console.error("Failed to initialize Stockfish:", err);
}
}
async getBestMove(fen: string, level: StockfishLevel): Promise<string> {
return new Promise((resolve) => {
await this.initPromise;
if (!this.process || !this.ready) {
resolve("");
return;
console.warn("Stockfish not ready");
return "";
}
const id = `${Date.now()}-${Math.random()}`;
this.resolvers.set(id, resolve);
return new Promise((resolve) => {
this.resolvers.push(resolve);
this.send("ucinewgame");
this.send(`setoption name Skill Level value ${level.skillLevel}`);
this.send(`position fen ${fen}`);
this.send(`go depth ${level.depth} movetime ${level.moveTime}`);
this.process!.stdin!.write("ucinewgame\n");
this.process!.stdin!.write(`setoption name Skill Level value ${level.skillLevel}\n`);
this.process!.stdin!.write(`position fen ${fen}\n`);
this.process!.stdin!.write(`go depth ${level.depth} movetime ${level.moveTime}\n`);
// Timeout safety
setTimeout(() => {
if (this.resolvers.has(id)) {
this.resolvers.delete(id);
const idx = this.resolvers.indexOf(resolve);
if (idx !== -1) {
this.resolvers.splice(idx, 1);
resolve("");
}
}, level.moveTime + 5000);
@@ -110,13 +119,11 @@ export class StockfishEngine {
}
destroy() {
this.send("quit");
this.process?.kill();
this.process = null;
}
}
// Singleton engine instance
let engineInstance: StockfishEngine | null = null;
export const getEngine = (): StockfishEngine => {

View File

@@ -0,0 +1,116 @@
import type { Request, Response } from "express";
import TournamentModel from "../db/models/tournament.model.js";
import { activeGames } from "../db/models/game.model.js";
import { db } from "../db/index.js";
import { nanoid } from "nanoid";
import type { Game } from "@michess/types";
export const createTournament = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
const { name, timeControl, maxPlayers } = req.body;
if (!name) { res.status(400).json({ message: "Name erforderlich." }); return; }
try {
const tournament = await TournamentModel.createTournament(
req.session.user.id as number, req.session.user.name!, name,
timeControl ? parseInt(timeControl) : undefined, maxPlayers ? parseInt(maxPlayers) : 8
);
await TournamentModel.joinTournament(tournament.code!, req.session.user.id as number, req.session.user.name!);
const updated = await TournamentModel.findByCode(tournament.code!);
res.status(201).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const getTournaments = async (_req: Request, res: Response) => {
try {
const tournaments = await TournamentModel.findAll();
res.status(200).json(tournaments);
} catch (e) { res.status(500).end(); }
};
export const getTournament = async (req: Request, res: Response) => {
try {
const tournament = await TournamentModel.findByCode(req.params.code);
if (!tournament) { res.status(404).end(); return; }
res.status(200).json(tournament);
} catch (e) { res.status(500).end(); }
};
export const joinTournament = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const result = await TournamentModel.joinTournament(req.params.code, req.session.user.id as number, req.session.user.name!);
if (!result) { res.status(400).json({ message: "Turnier nicht verfügbar." }); return; }
const updated = await TournamentModel.findByCode(req.params.code);
res.status(200).json(updated);
} catch (e) { res.status(500).end(); }
};
export const startTournament = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const tournament = await TournamentModel.findByCode(req.params.code);
if (!tournament) { res.status(404).end(); return; }
if (tournament.hostId !== req.session.user.id) { res.status(403).end(); return; }
if (tournament.status !== "waiting") { res.status(400).json({ message: "Turnier bereits gestartet." }); return; }
if (!tournament.players || tournament.players.length < 2) { res.status(400).json({ message: "Mindestens 2 Spieler erforderlich." }); return; }
const allRounds = await TournamentModel.startTournament(tournament.id!);
if (!allRounds) { res.status(500).end(); return; }
// Create active games for round 1
const round1 = allRounds[0];
for (const roundRow of round1) {
const code = nanoid(6);
const tc = tournament.timeControl;
const row = roundRow as any;
const whiteUser = { id: row.white_id, name: row.white_name, connected: false };
const blackUser = { id: row.black_id, name: row.black_name, connected: false };
const game: Game = {
code, unlisted: true, host: whiteUser,
white: whiteUser, black: blackUser, pgn: "",
startedAt: Date.now(),
timeControl: tc || undefined,
whiteTimeMs: tc ? tc * 60 * 1000 : undefined,
blackTimeMs: tc ? tc * 60 * 1000 : undefined,
tournamentCode: req.params.code
};
activeGames.push(game);
await TournamentModel.setRoundGameCode(row.id, code);
}
const updated = await TournamentModel.findByCode(req.params.code);
res.status(200).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const cancelTournament = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const tournament = await TournamentModel.findByCode(req.params.code);
if (!tournament) { res.status(404).end(); return; }
if (tournament.hostId !== req.session.user.id) { res.status(403).end(); return; }
if (tournament.status === "finished") { res.status(400).json({ message: "Turnier bereits beendet." }); return; }
await db.query(`UPDATE "tournament" SET status='finished' WHERE code=$1`, [req.params.code]);
const updated = await TournamentModel.findByCode(req.params.code);
res.status(200).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const addBot = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
const { botName } = req.body;
if (!botName) { res.status(400).json({ message: "Bot name erforderlich." }); return; }
try {
const tournament = await TournamentModel.findByCode(req.params.code);
if (!tournament) { res.status(404).end(); return; }
if (tournament.hostId !== req.session.user.id) { res.status(403).end(); return; }
if (tournament.status !== "waiting") { res.status(400).json({ message: "Turnier bereits gestartet." }); return; }
const botRes = await db.query(`SELECT id, name FROM "user" WHERE name=$1 AND role='bot'`, [botName]);
if (!botRes.rows[0]) { res.status(404).json({ message: "Bot nicht gefunden." }); return; }
const bot = botRes.rows[0];
const result = await TournamentModel.joinTournament(req.params.code, bot.id, bot.name);
if (!result) { res.status(400).json({ message: "Bot konnte nicht hinzugefügt werden." }); return; }
const updated = await TournamentModel.findByCode(req.params.code);
res.status(200).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};

View File

@@ -22,7 +22,8 @@ export const getUserProfile = async (req: Request, res: Response) => {
name: users[0].name,
wins: users[0].wins,
losses: users[0].losses,
draws: users[0].draws
draws: users[0].draws,
elo: users[0].elo
};
res.status(200).json({ ...publicUser, recentGames });

View File

@@ -48,4 +48,58 @@ export const INIT_TABLES = /* sql */ `
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS banned BOOLEAN DEFAULT FALSE;
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS vs_ai BOOLEAN DEFAULT FALSE;
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS ai_level INTEGER;
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS time_control INTEGER;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS elo INTEGER DEFAULT 1200;
CREATE TABLE IF NOT EXISTS "correspondence_game" (
id SERIAL PRIMARY KEY,
code VARCHAR(8) UNIQUE NOT NULL,
white_id INT REFERENCES "user"(id),
black_id INT REFERENCES "user"(id),
white_name VARCHAR(128),
black_name VARCHAR(128),
pgn TEXT DEFAULT '',
winner VARCHAR(5),
end_reason VARCHAR(16),
days_per_move INTEGER DEFAULT 3,
last_move_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "tournament" (
id SERIAL PRIMARY KEY,
code VARCHAR(8) UNIQUE NOT NULL,
name VARCHAR(128) NOT NULL,
host_id INT REFERENCES "user"(id),
host_name VARCHAR(128),
status VARCHAR(16) DEFAULT 'waiting',
time_control INTEGER,
max_players INTEGER DEFAULT 8,
current_round INTEGER DEFAULT 0,
total_rounds INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "tournament_player" (
tournament_id INT REFERENCES "tournament"(id) ON DELETE CASCADE,
user_id INT REFERENCES "user"(id) ON DELETE CASCADE,
user_name VARCHAR(128),
score DECIMAL(4,1) DEFAULT 0,
games_played INTEGER DEFAULT 0,
PRIMARY KEY (tournament_id, user_id)
);
CREATE TABLE IF NOT EXISTS "tournament_round" (
id SERIAL PRIMARY KEY,
tournament_id INT REFERENCES "tournament"(id) ON DELETE CASCADE,
round INTEGER NOT NULL,
game_code VARCHAR(8),
game_id INT,
white_id INT REFERENCES "user"(id),
white_name VARCHAR(128),
black_id INT REFERENCES "user"(id),
black_name VARCHAR(128),
result VARCHAR(5)
);
`;

View File

@@ -0,0 +1,79 @@
import { db } from "../index.js";
import type { CorrespondenceGame } from "@michess/types";
import { nanoid } from "nanoid";
function mapRow(r: any): CorrespondenceGame {
return {
id: r.id,
code: r.code,
white: { id: r.white_id, name: r.white_name },
black: r.black_id ? { id: r.black_id, name: r.black_name } : undefined,
pgn: r.pgn || "",
winner: r.winner,
endReason: r.end_reason,
daysPerMove: r.days_per_move,
lastMoveAt: r.last_move_at?.getTime(),
startedAt: r.started_at?.getTime(),
endedAt: r.ended_at?.getTime()
};
}
export const create = async (userId: number, userName: string, daysPerMove = 3): Promise<CorrespondenceGame> => {
const code = nanoid(8);
const res = await db.query(
`INSERT INTO "correspondence_game"(code, white_id, white_name, days_per_move) VALUES($1, $2, $3, $4) RETURNING *`,
[code, userId, userName, daysPerMove]
);
return mapRow(res.rows[0]);
};
export const join = async (code: string, userId: number, userName: string): Promise<CorrespondenceGame | null> => {
const res = await db.query(
`UPDATE "correspondence_game" SET black_id=$1, black_name=$2 WHERE code=$3 AND black_id IS NULL AND white_id != $1 RETURNING *`,
[userId, userName, code]
);
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
export const findByCode = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await db.query(`SELECT * FROM "correspondence_game" WHERE code=$1`, [code]);
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
export const findByUserId = async (userId: number): Promise<CorrespondenceGame[]> => {
const res = await db.query(
`SELECT * FROM "correspondence_game" WHERE (white_id=$1 OR black_id=$1) AND ended_at IS NULL ORDER BY last_move_at DESC`,
[userId]
);
return res.rows.map(mapRow);
};
export const applyMove = async (code: string, pgn: string, winner?: string, endReason?: string): Promise<CorrespondenceGame | null> => {
let res;
if (winner) {
res = await db.query(
`UPDATE "correspondence_game" SET pgn=$1, winner=$2, end_reason=$3, last_move_at=NOW(), ended_at=NOW() WHERE code=$4 RETURNING *`,
[pgn, winner, endReason, code]
);
} else {
res = await db.query(
`UPDATE "correspondence_game" SET pgn=$1, last_move_at=NOW() WHERE code=$2 RETURNING *`,
[pgn, code]
);
}
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
export const resign = async (code: string, resigningUserId: number): Promise<CorrespondenceGame | null> => {
const game = await findByCode(code);
if (!game) return null;
const winner = game.white?.id === resigningUserId ? "black" : "white";
const res = await db.query(
`UPDATE "correspondence_game" SET winner=$1, end_reason='resign', ended_at=NOW() WHERE code=$2 RETURNING *`,
[winner, code]
);
return res.rows[0] ? mapRow(res.rows[0]) : null;
};
const CorrespondenceModel = { create, join, findByCode, findByUserId, applyMove, resign };
export default CorrespondenceModel;

View File

@@ -1,24 +1,58 @@
import type { Game, User } from "@chessu/types";
import type { Game, User } from "@michess/types";
import { db } from "../index.js";
import { BOTS } from "../../bots.js";
export const activeGames: Game[] = [];
const ELO_K = 32;
async function updateElo(game: Game): Promise<void> {
if (!game.winner) return;
const whiteId = typeof game.white?.id === "number" ? game.white.id : undefined;
const blackId = typeof game.black?.id === "number" ? game.black.id : undefined;
if (!whiteId && !blackId) return;
let whiteElo = 1200, blackElo = 1200;
let whiteIsBot = false, blackIsBot = false;
if (whiteId) {
const r = await db.query(`SELECT elo, role FROM "user" WHERE id=$1`, [whiteId]);
if (r.rows[0]) { whiteElo = r.rows[0].elo ?? 1200; whiteIsBot = r.rows[0].role === "bot"; }
} else {
const bot = BOTS.find(b => b.name === game.white?.name);
if (bot) { whiteElo = bot.elo; whiteIsBot = true; }
}
if (blackId) {
const r = await db.query(`SELECT elo, role FROM "user" WHERE id=$1`, [blackId]);
if (r.rows[0]) { blackElo = r.rows[0].elo ?? 1200; blackIsBot = r.rows[0].role === "bot"; }
} else {
const bot = BOTS.find(b => b.name === game.black?.name);
if (bot) { blackElo = bot.elo; blackIsBot = true; }
}
if (whiteIsBot && blackIsBot) return;
const actualWhite = game.winner === "white" ? 1 : game.winner === "draw" ? 0.5 : 0;
const expectedWhite = 1 / (1 + Math.pow(10, (blackElo - whiteElo) / 400));
const newWhiteElo = Math.max(100, Math.round(whiteElo + ELO_K * (actualWhite - expectedWhite)));
const newBlackElo = Math.max(100, Math.round(blackElo + ELO_K * ((1 - actualWhite) - (1 - expectedWhite))));
if (whiteId && !whiteIsBot) await db.query(`UPDATE "user" SET elo=$1 WHERE id=$2`, [newWhiteElo, whiteId]);
if (blackId && !blackIsBot) await db.query(`UPDATE "user" SET elo=$1 WHERE id=$2`, [newBlackElo, blackId]);
}
export const save = async (game: Game) => {
try {
const white: User = {};
const black: User = {};
if (typeof game.white?.id === "string") {
white.name = game.white?.name;
} else {
const white: User = { name: game.white?.name };
const black: User = { name: game.black?.name };
if (typeof game.white?.id !== "string") {
white.id = game.white?.id;
}
if (typeof game.black?.id === "string") {
black.name = game.black?.name;
} else {
if (typeof game.black?.id !== "string") {
black.id = game.black?.id;
}
const res = await db.query(
`INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
`INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at, vs_ai, ai_level, time_control) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[
game.winner || null,
game.endReason || null,
@@ -27,7 +61,10 @@ export const save = async (game: Game) => {
white.name || null,
black.id || null,
black.name || null,
new Date(game.startedAt as number)
new Date(game.startedAt as number),
game.vsAi || false,
game.aiLevel || null,
game.timeControl || null
]
);
if (black.id || white.id) {
@@ -51,11 +88,12 @@ export const save = async (game: Game) => {
]);
}
}
await updateElo(game);
}
return {
id: res.rows[0].id,
winner: res.rows[0].winner,
endReason: res.rows[0].reason,
endReason: res.rows[0].end_reason,
pgn: res.rows[0].pgn,
white: {
id: res.rows[0].white_id || undefined,
@@ -66,7 +104,10 @@ export const save = async (game: Game) => {
name: res.rows[0].black_name || undefined
},
startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined
endedAt: res.rows[0].ended_at?.getTime() || undefined,
vsAi: res.rows[0].vs_ai || undefined,
aiLevel: res.rows[0].ai_level || undefined,
timeControl: res.rows[0].time_control || undefined
} as Game;
} catch (err: unknown) {
console.log(err);
@@ -77,7 +118,7 @@ export const save = async (game: Game) => {
export const findById = async (id: number) => {
try {
const res = await db.query(
`SELECT game.id, game.winner, game.end_reason, game.pgn, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE game.id=$1`,
`SELECT game.id, game.winner, game.end_reason, game.pgn, game.vs_ai, game.ai_level, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE game.id=$1`,
[id]
);
if (res.rowCount) {
@@ -89,7 +130,9 @@ export const findById = async (id: number) => {
white: { id: res.rows[0].white_id || undefined, name: res.rows[0].white_name },
black: { id: res.rows[0].black_id || undefined, name: res.rows[0].black_name },
startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined
endedAt: res.rows[0].ended_at?.getTime() || undefined,
vsAi: res.rows[0].vs_ai || undefined,
aiLevel: res.rows[0].ai_level || undefined
} as Game;
} else return null;
} catch (err: unknown) {
@@ -105,7 +148,7 @@ export const findByUserId = async (id: number, limit = 10) => {
try {
// TODO: pagination
const res = await db.query(
`SELECT game.id, game.winner, game.end_reason, game.pgn, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE white_user.id=$1 OR black_user.id=$1 ORDER BY id DESC LIMIT $2`,
`SELECT game.id, game.winner, game.end_reason, game.pgn, game.vs_ai, game.ai_level, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE white_user.id=$1 OR black_user.id=$1 ORDER BY id DESC LIMIT $2`,
[id, limit]
);
return res.rows.map((r) => {
@@ -117,7 +160,9 @@ export const findByUserId = async (id: number, limit = 10) => {
white: { id: r.white_id || undefined, name: r.white_name },
black: { id: r.black_id || undefined, name: r.black_name },
startedAt: r.started_at.getTime(),
endedAt: r.ended_at?.getTime() || undefined
endedAt: r.ended_at?.getTime() || undefined,
vsAi: r.vs_ai || undefined,
aiLevel: r.ai_level || undefined
} as Game;
});
} catch (err: unknown) {
@@ -137,7 +182,9 @@ export const remove = async (id: number) => {
white: { id: res.rows[0].white_id, name: res.rows[0].white_name },
black: { id: res.rows[0].black_id, name: res.rows[0].black_name },
startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined
endedAt: res.rows[0].ended_at?.getTime() || undefined,
vsAi: res.rows[0].vs_ai || undefined,
aiLevel: res.rows[0].ai_level || undefined
} as Game;
} catch (err: unknown) {
console.log(err);

View File

@@ -0,0 +1,183 @@
import { db } from "../index.js";
import { nanoid } from "nanoid";
import type { Tournament, TournamentPlayer, TournamentRound } from "@michess/types";
// Circle method for round-robin tournament scheduling
function generateRoundRobinPairings(players: TournamentPlayer[]): { white: TournamentPlayer; black: TournamentPlayer }[][] {
let ps: (TournamentPlayer | null)[] = [...players];
if (ps.length % 2 !== 0) ps.push(null); // BYE slot for odd number
const n = ps.length;
const rounds: { white: TournamentPlayer; black: TournamentPlayer }[][] = [];
for (let r = 0; r < n - 1; r++) {
const games: { white: TournamentPlayer; black: TournamentPlayer }[] = [];
for (let k = 0; k < n / 2; k++) {
const home = ps[k];
const away = ps[n - 1 - k];
if (home && away) {
// Alternate colors each round for fairness
games.push(r % 2 === 0 ? { white: home, black: away } : { white: away, black: home });
}
}
rounds.push(games);
// Rotate: keep index 0 fixed, rotate the rest clockwise
const last = ps[n - 1];
for (let i = n - 1; i > 1; i--) ps[i] = ps[i - 1];
ps[1] = last;
}
return rounds;
}
function mapTournament(t: any, players: any[], rounds: any[]): Tournament {
return {
id: t.id,
code: t.code,
name: t.name,
hostId: t.host_id,
hostName: t.host_name,
status: t.status,
timeControl: t.time_control,
maxPlayers: t.max_players,
currentRound: t.current_round,
totalRounds: t.total_rounds,
players: players.map(p => ({
tournamentId: p.tournament_id,
userId: p.user_id,
userName: p.user_name,
score: parseFloat(p.score),
gamesPlayed: p.games_played
})),
rounds: rounds.map(r => ({
id: r.id,
tournamentId: r.tournament_id,
round: r.round,
gameCode: r.game_code,
gameId: r.game_id,
whiteId: r.white_id,
whiteName: r.white_name,
blackId: r.black_id,
blackName: r.black_name,
result: r.result
})),
createdAt: t.created_at?.getTime()
};
}
export const createTournament = async (hostId: number, hostName: string, name: string, timeControl?: number, maxPlayers = 8): Promise<Tournament> => {
const code = nanoid(6);
const res = await db.query(
`INSERT INTO "tournament"(code, name, host_id, host_name, time_control, max_players) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
[code, name, hostId, hostName, timeControl || null, maxPlayers]
);
return mapTournament(res.rows[0], [], []);
};
export const joinTournament = async (code: string, userId: number, userName: string): Promise<boolean | null> => {
const t = await findByCode(code);
if (!t || t.status !== "waiting") return null;
if ((t.players?.length ?? 0) >= (t.maxPlayers ?? 8)) return null;
const existing = t.players?.find(p => p.userId === userId);
if (existing) return true; // already joined
try {
await db.query(
`INSERT INTO "tournament_player"(tournament_id, user_id, user_name) VALUES($1,$2,$3)`,
[t.id, userId, userName]
);
return true;
} catch { return null; }
};
export const startTournament = async (tournamentId: number): Promise<TournamentRound[][] | null> => {
const playersRes = await db.query(
`SELECT * FROM "tournament_player" WHERE tournament_id=$1`,
[tournamentId]
);
const players: TournamentPlayer[] = playersRes.rows.map(r => ({
tournamentId: r.tournament_id, userId: r.user_id, userName: r.user_name, score: 0, gamesPlayed: 0
}));
if (players.length < 2) return null;
const pairings = generateRoundRobinPairings(players);
const totalRounds = pairings.length;
const allRounds: TournamentRound[][] = [];
for (let r = 0; r < pairings.length; r++) {
const roundRows: TournamentRound[] = [];
for (const game of pairings[r]) {
const res = await db.query(
`INSERT INTO "tournament_round"(tournament_id, round, white_id, white_name, black_id, black_name) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
[tournamentId, r + 1, game.white.userId, game.white.userName, game.black.userId, game.black.userName]
);
roundRows.push(res.rows[0]);
}
allRounds.push(roundRows);
}
await db.query(
`UPDATE "tournament" SET status='active', current_round=1, total_rounds=$1 WHERE id=$2`,
[totalRounds, tournamentId]
);
return allRounds;
};
export const findByCode = async (code: string): Promise<Tournament | null> => {
const res = await db.query(`SELECT * FROM "tournament" WHERE code=$1`, [code]);
if (!res.rows[0]) return null;
const t = res.rows[0];
const playersRes = await db.query(`SELECT * FROM "tournament_player" WHERE tournament_id=$1 ORDER BY score DESC, games_played`, [t.id]);
const roundsRes = await db.query(`SELECT * FROM "tournament_round" WHERE tournament_id=$1 ORDER BY round, id`, [t.id]);
return mapTournament(t, playersRes.rows, roundsRes.rows);
};
export const findAll = async (): Promise<Tournament[]> => {
const res = await db.query(`SELECT * FROM "tournament" WHERE status != 'finished' ORDER BY created_at DESC`);
return res.rows.map(r => mapTournament(r, [], []));
};
export const setRoundGameCode = async (roundId: number, gameCode: string): Promise<void> => {
await db.query(`UPDATE "tournament_round" SET game_code=$1 WHERE id=$2`, [gameCode, roundId]);
};
export type NextRoundData = { rows: any[]; timeControl: number | null } | null;
export const updateRoundResult = async (gameCode: string, result: "white" | "black" | "draw"): Promise<NextRoundData> => {
const roundRes = await db.query(
`UPDATE "tournament_round" SET result=$1 WHERE game_code=$2 RETURNING *`,
[result, gameCode]
);
if (!roundRes.rows[0]) return null;
const round = roundRes.rows[0];
// Update scores
if (result === "white") {
await db.query(`UPDATE "tournament_player" SET score=score+1, games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.white_id]);
await db.query(`UPDATE "tournament_player" SET games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.black_id]);
} else if (result === "black") {
await db.query(`UPDATE "tournament_player" SET score=score+1, games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.black_id]);
await db.query(`UPDATE "tournament_player" SET games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.white_id]);
} else {
await db.query(`UPDATE "tournament_player" SET score=score+0.5, games_played=games_played+1 WHERE tournament_id=$1 AND (user_id=$2 OR user_id=$3)`, [round.tournament_id, round.white_id, round.black_id]);
}
// Check if all games in this round are done -> advance round or finish tournament
const pendingRes = await db.query(
`SELECT COUNT(*) FROM "tournament_round" WHERE tournament_id=$1 AND round=$2 AND result IS NULL`,
[round.tournament_id, round.round]
);
if (parseInt(pendingRes.rows[0].count) === 0) {
const nextRes = await db.query(
`SELECT tr.*, t.time_control, t.code AS tournament_code FROM "tournament_round" tr JOIN "tournament" t ON t.id=tr.tournament_id WHERE tr.tournament_id=$1 AND tr.round=$2`,
[round.tournament_id, round.round + 1]
);
if (nextRes.rows.length > 0) {
await db.query(`UPDATE "tournament" SET current_round=current_round+1 WHERE id=$1`, [round.tournament_id]);
return { rows: nextRes.rows, timeControl: nextRes.rows[0].time_control ?? null };
} else {
await db.query(`UPDATE "tournament" SET status='finished' WHERE id=$1`, [round.tournament_id]);
}
}
return null;
};
const TournamentModel = { createTournament, joinTournament, startTournament, findByCode, findAll, setRoundGameCode, updateRoundResult };
export default TournamentModel;

View File

@@ -1,4 +1,4 @@
import type { User } from "@chessu/types";
import type { User } from "@michess/types";
import { db } from "../index.js";
export const create = async (user: User, password: string) => {
@@ -8,7 +8,7 @@ export const create = async (user: User, password: string) => {
try {
const res = await db.query(
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws, role, banned`,
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws, elo, role, banned`,
[user.name, user.email || null, password]
);
return res.rows[0] as User;
@@ -24,7 +24,7 @@ export const findById = async (id: number) => {
}
try {
const res = await db.query(
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" WHERE id=$1`,
`SELECT id, name, email, wins, losses, draws, elo, role, banned FROM "user" WHERE id=$1`,
[id]
);
if (res.rowCount) {
@@ -40,7 +40,7 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
if (!user) {
try {
const res = await db.query(
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" LIMIT $1`,
`SELECT id, name, email, wins, losses, draws, elo, role, banned FROM "user" LIMIT $1`,
[limit ?? 10]
);
return res.rows as (User & { password?: string; banned?: boolean })[];
@@ -52,7 +52,7 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
try {
const res = await db.query(
`SELECT id, name, email, wins, losses, draws, role, banned${
`SELECT id, name, email, wins, losses, draws, elo, role, banned${
includePassword ? `, password` : ""
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
[user.name, user.email, limit ?? 1]
@@ -67,7 +67,7 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
export const searchByName = async (query: string, limit = 10) => {
try {
const res = await db.query(
`SELECT id, name, wins, losses, draws FROM "user" WHERE name ILIKE $1 LIMIT $2`,
`SELECT id, name, wins, losses, draws, elo FROM "user" WHERE name ILIKE $1 LIMIT $2`,
[`%${query}%`, limit]
);
return res.rows as User[];
@@ -83,11 +83,11 @@ export const update = async (id: number, updatedUser: User & { password?: string
}
try {
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, role, banned`;
let values: (string | number | undefined)[] = [updatedUser.name, updatedUser.email, id];
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, elo, role, banned`;
let values: (string | number | null | undefined)[] = [updatedUser.name, updatedUser.email, id];
if (updatedUser.password) {
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws, role, banned`;
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws, elo, role, banned`;
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
}
const res = await db.query(query, values);
@@ -129,7 +129,7 @@ export const adminUpdate = async (id: number, fields: { role?: string; banned?:
export const getAllUsers = async (limit = 50, offset = 0, search?: string) => {
try {
let query = `SELECT id, name, email, wins, losses, draws, role, banned, created_at FROM "user"`;
let query = `SELECT id, name, email, wins, losses, draws, elo, role, banned, created_at FROM "user"`;
const values: (string | number)[] = [];
if (search) {

View File

@@ -1,4 +1,4 @@
import type { User } from "@chessu/types";
import type { User } from "@michess/types";
import PGSimple from "connect-pg-simple";
import type { Session } from "express-session";
import session from "express-session";

View File

@@ -1,6 +1,10 @@
import express from "express";
import type { Request, Response } from "express";
import { Chess } from "chess.js";
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
import { requireAuth } from "../middleware/auth.js";
import GameModel from "../db/models/game.model.js";
import type { Game } from "@michess/types";
const router = express.Router();
@@ -14,10 +18,20 @@ router.post("/move", async (req: Request, res: Response) => {
}
const aiLevel = AI_LEVELS.find((l) => l.level === (level || 3)) || AI_LEVELS[2];
const engine = getEngine();
const move = await engine.getBestMove(fen, aiLevel);
res.status(200).json({ move: move || null });
let move: string | null = null;
if (aiLevel.randomChance > 0 && Math.random() < aiLevel.randomChance) {
const chess = new Chess(fen);
const moves = chess.moves({ verbose: true });
if (moves.length) {
const rand = moves[Math.floor(Math.random() * moves.length)];
move = rand.from + rand.to + (rand.promotion ?? "");
}
} else {
move = await getEngine().getBestMove(fen, aiLevel) || null;
}
res.status(200).json({ move });
} catch (err) {
console.error(err);
res.status(500).end();
@@ -28,4 +42,40 @@ router.get("/levels", (_req: Request, res: Response) => {
res.status(200).json(AI_LEVELS.map(({ level, name }) => ({ level, name })));
});
router.post("/save", requireAuth, async (req: Request, res: Response) => {
try {
const { pgn, winner, endReason, playerColor, level, startedAt, botName } = req.body;
const user = req.session.user!;
if (!pgn || !winner || !endReason || !playerColor) {
res.status(400).json({ message: "Missing required fields." });
return;
}
const humanPlayer = { id: user.id as number, name: user.name };
const aiPlayer = { name: botName || `Stockfish` };
const game: Game = {
pgn,
winner,
endReason,
white: playerColor === "white" ? humanPlayer : aiPlayer,
black: playerColor === "black" ? humanPlayer : aiPlayer,
startedAt: startedAt || Date.now(),
vsAi: true,
aiLevel: level || null
};
const saved = await GameModel.save(game);
if (!saved) {
res.status(500).json({ message: "Failed to save game." });
return;
}
res.status(200).json({ id: saved.id });
} catch (err) {
console.error(err);
res.status(500).end();
}
});
export default router;

View File

@@ -14,4 +14,9 @@ router.route("/logout").post(controller.logoutSession);
router.route("/register").post(controller.registerUser);
router.route("/login").post(controller.loginUser);
// SSO Keycloak
router.route("/sso/config").get(controller.getSsoConfig);
router.route("/sso/login").get(controller.ssoLogin);
router.route("/sso/callback").get(controller.ssoCallback);
export default router;

View File

@@ -0,0 +1,14 @@
import express from "express";
import {
createCorrespondenceGame, getMyCorrespondenceGames, getCorrespondenceGame,
joinCorrespondenceGame, makeCorrespondenceMove, resignCorrespondenceGame
} from "../controllers/correspondence.controller.js";
const router = express.Router();
router.post("/", createCorrespondenceGame);
router.get("/", getMyCorrespondenceGames);
router.get("/:code", getCorrespondenceGame);
router.post("/:code/join", joinCorrespondenceGame);
router.post("/:code/move", makeCorrespondenceMove);
router.post("/:code/resign", resignCorrespondenceGame);
export default router;

View File

@@ -4,6 +4,7 @@ import * as controller from "../controllers/games.controller.js";
const router = Router();
router.get("/live", controller.getLiveGames);
router.route("/").get(controller.getGames).post(controller.createGame);
router.route("/:code").get(controller.getActiveGame);

View File

@@ -3,8 +3,10 @@ import { Router } from "express";
import admin from "./admin.route.js";
import ai from "./ai.route.js";
import auth from "./auth.route.js";
import correspondence from "./correspondence.route.js";
import friends from "./friends.route.js";
import games from "./games.route.js";
import tournaments from "./tournament.route.js";
import users from "./users.route.js";
const router = Router();
@@ -15,5 +17,7 @@ router.use("/users", users);
router.use("/admin", admin);
router.use("/friends", friends);
router.use("/ai", ai);
router.use("/correspondence", correspondence);
router.use("/tournaments", tournaments);
export default router;

View File

@@ -0,0 +1,12 @@
import express from "express";
import { createTournament, getTournaments, getTournament, joinTournament, startTournament, cancelTournament, addBot } from "../controllers/tournament.controller.js";
const router = express.Router();
router.post("/", createTournament);
router.get("/", getTournaments);
router.get("/:code", getTournament);
router.post("/:code/join", joinTournament);
router.post("/:code/start", startTournament);
router.post("/:code/cancel", cancelTournament);
router.post("/:code/add-bot", addBot);
export default router;

View File

@@ -9,6 +9,7 @@ import { INIT_TABLES, db } from "./db/index.js";
import session from "./middleware/session.js";
import routes from "./routes/index.js";
import { init as initSocket } from "./socket/index.js";
import { BOTS } from "./bots.js";
const corsConfig = {
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
@@ -24,7 +25,7 @@ db.query(INIT_TABLES, async (err) => {
if (err) {
console.error(err);
} else {
console.log("Michess tables initialized");
console.log("MiChess tables initialized");
// Set admin role for ADMIN_EMAIL if configured
const adminEmail = process.env.ADMIN_EMAIL;
@@ -39,6 +40,14 @@ db.query(INIT_TABLES, async (err) => {
console.error("Failed to set admin role:", e);
}
}
// Create bot users if not exist
for (const bot of BOTS) {
await db.query(
`INSERT INTO "user" (name, role) VALUES ($1, 'bot') ON CONFLICT (name) DO NOTHING`,
[bot.name]
);
}
}
});
@@ -53,7 +62,7 @@ app.use("/v1", (req: Request, res: Response, next: NextFunction) => {
if (req.session.user?.id && typeof req.session.user.id === "number") {
const user = req.session.user as { banned?: boolean };
if (user.banned) {
req.session.destroy(() => {});
req.session.destroy((err) => { if (err) console.error(err); });
res.status(403).json({ message: "Your account has been banned." });
return;
}
@@ -81,5 +90,5 @@ initSocket();
const port = process.env.PORT || 3001;
server.listen(port, () => {
console.log(`Michess API server listening on :${port}`);
console.log(`MiChess API server listening on :${port}`);
});

View File

@@ -1,16 +1,139 @@
import type { Game } from "@chessu/types";
import type { Game } from "@michess/types";
import { Chess } from "chess.js";
import { nanoid } from "nanoid";
import type { DisconnectReason, Socket } from "socket.io";
import GameModel, { activeGames } from "../db/models/game.model.js";
import TournamentModel, { type NextRoundData } from "../db/models/tournament.model.js";
import { io } from "../server.js";
import { BOTS, BOT_NAMES } from "../bots.js";
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
async function startNextRoundGames(nextRound: NonNullable<NextRoundData>) {
for (const row of nextRound.rows) {
const code = nanoid(6);
const tc = nextRound.timeControl;
const whiteUser = { id: row.white_id, name: row.white_name, connected: false };
const blackUser = { id: row.black_id, name: row.black_name, connected: false };
const game: Game = {
code, unlisted: true, host: whiteUser,
white: whiteUser, black: blackUser, pgn: "",
startedAt: Date.now(),
timeControl: tc || undefined,
whiteTimeMs: tc ? tc * 60 * 1000 : undefined,
blackTimeMs: tc ? tc * 60 * 1000 : undefined,
tournamentCode: row.tournament_code,
};
activeGames.push(game);
await TournamentModel.setRoundGameCode(row.id, code);
// Auto-start bot vs bot games immediately
if (BOT_NAMES.has(whiteUser.name) && BOT_NAMES.has(blackUser.name)) {
game.white!.connected = true;
game.black!.connected = true;
if (tc) game.lastMoveAt = Date.now();
const chess = new Chess();
setTimeout(() => triggerBotMove(game, chess), 300);
}
}
}
// TODO: clean up
async function triggerBotMove(game: Game, chess: Chess) {
const turn = chess.turn();
const botPlayer = turn === "w" ? game.white : game.black;
const bot = BOTS.find(b => b.name === botPlayer?.name);
if (!bot || !game.code) return;
const sfLevel = AI_LEVELS.find(l => l.level === bot.level) ?? AI_LEVELS[2];
// Possibly play a random legal move instead of Stockfish's best (weakens lower levels)
let bestMove: string;
if (sfLevel.randomChance > 0 && Math.random() < sfLevel.randomChance) {
const moves = chess.moves({ verbose: true });
if (!moves.length) return;
const rand = moves[Math.floor(Math.random() * moves.length)];
bestMove = rand.from + rand.to + (rand.promotion ?? "");
} else {
bestMove = await getEngine().getBestMove(chess.fen(), sfLevel);
}
if (!bestMove) return;
// Check game still active
if (!activeGames.find(g => g.code === game.code)) return;
const from = bestMove.slice(0, 2);
const to = bestMove.slice(2, 4);
const promotion = bestMove[4] ?? "q";
// Time deduction for bot
if (game.timeControl && game.lastMoveAt !== undefined && game.whiteTimeMs !== undefined && game.blackTimeMs !== undefined) {
const elapsed = Date.now() - game.lastMoveAt;
if (turn === "w") game.whiteTimeMs = Math.max(0, game.whiteTimeMs - elapsed);
else game.blackTimeMs = Math.max(0, game.blackTimeMs - elapsed);
game.lastMoveAt = Date.now();
}
const result = chess.move({ from, to, promotion });
if (!result) return;
game.pgn = chess.pgn();
io.to(game.code).emit("receivedMove", { from, to, promotion });
if (game.timeControl) {
io.to(game.code).emit("clockUpdate", {
whiteTimeMs: game.whiteTimeMs,
blackTimeMs: game.blackTimeMs,
lastMoveAt: game.lastMoveAt
});
}
if (!chess.isGameOver()) {
// Bot vs Bot: recursively trigger next bot move
const nextPlayer = chess.turn() === "w" ? game.white : game.black;
if (BOT_NAMES.has(nextPlayer?.name ?? "")) {
setTimeout(() => triggerBotMove(game, chess), 300);
}
return;
}
const prevTurn = turn; // the bot was this turn
let reason: Game["endReason"];
if (chess.isCheckmate()) reason = "checkmate";
else if (chess.isStalemate()) reason = "stalemate";
else if (chess.isThreefoldRepetition()) reason = "repetition";
else if (chess.isInsufficientMaterial()) reason = "insufficient";
else reason = "draw";
const winnerSide = reason === "checkmate" ? (prevTurn === "w" ? "white" : "black") : undefined;
const winnerName = winnerSide === "white" ? game.white?.name : game.black?.name;
game.winner = reason === "checkmate" ? winnerSide : "draw";
game.endReason = reason;
const saved = (await GameModel.save(game)) as Game;
game.id = saved?.id;
const nextRound = await TournamentModel.updateRoundResult(game.code, game.winner as "white" | "black" | "draw");
io.to(game.code).emit("gameOver", { reason, winnerName, winnerSide, id: game.id });
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
// Create active games for the next tournament round if one was returned
if (nextRound) await startNextRoundGames(nextRound);
}
export async function joinLobby(this: Socket, gameCode: string) {
const game = activeGames.find((g) => g.code === gameCode);
if (!game) return;
// Leave any previous game BEFORE setting connection state on this game.
// Calling leaveLobby after setting connected=true would cause leaveLobby to
// find *this* game (via the connected flag) and set connected=false, breaking
// bot-trigger and clock-start logic for tournament games.
if (this.rooms.size >= 2) {
await leaveLobby.call(this);
}
if (game.host && game.host?.id === this.request.session.user.id) {
game.host.connected = true;
if (game.host.name !== this.request.session.user.name) {
@@ -38,10 +161,6 @@ export async function joinLobby(this: Socket, gameCode: string) {
game.observers?.push(user);
}
if (this.rooms.size >= 2) {
await leaveLobby.call(this);
}
if (game.timeout) {
clearTimeout(game.timeout);
game.timeout = undefined;
@@ -49,6 +168,31 @@ export async function joinLobby(this: Socket, gameCode: string) {
await this.join(gameCode);
io.to(game.code as string).emit("receivedLatestGame", game);
// Mark bots as connected so clock start and bot trigger logic see both players ready
const chess = new Chess();
if (game.pgn) chess.loadPgn(game.pgn);
if (BOT_NAMES.has(game.white?.name ?? "")) game.white!.connected = true;
if (BOT_NAMES.has(game.black?.name ?? "")) game.black!.connected = true;
// Start clock if both players are connected and game has timeControl but hasn't started timing yet
if (game.timeControl && game.white?.connected && game.black?.connected && !game.lastMoveAt && !game.pgn) {
game.lastMoveAt = Date.now();
io.to(game.code as string).emit("clockUpdate", {
whiteTimeMs: game.whiteTimeMs,
blackTimeMs: game.blackTimeMs,
lastMoveAt: game.lastMoveAt
});
}
if (game.white?.connected && game.black?.connected && !chess.isGameOver()) {
const nextTurn = chess.turn();
const nextPlayer = nextTurn === "w" ? game.white : game.black;
if (BOT_NAMES.has(nextPlayer?.name ?? "")) {
// Slight delay so client receives the current game state first
setTimeout(() => triggerBotMove(game, chess), 500);
}
}
}
export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) {
@@ -141,6 +285,8 @@ export async function claimAbandoned(this: Socket, type: "win" | "draw") {
const { id } = (await GameModel.save(game)) as Game;
game.id = id;
const nextRound1 = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
const gameOver = {
reason: game.endReason,
winnerName: this.request.session.user.name,
@@ -152,6 +298,7 @@ export async function claimAbandoned(this: Socket, type: "win" | "draw") {
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
if (nextRound1) await startNextRoundGames(nextRound1);
}
// eslint-disable-next-line no-unused-vars
@@ -178,11 +325,56 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
throw new Error("not turn to move");
}
// Time tracking
let timeExpired = false;
if (game.timeControl && game.lastMoveAt && game.whiteTimeMs !== undefined && game.blackTimeMs !== undefined) {
const elapsed = Date.now() - game.lastMoveAt;
if (prevTurn === "w") {
game.whiteTimeMs = Math.max(0, game.whiteTimeMs - elapsed);
if (game.whiteTimeMs <= 0) timeExpired = true;
} else {
game.blackTimeMs = Math.max(0, game.blackTimeMs - elapsed);
if (game.blackTimeMs <= 0) timeExpired = true;
}
game.lastMoveAt = Date.now();
}
if (timeExpired) {
game.winner = prevTurn === "w" ? "black" : "white";
game.endReason = "timeout";
const saved = (await GameModel.save(game)) as Game;
const id = saved?.id;
game.id = id;
const nextRoundT = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
if (nextRoundT) await startNextRoundGames(nextRoundT);
return;
}
const newMove = chess.move(m);
if (newMove) {
game.pgn = chess.pgn();
this.to(game.code as string).emit("receivedMove", m);
// Emit clock update after move
if (game.timeControl) {
io.to(game.code as string).emit("clockUpdate", {
whiteTimeMs: game.whiteTimeMs,
blackTimeMs: game.blackTimeMs,
lastMoveAt: game.lastMoveAt
});
}
// Trigger bot move if next player is a bot
if (!chess.isGameOver() && BOT_NAMES.has((chess.turn() === "w" ? game.white : game.black)?.name ?? "")) {
const chessCopy = new Chess();
chessCopy.loadPgn(chess.pgn());
setTimeout(() => triggerBotMove(game, chessCopy), 300);
}
if (chess.isGameOver()) {
let reason: Game["endReason"];
if (chess.isCheckmate()) reason = "checkmate";
@@ -208,10 +400,12 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
const { id } = (await GameModel.save(game)) as Game; // save game to db
game.id = id;
const nextRoundM = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide, id });
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
if (nextRoundM) await startNextRoundGames(nextRoundM);
}
} else {
throw new Error("invalid move");
@@ -240,6 +434,14 @@ export async function joinAsPlayer(this: Socket) {
side: "white"
});
game.startedAt = Date.now();
if (game.timeControl) {
game.lastMoveAt = Date.now();
io.to(game.code as string).emit("clockUpdate", {
whiteTimeMs: game.whiteTimeMs,
blackTimeMs: game.blackTimeMs,
lastMoveAt: game.lastMoveAt
});
}
} else if (!game.black) {
const sessionUser = {
id: this.request.session.user.id,
@@ -253,6 +455,14 @@ export async function joinAsPlayer(this: Socket) {
side: "black"
});
game.startedAt = Date.now();
if (game.timeControl) {
game.lastMoveAt = Date.now();
io.to(game.code as string).emit("clockUpdate", {
whiteTimeMs: game.whiteTimeMs,
blackTimeMs: game.blackTimeMs,
lastMoveAt: game.lastMoveAt
});
}
} else {
console.log("joinAsPlayer: attempted to join a game with already 2 players");
}
@@ -265,3 +475,30 @@ export async function chat(this: Socket, message: string) {
message
});
}
export async function claimTimeout(this: Socket) {
const game = activeGames.find(g => g.code === Array.from(this.rooms)[1]);
if (!game || !game.timeControl || !game.lastMoveAt || game.endReason || game.winner) return;
if (!game.white || !game.black) return;
const chess = new Chess();
if (game.pgn) chess.loadPgn(game.pgn);
const elapsed = Date.now() - game.lastMoveAt;
const turn = chess.turn(); // 'w' or 'b'
const currentTimeMs = turn === "w" ? (game.whiteTimeMs ?? 0) : (game.blackTimeMs ?? 0);
if (currentTimeMs - elapsed > 1000) return; // 1s tolerance
game.winner = turn === "w" ? "black" : "white";
game.endReason = "timeout";
const saved = (await GameModel.save(game)) as Game;
const id = saved?.id;
game.id = id;
const nextRoundCT = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
if (nextRoundCT) await startNextRoundGames(nextRoundCT);
}

View File

@@ -4,12 +4,14 @@ import { io } from "../server.js";
import {
chat,
claimAbandoned,
claimTimeout,
getLatestGame,
joinAsPlayer,
joinLobby,
leaveLobby,
sendMove
} from "./game.socket.js";
import { onlineUsers } from "./state.js";
const socketConnect = (socket: Socket) => {
const req = socket.request;
@@ -24,7 +26,28 @@ const socketConnect = (socket: Socket) => {
});
});
socket.on("disconnect", leaveLobby);
// Track online users and join personal notification room
const userId = req.session.user?.id;
if (userId && typeof userId === "number") {
onlineUsers.set(userId, socket.id);
socket.join(`user:${userId}`);
}
socket.on("disconnect", () => {
if (userId && typeof userId === "number") onlineUsers.delete(userId);
leaveLobby.call(socket);
});
// Forward game invites to target user's personal room
socket.on("sendGameInvite", ({ toId, gameCode }: { toId: number; gameCode: string }) => {
const fromUser = req.session.user;
if (!fromUser?.id || typeof fromUser.id === "string") return;
io.to(`user:${toId}`).emit("gameInviteReceived", {
fromId: fromUser.id,
fromName: fromUser.name,
gameCode
});
});
socket.on("joinLobby", joinLobby);
socket.on("leaveLobby", leaveLobby);
@@ -34,6 +57,7 @@ const socketConnect = (socket: Socket) => {
socket.on("joinAsPlayer", joinAsPlayer);
socket.on("chat", chat);
socket.on("claimAbandoned", claimAbandoned);
socket.on("claimTimeout", claimTimeout);
};
export const init = () => {

View File

@@ -0,0 +1,2 @@
// Shared socket state — imported by socket/index.ts and controllers to avoid circular deps
export const onlineUsers = new Map<number, string>();

9
server/src/types/stockfish.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module "stockfish" {
interface StockfishInstance {
postMessage(cmd: string): void;
onmessage: ((line: string | { data: string }) => void) | null;
terminate?: () => void;
}
function Stockfish(): StockfishInstance;
export default Stockfish;
}

View File

@@ -7,6 +7,8 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
"skipLibCheck": true,
"typeRoots": ["./src/types", "./node_modules/@types"]
},
"include": ["src/**/*"]
}

71
types/index.d.ts vendored
View File

@@ -4,7 +4,7 @@ export interface Game {
white?: User;
black?: User;
winner?: "white" | "black" | "draw";
endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned";
endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned" | "timeout" | "resign";
host?: User;
code?: string;
unlisted?: boolean;
@@ -14,6 +14,11 @@ export interface Game {
endedAt?: number;
vsAi?: boolean;
aiLevel?: number;
timeControl?: number;
whiteTimeMs?: number;
blackTimeMs?: number;
lastMoveAt?: number;
tournamentCode?: string;
}
export interface User {
@@ -23,6 +28,7 @@ export interface User {
wins?: number;
losses?: number;
draws?: number;
elo?: number;
role?: "user" | "admin";
// mainly for players, not spectators
@@ -50,3 +56,66 @@ export interface Friendship {
draws?: number;
createdAt?: string;
}
export interface CorrespondenceGame {
id?: number;
code?: string;
white?: { id?: number; name?: string };
black?: { id?: number; name?: string };
pgn?: string;
winner?: "white" | "black" | "draw";
endReason?: "checkmate" | "stalemate" | "repetition" | "insufficient" | "resign" | "timeout" | "draw";
daysPerMove?: number;
lastMoveAt?: number;
startedAt?: number;
endedAt?: number;
}
export interface Tournament {
id?: number;
code?: string;
name?: string;
hostId?: number;
hostName?: string;
status?: "waiting" | "active" | "finished";
timeControl?: number;
maxPlayers?: number;
currentRound?: number;
totalRounds?: number;
players?: TournamentPlayer[];
rounds?: TournamentRound[];
createdAt?: number;
}
export interface TournamentPlayer {
tournamentId?: number;
userId?: number;
userName?: string;
score?: number;
gamesPlayed?: number;
}
export type NotificationType = "friendRequest" | "friendAccepted" | "gameInvite";
export interface AppNotification {
id: string;
type: NotificationType;
fromId: number;
fromName: string;
gameCode?: string;
createdAt: number;
read: boolean;
}
export interface TournamentRound {
id?: number;
tournamentId?: number;
round?: number;
gameCode?: string;
gameId?: number;
whiteId?: number;
whiteName?: string;
blackId?: number;
blackName?: string;
result?: "white" | "black" | "draw" | null;
}

View File

@@ -1,5 +1,5 @@
{
"name": "@chessu/types",
"name": "@michess/types",
"private": "true",
"version": "0.0.0"
}