Compare commits
65 Commits
f909419f8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37fc530b74 | ||
|
|
77e30e268b | ||
|
|
85f50c5530 | ||
|
|
a21e2df6c4 | ||
|
|
f31e4e0800 | ||
|
|
5ac768d75f | ||
|
|
d47e87ef82 | ||
|
|
ca64163899 | ||
|
|
e8c1e3539d | ||
|
|
c9919479bf | ||
|
|
37e2495e3d | ||
|
|
5e996f791e | ||
|
|
6f96b26e4c | ||
|
|
d60899fdbf | ||
|
|
2707efe115 | ||
|
|
bbf26826c6 | ||
|
|
0b3228c9d7 | ||
|
|
86fbdd39ed | ||
|
|
536160bba6 | ||
|
|
157c14c11e | ||
|
|
b29c467766 | ||
|
|
50ef567a1a | ||
|
|
4825a57a54 | ||
|
|
1501b859b2 | ||
|
|
dc7f718117 | ||
|
|
aeb5fe313e | ||
|
|
09e5271dfb | ||
|
|
0cc5a8e99c | ||
|
|
f7b87f2941 | ||
|
|
4bc6ea58e6 | ||
|
|
157fcf8fa4 | ||
|
|
446fce247f | ||
|
|
39592bd579 | ||
|
|
ebbc8227fd | ||
|
|
6b676d3015 | ||
|
|
6d677348d3 | ||
|
|
3508c664e0 | ||
|
|
81f60c9846 | ||
|
|
8a6d7ddffb | ||
|
|
0e7b9c61e1 | ||
|
|
3a9681348f | ||
|
|
0edc3cc7fd | ||
|
|
dfdf508064 | ||
|
|
46baf29eff | ||
|
|
c1ea929753 | ||
|
|
b562839bd6 | ||
|
|
7b1f898b9e | ||
|
|
47d9464ad4 | ||
|
|
c056b93459 | ||
|
|
641cdc7aad | ||
|
|
e4c7aba178 | ||
|
|
694177c108 | ||
|
|
4ab8cd3834 | ||
|
|
b79a442b0c | ||
|
|
50464981c2 | ||
|
|
7dac042d73 | ||
|
|
29968f023d | ||
|
|
8fdbc660a5 | ||
|
|
2ac873c5b1 | ||
|
|
a13ff39fd5 | ||
|
|
b55c382983 | ||
|
|
bf1b2159a7 | ||
|
|
d5008a88fa | ||
|
|
72c14280f0 | ||
|
|
394644bc81 |
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +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
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# ============================================
|
||||||
|
# Michess – Konfigurationsdatei
|
||||||
|
# Kopiere diese Datei nach .env und passe sie an
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Datenbank
|
||||||
|
POSTGRES_DB=michess
|
||||||
|
POSTGRES_USER=michess
|
||||||
|
POSTGRES_PASSWORD=sicheres_passwort_hier
|
||||||
|
|
||||||
|
# Session (mindestens 32 zufällige Zeichen)
|
||||||
|
SESSION_SECRET=aendere_mich_in_produktion_mindestens_32_zeichen
|
||||||
|
|
||||||
|
# CORS: URL unter der das Frontend erreichbar ist
|
||||||
|
CORS_ORIGIN=http://deine-nas-ip:3000
|
||||||
|
|
||||||
|
# Admin: Diese E-Mail-Adresse bekommt automatisch Admin-Rechte
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Ports (optional, Standard: 3000 + 3001)
|
||||||
|
MICHESS_PORT=3000
|
||||||
|
MICHESS_API_PORT=3001
|
||||||
|
|
||||||
|
# App-URL (für Next.js Metadaten)
|
||||||
|
NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000
|
||||||
|
NEXT_PUBLIC_API_URL=http://deine-nas-ip:3001
|
||||||
|
|
||||||
|
# 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
|
||||||
64
.gitea/workflows/docker-build.yml
Normal file
64
.gitea/workflows/docker-build.yml
Normal 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
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ yarn.lock
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
|
.pnpm-store
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
.next
|
.next
|
||||||
|
|||||||
247
Brain.md
Normal file
247
Brain.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Keycloak SSO Integration & UX Overhaul
|
||||||
|
|
||||||
|
### 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`.
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
## Commit-Stil
|
||||||
```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.
|
|
||||||
|
|
||||||
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`.
|
Prefixe: `feat:`, `fix:`, `ui:`, `admin:`, `docker:`, `docs:`
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
FROM node:lts-bookworm-slim AS build
|
||||||
|
|
||||||
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
|
|
||||||
|
WORKDIR /opt/michess/
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
CMD ["node", "scripts/start-production.mjs"]
|
||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
189
Plan.md
Normal file
189
Plan.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Plan.md — MiChess Entwicklungsplan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
**MiChess** ist eine selbst-gehostete Schachplattform für eine private Community, inspiriert von Lichess.org.
|
||||||
|
Läuft als Docker-Container auf einer Synology/QNAP NAS, komplett ohne Cloud-Abhängigkeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundation (Basis)
|
||||||
|
|
||||||
|
### 1.1 Projekt-Setup
|
||||||
|
- [x] chessu (MIT) als Basis klonen
|
||||||
|
- [x] Brain.md & Plan.md erstellen
|
||||||
|
- [ ] Git remote auf `https://git.mischlabs.de/MrDiderot/MiChess.git` setzen
|
||||||
|
- [ ] Initaler Push (chessu-Basis + eigene Dateien)
|
||||||
|
|
||||||
|
### 1.2 Rebranding chessu → MiChess
|
||||||
|
- [ ] `package.json` Namen anpassen
|
||||||
|
- [ ] README durch MiChess-README ersetzen
|
||||||
|
- [ ] Alle Titel/Meta-Tags im Frontend ändern
|
||||||
|
- [ ] Logo-Platzhalter anpassen
|
||||||
|
|
||||||
|
### 1.3 Docker NAS-Deployment
|
||||||
|
- [ ] `docker-compose.yml` überarbeiten (michess-app, postgres, eigene Secrets)
|
||||||
|
- [ ] `.env.example` erstellen mit allen benötigten Variablen
|
||||||
|
- [ ] Stockfish in Docker-Image einbauen (`apk add stockfish`)
|
||||||
|
- [ ] Update-Script: `scripts/update.sh` (git pull + docker restart)
|
||||||
|
- [ ] Deployment-README schreiben (einziger Befehl für NAS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Datenbank-Erweiterungen
|
||||||
|
|
||||||
|
### 2.1 Schema-Änderungen
|
||||||
|
```sql
|
||||||
|
-- User: Admin-Rolle hinzufügen
|
||||||
|
ALTER TABLE "user" ADD COLUMN role VARCHAR(16) DEFAULT 'user';
|
||||||
|
|
||||||
|
-- Freundschaftsanfragen
|
||||||
|
CREATE TABLE IF NOT EXISTS "friend_request" (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
from_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
to_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(16) DEFAULT 'pending', -- pending | accepted | rejected
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(from_id, to_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Freunde (bidirektional, normalisiert als kleinere_id + größere_id)
|
||||||
|
CREATE TABLE IF NOT EXISTS "friendship" (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id_1 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
user_id_2 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id_1, user_id_2)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Stockfish AI-Integration
|
||||||
|
|
||||||
|
### 3.1 Backend
|
||||||
|
- [ ] `stockfish` npm-Paket als Server-Dependency
|
||||||
|
- [ ] `server/src/controllers/stockfish.controller.ts` — Stockfish-Prozess-Manager
|
||||||
|
- [ ] Schwierigkeitsgrade:
|
||||||
|
| Level | Name | Stockfish Skill | Depth |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Anfänger | 0 | 1 |
|
||||||
|
| 2 | Leicht | 3 | 3 |
|
||||||
|
| 3 | Mittel | 8 | 5 |
|
||||||
|
| 4 | Fortgeschritten | 14 | 10 |
|
||||||
|
| 5 | Experte | 18 | 15 |
|
||||||
|
| 6 | Meister | 20 | 20 |
|
||||||
|
- [ ] Socket-Events: `ai:move`, `ai:game_start`, `ai:game_end`
|
||||||
|
|
||||||
|
### 3.2 Frontend
|
||||||
|
- [ ] "Gegen KI spielen" Button auf Startseite
|
||||||
|
- [ ] Schwierigkeitsgrad-Auswahl Modal
|
||||||
|
- [ ] KI-Spielpartie-UI (keine zweite Socket-Session nötig, nur lokal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Admin-Panel
|
||||||
|
|
||||||
|
### 4.1 Backend
|
||||||
|
- [ ] `isAdmin` Middleware für `/v1/admin/*`
|
||||||
|
- [ ] `GET /v1/admin/users` — alle Nutzer auflisten (paginiert)
|
||||||
|
- [ ] `PATCH /v1/admin/users/:id` — Nutzer sperren/entsperren, Rolle ändern
|
||||||
|
- [ ] `DELETE /v1/admin/users/:id` — Nutzer löschen
|
||||||
|
- [ ] `POST /v1/admin/update` — git pull + trigger restart
|
||||||
|
- [ ] `GET /v1/admin/stats` — Nutzerzahl, Spiele, etc.
|
||||||
|
|
||||||
|
### 4.2 Frontend
|
||||||
|
- [ ] `/admin` Route (nur für Admins sichtbar)
|
||||||
|
- [ ] Dashboard mit Statistiken
|
||||||
|
- [ ] Nutzertabelle mit Such-/Filterfunktion
|
||||||
|
- [ ] "Website aktualisieren" Button mit Bestätigung
|
||||||
|
- [ ] Nutzer sperren/entsperren/löschen
|
||||||
|
|
||||||
|
### 4.3 Erster Admin
|
||||||
|
- [ ] `ADMIN_EMAIL` env-Variable
|
||||||
|
- [ ] Beim Server-Start: wenn User mit dieser E-Mail existiert → Role = 'admin'
|
||||||
|
- [ ] Alternativ: `ADMIN_SETUP_TOKEN` für initiale Admin-Einrichtung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Freundessystem
|
||||||
|
|
||||||
|
### 5.1 Backend
|
||||||
|
- [ ] `POST /v1/friends/request` — Anfrage senden (via Username)
|
||||||
|
- [ ] `GET /v1/friends/requests` — eigene Anfragen (eingehend + ausgehend)
|
||||||
|
- [ ] `PATCH /v1/friends/requests/:id` — annehmen/ablehnen
|
||||||
|
- [ ] `GET /v1/friends` — eigene Freundesliste
|
||||||
|
- [ ] `DELETE /v1/friends/:id` — Freundschaft beenden
|
||||||
|
- [ ] `GET /v1/users/search?q=` — Nutzer suchen
|
||||||
|
|
||||||
|
### 5.2 Frontend
|
||||||
|
- [ ] Freundesliste im User-Profil
|
||||||
|
- [ ] "Freund hinzufügen" Button auf fremden Profilseiten
|
||||||
|
- [ ] Benachrichtigungs-Badge bei ausstehenden Anfragen
|
||||||
|
- [ ] "Freund herausfordern" → direktes Spiel erstellen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Registrierungs-Flow
|
||||||
|
|
||||||
|
### Aktueller chessu-Flow:
|
||||||
|
`name + email + password` → Account erstellt
|
||||||
|
|
||||||
|
### Gewünschter MiChess-Flow:
|
||||||
|
1. `email + password` → Account erstellt (noch kein Nutzername)
|
||||||
|
2. Weitergeleitet zu "Nutzername wählen" (einmalig, nicht änderbar)
|
||||||
|
3. Nutzername prüfen: verfügbar? gültig? → bestätigen
|
||||||
|
4. Account aktiv
|
||||||
|
|
||||||
|
- [ ] `name` Feld nullable machen in DB (bis Schritt 2)
|
||||||
|
- [ ] `POST /v1/auth/choose-username` Endpoint
|
||||||
|
- [ ] "Username wählen"-Seite im Frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Sonstige Verbesserungen
|
||||||
|
|
||||||
|
- [ ] Zeitkontrolle für Partien (Bullet/Blitz/Rapid/Classical)
|
||||||
|
- [ ] Rating-System (Elo) für Nutzer-vs-Nutzer
|
||||||
|
- [ ] Rematch-Funktion
|
||||||
|
- [ ] Spectator-Verbesserungen (Live-Zuschauer)
|
||||||
|
- [ ] Mobile-Optimierung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment-Anleitung (Ziel-Zustand)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Einmalige Erstinstallation auf der NAS:
|
||||||
|
git clone https://git.mischlabs.de/MrDiderot/MiChess.git michess
|
||||||
|
cd michess
|
||||||
|
cp .env.example .env
|
||||||
|
# .env anpassen (Passwörter, Admin-Email, etc.)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Update (manuell oder per Admin-Panel):
|
||||||
|
./scripts/update.sh
|
||||||
|
# oder via Admin-Panel "Website aktualisieren" Button
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git-Strategie
|
||||||
|
|
||||||
|
- `main` — Production-Branch (immer deploybar)
|
||||||
|
- Features werden direkt auf main entwickelt (kleines Team)
|
||||||
|
- Issues aus `git.mischlabs.de` als Feature-Tracker
|
||||||
|
- Commit-Messages: `feat:`, `fix:`, `admin:`, `docker:`, `ui:` Prefixe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioritäten (Reihenfolge)
|
||||||
|
|
||||||
|
1. **P0**: Foundation + Docker + Git (läuft auf NAS)
|
||||||
|
2. **P0**: Stockfish AI (Kernfeature)
|
||||||
|
3. **P1**: Admin-Panel (Verwaltbarkeit)
|
||||||
|
4. **P1**: Freundessystem (Social)
|
||||||
|
5. **P2**: Registrierungs-Flow verfeinern
|
||||||
|
6. **P3**: Zeitkontrolle + Rating
|
||||||
92
README.md
92
README.md
@@ -1,63 +1,59 @@
|
|||||||
Notes from the maintainer:
|
# MiChess
|
||||||
|
|
||||||
> I started this project when I was still new to TS and full-stack development. Bad practices, confusing patterns, and messed up types are everywhere. Suggestions for improvements are very much welcome!
|
Eine selbst-gehostete Schachplattform, inspiriert von Lichess.org.
|
||||||
|
Läuft als Docker-Container auf einer NAS — lokal, privat, ohne Cloud!
|
||||||
|
|
||||||
> The client is currently being rewritten at the `frontend-refactor` branch ([#24](https://github.com/dotnize/chessu/pull/24)). Contributions to `main` that involve the frontend may get overwritten, but I'll try to cherry-pick those that can be applied to the new branch.
|
> Basiert auf [chessu](https://github.com/dotnize/chessu) (MIT) von dotnize — vielen Dank für die solide Basis.
|
||||||
|
|
||||||
<h1 align="center">
|
## Features
|
||||||
<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>
|
|
||||||
|
|
||||||
<p align="center">Yet another Chess web app. Live demo at <a href="https://ches.su">ches.su</a>.</p>
|
- 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
|
||||||
|
|
||||||
<p align="center">
|
## Tech Stack
|
||||||
<img src="./assets/demo.jpg" alt="chessu" width="640" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
- play against other users in real-time
|
Next.js 14 · Tailwind CSS · daisyUI · react-chessboard · chess.js · Express.js · Socket.io · PostgreSQL · TypeScript · Docker
|
||||||
- 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))
|
|
||||||
|
|
||||||
Built with Next.js 14, Tailwind CSS + daisyUI, react-chessboard, chess.js, Express.js, socket.io and PostgreSQL.
|
## Deployment (NAS / Docker)
|
||||||
|
|
||||||
## Development
|
```bash
|
||||||
|
# 1. Repository klonen
|
||||||
|
git clone https://git.mischlabs.de/MrDiderot/MiChess.git michess
|
||||||
|
cd michess
|
||||||
|
|
||||||
> Node.js 18 or newer is recommended.
|
# 2. Konfiguration anpassen
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Passwörter, ADMIN_EMAIL etc. setzen
|
||||||
|
|
||||||
This project is structured as a monorepo using **pnpm** workspaces, separated into three packages:
|
# 3. Starten
|
||||||
|
docker compose up -d --build
|
||||||
- `client` - Next.js application for the front-end, deployed to [ches.su](https://ches.su) via Vercel.
|
|
||||||
- `server` - Node/Express.js application for the back-end, deployed to [server.ches.su](https://server.ches.su) via Railway.
|
|
||||||
- `types` - Shared type definitions required by the client and server.
|
|
||||||
|
|
||||||
### Getting started
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Contributing
|
Frontend: `http://deine-nas-ip:3000`
|
||||||
|
Backend API: `http://deine-nas-ip:3001`
|
||||||
|
|
||||||
Please read our [Contributing Guidelines](./CONTRIBUTING.md) before starting a pull request.
|
## Update
|
||||||
|
|
||||||
## License
|
```bash
|
||||||
|
# Manuell via Script
|
||||||
|
./scripts/update.sh
|
||||||
|
|
||||||
[MIT](./LICENSE)
|
# Oder im Admin-Panel → "Jetzt aktualisieren"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entwicklung (lokal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
[MIT](./LICENSE) · Copyright © 2024-present Tom Misch
|
||||||
|
|||||||
127
brain.old
Normal file
127
brain.old
Normal 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
|
||||||
@@ -5,5 +5,8 @@
|
|||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": ["error", { "args": "none" }]
|
||||||
|
},
|
||||||
"root": true
|
"root": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "@chessu/client",
|
"name": "@michess/client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"isolated-install": "pnpm -w install:client",
|
"isolated-install": "pnpm -w install:client",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -p 3000",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tabler/icons-react": "^2.41.0",
|
"@tabler/icons-react": "^2.47.0",
|
||||||
"chess.js": "1.0.0-beta.6",
|
"chess.js": "1.0.0-beta.8",
|
||||||
"next": "^14.0.3",
|
"next": "^14.2.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-chessboard": "^2.1.3",
|
"react-chessboard": "^2.1.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"socket.io-client": "^4.7.2"
|
"socket.io-client": "^4.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chessu/types": "*",
|
"@michess/types": "workspace:*",
|
||||||
"@types/node": "^18.18.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.3.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.19",
|
||||||
"daisyui": "^2.52.0",
|
"daisyui": "^2.52.0",
|
||||||
"eslint-config-next": "^14.0.3",
|
"eslint-config-next": "^14.2.5",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.3.5",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export async function generateMetadata({ params }: { params: { code: string } })
|
|||||||
return {
|
return {
|
||||||
description: `Play or watch a game with ${game.host?.name}`,
|
description: `Play or watch a game with ${game.host?.name}`,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "chessu",
|
title: "MiChess",
|
||||||
description: `Play or watch a game with ${game.host?.name}`,
|
description: `Play or watch a game with ${game.host?.name}`,
|
||||||
url: `https://ches.su/${game.code}`,
|
url: `https://ches.su/${game.code}`,
|
||||||
siteName: "chessu",
|
siteName: "MiChess",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
type: "website"
|
type: "website"
|
||||||
},
|
},
|
||||||
|
|||||||
198
client/src/app/admin/page.tsx
Normal file
198
client/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { API_URL } from "@/config";
|
||||||
|
import { useSession } from "@/context/session";
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
draws: number;
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalUsers: number;
|
||||||
|
totalGames: number;
|
||||||
|
newUsersThisWeek: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { user } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [updateLog, setUpdateLog] = useState("");
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user === null) router.push("/");
|
||||||
|
if (user && user.role !== "admin") router.push("/");
|
||||||
|
}, [user, router]);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
const res = await fetch(`${API_URL}/v1/admin/stats`, { credentials: "include" });
|
||||||
|
if (res.ok) setStats(await res.json());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
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}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ banned: !banned })
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAdmin = async (id: number, role: string) => {
|
||||||
|
const newRole = role === "admin" ? "user" : "admin";
|
||||||
|
await fetch(`${API_URL}/v1/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ role: newRole })
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (id: number, name: string) => {
|
||||||
|
if (!confirm(`Nutzer "${name}" wirklich löschen?`)) return;
|
||||||
|
await fetch(`${API_URL}/v1/admin/users/${id}`, { method: "DELETE", credentials: "include" });
|
||||||
|
fetchUsers();
|
||||||
|
fetchStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerUpdate = async () => {
|
||||||
|
if (!confirm("Website jetzt aus Git aktualisieren?")) return;
|
||||||
|
setUpdating(true);
|
||||||
|
setUpdateLog("Update wird ausgeführt...");
|
||||||
|
const res = await fetch(`${API_URL}/v1/admin/update`, { method: "POST", credentials: "include" });
|
||||||
|
const data = await res.json();
|
||||||
|
setUpdateLog(data.output || data.message || "Fertig.");
|
||||||
|
setUpdating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user || user.role !== "admin") {
|
||||||
|
return <div className="flex items-center justify-center w-full py-20 text-xl">Kein Zugriff.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl py-8 px-4 flex flex-col gap-8">
|
||||||
|
<h1 className="text-3xl font-bold">Admin-Panel</h1>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="stats stats-horizontal shadow w-full">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-title">Nutzer gesamt</div>
|
||||||
|
<div className="stat-value">{stats.totalUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-title">Spiele gesamt</div>
|
||||||
|
<div className="stat-value">{stats.totalGames}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-title">Neue Nutzer (7 Tage)</div>
|
||||||
|
<div className="stat-value">{stats.newUsersThisWeek}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card bg-base-200 shadow p-6 flex flex-col gap-3">
|
||||||
|
<h2 className="text-xl font-semibold">Website aktualisieren</h2>
|
||||||
|
<p className="text-sm opacity-70">Führt <code>git pull</code> im App-Verzeichnis aus. Container muss danach neugestartet werden.</p>
|
||||||
|
<button className="btn btn-primary w-fit" onClick={triggerUpdate} disabled={updating}>
|
||||||
|
{updating ? <span className="loading loading-spinner loading-sm" /> : null}
|
||||||
|
Jetzt aktualisieren
|
||||||
|
</button>
|
||||||
|
{updateLog && (
|
||||||
|
<pre className="bg-base-300 rounded p-3 text-xs overflow-auto max-h-40">{updateLog}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card bg-base-200 shadow p-6 flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">Nutzerverwaltung</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nutzer suchen..."
|
||||||
|
className="input input-bordered w-full max-w-sm"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th><th>Name</th><th>E-Mail</th><th>W/L/D</th><th>Rolle</th><th>Status</th><th>Registriert</th><th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className={u.banned ? "opacity-50" : ""}>
|
||||||
|
<td>{u.id}</td>
|
||||||
|
<td className="font-medium">{u.name}</td>
|
||||||
|
<td>{u.email}</td>
|
||||||
|
<td>{u.wins}/{u.losses}/{u.draws}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge badge-sm ${u.role === "admin" ? "badge-warning" : "badge-ghost"}`}>{u.role}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge badge-sm ${u.banned ? "badge-error" : "badge-success"}`}>
|
||||||
|
{u.banned ? "Gesperrt" : "Aktiv"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{new Date(u.created_at).toLocaleDateString("de-DE")}</td>
|
||||||
|
<td className="flex gap-1 flex-wrap">
|
||||||
|
<button className="btn btn-xs btn-outline" onClick={() => toggleBan(u.id, u.banned)}>
|
||||||
|
{u.banned ? "Entsperren" : "Sperren"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-xs btn-outline btn-warning" onClick={() => toggleAdmin(u.id, u.role)} disabled={u.id === (user.id as number)}>
|
||||||
|
{u.role === "admin" ? "Admin entfernen" : "Admin machen"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-xs btn-outline btn-error" onClick={() => deleteUser(u.id, u.name)} disabled={u.id === (user.id as number)}>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button className="btn btn-sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>← Zurück</button>
|
||||||
|
<span className="text-sm">Seite {page + 1} — {total} Nutzer gesamt</span>
|
||||||
|
<button className="btn btn-sm" disabled={(page + 1) * limit >= total} onClick={() => setPage(p => p + 1)}>Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
client/src/app/ai/page.tsx
Normal file
230
client/src/app/ai/page.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import { Chessboard } from "react-chessboard";
|
||||||
|
import { Chess } from "chess.js";
|
||||||
|
import { API_URL } from "@/config";
|
||||||
|
import { useSession } from "@/context/session";
|
||||||
|
import { BOTS } from "@/bots";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export default function AiGamePage() {
|
||||||
|
const { user } = useSession();
|
||||||
|
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.";
|
||||||
|
if (g.isDraw()) return "Unentschieden!";
|
||||||
|
if (g.isCheck()) return g.turn() === "w" ? "Weiß ist im Schach!" : "Schwarz ist im Schach!";
|
||||||
|
return g.turn() === "w" ? "Weiß am Zug" : "Schwarz am Zug";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const 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`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fen, level })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const { move } = await res.json();
|
||||||
|
if (move) {
|
||||||
|
setGame((prev) => {
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AI move error:", e);
|
||||||
|
}
|
||||||
|
setThinking(false);
|
||||||
|
}, [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;
|
||||||
|
|
||||||
|
// 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" });
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!move) return false;
|
||||||
|
|
||||||
|
setLastMove({ from: move.from, to: move.to });
|
||||||
|
setGame(newGame);
|
||||||
|
setStatus(getStatus(newGame));
|
||||||
|
|
||||||
|
if (newGame.isGameOver()) {
|
||||||
|
saveGame(newGame, playerColor, selectedLevel);
|
||||||
|
} else {
|
||||||
|
requestAiMove(newGame.fen(), selectedLevel, playerColor);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [game, playerColor, thinking, selectedLevel, getStatus, requestAiMove, saveGame]);
|
||||||
|
|
||||||
|
const startGame = (color: "white" | "black") => {
|
||||||
|
const newGame = new Chess();
|
||||||
|
setGame(newGame);
|
||||||
|
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, color);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGame = () => {
|
||||||
|
setGameStarted(false);
|
||||||
|
setGame(new Chess());
|
||||||
|
setLastMove(null);
|
||||||
|
setSavedGameId(null);
|
||||||
|
gameSavedRef.current = false;
|
||||||
|
setStatus("");
|
||||||
|
};
|
||||||
|
|
||||||
|
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)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gameStarted) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8 py-10 w-full max-w-xl px-4">
|
||||||
|
<h1 className="text-3xl font-bold">Gegen KI spielen</h1>
|
||||||
|
|
||||||
|
<div className="card bg-base-200 shadow p-6 w-full flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Gegner wählen</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{BOTS.map((b) => (
|
||||||
|
<button
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Farbe wählen</h2>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button className="btn btn-outline flex-1" onClick={() => startGame("white")}>
|
||||||
|
♔ Als Weiß spielen
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline flex-1" onClick={() => startGame("black")}>
|
||||||
|
♚ Als Schwarz spielen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6 py-6 w-full max-w-5xl px-4 items-start justify-center">
|
||||||
|
<div className="w-full max-w-[560px]">
|
||||||
|
<Chessboard
|
||||||
|
position={game.fen()}
|
||||||
|
onPieceDrop={onDrop}
|
||||||
|
boardOrientation={playerColor}
|
||||||
|
customSquareStyles={customSquareStyles}
|
||||||
|
arePiecesDraggable={!game.isGameOver() && !thinking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 min-w-[200px]">
|
||||||
|
<div className="card bg-base-200 shadow p-4">
|
||||||
|
<p className="font-semibold text-sm opacity-70">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>
|
||||||
|
|
||||||
|
<div className="card bg-base-200 shadow p-3 max-h-64 overflow-y-auto">
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Züge</h3>
|
||||||
|
<div className="text-xs font-mono">
|
||||||
|
{game.history().map((move, i) => (
|
||||||
|
<span key={i}>{i % 2 === 0 ? `${Math.floor(i / 2) + 1}. ` : ""}{move} </span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import ArchivedGame from "@/components/archive/ArchivedGame";
|
import ArchivedGame from "@/components/archive/ArchivedGame";
|
||||||
import { fetchArchivedGame } from "@/lib/game";
|
import { fetchArchivedGame } from "@/lib/game";
|
||||||
import type { Game } from "@chessu/types";
|
import type { Game } from "@michess/types";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: { id: number } }) {
|
export async function generateMetadata({ params }: { params: { id: number } }) {
|
||||||
@@ -19,10 +19,10 @@ export async function generateMetadata({ params }: { params: { id: number } }) {
|
|||||||
return {
|
return {
|
||||||
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
|
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "chessu",
|
title: "MiChess",
|
||||||
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
|
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
|
||||||
url: `https://ches.su/archive/${game.id}`,
|
url: `https://ches.su/archive/${game.id}`,
|
||||||
siteName: "chessu",
|
siteName: "MiChess",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
type: "website"
|
type: "website"
|
||||||
},
|
},
|
||||||
|
|||||||
251
client/src/app/correspondence/[code]/page.tsx
Normal file
251
client/src/app/correspondence/[code]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
client/src/app/correspondence/page.tsx
Normal file
118
client/src/app/correspondence/page.tsx
Normal 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
17
client/src/app/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
client/src/app/friends/page.tsx
Normal file
189
client/src/app/friends/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
id: number;
|
||||||
|
friend_id: number;
|
||||||
|
friend_name: string;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
draws: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FriendRequest {
|
||||||
|
id: number;
|
||||||
|
from_id: number;
|
||||||
|
to_id: number;
|
||||||
|
from_name?: string;
|
||||||
|
to_name?: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FriendsPage() {
|
||||||
|
const { user } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const { 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("/");
|
||||||
|
if (user && user.id) {
|
||||||
|
fetchFriends();
|
||||||
|
fetchRequests();
|
||||||
|
}
|
||||||
|
}, [user, router]);
|
||||||
|
|
||||||
|
const fetchFriends = async () => {
|
||||||
|
const res = await fetch(`${API_URL}/v1/friends`, { credentials: "include" });
|
||||||
|
if (res.ok) setFriends(await res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
const res = await fetch(`${API_URL}/v1/friends/requests`, { credentials: "include" });
|
||||||
|
if (res.ok) setRequests(await res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendRequest = async () => {
|
||||||
|
setAddMsg("");
|
||||||
|
const res = await fetch(`${API_URL}/v1/friends/request`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: addUsername })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setAddMsg(res.ok ? `Anfrage an "${data.toName}" gesendet!` : data.message || "Fehler.");
|
||||||
|
if (res.ok) { setAddUsername(""); fetchRequests(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const respond = async (id: number, action: "accept" | "reject") => {
|
||||||
|
await fetch(`${API_URL}/v1/friends/requests/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action })
|
||||||
|
});
|
||||||
|
fetchFriends();
|
||||||
|
fetchRequests();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFriend = async (friendId: number, name: string) => {
|
||||||
|
if (!confirm(`"${name}" als Freund entfernen?`)) return;
|
||||||
|
await fetch(`${API_URL}/v1/friends/${friendId}`, { method: "DELETE", credentials: "include" });
|
||||||
|
fetchFriends();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user?.id || typeof user.id === "string") {
|
||||||
|
return <div className="flex items-center justify-center w-full py-20">Bitte einloggen.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-3xl py-8 px-4 flex flex-col gap-8">
|
||||||
|
<h1 className="text-3xl font-bold">Freunde</h1>
|
||||||
|
|
||||||
|
{/* Add friend */}
|
||||||
|
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Freund hinzufügen</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nutzername"
|
||||||
|
className="input input-bordered flex-1"
|
||||||
|
value={addUsername}
|
||||||
|
onChange={(e) => setAddUsername(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && sendRequest()}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={sendRequest} disabled={!addUsername.trim()}>
|
||||||
|
Anfrage senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{addMsg && <p className="text-sm">{addMsg}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incoming requests */}
|
||||||
|
{requests.incoming.length > 0 && (
|
||||||
|
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Eingehende Anfragen ({requests.incoming.length})</h2>
|
||||||
|
{requests.incoming.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between gap-2">
|
||||||
|
<Link href={`/user/${r.from_name}`} className="font-medium link link-hover">
|
||||||
|
{r.from_name}
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn btn-sm btn-success" onClick={() => respond(r.id, "accept")}>Annehmen</button>
|
||||||
|
<button className="btn btn-sm btn-outline btn-error" onClick={() => respond(r.id, "reject")}>Ablehnen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Outgoing requests */}
|
||||||
|
{requests.outgoing.length > 0 && (
|
||||||
|
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Gesendete Anfragen</h2>
|
||||||
|
{requests.outgoing.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center gap-2 opacity-70">
|
||||||
|
<span>→</span>
|
||||||
|
<Link href={`/user/${r.to_name}`} className="link link-hover">{r.to_name}</Link>
|
||||||
|
<span className="badge badge-sm">ausstehend</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Friends list */}
|
||||||
|
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Meine Freunde ({friends.length})</h2>
|
||||||
|
{friends.length === 0 && <p className="opacity-60">Noch keine Freunde. Füge jemanden hinzu!</p>}
|
||||||
|
{friends.map((f) => (
|
||||||
|
<div key={f.id} className="flex 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>
|
||||||
|
<span className="text-sm opacity-60">{f.wins}W / {f.losses}L / {f.draws}D</span>
|
||||||
|
<button className="btn btn-xs btn-outline btn-error" onClick={() => removeFriend(f.friend_id, f.friend_name)}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,23 +4,22 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import AuthModal from "@/components/auth/AuthModal";
|
|
||||||
|
|
||||||
import ContextProvider from "@/context/ContextProvider";
|
import ContextProvider from "@/context/ContextProvider";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "chessu",
|
title: "MiChess",
|
||||||
description: "Play Chess online.",
|
description: "Schach spielen – lokal, privat, kostenlos.",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "chessu",
|
title: "MiChess",
|
||||||
description: "Play Chess online.",
|
description: "Schach spielen – lokal, privat, kostenlos.",
|
||||||
url: "https://ches.su",
|
siteName: "MiChess",
|
||||||
siteName: "chessu",
|
locale: "de_DE",
|
||||||
locale: "en_US",
|
|
||||||
type: "website"
|
type: "website"
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: false,
|
||||||
follow: false,
|
follow: false,
|
||||||
nocache: true,
|
nocache: true,
|
||||||
noarchive: true
|
noarchive: true
|
||||||
@@ -33,12 +32,12 @@ export const metadata = {
|
|||||||
apple: { url: "/apple-touch-icon.png", sizes: "180x180" }
|
apple: { url: "/apple-touch-icon.png", sizes: "180x180" }
|
||||||
},
|
},
|
||||||
manifest: "/site.webmanifest",
|
manifest: "/site.webmanifest",
|
||||||
metadataBase: new URL(process.env.VERCEL ? "https://ches.su" : "http://localhost:3000")
|
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000")
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="overflow-x-hidden">
|
<html lang="de" className="overflow-x-hidden">
|
||||||
<body className="overflow-x-hidden">
|
<body className="overflow-x-hidden">
|
||||||
<ContextProvider>
|
<ContextProvider>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -47,19 +46,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AuthModal />
|
|
||||||
</ContextProvider>
|
</ContextProvider>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{/* next/script issue: https://github.com/vercel/next.js/issues/43402 */}
|
|
||||||
<script
|
<script
|
||||||
id="load-theme"
|
id="load-theme"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
__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 {
|
} else {
|
||||||
document.documentElement.setAttribute("data-theme", "chessuLight");
|
document.documentElement.setAttribute("data-theme", "michessLight");
|
||||||
}`
|
}`
|
||||||
}}
|
}}
|
||||||
></script>
|
></script>
|
||||||
|
|||||||
@@ -1,27 +1,47 @@
|
|||||||
import CreateGame from "@/components/home/CreateGame";
|
"use client";
|
||||||
import JoinGame from "@/components/home/JoinGame";
|
|
||||||
import PublicGames from "@/components/home/PublicGames/PublicGames";
|
|
||||||
|
|
||||||
export const revalidate = 0;
|
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 default function Home() {
|
export default function Home() {
|
||||||
|
const { user } = useSession();
|
||||||
|
const isLoggedIn = user?.id && typeof user.id === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-wrap items-center 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 />
|
<PublicGames />
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<h2 className="mb-4 text-xl font-bold leading-tight">Join from invite</h2>
|
|
||||||
<JoinGame />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divider divider-vertical">or</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<h2 className="mb-4 text-xl font-bold leading-tight">Create game</h2>
|
|
||||||
<CreateGame />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Letzte Spiele des eingeloggten Users */}
|
||||||
|
{isLoggedIn && user.id && typeof user.id === "number" && (
|
||||||
|
<RecentGames userId={user.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Laufende Partien – Live-Kacheln */}
|
||||||
|
<LiveGames />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
81
client/src/app/play/page.tsx
Normal file
81
client/src/app/play/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
client/src/app/tournament/[code]/page.tsx
Normal file
267
client/src/app/tournament/[code]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
client/src/app/tournament/create/page.tsx
Normal file
107
client/src/app/tournament/create/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
client/src/app/tournament/page.tsx
Normal file
97
client/src/app/tournament/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,13 +16,13 @@ export async function generateMetadata({ params }: { params: { name: string } })
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: `${data.name} | chessu`,
|
title: `${data.name} | MiChess`,
|
||||||
description: `${data.name}'s profile`,
|
description: `${data.name}'s profile`,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${data.name} | chessu`,
|
title: `${data.name} | MiChess`,
|
||||||
description: `${data.name}'s profile on chessu`,
|
description: `${data.name}'s profile on MiChess`,
|
||||||
url: `https://ches.su/user/${data.name}`,
|
url: `https://ches.su/user/${data.name}`,
|
||||||
siteName: "chessu",
|
siteName: "MiChess",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
type: "website"
|
type: "website"
|
||||||
},
|
},
|
||||||
@@ -43,7 +43,12 @@ export default async function Profile({ params }: { params: { name: string } })
|
|||||||
return (
|
return (
|
||||||
<div className="mt-8 flex w-full flex-col gap-8">
|
<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 className="flex flex-wrap items-center justify-between gap-4 md:gap-8">
|
||||||
|
<div>
|
||||||
<h1 className="text-4xl font-bold">{data.name}</h1>
|
<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 gap-6">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-sm">Wins</span>
|
<span className="text-sm">Wins</span>
|
||||||
@@ -62,7 +67,7 @@ export default async function Profile({ params }: { params: { name: string } })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<ul className="bg-base-300 flex h-[60vh] flex-col gap-1 overflow-y-scroll rounded-lg">
|
||||||
{data.recentGames.map((game) => {
|
{data.recentGames.map((game) => {
|
||||||
let endReason = game.endReason as string;
|
let endReason = game.endReason as string;
|
||||||
@@ -77,6 +82,9 @@ export default async function Profile({ params }: { params: { name: string } })
|
|||||||
key={game.id}
|
key={game.id}
|
||||||
className="border-base-100 flex flex-wrap items-center justify-between gap-8 border-b-2 p-3"
|
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 w-72 justify-between">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="flex items-center gap-1 text-sm">
|
<span className="flex items-center gap-1 text-sm">
|
||||||
|
|||||||
8
client/src/bots.ts
Normal file
8
client/src/bots.ts
Normal 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];
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
import { IconBrandGithub } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
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">
|
<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">
|
<div className="items-center">
|
||||||
<p>
|
<p>© {new Date().getFullYear()} MiChess — Dein privates Schachbrett</p>
|
||||||
© 2023{" "}
|
|
||||||
<a
|
|
||||||
href="https://n9ze.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-hover"
|
|
||||||
>
|
|
||||||
nize
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="items-center">
|
<div className="items-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/dotnize/chessu"
|
href="https://git.mischlabs.de/MrDiderot/MiChess"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn btn-ghost btn-sm gap-1 normal-case"
|
className="btn btn-ghost btn-sm gap-1 normal-case"
|
||||||
>
|
>
|
||||||
<IconBrandGithub className="inline-block" size={16} />
|
Gitea
|
||||||
GitHub
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,8 +1,42 @@
|
|||||||
import { IconExternalLink, IconUser } from "@tabler/icons-react";
|
"use client";
|
||||||
|
|
||||||
|
import { IconUser, IconShield, IconUserCircle, IconSettings2, IconLogout } from "@tabler/icons-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import NotificationPanel from "./NotificationPanel";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
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() {
|
export default function Header() {
|
||||||
|
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 (
|
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">
|
<header className="navbar border-base-300 dark:border-neutral mx-1 w-auto justify-center border-b-2 md:mx-16 lg:mx-40">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
@@ -20,26 +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" />
|
<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>
|
</svg>
|
||||||
chessu
|
MiChess
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
|
||||||
title="Project roadmap"
|
|
||||||
className="badge badge-sm badge-secondary gap-0.5"
|
|
||||||
href="https://github.com/users/dotnize/projects/2"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
pre-alpha
|
|
||||||
<IconExternalLink size={12} />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-none">
|
<div className="flex-none gap-1">
|
||||||
|
{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 />
|
<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">
|
<div className="w-10 rounded-full">
|
||||||
<IconUser className="m-auto block h-full" />
|
<IconUser className="m-auto block h-full" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
90
client/src/components/InviteFriendsModal.tsx
Normal file
90
client/src/components/InviteFriendsModal.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
client/src/components/NotificationPanel.tsx
Normal file
80
client/src/components/NotificationPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export default function ThemeToggle() {
|
|||||||
const [darkTheme, setDarkTheme] = useState(true);
|
const [darkTheme, setDarkTheme] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document.documentElement.getAttribute("data-theme") === "chessuDark") {
|
if (document.documentElement.getAttribute("data-theme") === "michessDark") {
|
||||||
setDarkTheme(true);
|
setDarkTheme(true);
|
||||||
} else {
|
} else {
|
||||||
setDarkTheme(false);
|
setDarkTheme(false);
|
||||||
@@ -16,14 +16,14 @@ export default function ThemeToggle() {
|
|||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
if (
|
if (
|
||||||
document.documentElement.getAttribute("data-theme") === "chessuDark" ||
|
document.documentElement.getAttribute("data-theme") === "michessDark" ||
|
||||||
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)
|
(!("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";
|
localStorage.theme = "light";
|
||||||
setDarkTheme(false);
|
setDarkTheme(false);
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute("data-theme", "chessuDark");
|
document.documentElement.setAttribute("data-theme", "michessDark");
|
||||||
localStorage.theme = "dark";
|
localStorage.theme = "dark";
|
||||||
setDarkTheme(true);
|
setDarkTheme(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CustomSquares } from "@/types";
|
import type { CustomSquares } from "@/types";
|
||||||
import { Game } from "@chessu/types";
|
import { Game } from "@michess/types";
|
||||||
import {
|
import {
|
||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
@@ -225,8 +225,8 @@ export default function ArchivedGame({ game }: { game: Game }) {
|
|||||||
<div className="h-min">
|
<div className="h-min">
|
||||||
<Chessboard
|
<Chessboard
|
||||||
boardWidth={boardWidth}
|
boardWidth={boardWidth}
|
||||||
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
|
customDarkSquareStyle={{ backgroundColor: "#2F8F72" }}
|
||||||
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
|
customLightSquareStyle={{ backgroundColor: "#DFF8E8" }}
|
||||||
position={navFen || actualGame.fen()}
|
position={navFen || actualGame.fen()}
|
||||||
boardOrientation={flipBoard ? "black" : "white"}
|
boardOrientation={flipBoard ? "black" : "white"}
|
||||||
isDraggablePiece={() => false}
|
isDraggablePiece={() => false}
|
||||||
|
|||||||
185
client/src/components/auth/AuthDropdownContent.tsx
Normal file
185
client/src/components/auth/AuthDropdownContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SessionContext } from "@/context/session";
|
import { SessionContext } from "@/context/session";
|
||||||
import type { Game } from "@chessu/types";
|
import type { Game } from "@michess/types";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
import GamePage from "./GamePage";
|
import GamePage from "./GamePage";
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ import { SessionContext } from "@/context/session";
|
|||||||
import { useContext, useEffect, useReducer, useRef, useState } from "react";
|
import { useContext, useEffect, useReducer, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { Message } from "@/types";
|
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 type { Move, Square } from "chess.js";
|
||||||
import { Chess } from "chess.js";
|
import { Chess } from "chess.js";
|
||||||
import type { ClearPremoves } from "react-chessboard";
|
import type { ClearPremoves } from "react-chessboard";
|
||||||
import { Chessboard } 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 { io } from "socket.io-client";
|
||||||
|
|
||||||
import { lobbyReducer, squareReducer } from "./reducers";
|
import { lobbyReducer, squareReducer } from "./reducers";
|
||||||
@@ -54,6 +55,11 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
|||||||
const [navFen, setNavFen] = useState<string | null>(null);
|
const [navFen, setNavFen] = useState<string | null>(null);
|
||||||
const [navIndex, setNavIndex] = useState<number | 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 [playBtnLoading, setPlayBtnLoading] = useState(false);
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
const [chatMessages, setChatMessages] = useState<Message[]>([
|
const [chatMessages, setChatMessages] = useState<Message[]>([
|
||||||
@@ -65,6 +71,30 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
|||||||
const chatListRef = useRef<HTMLUListElement>(null);
|
const chatListRef = useRef<HTMLUListElement>(null);
|
||||||
const moveListRef = useRef<HTMLDivElement>(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);
|
const [abandonSeconds, setAbandonSeconds] = useState(60);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -114,7 +144,14 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
|||||||
updateCustomSquares,
|
updateCustomSquares,
|
||||||
makeMove,
|
makeMove,
|
||||||
setNavFen,
|
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 () => {
|
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.side === "s" || !lobby.white?.id || !lobby.black?.id) return;
|
||||||
|
|
||||||
if (!lobby.endReason && lobby.side === lobby.actualGame.turn()) {
|
if (!lobby.endReason && lobby.side === lobby.actualGame.turn()) {
|
||||||
document.title = "(your turn) chessu";
|
document.title = "(your turn) MiChess";
|
||||||
} else {
|
} else {
|
||||||
document.title = "chessu";
|
document.title = "MiChess";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,12 +445,8 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copyInvite() {
|
function copyInvite() {
|
||||||
const text = `https://ches.su/${lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}`;
|
const text = `${APP_URL}/${lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}`;
|
||||||
if ("clipboard" in navigator) {
|
navigator.clipboard.writeText(text).catch(() => {});
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
} else {
|
|
||||||
document.execCommand("copy", true, text);
|
|
||||||
}
|
|
||||||
setCopiedLink(true);
|
setCopiedLink(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopiedLink(false);
|
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") {
|
function claimAbandoned(type: "win" | "draw") {
|
||||||
if (
|
if (
|
||||||
lobby.side === "s" ||
|
lobby.side === "s" ||
|
||||||
@@ -543,8 +582,8 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
|||||||
|
|
||||||
<Chessboard
|
<Chessboard
|
||||||
boardWidth={boardWidth}
|
boardWidth={boardWidth}
|
||||||
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
|
customDarkSquareStyle={{ backgroundColor: "#2F8F72" }}
|
||||||
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
|
customLightSquareStyle={{ backgroundColor: "#DFF8E8" }}
|
||||||
position={navFen || lobby.actualGame.fen()}
|
position={navFen || lobby.actualGame.fen()}
|
||||||
boardOrientation={lobby.side === "b" ? "black" : "white"}
|
boardOrientation={lobby.side === "b" ? "black" : "white"}
|
||||||
isDraggablePiece={isDraggablePiece}
|
isDraggablePiece={isDraggablePiece}
|
||||||
@@ -572,6 +611,29 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
|||||||
{getPlayerHtml("bottom")}
|
{getPlayerHtml("bottom")}
|
||||||
</div>
|
</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="flex flex-1 flex-col gap-1">
|
||||||
<div className="mb-2 flex w-full flex-col items-end gap-1">
|
<div className="mb-2 flex w-full flex-col items-end gap-1">
|
||||||
{lobby.endReason ? "Archived link:" : "Invite friends:"}
|
{lobby.endReason ? "Archived link:" : "Invite friends:"}
|
||||||
@@ -586,13 +648,25 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
|||||||
onClick={copyInvite}
|
onClick={copyInvite}
|
||||||
>
|
>
|
||||||
<IconCopy size={16} />
|
<IconCopy size={16} />
|
||||||
ches.su/{lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}
|
{APP_URL.replace(/^https?:\/\//, "")}/{lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}
|
||||||
</label>
|
</label>
|
||||||
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
|
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
|
||||||
copied to clipboard
|
copied to clipboard
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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}>
|
<div className="h-32 w-full overflow-y-scroll" ref={moveListRef}>
|
||||||
<table className="table-compact table w-full">
|
<table className="table-compact table w-full">
|
||||||
<tbody>{getMoveListHtml()}</tbody>
|
<tbody>{getMoveListHtml()}</tbody>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Action, CustomSquares, Lobby, Message } from "@/types";
|
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 { Dispatch, SetStateAction } from "react";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export function initSocket(
|
|||||||
makeMove: Function;
|
makeMove: Function;
|
||||||
setNavFen: Dispatch<SetStateAction<string | null>>;
|
setNavFen: Dispatch<SetStateAction<string | null>>;
|
||||||
setNavIndex: Dispatch<SetStateAction<number | null>>;
|
setNavIndex: Dispatch<SetStateAction<number | null>>;
|
||||||
|
onClockUpdate?: (_data: { whiteTimeMs: number; blackTimeMs: number; lastMoveAt: number }) => void;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
@@ -34,6 +35,18 @@ export function initSocket(
|
|||||||
actions.updateLobby({ type: "updateLobby", payload: latestGame });
|
actions.updateLobby({ type: "updateLobby", payload: latestGame });
|
||||||
|
|
||||||
syncSide(user, latestGame, lobby, actions);
|
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 }) => {
|
socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => {
|
||||||
@@ -75,6 +88,8 @@ export function initSocket(
|
|||||||
}
|
}
|
||||||
} else if (reason === "checkmate") {
|
} else if (reason === "checkmate") {
|
||||||
m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
|
m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
|
||||||
|
} else if (reason === "timeout") {
|
||||||
|
m.message = `Zeit abgelaufen! ${winnerSide === "white" ? "Weiß" : "Schwarz"} gewinnt.`;
|
||||||
} else {
|
} else {
|
||||||
let message = "The game has ended in a draw";
|
let message = "The game has ended in a draw";
|
||||||
if (reason === "repetition") {
|
if (reason === "repetition") {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Action, CustomSquares, Lobby } from "@/types";
|
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";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export const syncPgn = (
|
export const syncPgn = (
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export default function CreateGame() {
|
|||||||
|
|
||||||
const target = e.target as HTMLFormElement;
|
const target = e.target as HTMLFormElement;
|
||||||
const unlisted = target.elements.namedItem("createUnlisted") as HTMLInputElement;
|
const unlisted = target.elements.namedItem("createUnlisted") as HTMLInputElement;
|
||||||
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement)
|
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement).value;
|
||||||
.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) {
|
if (game) {
|
||||||
router.push(`/${game.code}`);
|
router.push(`/${game.code}`);
|
||||||
@@ -33,35 +34,45 @@ export default function CreateGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form-control" onSubmit={submitCreateGame}>
|
<form className="flex flex-col gap-3" onSubmit={submitCreateGame}>
|
||||||
<label className="label cursor-pointer">
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
<span className="label-text">Unlisted/invite-only</span>
|
<span className="label-text text-sm">Nur per Einladung</span>
|
||||||
<input type="checkbox" className="checkbox" name="createUnlisted" id="createUnlisted" />
|
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm" name="createUnlisted" id="createUnlisted" />
|
||||||
</label>
|
</label>
|
||||||
<label className="label" htmlFor="createStartingSide">
|
|
||||||
<span className="label-text">Select your side</span>
|
|
||||||
</label>
|
|
||||||
<div className="input-group">
|
|
||||||
<select
|
<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"
|
name="createStartingSide"
|
||||||
id="createStartingSide"
|
id="createStartingSide"
|
||||||
>
|
>
|
||||||
<option value="random">Random</option>
|
<option value="random">Zufällig</option>
|
||||||
<option value="white">White</option>
|
<option value="white">Weiß</option>
|
||||||
<option value="black">Black</option>
|
<option value="black">Schwarz</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
"btn" +
|
"btn btn-primary btn-sm" +
|
||||||
(buttonLoading ? " loading" : "") +
|
(buttonLoading ? " loading" : "") +
|
||||||
(!session?.user?.id ? " btn-disabled text-base-content" : "")
|
(!session?.user?.id ? " btn-disabled" : "")
|
||||||
}
|
}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Create
|
Erstellen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{!session?.user?.id && (
|
||||||
|
<p className="text-xs opacity-60">Bitte einloggen um ein Spiel zu erstellen</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export default function JoinGame() {
|
|||||||
|
|
||||||
setButtonLoading(true);
|
setButtonLoading(true);
|
||||||
|
|
||||||
if (code.startsWith("ches.su")) {
|
if (!code.startsWith("http") && code.includes("/")) {
|
||||||
code = "http://" + code;
|
code = "https://" + code;
|
||||||
}
|
}
|
||||||
if (code.startsWith("http")) {
|
if (code.startsWith("http")) {
|
||||||
code = new URL(code).pathname.split("/")[1];
|
code = new URL(code).pathname.split("/")[1];
|
||||||
|
|||||||
51
client/src/components/home/LiveGames.tsx
Normal file
51
client/src/components/home/LiveGames.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { fetchPublicGames } from "@/lib/game";
|
import { fetchPublicGames } from "@/lib/game";
|
||||||
|
import type { Game } from "@michess/types";
|
||||||
import JoinButton from "./JoinButton";
|
import JoinButton from "./JoinButton";
|
||||||
import RefreshButton from "./RefreshButton";
|
import RefreshButton from "./RefreshButton";
|
||||||
|
|
||||||
export default async function PublicGames() {
|
export default function PublicGames() {
|
||||||
const games = await fetchPublicGames();
|
const [games, setGames] = useState<Game[]>([]);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await fetchPublicGames();
|
||||||
|
setGames(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<h2 className="mb-2 text-2xl font-bold leading-tight">
|
<h2 className="mb-2 text-2xl font-bold leading-tight">
|
||||||
Public games <RefreshButton />
|
Public games <RefreshButton onRefresh={load} />
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="bg-base-200 h-80 max-h-80 overflow-y-auto rounded-xl">
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{games && games.length > 0 ? (
|
{games.length > 0 ? (
|
||||||
games.map((game) => (
|
games.map((game) => (
|
||||||
<tr key={game.code} className="group">
|
<tr key={game.code} className="group">
|
||||||
<td className={typeof game.host?.id === "number" ? "text-primary" : ""}>
|
<td className={typeof game.host?.id === "number" ? "text-primary" : ""}>
|
||||||
|
|||||||
@@ -2,25 +2,38 @@
|
|||||||
|
|
||||||
import { IconRefresh } from "@tabler/icons-react";
|
import { IconRefresh } from "@tabler/icons-react";
|
||||||
import { useRouter } from "next/navigation";
|
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 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(() => {
|
startTransition(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = isLoading || isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Refresh public games"
|
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}
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<IconRefresh size={16} />
|
{!loading && <IconRefresh size={16} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
client/src/components/home/RecentGames.tsx
Normal file
82
client/src/components/home/RecentGames.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
import { IconCopy } from "@tabler/icons-react";
|
import { IconCopy } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { APP_URL } from "@/config";
|
||||||
|
|
||||||
export default function CopyLink({ name }: { name: string }) {
|
export default function CopyLink({ name }: { name: string }) {
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
const host = APP_URL.replace(/^https?:\/\//, "");
|
||||||
|
|
||||||
function copyLink() {
|
function copyLink() {
|
||||||
const text = `https://ches.su/user/${name}`;
|
navigator.clipboard.writeText(`${APP_URL}/user/${name}`).catch(() => {});
|
||||||
|
|
||||||
if ("clipboard" in navigator) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
} else {
|
|
||||||
document.execCommand("copy", true, text);
|
|
||||||
}
|
|
||||||
setCopiedLink(true);
|
setCopiedLink(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopiedLink(false);
|
setCopiedLink(false);
|
||||||
@@ -27,7 +23,7 @@ export default function CopyLink({ name }: { name: string }) {
|
|||||||
onClick={copyLink}
|
onClick={copyLink}
|
||||||
>
|
>
|
||||||
<IconCopy size={16} />
|
<IconCopy size={16} />
|
||||||
ches.su/user/{name}
|
{host}/user/{name}
|
||||||
</label>
|
</label>
|
||||||
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
|
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
|
||||||
copied to clipboard
|
copied to clipboard
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// back-end server url
|
|
||||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
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";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { User } from "@chessu/types";
|
import type { User } from "@michess/types";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { fetchSession } from "@/lib/auth";
|
import { fetchSession } from "@/lib/auth";
|
||||||
|
import NotificationProvider from "./NotificationContext";
|
||||||
import { SessionContext } from "./session";
|
import { SessionContext } from "./session";
|
||||||
|
|
||||||
export default function ContextProvider({ children }: { children: ReactNode }) {
|
export default function ContextProvider({ children }: { children: ReactNode }) {
|
||||||
@@ -19,5 +20,9 @@ export default function ContextProvider({ children }: { children: ReactNode }) {
|
|||||||
getSession();
|
getSession();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <SessionContext.Provider value={{ user, setUser }}>{children}</SessionContext.Provider>;
|
return (
|
||||||
|
<SessionContext.Provider value={{ user, setUser }}>
|
||||||
|
<NotificationProvider>{children}</NotificationProvider>
|
||||||
|
</SessionContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
87
client/src/context/NotificationContext.tsx
Normal file
87
client/src/context/NotificationContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { User } from "@chessu/types";
|
import type { User } from "@michess/types";
|
||||||
import { createContext, Dispatch, SetStateAction } from "react";
|
import { createContext, Dispatch, SetStateAction, useContext } from "react";
|
||||||
|
|
||||||
export const SessionContext = createContext<{
|
export const SessionContext = createContext<{
|
||||||
user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
|
user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
|
||||||
setUser: Dispatch<SetStateAction<User | null>>;
|
setUser: Dispatch<SetStateAction<User | null>>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
export const useSession = () => {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) throw new Error("useSession must be used within ContextProvider");
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { API_URL } from "@/config";
|
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 () => {
|
export const fetchSession = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -30,8 +43,10 @@ export const setGuestSession = async (name: string) => {
|
|||||||
const user: User = await res.json();
|
const user: User = await res.json();
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
return await readErrorMessage(res, "Could not start guest session.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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) {
|
if (res.status === 201) {
|
||||||
const user: User = await res.json();
|
const user: User = await res.json();
|
||||||
return user;
|
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) {
|
} catch (err) {
|
||||||
console.error(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) {
|
if (res.status === 200) {
|
||||||
const user: User = await res.json();
|
const user: User = await res.json();
|
||||||
return user;
|
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) {
|
} catch (err) {
|
||||||
console.error(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) {
|
if (res.status === 200) {
|
||||||
const user: User = await res.json();
|
const user: User = await res.json();
|
||||||
return user;
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
return "Could not reach the server. Please try again.";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
46
client/src/lib/correspondence.ts
Normal file
46
client/src/lib/correspondence.ts
Normal 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();
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { API_URL } from "@/config";
|
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 {
|
try {
|
||||||
const res = await fetch(`${API_URL}/v1/games`, {
|
const res = await fetch(`${API_URL}/v1/games`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -9,7 +9,7 @@ export const createGame = async (side: string, unlisted: boolean) => {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ side, unlisted }),
|
body: JSON.stringify({ side, unlisted, timeControl: timeControl || null }),
|
||||||
cache: "no-store"
|
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 () => {
|
export const fetchPublicGames = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });
|
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });
|
||||||
|
|||||||
52
client/src/lib/tournament.ts
Normal file
52
client/src/lib/tournament.ts
Normal 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();
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { API_URL } from "@/config";
|
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) => {
|
export const fetchProfileData = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Game, User } from "@chessu/types";
|
import type { Game, User } from "@michess/types";
|
||||||
import type { Chess } from "chess.js";
|
import type { Chess } from "chess.js";
|
||||||
|
|
||||||
export interface Lobby extends Game {
|
export interface Lobby extends Game {
|
||||||
|
|||||||
@@ -5,38 +5,47 @@ module.exports = {
|
|||||||
extend: {}
|
extend: {}
|
||||||
},
|
},
|
||||||
plugins: [require("daisyui")],
|
plugins: [require("daisyui")],
|
||||||
darkMode: ["class", '[data-theme="chessuDark"]'],
|
darkMode: ["class", '[data-theme="michessDark"]'],
|
||||||
daisyui: {
|
daisyui: {
|
||||||
// based on daisyUI night and winter themes
|
// based on daisyUI night and winter themes
|
||||||
themes: [
|
themes: [
|
||||||
{
|
{
|
||||||
chessuLight: {
|
michessLight: {
|
||||||
primary: "#047AFF",
|
primary: "#10B981",
|
||||||
secondary: "#818CF8",
|
"primary-content": "#052E24",
|
||||||
accent: "#C148AC",
|
secondary: "#2DD4BF",
|
||||||
neutral: "#9ab3d9",
|
"secondary-content": "#042F2E",
|
||||||
"base-100": "#FFFFFF",
|
accent: "#84CC16",
|
||||||
"base-200": "#F2F7FF",
|
"accent-content": "#1F2A05",
|
||||||
"base-300": "#E3E9F4",
|
neutral: "#5F746B",
|
||||||
"base-content": "#394E6A",
|
"base-100": "#F8FFFB",
|
||||||
info: "#93E7FB",
|
"base-200": "#ECFDF5",
|
||||||
success: "#81CFD1",
|
"base-300": "#D1FAE5",
|
||||||
warning: "#EFD7BB",
|
"base-content": "#12352B",
|
||||||
error: "#E58B8B"
|
info: "#5EEAD4",
|
||||||
|
success: "#22C55E",
|
||||||
|
warning: "#FBBF24",
|
||||||
|
error: "#EF7D7D"
|
||||||
},
|
},
|
||||||
chessuDark: {
|
michessDark: {
|
||||||
primary: "#38BDF8",
|
primary: "#34D399",
|
||||||
secondary: "#818CF8",
|
"primary-content": "#022C22",
|
||||||
accent: "#1d4ed8",
|
secondary: "#2DD4BF",
|
||||||
neutral: "#1E293B",
|
"secondary-content": "#042F2E",
|
||||||
"base-100": "#0F172A",
|
accent: "#A3E635",
|
||||||
info: "#0CA5E9",
|
"accent-content": "#1A2E05",
|
||||||
success: "#2DD4BF",
|
neutral: "#18362C",
|
||||||
|
"base-100": "#071A14",
|
||||||
|
"base-200": "#0D261F",
|
||||||
|
"base-300": "#12352B",
|
||||||
|
"base-content": "#DFFDF0",
|
||||||
|
info: "#5EEAD4",
|
||||||
|
success: "#4ADE80",
|
||||||
warning: "#F4BF50",
|
warning: "#F4BF50",
|
||||||
error: "#FB7085"
|
error: "#FB7085"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
darkTheme: "chessuDark"
|
darkTheme: "michessDark"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
services:
|
||||||
|
michess:
|
||||||
|
image: git.mischlabs.de/mrdiderot/michess:latest
|
||||||
|
container_name: michess
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PGHOST: db
|
||||||
|
PGPORT: 5432
|
||||||
|
PGDATABASE: ${POSTGRES_DB:-michess}
|
||||||
|
PGUSER: ${POSTGRES_USER:-michess}
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||||
|
PORT: 3001
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-change-this-secret-in-production}
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://michess.mischlabs.de}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
|
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
|
||||||
|
networks:
|
||||||
|
- michess_net
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: michess_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-michess}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-michess}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- michess_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-michess}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
michess_net:
|
||||||
|
driver: bridge
|
||||||
18
package.json
18
package.json
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "chessu",
|
"name": "michess",
|
||||||
"private": "true",
|
"private": "true",
|
||||||
"author": "dotnize",
|
"author": "Tom Misch",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"install:client": "pnpm install --filter client --config.dedupe-peer-dependents=false",
|
"install:client": "pnpm install --filter client",
|
||||||
"install:server": "pnpm install --filter server --config.dedupe-peer-dependents=false",
|
"install:server": "pnpm install --filter server",
|
||||||
"dev": "concurrently \"pnpm --filter client dev\" \"pnpm --filter server dev\"",
|
"dev": "concurrently \"pnpm --filter client dev\" \"pnpm --filter server dev\"",
|
||||||
"dev:client": "pnpm --filter client dev",
|
"dev:client": "pnpm --filter client dev",
|
||||||
"dev:server": "pnpm --filter server dev",
|
"dev:server": "pnpm --filter server dev",
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.54.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.7"
|
"prettier-plugin-tailwindcss": "^0.6.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6984
pnpm-lock.yaml
generated
6984
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
33
scripts/start-production.mjs
Normal file
33
scripts/start-production.mjs
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
21
scripts/update.sh
Executable file
21
scripts/update.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 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 ""
|
||||||
|
echo "1. Image neu bauen..."
|
||||||
|
docker compose build michess
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Container neu starten..."
|
||||||
|
docker compose up -d michess
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Update abgeschlossen!"
|
||||||
|
docker compose ps michess
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@chessu/server",
|
"name": "@michess/server",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/server.js",
|
"main": "./dist/server.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -10,33 +10,34 @@
|
|||||||
"dev": "tsc-watch --noClear --onSuccess \"node dist/server.js\""
|
"dev": "tsc-watch --noClear --onSuccess \"node dist/server.js\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argon2": "^0.31.2",
|
"argon2": "^0.40.3",
|
||||||
"chess.js": "1.0.0-beta.6",
|
"chess.js": "1.0.0-beta.8",
|
||||||
"connect-pg-simple": "^9.0.1",
|
"connect-pg-simple": "^9.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.19.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.18.0",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.7",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.12.0",
|
||||||
"socket.io": "^4.7.2",
|
"socket.io": "^4.7.5",
|
||||||
"xss": "^1.0.14"
|
"xss": "^1.0.15",
|
||||||
|
"openid-client": "^5.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chessu/types": "*",
|
"@michess/types": "workspace:*",
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
"@types/connect-pg-simple": "^7.0.3",
|
||||||
"@types/cors": "^2.8.16",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.17.10",
|
"@types/express-session": "^1.18.0",
|
||||||
"@types/node": "^18.18.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.11.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.11.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"tsc-watch": "^6.0.4",
|
"tsc-watch": "^6.2.0",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.5.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"bufferutil": "^4.0.8",
|
"bufferutil": "^4.0.8",
|
||||||
|
|||||||
8
server/src/bots.ts
Normal file
8
server/src/bots.ts
Normal 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));
|
||||||
113
server/src/controllers/admin.controller.ts
Normal file
113
server/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import UserModel from "../db/models/user.model.js";
|
||||||
|
|
||||||
|
export const getStats = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const usersRes = await db.query(`SELECT COUNT(*) FROM "user"`);
|
||||||
|
const gamesRes = await db.query(`SELECT COUNT(*) FROM "game"`);
|
||||||
|
const activeUsersRes = await db.query(
|
||||||
|
`SELECT COUNT(*) FROM "user" WHERE created_at > NOW() - INTERVAL '7 days'`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
totalUsers: parseInt(usersRes.rows[0].count),
|
||||||
|
totalGames: parseInt(gamesRes.rows[0].count),
|
||||||
|
newUsersThisWeek: parseInt(activeUsersRes.rows[0].count)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listUsers = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
const offset = parseInt(req.query.offset as string) || 0;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
|
||||||
|
const users = await UserModel.getAllUsers(limit, offset, search);
|
||||||
|
const countRes = await db.query(
|
||||||
|
search
|
||||||
|
? `SELECT COUNT(*) FROM "user" WHERE name ILIKE $1 OR email ILIKE $1`
|
||||||
|
: `SELECT COUNT(*) FROM "user"`,
|
||||||
|
search ? [`%${search}%`] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
users,
|
||||||
|
total: parseInt(countRes.rows[0].count),
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { role, banned } = req.body;
|
||||||
|
|
||||||
|
// Prevent admin from banning themselves
|
||||||
|
if (req.session.user?.id === id && banned === true) {
|
||||||
|
res.status(400).json({ message: "Cannot ban yourself." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await UserModel.adminUpdate(id, { role, banned });
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
|
||||||
|
if (req.session.user?.id === id) {
|
||||||
|
res.status(400).json({ message: "Cannot delete your own account." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await UserModel.remove(id);
|
||||||
|
if (!removed) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json(removed);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triggerUpdate = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workdir = process.env.APP_DIR || "/opt/michess";
|
||||||
|
|
||||||
|
exec(`cd ${workdir} && git pull origin main 2>&1`, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ message: "Git pull failed", output: stderr || err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({
|
||||||
|
message: "Update successful. Restart the container to apply changes.",
|
||||||
|
output: stdout
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { User } from "@chessu/types";
|
import type { User } from "@michess/types";
|
||||||
import { hash, verify } from "argon2";
|
import { hash, verify } from "argon2";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import xss from "xss";
|
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 { activeGames } from "../db/models/game.model.js";
|
||||||
import UserModel from "../db/models/user.model.js";
|
import UserModel from "../db/models/user.model.js";
|
||||||
import { io } from "../server.js";
|
import { io } from "../server.js";
|
||||||
@@ -222,7 +225,9 @@ export const loginUser = async (req: Request, res: Response) => {
|
|||||||
email: users[0].email,
|
email: users[0].email,
|
||||||
wins: users[0].wins,
|
wins: users[0].wins,
|
||||||
losses: users[0].losses,
|
losses: users[0].losses,
|
||||||
draws: users[0].draws
|
draws: users[0].draws,
|
||||||
|
elo: users[0].elo,
|
||||||
|
role: users[0].role
|
||||||
};
|
};
|
||||||
req.session.save(() => {
|
req.session.save(() => {
|
||||||
res.status(200).json(req.session.user);
|
res.status(200).json(req.session.user);
|
||||||
@@ -315,3 +320,123 @@ export const updateUser = async (req: Request, res: Response) => {
|
|||||||
res.status(500).end();
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
85
server/src/controllers/correspondence.controller.ts
Normal file
85
server/src/controllers/correspondence.controller.ts
Normal 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(); }
|
||||||
|
};
|
||||||
210
server/src/controllers/friends.controller.ts
Normal file
210
server/src/controllers/friends.controller.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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 {
|
||||||
|
const fromId = req.session.user?.id as number;
|
||||||
|
const { username } = req.body;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
res.status(400).json({ message: "Username required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await UserModel.findByNameEmail({ name: username, email: username });
|
||||||
|
if (!targets || !targets.length) {
|
||||||
|
res.status(404).json({ message: "User not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toId = targets[0].id as number;
|
||||||
|
|
||||||
|
if (toId === fromId) {
|
||||||
|
res.status(400).json({ message: "Cannot add yourself." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check already friends
|
||||||
|
const existing = await db.query(
|
||||||
|
`SELECT id FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||||
|
[fromId, toId]
|
||||||
|
);
|
||||||
|
if (existing.rowCount) {
|
||||||
|
res.status(409).json({ message: "Already friends." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing request
|
||||||
|
const existingReq = await db.query(
|
||||||
|
`SELECT id, status FROM "friend_request" WHERE (from_id=$1 AND to_id=$2) OR (from_id=$2 AND to_id=$1)`,
|
||||||
|
[fromId, toId]
|
||||||
|
);
|
||||||
|
if (existingReq.rowCount) {
|
||||||
|
res.status(409).json({ message: "Request already exists." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO "friend_request"(from_id, to_id) VALUES($1, $2) RETURNING id`,
|
||||||
|
[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);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRequests = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
|
||||||
|
const incoming = await db.query(
|
||||||
|
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||||
|
u.name as from_name
|
||||||
|
FROM "friend_request" fr
|
||||||
|
JOIN "user" u ON u.id = fr.from_id
|
||||||
|
WHERE fr.to_id=$1 AND fr.status='pending'
|
||||||
|
ORDER BY fr.created_at DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const outgoing = await db.query(
|
||||||
|
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||||
|
u.name as to_name
|
||||||
|
FROM "friend_request" fr
|
||||||
|
JOIN "user" u ON u.id = fr.to_id
|
||||||
|
WHERE fr.from_id=$1 AND fr.status='pending'
|
||||||
|
ORDER BY fr.created_at DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
incoming: incoming.rows,
|
||||||
|
outgoing: outgoing.rows
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const respondToRequest = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
const requestId = parseInt(req.params.id);
|
||||||
|
const { action } = req.body; // "accept" | "reject"
|
||||||
|
|
||||||
|
if (action !== "accept" && action !== "reject") {
|
||||||
|
res.status(400).json({ message: "Invalid action." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqRow = await db.query(
|
||||||
|
`SELECT * FROM "friend_request" WHERE id=$1 AND to_id=$2 AND status='pending'`,
|
||||||
|
[requestId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reqRow.rowCount) {
|
||||||
|
res.status(404).json({ message: "Request not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from_id, to_id } = reqRow.rows[0];
|
||||||
|
|
||||||
|
await db.query(`UPDATE "friend_request" SET status=$1 WHERE id=$2`, [
|
||||||
|
action === "accept" ? "accepted" : "rejected",
|
||||||
|
requestId
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (action === "accept") {
|
||||||
|
const [a, b] = from_id < to_id ? [from_id, to_id] : [to_id, from_id];
|
||||||
|
await db.query(
|
||||||
|
`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." });
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFriends = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
f.id,
|
||||||
|
CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END as friend_id,
|
||||||
|
u.name as friend_name,
|
||||||
|
u.wins, u.losses, u.draws,
|
||||||
|
f.created_at
|
||||||
|
FROM "friendship" f
|
||||||
|
JOIN "user" u ON u.id = CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END
|
||||||
|
WHERE f.user_id_1=$1 OR f.user_id_2=$1
|
||||||
|
ORDER BY u.name ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeFriend = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
const friendId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`DELETE FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||||
|
[userId, friendId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({ message: "Friend removed." });
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchUsers = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const q = req.query.q as string;
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
res.status(400).json({ message: "Query too short." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await UserModel.searchByName(q, 10);
|
||||||
|
res.status(200).json(users);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 type { Request, Response } from "express";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { Chess } from "chess.js";
|
||||||
|
|
||||||
import GameModel, { activeGames } from "../db/models/game.model.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) => {
|
export const getGames = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.query.id && !req.query.userid) {
|
if (!req.query.id && !req.query.userid) {
|
||||||
@@ -78,11 +97,15 @@ export const createGame = async (req: Request, res: Response) => {
|
|||||||
connected: false
|
connected: false
|
||||||
};
|
};
|
||||||
const unlisted: boolean = req.body.unlisted ?? false;
|
const unlisted: boolean = req.body.unlisted ?? false;
|
||||||
|
const timeControl: number | undefined = req.body.timeControl ? parseInt(req.body.timeControl) : undefined;
|
||||||
const game: Game = {
|
const game: Game = {
|
||||||
code: nanoid(6),
|
code: nanoid(6),
|
||||||
unlisted,
|
unlisted,
|
||||||
host: user,
|
host: user,
|
||||||
pgn: ""
|
pgn: "",
|
||||||
|
timeControl: timeControl || undefined,
|
||||||
|
whiteTimeMs: timeControl ? timeControl * 60 * 1000 : undefined,
|
||||||
|
blackTimeMs: timeControl ? timeControl * 60 * 1000 : undefined
|
||||||
};
|
};
|
||||||
if (req.body.side === "white") {
|
if (req.body.side === "white") {
|
||||||
game.white = user;
|
game.white = user;
|
||||||
|
|||||||
134
server/src/controllers/stockfish.controller.ts
Normal file
134
server/src/controllers/stockfish.controller.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { spawn, ChildProcess } from "child_process";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
|
export interface StockfishLevel {
|
||||||
|
level: number;
|
||||||
|
name: string;
|
||||||
|
skillLevel: number;
|
||||||
|
depth: number;
|
||||||
|
moveTime: number;
|
||||||
|
randomChance: number; // 0–1 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: 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: ChildProcess | null = null;
|
||||||
|
private ready = false;
|
||||||
|
private resolvers: ((move: string) => void)[] = [];
|
||||||
|
private initPromise: Promise<void>;
|
||||||
|
private buffer = "";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initPromise = this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async init() {
|
||||||
|
try {
|
||||||
|
// Debian installs stockfish to /usr/games/stockfish
|
||||||
|
const sfBin = ["/usr/games/stockfish", "/usr/bin/stockfish", "stockfish"]
|
||||||
|
.find(p => existsSync(p)) ?? "stockfish";
|
||||||
|
|
||||||
|
this.process = spawn(sfBin, [], {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("error", (err) => {
|
||||||
|
console.error("Stockfish process error:", err.message);
|
||||||
|
this.process = null;
|
||||||
|
this.ready = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let readyOk = false;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const msg = line.trim();
|
||||||
|
if (msg === "uciok") {
|
||||||
|
this.process!.stdin!.write("isready\n");
|
||||||
|
}
|
||||||
|
if (msg === "readyok") {
|
||||||
|
readyOk = true;
|
||||||
|
this.ready = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
if (msg.startsWith("bestmove ")) {
|
||||||
|
const move = msg.split(" ")[1];
|
||||||
|
const resolver = this.resolvers.shift();
|
||||||
|
if (resolver) resolver(move === "(none)" ? "" : move);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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> {
|
||||||
|
await this.initPromise;
|
||||||
|
|
||||||
|
if (!this.process || !this.ready) {
|
||||||
|
console.warn("Stockfish not ready");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.resolvers.push(resolve);
|
||||||
|
|
||||||
|
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`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const idx = this.resolvers.indexOf(resolve);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.resolvers.splice(idx, 1);
|
||||||
|
resolve("");
|
||||||
|
}
|
||||||
|
}, level.moveTime + 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.process?.kill();
|
||||||
|
this.process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let engineInstance: StockfishEngine | null = null;
|
||||||
|
|
||||||
|
export const getEngine = (): StockfishEngine => {
|
||||||
|
if (!engineInstance) {
|
||||||
|
engineInstance = new StockfishEngine();
|
||||||
|
}
|
||||||
|
return engineInstance;
|
||||||
|
};
|
||||||
116
server/src/controllers/tournament.controller.ts
Normal file
116
server/src/controllers/tournament.controller.ts
Normal 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(); }
|
||||||
|
};
|
||||||
@@ -22,7 +22,8 @@ export const getUserProfile = async (req: Request, res: Response) => {
|
|||||||
name: users[0].name,
|
name: users[0].name,
|
||||||
wins: users[0].wins,
|
wins: users[0].wins,
|
||||||
losses: users[0].losses,
|
losses: users[0].losses,
|
||||||
draws: users[0].draws
|
draws: users[0].draws,
|
||||||
|
elo: users[0].elo
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(200).json({ ...publicUser, recentGames });
|
res.status(200).json({ ...publicUser, recentGames });
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ export const INIT_TABLES = /* sql */ `
|
|||||||
CREATE TABLE IF NOT EXISTS "user" (
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(128) UNIQUE NOT NULL,
|
name VARCHAR(128) UNIQUE NOT NULL,
|
||||||
email VARCHAR(128),
|
email VARCHAR(128) UNIQUE,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
|
role VARCHAR(16) DEFAULT 'user',
|
||||||
wins INTEGER DEFAULT 0,
|
wins INTEGER DEFAULT 0,
|
||||||
losses INTEGER DEFAULT 0,
|
losses INTEGER DEFAULT 0,
|
||||||
draws INTEGER DEFAULT 0,
|
draws INTEGER DEFAULT 0,
|
||||||
|
banned BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS "game" (
|
CREATE TABLE IF NOT EXISTS "game" (
|
||||||
@@ -22,7 +24,82 @@ export const INIT_TABLES = /* sql */ `
|
|||||||
white_name VARCHAR(32),
|
white_name VARCHAR(32),
|
||||||
black_id INT REFERENCES "user",
|
black_id INT REFERENCES "user",
|
||||||
black_name VARCHAR(32),
|
black_name VARCHAR(32),
|
||||||
|
vs_ai BOOLEAN DEFAULT FALSE,
|
||||||
|
ai_level INTEGER,
|
||||||
started_at TIMESTAMP NOT NULL,
|
started_at TIMESTAMP NOT NULL,
|
||||||
ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "friend_request" (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
from_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
to_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(16) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(from_id, to_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "friendship" (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id_1 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
user_id_2 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id_1, user_id_2)
|
||||||
|
);
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role VARCHAR(16) DEFAULT 'user';
|
||||||
|
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)
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|||||||
79
server/src/db/models/correspondence.model.ts
Normal file
79
server/src/db/models/correspondence.model.ts
Normal 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;
|
||||||
@@ -1,24 +1,58 @@
|
|||||||
import type { Game, User } from "@chessu/types";
|
import type { Game, User } from "@michess/types";
|
||||||
import { db } from "../index.js";
|
import { db } from "../index.js";
|
||||||
|
import { BOTS } from "../../bots.js";
|
||||||
|
|
||||||
export const activeGames: Game[] = [];
|
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) => {
|
export const save = async (game: Game) => {
|
||||||
try {
|
try {
|
||||||
const white: User = {};
|
const white: User = { name: game.white?.name };
|
||||||
const black: User = {};
|
const black: User = { name: game.black?.name };
|
||||||
if (typeof game.white?.id === "string") {
|
if (typeof game.white?.id !== "string") {
|
||||||
white.name = game.white?.name;
|
|
||||||
} else {
|
|
||||||
white.id = game.white?.id;
|
white.id = game.white?.id;
|
||||||
}
|
}
|
||||||
if (typeof game.black?.id === "string") {
|
if (typeof game.black?.id !== "string") {
|
||||||
black.name = game.black?.name;
|
|
||||||
} else {
|
|
||||||
black.id = game.black?.id;
|
black.id = game.black?.id;
|
||||||
}
|
}
|
||||||
const res = await db.query(
|
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.winner || null,
|
||||||
game.endReason || null,
|
game.endReason || null,
|
||||||
@@ -27,7 +61,10 @@ export const save = async (game: Game) => {
|
|||||||
white.name || null,
|
white.name || null,
|
||||||
black.id || null,
|
black.id || null,
|
||||||
black.name || 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) {
|
if (black.id || white.id) {
|
||||||
@@ -51,11 +88,12 @@ export const save = async (game: Game) => {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await updateElo(game);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: res.rows[0].id,
|
id: res.rows[0].id,
|
||||||
winner: res.rows[0].winner,
|
winner: res.rows[0].winner,
|
||||||
endReason: res.rows[0].reason,
|
endReason: res.rows[0].end_reason,
|
||||||
pgn: res.rows[0].pgn,
|
pgn: res.rows[0].pgn,
|
||||||
white: {
|
white: {
|
||||||
id: res.rows[0].white_id || undefined,
|
id: res.rows[0].white_id || undefined,
|
||||||
@@ -66,7 +104,10 @@ export const save = async (game: Game) => {
|
|||||||
name: res.rows[0].black_name || undefined
|
name: res.rows[0].black_name || undefined
|
||||||
},
|
},
|
||||||
startedAt: res.rows[0].started_at.getTime(),
|
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;
|
} as Game;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@@ -77,7 +118,7 @@ export const save = async (game: Game) => {
|
|||||||
export const findById = async (id: number) => {
|
export const findById = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
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]
|
[id]
|
||||||
);
|
);
|
||||||
if (res.rowCount) {
|
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 },
|
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 },
|
black: { id: res.rows[0].black_id || undefined, name: res.rows[0].black_name },
|
||||||
startedAt: res.rows[0].started_at.getTime(),
|
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;
|
} as Game;
|
||||||
} else return null;
|
} else return null;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -105,7 +148,7 @@ export const findByUserId = async (id: number, limit = 10) => {
|
|||||||
try {
|
try {
|
||||||
// TODO: pagination
|
// TODO: pagination
|
||||||
const res = await db.query(
|
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]
|
[id, limit]
|
||||||
);
|
);
|
||||||
return res.rows.map((r) => {
|
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 },
|
white: { id: r.white_id || undefined, name: r.white_name },
|
||||||
black: { id: r.black_id || undefined, name: r.black_name },
|
black: { id: r.black_id || undefined, name: r.black_name },
|
||||||
startedAt: r.started_at.getTime(),
|
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;
|
} as Game;
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} 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 },
|
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 },
|
black: { id: res.rows[0].black_id, name: res.rows[0].black_name },
|
||||||
startedAt: res.rows[0].started_at.getTime(),
|
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;
|
} as Game;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
|||||||
183
server/src/db/models/tournament.model.ts
Normal file
183
server/src/db/models/tournament.model.ts
Normal 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;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { User } from "@chessu/types";
|
import type { User } from "@michess/types";
|
||||||
import { db } from "../index.js";
|
import { db } from "../index.js";
|
||||||
|
|
||||||
export const create = async (user: User, password: string) => {
|
export const create = async (user: User, password: string) => {
|
||||||
@@ -8,7 +8,7 @@ export const create = async (user: User, password: string) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws`,
|
`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]
|
[user.name, user.email || null, password]
|
||||||
);
|
);
|
||||||
return res.rows[0] as User;
|
return res.rows[0] as User;
|
||||||
@@ -24,11 +24,11 @@ export const findById = async (id: number) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, email, wins, losses, draws FROM "user" WHERE id=$1`,
|
`SELECT id, name, email, wins, losses, draws, elo, role, banned FROM "user" WHERE id=$1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
if (res.rowCount) {
|
if (res.rowCount) {
|
||||||
return res.rows[0] as User;
|
return res.rows[0] as User & { banned?: boolean };
|
||||||
} else return null;
|
} else return null;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@@ -37,14 +37,13 @@ export const findById = async (id: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
|
export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
|
||||||
// if user is not specified, get all users
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, email, wins, losses, draws FROM "user" LIMIT $1`,
|
`SELECT id, name, email, wins, losses, draws, elo, role, banned FROM "user" LIMIT $1`,
|
||||||
[limit ?? 10]
|
[limit ?? 10]
|
||||||
);
|
);
|
||||||
return res.rows as (User & { password?: string })[];
|
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
return null;
|
return null;
|
||||||
@@ -53,12 +52,25 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, email, wins, losses, draws${
|
`SELECT id, name, email, wins, losses, draws, elo, role, banned${
|
||||||
includePassword ? `, password` : ""
|
includePassword ? `, password` : ""
|
||||||
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
||||||
[user.name, user.email, limit ?? 1]
|
[user.name, user.email, limit ?? 1]
|
||||||
);
|
);
|
||||||
return res.rows as (User & { password?: string })[];
|
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.log(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchByName = async (query: string, limit = 10) => {
|
||||||
|
try {
|
||||||
|
const res = await db.query(
|
||||||
|
`SELECT id, name, wins, losses, draws, elo FROM "user" WHERE name ILIKE $1 LIMIT $2`,
|
||||||
|
[`%${query}%`, limit]
|
||||||
|
);
|
||||||
|
return res.rows as User[];
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
return null;
|
return null;
|
||||||
@@ -71,11 +83,11 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws`;
|
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, elo, role, banned`;
|
||||||
let values = [updatedUser.name, updatedUser.email, id];
|
let values: (string | number | null | undefined)[] = [updatedUser.name, updatedUser.email, id];
|
||||||
|
|
||||||
if (updatedUser.password) {
|
if (updatedUser.password) {
|
||||||
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws`;
|
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];
|
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
|
||||||
}
|
}
|
||||||
const res = await db.query(query, values);
|
const res = await db.query(query, values);
|
||||||
@@ -86,6 +98,58 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const adminUpdate = async (id: number, fields: { role?: string; banned?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const values: (string | boolean | number)[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (fields.role !== undefined) {
|
||||||
|
setClauses.push(`role=$${paramIndex++}`);
|
||||||
|
values.push(fields.role);
|
||||||
|
}
|
||||||
|
if (fields.banned !== undefined) {
|
||||||
|
setClauses.push(`banned=$${paramIndex++}`);
|
||||||
|
values.push(fields.banned);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setClauses.length) return null;
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const res = await db.query(
|
||||||
|
`UPDATE "user" SET ${setClauses.join(", ")} WHERE id=$${paramIndex} RETURNING id, name, email, role, banned`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return res.rows[0] as User & { banned?: boolean };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.log(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllUsers = async (limit = 50, offset = 0, search?: string) => {
|
||||||
|
try {
|
||||||
|
let query = `SELECT id, name, email, wins, losses, draws, elo, role, banned, created_at FROM "user"`;
|
||||||
|
const values: (string | number)[] = [];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query += ` WHERE name ILIKE $1 OR email ILIKE $1`;
|
||||||
|
values.push(`%${search}%`);
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT $${values.length + 1} OFFSET $${values.length + 2}`;
|
||||||
|
values.push(limit, offset);
|
||||||
|
} else {
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT $1 OFFSET $2`;
|
||||||
|
values.push(limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await db.query(query, values);
|
||||||
|
return res.rows as (User & { banned?: boolean; created_at?: string })[];
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.log(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const remove = async (id: number) => {
|
export const remove = async (id: number) => {
|
||||||
if (id === 0) {
|
if (id === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -106,7 +170,10 @@ const UserModel = {
|
|||||||
create,
|
create,
|
||||||
findById,
|
findById,
|
||||||
findByNameEmail,
|
findByNameEmail,
|
||||||
|
searchByName,
|
||||||
update,
|
update,
|
||||||
|
adminUpdate,
|
||||||
|
getAllUsers,
|
||||||
remove
|
remove
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
21
server/src/middleware/auth.ts
Normal file
21
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||||
|
res.status(401).json({ message: "Not authenticated." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||||
|
res.status(401).json({ message: "Not authenticated." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.session.user.role !== "admin") {
|
||||||
|
res.status(403).json({ message: "Admin access required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { User } from "@chessu/types";
|
import type { User } from "@michess/types";
|
||||||
import PGSimple from "connect-pg-simple";
|
import PGSimple from "connect-pg-simple";
|
||||||
import type { Session } from "express-session";
|
import type { Session } from "express-session";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
@@ -27,7 +27,7 @@ const sessionMiddleware = session({
|
|||||||
secret: process.env.SESSION_SECRET || "make sure to change this!",
|
secret: process.env.SESSION_SECRET || "make sure to change this!",
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
name: "chessu",
|
name: "michess",
|
||||||
proxy: true,
|
proxy: true,
|
||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
|
|||||||
15
server/src/routes/admin.route.ts
Normal file
15
server/src/routes/admin.route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import express from "express";
|
||||||
|
import * as admin from "../controllers/admin.controller.js";
|
||||||
|
import { requireAdmin } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
router.get("/stats", admin.getStats);
|
||||||
|
router.get("/users", admin.listUsers);
|
||||||
|
router.patch("/users/:id", admin.updateUser);
|
||||||
|
router.delete("/users/:id", admin.deleteUser);
|
||||||
|
router.post("/update", admin.triggerUpdate);
|
||||||
|
|
||||||
|
export default router;
|
||||||
81
server/src/routes/ai.route.ts
Normal file
81
server/src/routes/ai.route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
router.post("/move", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { fen, level } = req.body;
|
||||||
|
|
||||||
|
if (!fen || typeof fen !== "string") {
|
||||||
|
res.status(400).json({ message: "FEN required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiLevel = AI_LEVELS.find((l) => l.level === (level || 3)) || AI_LEVELS[2];
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -14,4 +14,9 @@ router.route("/logout").post(controller.logoutSession);
|
|||||||
router.route("/register").post(controller.registerUser);
|
router.route("/register").post(controller.registerUser);
|
||||||
router.route("/login").post(controller.loginUser);
|
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;
|
export default router;
|
||||||
|
|||||||
14
server/src/routes/correspondence.route.ts
Normal file
14
server/src/routes/correspondence.route.ts
Normal 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;
|
||||||
15
server/src/routes/friends.route.ts
Normal file
15
server/src/routes/friends.route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import express from "express";
|
||||||
|
import * as friends from "../controllers/friends.controller.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get("/", friends.getFriends);
|
||||||
|
router.post("/request", friends.sendRequest);
|
||||||
|
router.get("/requests", friends.getRequests);
|
||||||
|
router.patch("/requests/:id", friends.respondToRequest);
|
||||||
|
router.delete("/:id", friends.removeFriend);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -4,6 +4,7 @@ import * as controller from "../controllers/games.controller.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/live", controller.getLiveGames);
|
||||||
router.route("/").get(controller.getGames).post(controller.createGame);
|
router.route("/").get(controller.getGames).post(controller.createGame);
|
||||||
|
|
||||||
router.route("/:code").get(controller.getActiveGame);
|
router.route("/:code").get(controller.getActiveGame);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import admin from "./admin.route.js";
|
||||||
|
import ai from "./ai.route.js";
|
||||||
import auth from "./auth.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 games from "./games.route.js";
|
||||||
|
import tournaments from "./tournament.route.js";
|
||||||
import users from "./users.route.js";
|
import users from "./users.route.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -9,5 +14,10 @@ const router = Router();
|
|||||||
router.use("/games", games);
|
router.use("/games", games);
|
||||||
router.use("/auth", auth);
|
router.use("/auth", auth);
|
||||||
router.use("/users", users);
|
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;
|
export default router;
|
||||||
|
|||||||
12
server/src/routes/tournament.route.ts
Normal file
12
server/src/routes/tournament.route.ts
Normal 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;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import * as controller from "../controllers/users.controller.js";
|
import * as controller from "../controllers/users.controller.js";
|
||||||
|
import { searchUsers } from "../controllers/friends.controller.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/search", searchUsers);
|
||||||
router.route("/:name").get(controller.getUserProfile);
|
router.route("/:name").get(controller.getUserProfile);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { INIT_TABLES, db } from "./db/index.js";
|
|||||||
import session from "./middleware/session.js";
|
import session from "./middleware/session.js";
|
||||||
import routes from "./routes/index.js";
|
import routes from "./routes/index.js";
|
||||||
import { init as initSocket } from "./socket/index.js";
|
import { init as initSocket } from "./socket/index.js";
|
||||||
|
import { BOTS } from "./bots.js";
|
||||||
|
|
||||||
const corsConfig = {
|
const corsConfig = {
|
||||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
||||||
@@ -20,11 +21,33 @@ const server = createServer(app);
|
|||||||
|
|
||||||
// database
|
// database
|
||||||
await db.connect();
|
await db.connect();
|
||||||
db.query(INIT_TABLES, (err) => {
|
db.query(INIT_TABLES, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} else {
|
} else {
|
||||||
console.log("Tables initialized");
|
console.log("MiChess tables initialized");
|
||||||
|
|
||||||
|
// Set admin role for ADMIN_EMAIL if configured
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
|
if (adminEmail) {
|
||||||
|
try {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE "user" SET role='admin' WHERE email=$1 AND role='user'`,
|
||||||
|
[adminEmail]
|
||||||
|
);
|
||||||
|
console.log(`Admin role ensured for: ${adminEmail}`);
|
||||||
|
} catch (e) {
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +56,20 @@ app.use(cors(corsConfig));
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.set("trust proxy", 1);
|
app.set("trust proxy", 1);
|
||||||
app.use(session);
|
app.use(session);
|
||||||
|
|
||||||
|
// Ban check middleware
|
||||||
|
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((err) => { if (err) console.error(err); });
|
||||||
|
res.status(403).json({ message: "Your account has been banned." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.use("/v1", routes);
|
app.use("/v1", routes);
|
||||||
|
|
||||||
// socket.io
|
// socket.io
|
||||||
@@ -41,8 +78,8 @@ io.use((socket, next) => {
|
|||||||
session(socket.request as Request, {} as Response, next as NextFunction);
|
session(socket.request as Request, {} as Response, next as NextFunction);
|
||||||
});
|
});
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const session = socket.request.session;
|
const sess = socket.request.session;
|
||||||
if (session && session.user) {
|
if (sess && sess.user) {
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
console.log("io.use: no session");
|
console.log("io.use: no session");
|
||||||
@@ -53,5 +90,5 @@ initSocket();
|
|||||||
|
|
||||||
const port = process.env.PORT || 3001;
|
const port = process.env.PORT || 3001;
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`chessu api server listening on :${port}`);
|
console.log(`MiChess API server listening on :${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,139 @@
|
|||||||
import type { Game } from "@chessu/types";
|
import type { Game } from "@michess/types";
|
||||||
import { Chess } from "chess.js";
|
import { Chess } from "chess.js";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
import type { DisconnectReason, Socket } from "socket.io";
|
import type { DisconnectReason, Socket } from "socket.io";
|
||||||
|
|
||||||
import GameModel, { activeGames } from "../db/models/game.model.js";
|
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 { 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
|
// 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) {
|
export async function joinLobby(this: Socket, gameCode: string) {
|
||||||
const game = activeGames.find((g) => g.code === gameCode);
|
const game = activeGames.find((g) => g.code === gameCode);
|
||||||
if (!game) return;
|
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) {
|
if (game.host && game.host?.id === this.request.session.user.id) {
|
||||||
game.host.connected = true;
|
game.host.connected = true;
|
||||||
if (game.host.name !== this.request.session.user.name) {
|
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);
|
game.observers?.push(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.rooms.size >= 2) {
|
|
||||||
await leaveLobby.call(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game.timeout) {
|
if (game.timeout) {
|
||||||
clearTimeout(game.timeout);
|
clearTimeout(game.timeout);
|
||||||
game.timeout = undefined;
|
game.timeout = undefined;
|
||||||
@@ -49,6 +168,31 @@ export async function joinLobby(this: Socket, gameCode: string) {
|
|||||||
|
|
||||||
await this.join(gameCode);
|
await this.join(gameCode);
|
||||||
io.to(game.code as string).emit("receivedLatestGame", game);
|
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) {
|
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;
|
const { id } = (await GameModel.save(game)) as Game;
|
||||||
game.id = id;
|
game.id = id;
|
||||||
|
|
||||||
|
const nextRound1 = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
|
||||||
|
|
||||||
const gameOver = {
|
const gameOver = {
|
||||||
reason: game.endReason,
|
reason: game.endReason,
|
||||||
winnerName: this.request.session.user.name,
|
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);
|
if (game.timeout) clearTimeout(game.timeout);
|
||||||
activeGames.splice(activeGames.indexOf(game), 1);
|
activeGames.splice(activeGames.indexOf(game), 1);
|
||||||
|
if (nextRound1) await startNextRoundGames(nextRound1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// 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");
|
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);
|
const newMove = chess.move(m);
|
||||||
|
|
||||||
if (newMove) {
|
if (newMove) {
|
||||||
game.pgn = chess.pgn();
|
game.pgn = chess.pgn();
|
||||||
this.to(game.code as string).emit("receivedMove", m);
|
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()) {
|
if (chess.isGameOver()) {
|
||||||
let reason: Game["endReason"];
|
let reason: Game["endReason"];
|
||||||
if (chess.isCheckmate()) reason = "checkmate";
|
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
|
const { id } = (await GameModel.save(game)) as Game; // save game to db
|
||||||
game.id = id;
|
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 });
|
io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide, id });
|
||||||
|
|
||||||
if (game.timeout) clearTimeout(game.timeout);
|
if (game.timeout) clearTimeout(game.timeout);
|
||||||
activeGames.splice(activeGames.indexOf(game), 1);
|
activeGames.splice(activeGames.indexOf(game), 1);
|
||||||
|
if (nextRoundM) await startNextRoundGames(nextRoundM);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("invalid move");
|
throw new Error("invalid move");
|
||||||
@@ -240,6 +434,14 @@ export async function joinAsPlayer(this: Socket) {
|
|||||||
side: "white"
|
side: "white"
|
||||||
});
|
});
|
||||||
game.startedAt = Date.now();
|
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) {
|
} else if (!game.black) {
|
||||||
const sessionUser = {
|
const sessionUser = {
|
||||||
id: this.request.session.user.id,
|
id: this.request.session.user.id,
|
||||||
@@ -253,6 +455,14 @@ export async function joinAsPlayer(this: Socket) {
|
|||||||
side: "black"
|
side: "black"
|
||||||
});
|
});
|
||||||
game.startedAt = Date.now();
|
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 {
|
} else {
|
||||||
console.log("joinAsPlayer: attempted to join a game with already 2 players");
|
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
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { io } from "../server.js";
|
|||||||
import {
|
import {
|
||||||
chat,
|
chat,
|
||||||
claimAbandoned,
|
claimAbandoned,
|
||||||
|
claimTimeout,
|
||||||
getLatestGame,
|
getLatestGame,
|
||||||
joinAsPlayer,
|
joinAsPlayer,
|
||||||
joinLobby,
|
joinLobby,
|
||||||
leaveLobby,
|
leaveLobby,
|
||||||
sendMove
|
sendMove
|
||||||
} from "./game.socket.js";
|
} from "./game.socket.js";
|
||||||
|
import { onlineUsers } from "./state.js";
|
||||||
|
|
||||||
const socketConnect = (socket: Socket) => {
|
const socketConnect = (socket: Socket) => {
|
||||||
const req = socket.request;
|
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("joinLobby", joinLobby);
|
||||||
socket.on("leaveLobby", leaveLobby);
|
socket.on("leaveLobby", leaveLobby);
|
||||||
@@ -34,6 +57,7 @@ const socketConnect = (socket: Socket) => {
|
|||||||
socket.on("joinAsPlayer", joinAsPlayer);
|
socket.on("joinAsPlayer", joinAsPlayer);
|
||||||
socket.on("chat", chat);
|
socket.on("chat", chat);
|
||||||
socket.on("claimAbandoned", claimAbandoned);
|
socket.on("claimAbandoned", claimAbandoned);
|
||||||
|
socket.on("claimTimeout", claimTimeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const init = () => {
|
export const init = () => {
|
||||||
|
|||||||
2
server/src/socket/state.ts
Normal file
2
server/src/socket/state.ts
Normal 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
9
server/src/types/stockfish.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
}
|
"typeRoots": ["./src/types", "./node_modules/@types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
95
types/index.d.ts
vendored
95
types/index.d.ts
vendored
@@ -4,7 +4,7 @@ export interface Game {
|
|||||||
white?: User;
|
white?: User;
|
||||||
black?: User;
|
black?: User;
|
||||||
winner?: "white" | "black" | "draw";
|
winner?: "white" | "black" | "draw";
|
||||||
endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned";
|
endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned" | "timeout" | "resign";
|
||||||
host?: User;
|
host?: User;
|
||||||
code?: string;
|
code?: string;
|
||||||
unlisted?: boolean;
|
unlisted?: boolean;
|
||||||
@@ -12,6 +12,13 @@ export interface Game {
|
|||||||
observers?: User[];
|
observers?: User[];
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
endedAt?: number;
|
endedAt?: number;
|
||||||
|
vsAi?: boolean;
|
||||||
|
aiLevel?: number;
|
||||||
|
timeControl?: number;
|
||||||
|
whiteTimeMs?: number;
|
||||||
|
blackTimeMs?: number;
|
||||||
|
lastMoveAt?: number;
|
||||||
|
tournamentCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -21,8 +28,94 @@ export interface User {
|
|||||||
wins?: number;
|
wins?: number;
|
||||||
losses?: number;
|
losses?: number;
|
||||||
draws?: number;
|
draws?: number;
|
||||||
|
elo?: number;
|
||||||
|
role?: "user" | "admin";
|
||||||
|
|
||||||
// mainly for players, not spectators
|
// mainly for players, not spectators
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
disconnectedOn?: number;
|
disconnectedOn?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FriendRequest {
|
||||||
|
id?: number;
|
||||||
|
fromId?: number;
|
||||||
|
toId?: number;
|
||||||
|
fromName?: string;
|
||||||
|
toName?: string;
|
||||||
|
status?: "pending" | "accepted" | "rejected";
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Friendship {
|
||||||
|
id?: number;
|
||||||
|
userId?: number;
|
||||||
|
friendId?: number;
|
||||||
|
friendName?: string;
|
||||||
|
wins?: number;
|
||||||
|
losses?: number;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@chessu/types",
|
"name": "@michess/types",
|
||||||
"private": "true",
|
"private": "true",
|
||||||
"version": "0.0.0"
|
"version": "0.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user