commit 94b6cc3be92df904f27cea0da9b90adbdd8cfec5 Author: Mike Date: Thu Jul 2 08:31:36 2026 +0200 Initial PlayBull release with Trilogy Hub integration. Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66501e9 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# PlayBull — kopiere nach .env und fuelle aus +PORT=3080 +JWT_SECRET= +FINNHUB_API_KEY= +START_BALANCE=10000 +TRUST_PROXY=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09e986f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +data/ +*.log +.DS_Store +.cursor/ +.env +deploy/env.homelab +deploy/homelab.credentials diff --git a/README.md b/README.md new file mode 100644 index 0000000..539bed8 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# 🐂 PlayBull — Trading & Gamble (Spielgeld) + +Eine mobile-first Web-App, die **Live-Trading** mit echten Aktienkursen und ein **Casino** +verbindet. Alles mit Spielgeld, alle Spieler teilen denselben Live-Markt. + +> ⚠️ Reines Spaßprojekt mit **Spielgeld**. Kein echtes Geld, kein echtes Glücksspiel. + +## Features + +### 📈 Trading (für alle sichtbar, Handel mit Login) +- **Echte Startkurse** über die Finnhub-API (Aktien) + Live-Simulation +- **Geteilter Live-Markt**: Kurse sind bei allen identisch und ticken jede Sekunde +- **Long & Short**, Position-Netting, Live-P&L, Depot/Net-Worth +- 14 Assets: Aktien (AAPL, TSLA, NVDA, …), Krypto/Meme (BTC, ETH, DOGE, BULL) +- Live-Chart pro Asset (Canvas) + +### 🎛️ Markt-Manipulation (Spaßmodus: jeder eingeloggte User) +- **Pump 🚀 / Dump 📉**, Kurs hart **setzen**, **Halt/Resume**, **Reset** +- Wirkt sofort für **alle** Spieler gleichzeitig (geteilter Live-Status) + +### 🎰 Casino (Login erforderlich) +- **🐔 Chicken Road** — Straße überqueren, Multiplikator steigt, rechtzeitig cashen (4 Schwierigkeiten) +- **🃏 10×10 Karten-Deck** — 100 Felder, Bomben vermeiden, Multiplikator steigt pro sicherer Karte +- **🚀 Crash** — server-autoritativ, raus bevor die Rakete crasht +- **🎲 Dice** — Über/Unter mit einstellbarem Risiko +- **🪙 Coinflip** — Kopf oder Zahl, 2× + +### ⚙️ Einstellungen / Admin (Spaßmodus) +- **Geld bearbeiten** bei sich selbst & anderen (hinzufügen / setzen, Quick-Buttons) +- Markt-Kontrolle direkt aus den Einstellungen +- Avatar wählen, Spielerliste + +### 🏆 Social +- Live-Aktivitäts-Feed (Trades, Wins, Manipulationen) als Ticker + Liste +- Rangliste nach Net-Worth + +## Tech-Stack +- **Backend**: Node.js, Express, Socket.IO, eingebautes `node:sqlite` +- **Auth**: JWT (httpOnly-Cookie) + bcrypt +- **Frontend**: Vanilla JS (ES-Module), Canvas-Charts, mobile-first CSS +- Keine Build-Tools nötig + +## Start + +```bash +npm install +npm start +``` + +Dann im Browser öffnen: **http://localhost:3000** (Handy: gleiche WLAN-IP, Port 3000). + +## Konfiguration (`.env`) + +| Variable | Bedeutung | +|---|---| +| `PORT` | Server-Port (Default 3000) | +| `JWT_SECRET` | Secret für Login-Tokens | +| `FINNHUB_API_KEY` | API-Key für echte Aktienkurse | +| `START_BALANCE` | Startguthaben neuer Spieler (Default 10000) | + +## Verbindung Trading ↔ Casino +Beide nutzen **denselben Spielgeld-Kontostand**. Gewinne aus dem Casino kannst du +direkt im Trading einsetzen — und umgekehrt. Der erste registrierte User existiert +gleichberechtigt; im Spaßmodus darf jeder eingeloggte Spieler den Markt manipulieren +und Guthaben bearbeiten. + +## Projektstruktur +``` +server/ + index.js Express + Socket.IO + Routen + config.js .env-Loader + db.js SQLite-Schema & Helper + auth.js Register/Login/JWT + market.js Finnhub + Live-Simulation + Manipulation + trading.js Buy/Sell/Short, Portfolio, P&L + games.js Chicken, Mines, Crash, Dice, Coinflip + realtime.js Socket-Broadcast + Feed +public/ + index.html, css/styles.css, js/app.js +``` diff --git a/deploy/caddy-trilogyhub.snippet b/deploy/caddy-trilogyhub.snippet new file mode 100644 index 0000000..ab36ec5 --- /dev/null +++ b/deploy/caddy-trilogyhub.snippet @@ -0,0 +1,21 @@ +# Trilogy Hub — Caddy-Block (Homelab: progress-caddy) +# Datei auf dem Server: /home/gizzler/pgs2/finalprogressharing/Caddyfile +# Nach Aenderung: docker exec progress-caddy caddy reload --config /etc/caddy/Caddyfile + +trilogyhub.de, www.trilogyhub.de { + encode gzip zstd + @ws { + header Connection *Upgrade* + header Upgrade websocket + } + reverse_proxy 192.168.178.49:3080 +} + +halmc.duckdns.org { + encode gzip zstd + @ws { + header Connection *Upgrade* + header Upgrade websocket + } + reverse_proxy 192.168.178.49:3080 +} diff --git a/deploy/cloudflare-ddns-update.sh b/deploy/cloudflare-ddns-update.sh new file mode 100644 index 0000000..ce5eccd --- /dev/null +++ b/deploy/cloudflare-ddns-update.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Cloudflare Dynamic DNS — aktualisiert A/AAAA wenn sich die Fritz!Box-WAN-IP aendert. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/env.homelab" +STATE_FILE="${SCRIPT_DIR}/.cf-ddns-last-ip" + +[[ -f "${ENV_FILE}" ]] || { echo "cloudflare-ddns: ${ENV_FILE} fehlt" >&2; exit 1; } +# shellcheck source=/dev/null +source <(sed 's/\r$//' "${ENV_FILE}") + +: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN fehlt}" +: "${CLOUDFLARE_ZONE_ID:?CLOUDFLARE_ZONE_ID fehlt}" + +APP_DOMAIN="${APP_DOMAIN:-trilogyhub.de}" +DDNS_RECORD="${DDNS_RECORD_NAME:-home.${APP_DOMAIN}}" +DDNS_PROXIED="${DDNS_PROXIED:-true}" + +IPV4="$(curl -4 -fsS --max-time 15 https://api.ipify.org 2>/dev/null || true)" +IPV6="$(curl -6 -fsS --max-time 15 https://api64.ipify.org 2>/dev/null || true)" +[[ -n "${IPV4}" || -n "${IPV6}" ]] || { echo "cloudflare-ddns: keine IP" >&2; exit 1; } + +CURRENT="${IPV4}|${IPV6}" +LAST="$(cat "${STATE_FILE}" 2>/dev/null || true)" +if [[ "${CURRENT}" == "${LAST}" ]]; then + echo "$(date -Is) cloudflare-ddns ${DDNS_RECORD}: unveraendert v4=${IPV4:-none}" + exit 0 +fi + +export CF_API="https://api.cloudflare.com/client/v4" +export CF_ZONE="${CLOUDFLARE_ZONE_ID}" +export CF_TOKEN="${CLOUDFLARE_API_TOKEN}" +export CF_PROXIED="${DDNS_PROXIED}" + +upsert() { + local rtype="$1" ip="$2" + [[ -n "${ip}" ]] || return 0 + python3 - "${DDNS_RECORD}" "${rtype}" "${ip}" <<'PY' +import json, os, sys, urllib.request +name, rtype, ip = sys.argv[1:4] +api = os.environ["CF_API"] +zone = os.environ["CF_ZONE"] +token = os.environ["CF_TOKEN"] +proxied = os.environ.get("CF_PROXIED", "true").lower() == "true" +headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} +list_url = f"{api}/zones/{zone}/dns_records?type={rtype}&name={name}" +with urllib.request.urlopen(urllib.request.Request(list_url, headers=headers), timeout=20) as r: + data = json.load(r) +if not data.get("success"): + raise SystemExit(f"list failed: {data}") +body = json.dumps({"type": rtype, "name": name, "content": ip, "ttl": 120, "proxied": proxied}).encode() +recs = data.get("result", []) +if recs: + url = f"{api}/zones/{zone}/dns_records/{recs[0]['id']}" + req = urllib.request.Request(url, data=body, headers=headers, method="PUT") + act = "aktualisiert" +else: + url = f"{api}/zones/{zone}/dns_records" + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + act = "angelegt" +with urllib.request.urlopen(req, timeout=20) as r: + out = json.load(r) +if not out.get("success"): + raise SystemExit(f"write failed: {out}") +print(f"{rtype} {name} -> {ip} ({act})") +PY +} + +upsert A "${IPV4}" +upsert AAAA "${IPV6}" +echo "${CURRENT}" > "${STATE_FILE}" +chmod 600 "${STATE_FILE}" 2>/dev/null || true +echo "$(date -Is) cloudflare-ddns fertig" diff --git a/deploy/cloudflared/config.yml b/deploy/cloudflared/config.yml new file mode 100644 index 0000000..fb7820c --- /dev/null +++ b/deploy/cloudflared/config.yml @@ -0,0 +1,26 @@ +# Cloudflare Tunnel — HTTPS am Edge, kein Port-Forwarding noetig. +# Token wird per systemd EnvironmentFile gesetzt (deploy/env.homelab). +# +# Zero Trust Dashboard: +# Networks → Tunnels → Create → Cloudflared +# Public Hostname: trilogyhub.de → http://127.0.0.1:3080 +# Tunnel-Token in CLOUDFLARE_TUNNEL_TOKEN eintragen +# +# Hinweis: Hostnames im Zero-Trust-Dashboard eintragen (Token-Install). +# Cloudflare-DDNS meldet WAN-IP fuer home.trilogyhub.de (optional). + +tunnel: playbull +credentials-file: /etc/cloudflared/playbull.json + +ingress: + - hostname: trilogyhub.de + service: http://127.0.0.1:3080 + originRequest: + noTLSVerify: true + httpHostHeader: trilogyhub.de + - hostname: www.trilogyhub.de + service: http://127.0.0.1:3080 + originRequest: + noTLSVerify: true + httpHostHeader: www.trilogyhub.de + - service: http_status:404 diff --git a/deploy/connect-trilogyhub.sh b/deploy/connect-trilogyhub.sh new file mode 100644 index 0000000..8f5bf9f --- /dev/null +++ b/deploy/connect-trilogyhub.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# trilogyhub.de mit Homelab-Website verbinden (Nginx + optional Certbot + Tunnel-Hinweis) +set -euo pipefail + +DOMAIN="${1:-trilogyhub.de}" +PORT="${APP_PORT:-3080}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ENV_FILE="${APP_DIR}/deploy/env.homelab" + +if [[ "${EUID}" -ne 0 ]]; then + echo "sudo bash deploy/connect-trilogyhub.sh" >&2 + exit 1 +fi + +[[ -f "${ENV_FILE}" ]] || { echo "${ENV_FILE} fehlt" >&2; exit 1; } +sed -i 's/\r$//' "${ENV_FILE}" 2>/dev/null || true +# shellcheck source=/dev/null +source "${ENV_FILE}" + +PORT="${APP_PORT:-3080}" +DOMAIN="${APP_DOMAIN:-${DOMAIN}}" + +echo "==> Verbinde ${DOMAIN} → 127.0.0.1:${PORT}" + +# env aktualisieren +grep -q '^APP_DOMAIN=' "${ENV_FILE}" && sed -i "s|^APP_DOMAIN=.*|APP_DOMAIN=${DOMAIN}|" "${ENV_FILE}" || echo "APP_DOMAIN=${DOMAIN}" >> "${ENV_FILE}" + +# LAN (Fritz-Netz) +sed -i 's/\r$//' "${APP_DIR}/deploy/nginx/bestewebsite-lan.conf" 2>/dev/null || true +cp "${APP_DIR}/deploy/nginx/trilogyhub.conf" /etc/nginx/sites-available/trilogyhub 2>/dev/null || \ +cp "${APP_DIR}/deploy/nginx/bestewebsite.conf" /etc/nginx/sites-available/trilogyhub + +# HTTP-only zuerst (fuer Certbot) +cat > /etc/nginx/sites-available/trilogyhub < /etc/nginx/sites-available/bestewebsite-legacy <<'LEG' +server { + listen 80; + listen [::]:80; + server_name halmc.duckdns.org pimmelschwanz.duckdns.org; + return 301 https://trilogyhub.de$request_uri; +} +LEG + ln -sf /etc/nginx/sites-available/bestewebsite-legacy /etc/nginx/sites-enabled/bestewebsite-legacy 2>/dev/null || true +fi + +nginx -t +systemctl reload nginx + +# App muss laufen +if ! curl -sf "http://127.0.0.1:${PORT}/" >/dev/null; then + echo "WARNUNG: nichts auf Port ${PORT} — playbull.service starten" >&2 + systemctl restart playbull 2>/dev/null || true +fi + +# Certbot wenn DNS auf diesen Server zeigt +if command -v certbot >/dev/null 2>&1; then + echo "==> Versuche Let's Encrypt fuer ${DOMAIN}..." + if certbot --nginx -d "${DOMAIN}" -d "www.${DOMAIN}" \ + --non-interactive --agree-tos --redirect \ + -m "admin@${DOMAIN}" 2>/dev/null; then + echo " SSL OK" + else + echo " SSL noch nicht moeglich (DNS/Cloudflare noch nicht aktiv) — HTTP auf Port 80 ist vorbereitet" + fi +fi + +# Cloudflare Tunnel +if [[ -n "${CLOUDFLARE_TUNNEL_TOKEN:-}" ]]; then + echo "==> Cloudflare Tunnel" + cloudflared service uninstall 2>/dev/null || true + cloudflared service install "${CLOUDFLARE_TUNNEL_TOKEN}" + systemctl enable cloudflared + systemctl restart cloudflared + echo " Im Cloudflare-Dashboard Public Hostname setzen:" + echo " ${DOMAIN} + www.${DOMAIN} → http://127.0.0.1:${PORT}" +fi + +nginx -t && systemctl reload nginx + +echo "" +echo "=== Fertig ===" +echo "App: http://127.0.0.1:${PORT}/" +echo "LAN: http://192.168.178.49/" +echo "Domain: http://${DOMAIN}/ (sobald DNS auf Homelab/Cloudflare zeigt)" +echo "HTTPS: https://${DOMAIN}/ (nach Cloudflare Active + Tunnel ODER Certbot)" diff --git a/deploy/duckdns-update.bat b/deploy/duckdns-update.bat new file mode 100644 index 0000000..ef5c461 --- /dev/null +++ b/deploy/duckdns-update.bat @@ -0,0 +1,15 @@ +@echo off +REM DuckDNS IP-Updater fuer Windows (optional, falls kein Linux-Homelab-Timer) +setlocal +cd /d "%~dp0.." +if not exist "deploy\env.homelab" ( + echo deploy\env.homelab fehlt + exit /b 1 +) +for /f "usebackq tokens=1,2 delims==" %%a in ("deploy\env.homelab") do ( + if "%%a"=="DUCKDNS_TOKEN" set DUCKDNS_TOKEN=%%b + if "%%a"=="DUCKDNS_DOMAIN" set DUCKDNS_DOMAIN=%%b +) +for /f "delims=" %%i in ('powershell -NoProfile -Command "(Invoke-WebRequest -Uri 'https://api.ipify.org' -UseBasicParsing).Content"') do set IPV4=%%i +for /f "tokens=1 delims=." %%s in ("%DUCKDNS_DOMAIN%") do set SUB=%%s +powershell -NoProfile -Command "$r=(Invoke-WebRequest -Uri 'https://www.duckdns.org/update?domains=%SUB%&token=%DUCKDNS_TOKEN%&verbose=true&ip=%IPV4%' -UseBasicParsing).Content; Write-Host (Get-Date -Format o) 'duckdns' '%SUB%:' $r 'ip=' '%IPV4%'" diff --git a/deploy/duckdns-update.sh b/deploy/duckdns-update.sh new file mode 100644 index 0000000..ec44896 --- /dev/null +++ b/deploy/duckdns-update.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# DuckDNS IP-Updater — holt oeffentliche IPv4/IPv6 und meldet sie an DuckDNS. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/env.homelab" + +: "${DUCKDNS_TOKEN:?DUCKDNS_TOKEN fehlt in deploy/env.homelab}" +: "${DUCKDNS_DOMAIN:?DUCKDNS_DOMAIN fehlt in deploy/env.homelab}" + +SUBDOMAIN="${DUCKDNS_DOMAIN%%.*}" + +IPV4="$(curl -4 -fsS --max-time 15 https://api.ipify.org 2>/dev/null || true)" +IPV6="$(curl -6 -fsS --max-time 15 https://api64.ipify.org 2>/dev/null || true)" + +if [[ -z "${IPV4}" && -z "${IPV6}" ]]; then + echo "duckdns-update: keine oeffentliche IP ermittelbar" >&2 + exit 1 +fi + +URL="https://www.duckdns.org/update?domains=${SUBDOMAIN}&token=${DUCKDNS_TOKEN}&verbose=true" +[[ -n "${IPV4}" ]] && URL+="&ip=${IPV4}" +[[ -n "${IPV6}" ]] && URL+="&ipv6=${IPV6}" + +RESP="$(curl -fsS --max-time 20 "${URL}")" +echo "$(date -Is) duckdns ${SUBDOMAIN}: ${RESP} (v4=${IPV4:-none} v6=${IPV6:-none})" + +case "${RESP}" in + OK*) exit 0 ;; + *) exit 1 ;; +esac diff --git a/deploy/env.homelab.example b/deploy/env.homelab.example new file mode 100644 index 0000000..47a0ffb --- /dev/null +++ b/deploy/env.homelab.example @@ -0,0 +1,33 @@ +# Homelab-Konfiguration (deploy/env.homelab auf dem Server — NICHT committen) +HOMELAB_SSH=gizzler@192.168.178.49 + +# === Hauptdomain (Strato, DNS bei Cloudflare) === +APP_DOMAIN=trilogyhub.de + +# Cloudflare Tunnel (Zero Trust → Networks → Tunnels → dein Tunnel → Install command / Token) +# Public Hostnames im Dashboard eintragen: +# trilogyhub.de → http://127.0.0.1:3080 +# www.trilogyhub.de → http://127.0.0.1:3080 +CLOUDFLARE_TUNNEL_TOKEN= + +# Cloudflare Dynamic DNS — meldet Fritz!Box-WAN-IP an Cloudflare (alle 5 Min) +# API Token: My Profile → API Tokens → Create → Zone DNS Edit (nur trilogyhub.de) +# Zone ID: Cloudflare Dashboard → trilogyhub.de → Overview → rechts „Zone ID“ +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ZONE_ID= +# Welcher DNS-Name bekommt die aktuelle IP? (home.* empfohlen wenn Hauptdomain per Tunnel laeuft) +DDNS_RECORD_NAME=home.trilogyhub.de +DDNS_PROXIED=true + +# Legacy DuckDNS (optional, kann leer bleiben) +DUCKDNS_TOKEN= +DUCKDNS_DOMAIN= + +# PlayBull +APP_PORT=3080 +APP_DIR=/home/gizzler/bestewebsite/bestewebsite +APP_USER=gizzler + +# App-Secrets +JWT_SECRET=bitte-aendern +FINNHUB_API_KEY= diff --git a/deploy/finish-on-server.sh b/deploy/finish-on-server.sh new file mode 100644 index 0000000..328155b --- /dev/null +++ b/deploy/finish-on-server.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Auf dem Homelab ausfuehren (nach push-homelab.ps1), wenn der Installer per SSH abbrach: +# cd ~/bestewebsite/bestewebsite && bash deploy/finish-on-server.sh +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$DIR" +chmod +x deploy/install-homelab.sh deploy/duckdns-update.sh 2>/dev/null || true + +echo "==> PlayBull Installer (braucht sudo-Passwort auf dem Server)" +echo " Ziel: ${APP_DIR} (playbull.service)" +sudo bash deploy/install-homelab.sh + +echo "" +echo "Pruefen:" +systemctl status playbull --no-pager || true diff --git a/deploy/fix-domain.sh b/deploy/fix-domain.sh new file mode 100644 index 0000000..0c2f063 --- /dev/null +++ b/deploy/fix-domain.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Domain-Migration auf dem Homelab (DuckDNS + Nginx + Certbot) +# Usage: sudo bash deploy/fix-domain.sh [neue-domain] +set -euo pipefail + +NEW_DOMAIN="${1:-trilogyhub.de}" +OLD_DOMAIN="${2:-halmc.duckdns.org}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Bitte als root: sudo bash deploy/fix-domain.sh" >&2 + exit 1 +fi + +ENV_FILE="${APP_DIR}/deploy/env.homelab" +if [[ ! -f "${ENV_FILE}" ]]; then + echo "deploy/env.homelab fehlt" >&2 + exit 1 +fi + +echo "==> Domain: ${OLD_DOMAIN} -> ${NEW_DOMAIN}" + +# env.homelab aktualisieren (Token behalten, CRLF entfernen) +sed -i 's/\r$//' "${ENV_FILE}" +if grep -q '^APP_DOMAIN=' "${ENV_FILE}"; then + sed -i "s|^APP_DOMAIN=.*|APP_DOMAIN=${NEW_DOMAIN}|" "${ENV_FILE}" +else + echo "APP_DOMAIN=${NEW_DOMAIN}" >> "${ENV_FILE}" +fi +if grep -q '^DUCKDNS_DOMAIN=' "${ENV_FILE}"; then + sed -i "s|^DUCKDNS_DOMAIN=.*|DUCKDNS_DOMAIN=${NEW_DOMAIN}|" "${ENV_FILE}" +else + echo "DUCKDNS_DOMAIN=${NEW_DOMAIN}" >> "${ENV_FILE}" +fi +chmod 600 "${ENV_FILE}" + +# DuckDNS IP aktualisieren +if grep -q '^DUCKDNS_TOKEN=.\+' "${ENV_FILE}" 2>/dev/null; then + echo "==> DuckDNS Update" + bash "${APP_DIR}/deploy/duckdns-update.sh" || echo " DuckDNS-Update fehlgeschlagen (manuell pruefen)" +else + echo " DuckDNS-Token leer — Update uebersprungen" +fi + +# Nginx LAN +echo "==> Nginx LAN" +sed -i 's/\r$//' "${APP_DIR}/deploy/nginx/bestewebsite-lan.conf" "${APP_DIR}/deploy/nginx/bestewebsite.conf" 2>/dev/null || true +cp "${APP_DIR}/deploy/nginx/bestewebsite-lan.conf" /etc/nginx/sites-available/bestewebsite-lan +ln -sf /etc/nginx/sites-available/bestewebsite-lan /etc/nginx/sites-enabled/00-bestewebsite-lan + +# Nginx öffentlich — zuerst HTTP-only für Certbot +echo "==> Nginx HTTP (Certbot-Vorbereitung)" +cat > /etc/nginx/sites-available/bestewebsite < Certbot fuer ${NEW_DOMAIN}" +CERTBOT_EMAIL="admin@${NEW_DOMAIN}" +if certbot certificates 2>/dev/null | grep -q "${NEW_DOMAIN}"; then + certbot renew --nginx --quiet || true +else + certbot --nginx -d "${NEW_DOMAIN}" --non-interactive --agree-tos --redirect -m "${CERTBOT_EMAIL}" || { + echo "Certbot fehlgeschlagen — pruefe ob ${NEW_DOMAIN} auf diese Maschine zeigt (Port 80)." >&2 + exit 1 + } +fi + +# Finales Nginx-Template — Certbot-Zertifikat einbinden (kein erneutes certbot install) +echo "==> Nginx HTTPS finalisieren" +cp "${APP_DIR}/deploy/nginx/bestewebsite.conf" /etc/nginx/sites-available/bestewebsite +if [[ ! -f "/etc/letsencrypt/live/${NEW_DOMAIN}/fullchain.pem" ]]; then + echo "Zertifikat fehlt fuer ${NEW_DOMAIN}" >&2 + exit 1 +fi +ln -sf /etc/nginx/sites-available/bestewebsite /etc/nginx/sites-enabled/bestewebsite +nginx -t +systemctl reload nginx + +# Alte Domain optional auf neue weiterleiten (falls noch DNS zeigt) +if [[ "${OLD_DOMAIN}" != "${NEW_DOMAIN}" ]]; then + cat > /etc/nginx/sites-available/bestewebsite-legacy < ${NEW_DOMAIN} aktiv" + else + rm -f /etc/nginx/sites-enabled/bestewebsite-legacy + fi +fi + +systemctl restart playbull || true + +echo "" +echo "=== Fertig ===" +echo "HTTPS: https://${NEW_DOMAIN}" +echo "LAN: http://192.168.178.49" +echo "Test: curl -sfI https://${NEW_DOMAIN}/ | head -3" diff --git a/deploy/homelab-exec.ps1 b/deploy/homelab-exec.ps1 new file mode 100644 index 0000000..65bc22a --- /dev/null +++ b/deploy/homelab-exec.ps1 @@ -0,0 +1,33 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Fuehrt einen Befehl auf dem Homelab aus (ohne interaktives SSH-Passwort). + +.USAGE + .\deploy\homelab-exec.ps1 "systemctl status playbull --no-pager" + .\deploy\homelab-exec.ps1 -Sudo "bash deploy/install-homelab.sh" +#> +param( + [Parameter(Position = 0, Mandatory = $true)] + [string]$Command, + + [switch]$Sudo, + [string]$WorkDir = "", + [int]$TimeoutSeconds = 120 +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/homelab-lib.ps1" + +$config = Get-HomelabConfig +$result = Invoke-HomelabRemote -Config $config -Command $Command -Sudo:$Sudo -WorkDir $WorkDir -TimeoutSeconds $TimeoutSeconds + +if ($result.Output) { + $result.Output | ForEach-Object { Write-Output $_ } +} +if ($result.Error) { + $result.Error | ForEach-Object { [Console]::Error.WriteLine($_) } +} +if ($result.ExitStatus -ne 0) { + exit $result.ExitStatus +} diff --git a/deploy/homelab-git-check.ps1 b/deploy/homelab-git-check.ps1 new file mode 100644 index 0000000..860cffe --- /dev/null +++ b/deploy/homelab-git-check.ps1 @@ -0,0 +1,13 @@ +#Requires -Version 5.1 +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/homelab-lib.ps1" +$config = Get-HomelabConfig +$cmds = @( + "git status -sb", + "git diff --stat | head -30", + "curl -s http://127.0.0.1:3080/api/images | head -c 200" +) +foreach ($c in $cmds) { + Write-Host "`n>> $c" -ForegroundColor Cyan + & "$PSScriptRoot/homelab-exec.ps1" $c +} diff --git a/deploy/homelab-lib.ps1 b/deploy/homelab-lib.ps1 new file mode 100644 index 0000000..5548cc2 --- /dev/null +++ b/deploy/homelab-lib.ps1 @@ -0,0 +1,130 @@ +# Gemeinsame Homelab-Hilfen (credentials + Posh-SSH) +function Read-HomelabKeyValueFile { + param([string]$Path) + $map = @{} + if (-not (Test-Path $Path)) { return $map } + Get-Content $Path | ForEach-Object { + $line = $_.Trim() + if ($line -eq "" -or $line.StartsWith("#")) { return } + if ($line -match '^([^=]+)=(.*)$') { + $map[$Matches[1].Trim()] = $Matches[2].Trim() + } + } + return $map +} + +function Ensure-PoshSshModule { + if (-not (Get-Module -ListAvailable -Name Posh-SSH)) { + Install-Module -Name Posh-SSH -Scope CurrentUser -Force -AllowClobber + } + Import-Module Posh-SSH -ErrorAction Stop +} + +function Get-HomelabConfig { + $deployDir = $PSScriptRoot + $credFile = Join-Path $deployDir "homelab.credentials" + if (-not (Test-Path $credFile)) { + throw "deploy/homelab.credentials fehlt." + } + + $cred = Read-HomelabKeyValueFile $credFile + $envFile = Read-HomelabKeyValueFile (Join-Path $deployDir "env.homelab") + + $hostName = $cred["HOMELAB_HOST"] + $user = $cred["HOMELAB_USER"] + $password = $cred["HOMELAB_PASSWORD"] + $sudoPassword = if ($cred["HOMELAB_SUDO_PASSWORD"]) { $cred["HOMELAB_SUDO_PASSWORD"] } else { $password } + + if ([string]::IsNullOrWhiteSpace($hostName)) { $hostName = "192.168.178.49" } + if ([string]::IsNullOrWhiteSpace($user)) { $user = "gizzler" } + if ([string]::IsNullOrWhiteSpace($password)) { + throw "HOMELAB_PASSWORD in deploy/homelab.credentials ist leer." + } + + $appDir = if ($cred["HOMELAB_APP_DIR"]) { $cred["HOMELAB_APP_DIR"] } + elseif ($envFile["APP_DIR"]) { $envFile["APP_DIR"] } + else { "/home/gizzler/bestewebsite/bestewebsite" } + + $sec = ConvertTo-SecureString $password -AsPlainText -Force + $psCred = New-Object System.Management.Automation.PSCredential ($user, $sec) + + return [PSCustomObject]@{ + HostName = $hostName + User = $user + Password = $password + SudoPassword = $sudoPassword + AppDir = $appDir + Credential = $psCred + SshTarget = "${user}@${hostName}" + } +} + +function Resolve-HomelabIPv4 { + param([string]$HostName) + if ($HostName -match '^\d+\.\d+\.\d+\.\d+$') { return $HostName } + try { + $v4 = [System.Net.Dns]::GetHostAddresses($HostName) | + Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } | + Select-Object -First 1 + if ($v4) { return $v4.ToString() } + } catch { } + return $HostName +} + +function New-HomelabSession { + param($Config) + Ensure-PoshSshModule + $target = Resolve-HomelabIPv4 $Config.HostName + $session = New-SSHSession -ComputerName $target -Credential $Config.Credential -AcceptKey -ConnectionTimeout 30 + if (-not $session) { throw "SSH-Verbindung zu $($Config.User)@${target} fehlgeschlagen." } + return $session +} + +function Invoke-HomelabRemote { + param( + $Config, + [string]$Command, + [switch]$Sudo, + [string]$WorkDir = "", + [int]$TimeoutSeconds = 120 + ) + if ([string]::IsNullOrWhiteSpace($WorkDir)) { $WorkDir = $Config.AppDir } + + $remoteCmd = $Command + if ($Sudo) { + $inner = if ($WorkDir) { "cd '$WorkDir' && $Command" } else { $Command } + $escaped = $inner.Replace("'", "'\''") + $remoteCmd = "echo '$($Config.SudoPassword)' | sudo -S bash -lc '$escaped'" + } elseif ($WorkDir) { + $remoteCmd = "cd '$WorkDir' && $Command" + } + + $session = New-HomelabSession $Config + try { + $result = Invoke-SSHCommand -SessionId $session.SessionId -Command $remoteCmd -TimeOut $TimeoutSeconds + return $result + } + finally { + Remove-SSHSession -SessionId $session.SessionId | Out-Null + } +} + +function Send-HomelabPath { + param( + $Config, + [string]$LocalPath, + [string]$RemoteDir + ) + Ensure-PoshSshModule + $target = Resolve-HomelabIPv4 $Config.HostName + $remoteDir = ($RemoteDir.TrimEnd('/') + '/') + + $sftp = New-SFTPSession -ComputerName $target -Credential $Config.Credential -AcceptKey + if (-not $sftp) { throw "SFTP-Verbindung zu ${target} fehlgeschlagen." } + try { + Set-SFTPItem -SessionId $sftp.SessionId -Path $LocalPath -Destination $remoteDir -Force + } + finally { + Remove-SFTPSession -SessionId $sftp.SessionId | Out-Null + } +} diff --git a/deploy/homelab-safe-pull.sh b/deploy/homelab-safe-pull.sh new file mode 100644 index 0000000..a1fb437 --- /dev/null +++ b/deploy/homelab-safe-pull.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Auf dem Homelab: Trilogy pullen, PlayBull-Overlay sichern. +set -euo pipefail +cd "$(dirname "$0")/.." +git stash push -u -m "playbull-vor-trilogy-pull-$(date +%Y%m%d-%H%M)" -- \ + . ':(exclude)node_modules' ':(exclude)node_modules/**' \ + ':(exclude)data' ':(exclude)data/**' \ + ':(exclude)playbull-deploy.tgz' ':(exclude)deploy/env.homelab' \ + ':(exclude)deploy/homelab.credentials' ':(exclude).env' || true +git pull origin main +git status -sb diff --git a/deploy/homelab.credentials.example b/deploy/homelab.credentials.example new file mode 100644 index 0000000..c0920df --- /dev/null +++ b/deploy/homelab.credentials.example @@ -0,0 +1,13 @@ +# Lokale Homelab-Zugangsdaten (NICHT committen) +# Kopieren nach: deploy/homelab.credentials +# Ziel auf dem Server: ~/bestewebsite/bestewebsite + +HOMELAB_HOST=mikeshomelab +HOMELAB_USER=gizzler +HOMELAB_PASSWORD=dein-ssh-passwort + +# Optional — falls sudo ein anderes Passwort braucht (sonst = HOMELAB_PASSWORD) +# HOMELAB_SUDO_PASSWORD= + +# Optional — Arbeitsverzeichnis auf dem Server (Standard aus env.homelab) +# HOMELAB_APP_DIR=/home/gizzler/bestewebsite/bestewebsite diff --git a/deploy/install-homelab.sh b/deploy/install-homelab.sh new file mode 100644 index 0000000..727e46d --- /dev/null +++ b/deploy/install-homelab.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# PlayBull Homelab-Installer — Ziel: ~/bestewebsite/bestewebsite +# DuckDNS + playbull.service + Cloudflare Tunnel (cloudflared service install) +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo "Bitte als root ausfuehren: sudo bash deploy/install-homelab.sh" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if [[ ! -f "${SCRIPT_DIR}/env.homelab" ]]; then + cp "${SCRIPT_DIR}/env.homelab.example" "${SCRIPT_DIR}/env.homelab" + echo "deploy/env.homelab erstellt — Token eintragen und erneut starten." >&2 + exit 1 +fi + +# shellcheck source=/dev/null +source <(sed 's/\r$//' "${SCRIPT_DIR}/env.homelab") + +APP_PORT="${APP_PORT:-3080}" +APP_DIR="${APP_DIR:-/home/gizzler/bestewebsite/bestewebsite}" +APP_DIR="${APP_DIR//$'\r'/}" +APP_USER="${APP_USER:-gizzler}" +APP_USER="${APP_USER//$'\r'/}" +DUCKDNS_DOMAIN="${DUCKDNS_DOMAIN//$'\r'/}" +DUCKDNS_TOKEN="${DUCKDNS_TOKEN//$'\r'/}" +APP_DOMAIN="${APP_DOMAIN:-trilogyhub.de}" +APP_DOMAIN="${APP_DOMAIN//$'\r'/}" +CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN//$'\r'/}" +CLOUDFLARE_ZONE_ID="${CLOUDFLARE_ZONE_ID//$'\r'/}" +CLOUDFLARE_TUNNEL_TOKEN="${CLOUDFLARE_TUNNEL_TOKEN//$'\r'/}" +JWT_SECRET="${JWT_SECRET//$'\r'/}" +FINNHUB_API_KEY="${FINNHUB_API_KEY//$'\r'/}" + +echo "==> PlayBull / Trilogy Hub Homelab Setup" +echo " Domain: ${APP_DOMAIN}" +echo " Port: ${APP_PORT}" +echo " Pfad: ${APP_DIR}" +echo "" + +install_unit() { + local src="$1" dest="$2" + sed -e "s|__APP_DIR__|${APP_DIR}|g" \ + -e "s|__APP_USER__|${APP_USER}|g" \ + "${src}" > "${dest}" +} + +need_pkg() { command -v "$1" >/dev/null 2>&1; } + +if [[ "${SKIP_APT:-0}" != "1" ]] && command -v apt-get >/dev/null 2>&1; then + echo "==> Paketlisten aktualisieren..." + apt-get update -qq + PKGS=(curl ca-certificates rsync build-essential libvips-dev) + MISSING=() + for p in "${PKGS[@]}"; do need_pkg "$p" || MISSING+=("$p"); done + if ((${#MISSING[@]})); then + apt-get install -y "${MISSING[@]}" + fi +elif [[ "${SKIP_APT:-0}" != "1" ]] && command -v dnf >/dev/null 2>&1; then + dnf install -y curl ca-certificates rsync gcc-c++ make vips-devel +fi + +if ! command -v cloudflared >/dev/null 2>&1; then + echo "==> cloudflared installieren" + ARCH="$(uname -m)" + case "${ARCH}" in + x86_64) CF_ARCH=amd64 ;; + aarch64|arm64) CF_ARCH=arm64 ;; + *) echo "Unsupported arch: ${ARCH}" >&2; exit 1 ;; + esac + curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${CF_ARCH}" \ + -o /usr/local/bin/cloudflared + chmod +x /usr/local/bin/cloudflared + ln -sf /usr/local/bin/cloudflared /usr/bin/cloudflared +fi + +if ! command -v node >/dev/null 2>&1 || [[ "$(node -p "process.versions.node.split('.')[0]")" -lt 24 ]]; then + echo "==> Node.js 24 installieren (NodeSource)" + if command -v apt-get >/dev/null 2>&1; then + curl -fsSL https://deb.nodesource.com/setup_24.x | bash - + apt-get install -y nodejs + else + dnf install -y nodejs + fi +fi + +mkdir -p "${APP_DIR}" "${APP_DIR}/data" "${APP_DIR}/public/uploads" +if [[ "${REPO_ROOT}" != "${APP_DIR}" ]]; then + echo "==> Dateien nach ${APP_DIR} synchronisieren" + rsync -a --delete \ + --exclude node_modules \ + --exclude data \ + --exclude .git \ + --exclude server.js \ + --exclude server.py \ + --exclude start.bat \ + "${REPO_ROOT}/" "${APP_DIR}/" +fi + +chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" 2>/dev/null || true +chmod +x "${APP_DIR}/deploy/duckdns-update.sh" \ + "${APP_DIR}/deploy/cloudflare-ddns-update.sh" \ + "${APP_DIR}/deploy/finish-on-server.sh" \ + "${APP_DIR}/deploy/sync-user.sh" 2>/dev/null || true + +# ---- .env (nur ergaenzen, nicht ueberschreiben) ---- +ENV_FILE="${APP_DIR}/.env" +touch "${ENV_FILE}" +grep -q '^PORT=' "${ENV_FILE}" 2>/dev/null \ + && sed -i "s/^PORT=.*/PORT=${APP_PORT}/" "${ENV_FILE}" \ + || echo "PORT=${APP_PORT}" >> "${ENV_FILE}" +grep -q '^TRUST_PROXY=' "${ENV_FILE}" 2>/dev/null || echo "TRUST_PROXY=1" >> "${ENV_FILE}" +grep -q '^START_BALANCE=' "${ENV_FILE}" 2>/dev/null || echo "START_BALANCE=10000" >> "${ENV_FILE}" +grep -q '^JWT_SECRET=' "${ENV_FILE}" 2>/dev/null || { [[ -n "${JWT_SECRET:-}" ]] && echo "JWT_SECRET=${JWT_SECRET}" >> "${ENV_FILE}"; } +grep -q '^FINNHUB_API_KEY=' "${ENV_FILE}" 2>/dev/null || { [[ -n "${FINNHUB_API_KEY:-}" ]] && echo "FINNHUB_API_KEY=${FINNHUB_API_KEY}" >> "${ENV_FILE}"; } + +if [[ "${APP_USER}" == "root" ]]; then + bash -c "cd '${APP_DIR}' && npm install --omit=dev" +else + sudo -u "${APP_USER}" bash -c "cd '${APP_DIR}' && npm install --omit=dev" +fi + +# ---- Alten bestewebsite-Service abloesen ---- +if systemctl is-active --quiet bestewebsite.service 2>/dev/null; then + echo "==> bestewebsite.service stoppen (Migration zu playbull)" + systemctl stop bestewebsite.service +fi +systemctl disable bestewebsite.service 2>/dev/null || true + +# ---- Cloudflare Tunnel (wie bestewebsite: cloudflared service install) ---- +if [[ -n "${CLOUDFLARE_TUNNEL_TOKEN:-}" ]]; then + echo "==> Cloudflare Tunnel" + systemctl stop cloudflared-playbull.service 2>/dev/null || true + systemctl disable cloudflared-playbull.service 2>/dev/null || true + cloudflared service uninstall 2>/dev/null || true + cloudflared service install "${CLOUDFLARE_TUNNEL_TOKEN}" + systemctl enable cloudflared + systemctl restart cloudflared + echo " cloudflared gestartet." +else + echo "" + echo ">>> CLOUDFLARE_TUNNEL_TOKEN fehlt in deploy/env.homelab" + echo " Zero Trust → Tunnel → Public Hostname: ${APP_DOMAIN} -> http://127.0.0.1:${APP_PORT}" + echo "" +fi + +# ---- Cloudflare Dynamic DNS (ersetzt DuckDNS) ---- +echo "==> Cloudflare DDNS Timer" +install_unit "${APP_DIR}/deploy/systemd/cloudflare-ddns-update.service" /etc/systemd/system/cloudflare-ddns-update.service +cp "${APP_DIR}/deploy/systemd/cloudflare-ddns-update.timer" /etc/systemd/system/ +chmod 600 "${APP_DIR}/deploy/env.homelab" +if [[ -n "${CLOUDFLARE_API_TOKEN:-}" && -n "${CLOUDFLARE_ZONE_ID:-}" ]]; then + "${APP_DIR}/deploy/cloudflare-ddns-update.sh" || echo " Cloudflare-DDNS fehlgeschlagen (API/Zone pruefen)" +else + echo " Cloudflare-DDNS uebersprungen (CLOUDFLARE_API_TOKEN oder ZONE_ID leer)" +fi + +# ---- DuckDNS (legacy, optional) ---- +if [[ -n "${DUCKDNS_TOKEN:-}" ]]; then + echo "==> DuckDNS (legacy)" + install_unit "${APP_DIR}/deploy/systemd/duckdns-update.service" /etc/systemd/system/duckdns-update.service + cp "${APP_DIR}/deploy/systemd/duckdns-update.timer" /etc/systemd/system/ + "${APP_DIR}/deploy/duckdns-update.sh" || true +fi + +# ---- playbull systemd ---- +echo "==> playbull.service" +install_unit "${APP_DIR}/deploy/systemd/playbull.service" /etc/systemd/system/playbull.service + +systemctl daemon-reload +if [[ -n "${CLOUDFLARE_API_TOKEN:-}" && -n "${CLOUDFLARE_ZONE_ID:-}" ]]; then + systemctl enable --now cloudflare-ddns-update.timer + systemctl start cloudflare-ddns-update.service || true +else + systemctl disable cloudflare-ddns-update.timer 2>/dev/null || true +fi +if [[ -n "${DUCKDNS_TOKEN:-}" ]]; then + systemctl enable --now duckdns-update.timer + systemctl start duckdns-update.service || true +else + systemctl disable duckdns-update.timer 2>/dev/null || true +fi +systemctl enable --now playbull.service +systemctl restart playbull.service + +# ---- Optional Nginx ---- +if [[ "${USE_NGINX_VHOST:-0}" == "1" ]]; then + echo "==> Nginx VHost (optional)" + if command -v apt-get >/dev/null 2>&1; then + apt-get install -y nginx certbot python3-certbot-nginx + fi + cp "${APP_DIR}/deploy/nginx/playbull.conf" /etc/nginx/sites-available/playbull + ln -sf /etc/nginx/sites-available/playbull /etc/nginx/sites-enabled/playbull + certbot --nginx -d "${APP_DOMAIN}" -d "www.${APP_DOMAIN}" --non-interactive --agree-tos -m "admin@${APP_DOMAIN}" || true + nginx -t && systemctl reload nginx +fi + +echo "" +echo "=== Fertig ===" +echo "Pfad: ${APP_DIR}" +echo "Intern: http://127.0.0.1:${APP_PORT}" +echo "DuckDNS: ${DUCKDNS_DOMAIN:-(deaktiviert)}" +echo "Status: systemctl status playbull cloudflared cloudflare-ddns-update.timer" +if [[ -n "${CLOUDFLARE_TUNNEL_TOKEN:-}" ]]; then + echo "HTTPS: https://${APP_DOMAIN}" +fi +echo "" +echo "Hinweis: JWT_SECRET und FINNHUB_API_KEY in ${ENV_FILE} pruefen, dann:" +echo " sudo systemctl restart playbull" diff --git a/deploy/nginx/bestewebsite-lan.conf b/deploy/nginx/bestewebsite-lan.conf new file mode 100644 index 0000000..5357407 --- /dev/null +++ b/deploy/nginx/bestewebsite-lan.conf @@ -0,0 +1,21 @@ +# LAN / Fritz-Netz — HTTP auf Server-IP und Hostname (ohne TLS) +# Install: sudo cp deploy/nginx/bestewebsite-lan.conf /etc/nginx/sites-available/bestewebsite-lan +# sudo ln -sf /etc/nginx/sites-available/bestewebsite-lan /etc/nginx/sites-enabled/00-bestewebsite-lan + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name 192.168.178.49 mikeshomelab _; + + client_max_body_size 30m; + + location / { + proxy_pass http://127.0.0.1:3080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } +} diff --git a/deploy/nginx/bestewebsite.conf b/deploy/nginx/bestewebsite.conf new file mode 100644 index 0000000..c5c1985 --- /dev/null +++ b/deploy/nginx/bestewebsite.conf @@ -0,0 +1,35 @@ +# Öffentlicher HTTPS-VHost — DuckDNS → PlayBull :3080 +# Install: sudo cp deploy/nginx/bestewebsite.conf /etc/nginx/sites-available/bestewebsite +# sudo ln -sf /etc/nginx/sites-available/bestewebsite /etc/nginx/sites-enabled/ +# sudo certbot --nginx -d halmc.duckdns.org --non-interactive --agree-tos --redirect +# sudo nginx -t && sudo systemctl reload nginx + +server { + server_name halmc.duckdns.org; + + client_max_body_size 30m; + + location / { + proxy_pass http://127.0.0.1:3080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + + listen [::]:443 ssl; + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/halmc.duckdns.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/halmc.duckdns.org/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + listen 80; + listen [::]:80; + server_name halmc.duckdns.org; + return 301 https://$host$request_uri; +} diff --git a/deploy/nginx/playbull.conf b/deploy/nginx/playbull.conf new file mode 100644 index 0000000..7655c50 --- /dev/null +++ b/deploy/nginx/playbull.conf @@ -0,0 +1,43 @@ +# Optional: 3. Website auf bestehendem Nginx :443 (SNI). +# PlayBull laeuft intern auf Port 3080 — kein Konflikt mit anderen Sites. +# +# Install: sudo cp deploy/nginx/playbull.conf /etc/nginx/sites-available/playbull +# sudo ln -sf /etc/nginx/sites-available/playbull /etc/nginx/sites-enabled/ +# sudo certbot --nginx -d halmc.duckdns.org +# sudo nginx -t && sudo systemctl reload nginx + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name halmc.duckdns.org; + + # certbot legt Zertifikate an: + # ssl_certificate /etc/letsencrypt/live/halmc.duckdns.org/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/halmc.duckdns.org/privkey.pem; + + client_max_body_size 10m; + + location / { + proxy_pass http://127.0.0.1:3080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 86400; + } +} + +server { + listen 80; + listen [::]:80; + server_name halmc.duckdns.org; + return 301 https://$host$request_uri; +} diff --git a/deploy/nginx/trilogyhub.conf b/deploy/nginx/trilogyhub.conf new file mode 100644 index 0000000..bd7285d --- /dev/null +++ b/deploy/nginx/trilogyhub.conf @@ -0,0 +1,33 @@ +# Öffentlicher HTTPS-VHost — trilogyhub.de (+ optional www) +# Mit Cloudflare Tunnel: oft nicht noetig (Tunnel terminiert HTTPS). +# Mit Nginx + Let's Encrypt: certbot --nginx -d trilogyhub.de -d www.trilogyhub.de + +server { + server_name trilogyhub.de www.trilogyhub.de; + + client_max_body_size 30m; + + location / { + proxy_pass http://127.0.0.1:3080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + + listen [::]:443 ssl; + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/trilogyhub.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/trilogyhub.de/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + listen 80; + listen [::]:80; + server_name trilogyhub.de www.trilogyhub.de; + return 301 https://$host$request_uri; +} diff --git a/deploy/push-homelab.ps1 b/deploy/push-homelab.ps1 new file mode 100644 index 0000000..ae20831 --- /dev/null +++ b/deploy/push-homelab.ps1 @@ -0,0 +1,139 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Kopiert PlayBull nach ~/bestewebsite/bestewebsite und startet install-homelab.sh. + Nutzt deploy/homelab.credentials (Posh-SSH) wenn vorhanden. +#> +param( + [string]$SshTarget = "", + [string]$RemoteDir = "", + [switch]$SkipInstall, + [switch]$UserOnly +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +$CredFile = Join-Path $PSScriptRoot "homelab.credentials" +$UseCredentials = Test-Path $CredFile + +function Assert-LastExit { + param([string]$Step) + if ($LASTEXITCODE -ne 0) { + throw "${Step} fehlgeschlagen (Exit ${LASTEXITCODE})." + } +} + +$EnvFile = Join-Path $PSScriptRoot "env.homelab" +if (-not (Test-Path $EnvFile)) { + Write-Error "deploy/env.homelab fehlt." +} + +$envContent = Get-Content $EnvFile -Raw +if ($RemoteDir -eq "" -and $envContent -match '(?m)^APP_DIR=(.+)$') { + $RemoteDir = $Matches[1].Trim() +} +if ([string]::IsNullOrWhiteSpace($RemoteDir)) { + $RemoteDir = "/home/gizzler/bestewebsite/bestewebsite" +} + +if ($UseCredentials) { + . "$PSScriptRoot/homelab-lib.ps1" + $config = Get-HomelabConfig + if ($SshTarget -eq "") { $SshTarget = $config.SshTarget } + + Write-Host "==> Sync nach $($config.SshTarget):${RemoteDir} (homelab.credentials)" -ForegroundColor Cyan + Invoke-HomelabRemote -Config $config -Command "mkdir -p '$RemoteDir'" -WorkDir "" | Out-Null + + $archiveWin = Join-Path $env:TEMP "playbull-deploy.tgz" + if (Test-Path $archiveWin) { Remove-Item $archiveWin -Force } + $tarExe = Join-Path $env:SystemRoot "System32\tar.exe" + $tarItems = @("server", "public", "package.json", "package-lock.json", "deploy", ".env.example") + Push-Location $Root + try { + & $tarExe -czf $archiveWin @tarItems + if ($LASTEXITCODE -ne 0) { throw "tar-Archiv fehlgeschlagen." } + } + finally { Pop-Location } + + $sizeMb = [math]::Round((Get-Item $archiveWin).Length / 1MB, 1) + Write-Host " -> Archiv ${sizeMb} MB" -ForegroundColor DarkGray + Send-HomelabPath -Config $config -LocalPath $archiveWin -RemoteDir $RemoteDir + + $extractCmd = @' +cp deploy/env.homelab deploy/env.homelab.bak 2>/dev/null || true +tar xzf playbull-deploy.tgz && rm -f playbull-deploy.tgz +if [ -f deploy/env.homelab.bak ]; then + for key in DUCKDNS_TOKEN CLOUDFLARE_TUNNEL_TOKEN CLOUDFLARE_API_TOKEN CLOUDFLARE_ZONE_ID JWT_SECRET FINNHUB_API_KEY APP_DOMAIN; do + old=$(grep -m1 "^${key}=" deploy/env.homelab.bak 2>/dev/null | cut -d= -f2- | tr -d '\r') + if [ -n "$old" ]; then + if grep -q "^${key}=" deploy/env.homelab 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${old}|" deploy/env.homelab + else + echo "${key}=${old}" >> deploy/env.homelab + fi + fi + done + rm -f deploy/env.homelab.bak +fi +sed -i 's/\r$//' deploy/env.homelab deploy/*.sh deploy/nginx/*.conf 2>/dev/null || true +'@ + $extract = Invoke-HomelabRemote -Config $config -Command $extractCmd -WorkDir $RemoteDir -TimeoutSeconds 300 + $extract.Output | ForEach-Object { Write-Output $_ } + if ($extract.ExitStatus -ne 0) { exit $extract.ExitStatus } + Remove-Item $archiveWin -Force -ErrorAction SilentlyContinue + + if ($UserOnly) { + Write-Host "==> User-Sync" + $r = Invoke-HomelabRemote -Config $config -Command "bash deploy/sync-user.sh" -WorkDir $RemoteDir -TimeoutSeconds 300 + $r.Output | ForEach-Object { Write-Output $_ } + if ($r.ExitStatus -ne 0) { exit $r.ExitStatus } + Write-Host "Fertig (User-Modus)." -ForegroundColor Green + exit 0 + } + + if ($SkipInstall) { + Write-Host "Sync fertig. Installer: .\deploy\homelab-exec.ps1 -Sudo 'bash deploy/install-homelab.sh'" -ForegroundColor Cyan + exit 0 + } + + Write-Host "==> Installer auf Homelab" + $install = Invoke-HomelabRemote -Config $config -Sudo -Command "chmod +x deploy/*.sh && bash deploy/install-homelab.sh" -WorkDir $RemoteDir -TimeoutSeconds 900 + $install.Output | ForEach-Object { Write-Output $_ } + if ($install.Error) { $install.Error | ForEach-Object { [Console]::Error.WriteLine($_) } } + if ($install.ExitStatus -ne 0) { exit $install.ExitStatus } + + Write-Host "" + Write-Host "==> Verifikation" -ForegroundColor Cyan + $verify = Invoke-HomelabRemote -Config $config -Command "systemctl is-active playbull cloudflared 2>/dev/null; curl -sf http://127.0.0.1:3080/api/market | head -c 80; echo; test -f public/image-editor.js && echo image-editor.js OK" -WorkDir $RemoteDir + $verify.Output | ForEach-Object { Write-Output $_ } + if ($verify.ExitStatus -ne 0) { exit $verify.ExitStatus } + + Write-Host "" + Write-Host "Fertig. https://trilogyhub.de" -ForegroundColor Green + exit 0 +} + +# Fallback: interaktives ssh/scp +if ($SshTarget -eq "" -and $envContent -match '(?m)^HOMELAB_SSH=(.+)$') { + $SshTarget = $Matches[1].Trim() +} +if ([string]::IsNullOrWhiteSpace($SshTarget) -or $SshTarget -eq "root@dein-server") { + Write-Error "HOMELAB_SSH setzen oder deploy/homelab.credentials anlegen." +} + +Write-Host "SSH mit Passwort (interaktiv)..." -ForegroundColor Yellow +ssh $SshTarget "mkdir -p '$RemoteDir'" +Assert-LastExit "ssh mkdir" + +$items = @("server", "public", "package.json", "package-lock.json", "deploy", ".env.example") +foreach ($item in $items) { + $local = Join-Path $Root $item + if (Test-Path $local) { + scp -r $local "${SshTarget}:${RemoteDir}/" + Assert-LastExit "scp $item" + } +} + +if ($SkipInstall) { exit 0 } +ssh -t $SshTarget "cd '$RemoteDir' && chmod +x deploy/*.sh && sudo bash deploy/install-homelab.sh" +Assert-LastExit "install-homelab.sh" diff --git a/deploy/quick-start-homelab.sh b/deploy/quick-start-homelab.sh new file mode 100644 index 0000000..fb0e98a --- /dev/null +++ b/deploy/quick-start-homelab.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Schnellstart auf dem Homelab als normaler User (gizzler) +# DuckDNS + systemd brauchen einmal: sudo bash deploy/install-homelab.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." + +echo "==> PlayBull Quick-Start ($(whoami)@$(hostname))" + +if [[ ! -f .env ]]; then + echo "Erstelle .env — bitte Werte eintragen:" + cp -n .env.example .env 2>/dev/null || true + ${EDITOR:-nano} .env +fi + +if [[ ! -f deploy/env.homelab ]]; then + cp deploy/env.homelab.example deploy/env.homelab + ${EDITOR:-nano} deploy/env.homelab +fi + +command -v node >/dev/null || { echo "Node.js fehlt. Bitte installieren (v24+)."; exit 1; } +npm install --omit=dev + +echo "" +echo "Test starten: npm start" +echo "Dauerbetrieb: sudo bash deploy/install-homelab.sh (fragt sudo-Passwort)" +echo "" diff --git a/deploy/setup-all.bat b/deploy/setup-all.bat new file mode 100644 index 0000000..52cdcb5 --- /dev/null +++ b/deploy/setup-all.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0.." +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-all.ps1" +pause diff --git a/deploy/setup-all.ps1 b/deploy/setup-all.ps1 new file mode 100644 index 0000000..0b17415 --- /dev/null +++ b/deploy/setup-all.ps1 @@ -0,0 +1,11 @@ +#Requires -Version 5.1 +# Ein-Klick: Sync + Installer + Verifikation auf dem Homelab +$ErrorActionPreference = "Stop" +Set-Location (Split-Path $PSScriptRoot -Parent) +Write-Host "=== PlayBull Homelab Setup ===" -ForegroundColor Cyan +& "$PSScriptRoot\push-homelab.ps1" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host "" +Write-Host "=== Status ===" -ForegroundColor Cyan +& "$PSScriptRoot\homelab-exec.ps1" "systemctl status playbull cloudflared --no-pager -l | head -40" +& "$PSScriptRoot\homelab-exec.ps1" "grep -E '^(PORT|JWT_SECRET|FINNHUB_API_KEY)=' .env | sed 's/=.*/=***/'" diff --git a/deploy/sync-user.sh b/deploy/sync-user.sh new file mode 100644 index 0000000..d5eb4c5 --- /dev/null +++ b/deploy/sync-user.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Ohne systemd/sudo: Dateien nach ~/playbull kopieren und npm install. +# Auf dem Homelab: cd ~/bestewebsite/bestewebsite && bash deploy/sync-user.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=/dev/null +[[ -f "${SCRIPT_DIR}/env.homelab" ]] && source "${SCRIPT_DIR}/env.homelab" +APP_DIR="${APP_DIR:-/home/gizzler/bestewebsite/bestewebsite}" +APP_PORT="${APP_PORT:-3080}" + +mkdir -p "${APP_DIR}" +rsync -a --delete \ + --exclude node_modules --exclude data --exclude .git \ + "${SRC}/" "${APP_DIR}/" + +cd "${APP_DIR}" +command -v node >/dev/null || { echo "Node.js fehlt (v24+)."; exit 1; } +npm install --omit=dev + +ENV="${APP_DIR}/.env" +touch "${ENV}" +grep -q '^PORT=' "${ENV}" 2>/dev/null && sed -i "s/^PORT=.*/PORT=${APP_PORT}/" "${ENV}" || echo "PORT=${APP_PORT}" >> "${ENV}" +grep -q '^TRUST_PROXY=' "${ENV}" 2>/dev/null || echo "TRUST_PROXY=1" >> "${ENV}" + +echo "" +echo "=== Sync fertig: ${APP_DIR} ===" +echo "Start (Test): cd ${APP_DIR} && node server/index.js" +echo "Dauerbetrieb: bash deploy/finish-on-server.sh (sudo + systemd)" +echo "" +echo "Falls schon laeuft: pkill -f 'node server/index.js' ; cd ${APP_DIR} && nohup node server/index.js >> data/server.log 2>&1 &" diff --git a/deploy/systemd/cloudflare-ddns-update.service b/deploy/systemd/cloudflare-ddns-update.service new file mode 100644 index 0000000..cc0a2c0 --- /dev/null +++ b/deploy/systemd/cloudflare-ddns-update.service @@ -0,0 +1,12 @@ +[Unit] +Description=Cloudflare Dynamic DNS updater (Trilogy Hub) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=__APP_DIR__/deploy/cloudflare-ddns-update.sh +User=root + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/cloudflare-ddns-update.timer b/deploy/systemd/cloudflare-ddns-update.timer new file mode 100644 index 0000000..325bbf7 --- /dev/null +++ b/deploy/systemd/cloudflare-ddns-update.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Cloudflare DDNS alle 5 Minuten pruefen + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/cloudflared-playbull.service b/deploy/systemd/cloudflared-playbull.service new file mode 100644 index 0000000..3e5fe7c --- /dev/null +++ b/deploy/systemd/cloudflared-playbull.service @@ -0,0 +1,17 @@ +[Unit] +Description=Cloudflare Tunnel for PlayBull (HTTPS) +After=network-online.target playbull.service +Wants=network-online.target +Requires=playbull.service + +[Service] +Type=simple +User=root +EnvironmentFile=/opt/playbull/deploy/env.homelab +# Token-Modus: einfachster Weg aus dem Zero-Trust-Dashboard +ExecStart=/bin/bash -c 'exec /usr/bin/cloudflared tunnel --no-autoupdate run --token "$CLOUDFLARE_TUNNEL_TOKEN"' +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/duckdns-update.service b/deploy/systemd/duckdns-update.service new file mode 100644 index 0000000..5711317 --- /dev/null +++ b/deploy/systemd/duckdns-update.service @@ -0,0 +1,12 @@ +[Unit] +Description=DuckDNS IP updater for PlayBull homelab +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=__APP_DIR__/deploy/duckdns-update.sh +User=root + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/duckdns-update.timer b/deploy/systemd/duckdns-update.timer new file mode 100644 index 0000000..bb82984 --- /dev/null +++ b/deploy/systemd/duckdns-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run DuckDNS IP update every 5 minutes + +[Timer] +OnBootSec=1min +OnUnitActiveSec=5min +AccuracySec=1min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/playbull.service b/deploy/systemd/playbull.service new file mode 100644 index 0000000..c7e5907 --- /dev/null +++ b/deploy/systemd/playbull.service @@ -0,0 +1,18 @@ +[Unit] +Description=PlayBull — Trilogy Hub + Trading + Casino +After=network.target + +[Service] +Type=simple +User=__APP_USER__ +Group=__APP_USER__ +WorkingDirectory=__APP_DIR__ +EnvironmentFile=__APP_DIR__/.env +Environment=NODE_ENV=production +Environment=TRUST_PROXY=1 +ExecStart=/usr/bin/node __APP_DIR__/server/index.js +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/test-ports.ps1 b/deploy/test-ports.ps1 new file mode 100644 index 0000000..ff31f10 --- /dev/null +++ b/deploy/test-ports.ps1 @@ -0,0 +1,5 @@ +$ip = "192.168.178.49" +foreach ($port in 22, 80, 443, 3080) { + $r = Test-NetConnection -ComputerName $ip -Port $port -WarningAction SilentlyContinue + Write-Output "Port ${port}: $($r.TcpTestSucceeded)" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b5ca2df --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1939 @@ +{ + "name": "playbull", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playbull", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie": "^0.6.0", + "cookie-parser": "^1.4.7", + "express": "^4.22.2", + "jsonwebtoken": "^9.0.3", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.5", + "socket.io": "^4.8.3" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5de8b7d --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "playbull", + "version": "1.0.0", + "description": "PlayBull - Spielgeld Trading & Gamble Plattform mit Live-Kursen", + "type": "module", + "main": "server/index.js", + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie": "^0.6.0", + "cookie-parser": "^1.4.7", + "express": "^4.22.2", + "jsonwebtoken": "^9.0.3", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.5", + "socket.io": "^4.8.3" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..703d74a --- /dev/null +++ b/public/app.js @@ -0,0 +1,797 @@ +/* ============================================================ + Trilogy Hub — app.js + Routing, animations, photo slots, theme, Paris dashboard + ============================================================ */ +(() => { + "use strict"; + + const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const $ = (s, c = document) => c.querySelector(s); + const $$ = (s, c = document) => Array.from(c.querySelectorAll(s)); + + /* -------------------- DATA -------------------- */ + const mikeProducts = [ + { name: "Japanese Maple 'Emperor'", price: "€280", desc: "A stunning deep-red showstopper for any garden." }, + { name: "Blue Atlas Cedar", price: "€450", desc: "Majestic evergreen with silver-blue needles. Rare and sought after." }, + { name: "Olive Tree (300 years old)", price: "€1,200", desc: "History in your garden. Delivered with full root care." }, + { name: "Himalayan Birch", price: "€190", desc: "Striking white bark, perfect for modern minimalist gardens." }, + ]; + + const mikeReviews = [ + { text: "Mike delivered a 200-year-old olive tree to our terrace. Unreal quality.", who: "Thomas K., Munich" }, + { text: "The Japanese Maple is the centerpiece of our garden. Worth every cent.", who: "Anna L., Stuttgart" }, + { text: "Professional, fast, and the tree arrived in perfect condition.", who: "Stefan B., Frankfurt" }, + { text: "Magic Tree Co. transformed our backyard. Highly recommend.", who: "Julia R., Heidelberg" }, + { text: "Best purchase we've ever made for our property.", who: "Markus & Sara W., Freiburg" }, + ]; + + const nicoListings = [ + { name: "Penthouse Stuttgart-Mitte", price: "€1.85M", size: "180m²", desc: "Floor-to-ceiling windows, rooftop terrace, panoramic city views." }, + { name: "Villa Bebenhausen", price: "€2.3M", size: "320m²", desc: "Secluded forest estate with heated pool and private driveway." }, + { name: "Modern Loft Tübingen", price: "€620,000", size: "110m²", desc: "Industrial chic in the heart of the old town. Fully renovated." }, + { name: "Investment Apartment Block Esslingen", price: "€4.1M", size: "8 units", desc: "Fully rented, 5.8% yield. Backed by PG Trading Management." }, + ]; + + const nicoTestimonials = [ + { text: "Nico sold our Stuttgart apartment in 12 days above asking price. Unmatched.", who: "Familie Berger" }, + { text: "NovaTerra found us our dream home in Tübingen. We never felt rushed.", who: "Dr. Katrin M." }, + { text: "Professional, discreet, and results-driven. The best broker in the region.", who: "Investment Group H." }, + { text: "Nico's market knowledge is extraordinary. He knows every street.", who: "Marc V., Stuttgart" }, + ]; + + // Play-money portfolio. Holdings total ≈ €7,277,360 + buying power €57,539.31 = €7,334,899.31 + const positions = [ + { ticker: "AAPL", company: "Apple Inc.", shares: 2000, avg: 165.0, price: 182.3 }, + { ticker: "NVDA", company: "NVIDIA Corp.", shares: 1500, avg: 420.0, price: 875.4 }, + { ticker: "TSLA", company: "Tesla Inc.", shares: 1000, avg: 240.0, price: 198.7 }, + { ticker: "MSFT", company: "Microsoft", shares: 1200, avg: 310.0, price: 378.9 }, + { ticker: "META", company: "Meta Platforms", shares: 900, avg: 285.0, price: 478.2 }, + { ticker: "AMZN", company: "Amazon", shares: 1500, avg: 135.0, price: 184.6 }, + { ticker: "GOOGL", company: "Alphabet Inc.", shares: 1000, avg: 140.0, price: 178.2 }, + { ticker: "AMD", company: "Adv. Micro Devices", shares: 2000, avg: 110.0, price: 162.4 }, + { ticker: "BTC-EUR", company: "Bitcoin", shares: 50, avg: 38000.0, price: 61200.0 }, + { ticker: "ETH-EUR", company: "Ethereum", shares: 200, avg: 2200.0, price: 3380.0 }, + ]; + + const tradeLog = [ + "BUY NVDA 200 shares @ €820.00 — June 18, 2026", + "BUY ETH-EUR 50 @ €2,900.00 — June 10, 2026", + "SELL TSLA 300 shares @ €225.00 — May 28, 2026", + "BUY BTC-EUR 10 @ €54,000.00 — May 12, 2026", + "BUY AAPL 500 shares @ €170.00 — April 30, 2026", + ]; + + const fmtEUR = (n, dec = 2) => "€" + n.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec }); + const fmtSigned = (n) => (n >= 0 ? "+" : "-") + "€" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + const PORTFOLIO_TOTAL = 7334899.31; + const BUYING_POWER = 57539.31; + const TODAY_PNL = 48213.55; + + /* -------------------- RENDER LISTS -------------------- */ + function slotMarkup(label, minH = "200px") { + return `
+
${label}
+ ${label}${label} +
`; + } + + function renderMike() { + $("#mikeProducts").innerHTML = mikeProducts.map((p, i) => ` +
+ ${slotMarkup("Product Photo " + (i + 1), "190px")} +
+
+

${p.name}

+ ${p.price} +
+

${p.desc}

+ +
+
`).join(""); + + $("#mikeReviews").innerHTML = mikeReviews.map((r) => ` +
+
★★★★★
+
"${r.text}"
+
— ${r.who}
+
`).join(""); + + $("#mikeGallery").innerHTML = Array.from({ length: 6 }, (_, i) => + `
${slotMarkup("Customer Photo " + (i + 1), "200px")}
`).join(""); + restoreSlotImages($("#page-magic-mike")); + } + + function renderNico() { + $("#nicoListings").innerHTML = nicoListings.map((p, i) => ` +
+ ${slotMarkup("Listing Photo " + (i + 1), "230px")} +
+
+ ${p.price}${p.size} +
+

${p.name}

+

${p.desc}

+ +
+
`).join(""); + + $("#nicoTestimonials").innerHTML = nicoTestimonials.map((r) => ` +
+ +
"${r.text}"
+
— ${r.who}
+
`).join(""); + + const galleryLabels = ["Client Handshake", "Property Visit", "Signing Moment", "Property Visit 2", "Client Handshake 2", "Signing Moment 2"]; + $("#nicoGallery").innerHTML = galleryLabels.map((l) => `
${slotMarkup(l, "200px")}
`).join(""); + restoreSlotImages($("#page-nico")); + } + + /* -------------------- PHOTO SLOTS -------------------- */ + const IMAGE_STORE_KEY = "trilogy-hub-uploads"; + + function getSlotId(slot) { + if (!slot.dataset.slotId) { + const page = slot.closest(".page")?.id || "global"; + const name = slot.dataset.slot || "upload"; + slot.dataset.slotId = `${page}::${name}`; + } + return slot.dataset.slotId; + } + + function readImageStore() { + try { return JSON.parse(localStorage.getItem(IMAGE_STORE_KEY) || "{}"); } + catch { return {}; } + } + + function writeImageStore(store) { + try { localStorage.setItem(IMAGE_STORE_KEY, JSON.stringify(store)); return true; } + catch (err) { console.warn("Image save to localStorage failed (quota?):", err); return false; } + } + + // Downscale + re-encode uploads so they actually fit in localStorage. + function compressImage(file, maxDim = 1600, quality = 0.82) { + return new Promise((resolve, reject) => { + if (!file || !file.type || !file.type.startsWith("image/")) { + reject(new Error("Not an image")); + return; + } + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + try { + let { width, height } = img; + if (width > maxDim || height > maxDim) { + const scale = maxDim / Math.max(width, height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + const canvas = document.createElement("canvas"); + canvas.width = width; canvas.height = height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width, height); + // PNGs with transparency keep PNG; everything else becomes JPEG for size. + const isPng = file.type === "image/png"; + const out = canvas.toDataURL(isPng ? "image/png" : "image/jpeg", quality); + resolve(out); + } catch (err) { reject(err); } + }; + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Image decode failed")); }; + img.src = url; + }); + } + + // Server mode: when the page is served by server.js, images are written as + // real files into ./uploads. Otherwise we fall back to localStorage. + const server = { available: false }; + let remoteImages = {}; // slotId -> "/uploads/xxx.jpg" + + async function loadServerImages() { + try { + const res = await fetch("/api/images", { cache: "no-store" }); + if (!res.ok) throw new Error("no server"); + remoteImages = await res.json(); + server.available = true; + } catch { + server.available = false; + } + } + + function normalizeImageEntry(val) { + if (!val) return null; + if (typeof val === "string") return { url: val, focus: { x: 0.5, y: 0.5 }, zoom: 1 }; + return { + url: val.url || val.dataUrl, + focus: val.focus || { x: val.focusX ?? 0.5, y: val.focusY ?? 0.5 }, + zoom: val.zoom ?? 1, + }; + } + + function applyImageToSlot(slot, entry) { + const meta = normalizeImageEntry(entry); + if (!meta?.url) return; + const img = slot.querySelector("img.preview"); + if (!img) return; + img.src = meta.url; + slot.dataset.focusX = meta.focus.x; + slot.dataset.focusY = meta.focus.y; + slot.dataset.zoom = meta.zoom; + slot.classList.add("has-image"); + + const paint = () => { + const { computeLayout, applyLayout } = window.TrilogyImageEditor || {}; + if (!computeLayout || !applyLayout) return; + const fw = slot.clientWidth; + const fh = slot.clientHeight; + if (fw < 2 || fh < 2) return; + const layout = computeLayout( + img.naturalWidth, img.naturalHeight, fw, fh, + { x: +slot.dataset.focusX, y: +slot.dataset.focusY }, + +slot.dataset.zoom, + ); + applyLayout(img, layout); + }; + if (img.complete && img.naturalWidth) paint(); + else img.addEventListener("load", paint, { once: true }); + } + + function saveSlotImage(slot, dataUrl, meta) { + const id = getSlotId(slot); + const focus = meta?.focus || { x: 0.5, y: 0.5 }; + const zoom = meta?.zoom ?? 1; + applyImageToSlot(slot, { url: dataUrl, focus, zoom }); + + const payload = { id, dataUrl, focusX: focus.x, focusY: focus.y, zoom }; + + if (server.available) { + fetch("/api/upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error("upload failed")))) + .then((data) => { + if (data && data.url) { + remoteImages[id] = { url: data.url, focus, zoom }; + applyImageToSlot(slot, remoteImages[id]); + } + }) + .catch((err) => { + console.warn("Server upload failed, falling back to localStorage:", err); + const store = readImageStore(); + store[id] = { url: dataUrl, focus, zoom }; + writeImageStore(store); + }); + } else { + const store = readImageStore(); + store[id] = { url: dataUrl, focus, zoom }; + writeImageStore(store); + } + } + + function restoreSlotImages(root = document) { + const store = readImageStore(); + $$(".photo-slot", root).forEach((slot) => { + const id = getSlotId(slot); + const entry = normalizeImageEntry(remoteImages[id] || store[id]); + if (entry) applyImageToSlot(slot, entry); + }); + } + + let fileInput; + function initPhotoSlots() { + fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "image/*"; + fileInput.style.display = "none"; + document.body.appendChild(fileInput); + + let currentSlot = null; + fileInput.addEventListener("change", () => { + const file = fileInput.files && fileInput.files[0]; + const slot = currentSlot; + currentSlot = null; + fileInput.value = ""; + if (!file || !slot) return; + + const existingFocus = { + x: parseFloat(slot.dataset.focusX) || 0.5, + y: parseFloat(slot.dataset.focusY) || 0.5, + }; + const existingZoom = parseFloat(slot.dataset.zoom) || 1; + + compressImage(file) + .then((dataUrl) => { + if (!window.TrilogyImageEditor) return { dataUrl, meta: { focus: existingFocus, zoom: existingZoom } }; + return window.TrilogyImageEditor.open({ + file, + slot, + focus: existingFocus, + zoom: existingZoom, + imageSrc: dataUrl, + }).then((meta) => (meta ? { dataUrl, meta } : null)); + }) + .then((result) => { + if (!result) return; + saveSlotImage(slot, result.dataUrl, result.meta); + }) + .catch(() => { + const reader = new FileReader(); + reader.onload = (e) => { + const dataUrl = e.target.result; + if (window.TrilogyImageEditor) { + window.TrilogyImageEditor.open({ file, slot, imageSrc: dataUrl }) + .then((meta) => { if (meta) saveSlotImage(slot, dataUrl, meta); }); + } else { + saveSlotImage(slot, dataUrl, { focus: { x: 0.5, y: 0.5 }, zoom: 1 }); + } + }; + reader.readAsDataURL(file); + }); + }); + + const openFor = (slot) => { currentSlot = slot; fileInput.click(); }; + + document.addEventListener("click", (e) => { + const slot = e.target.closest(".photo-slot"); + if (!slot) return; + e.preventDefault(); + e.stopPropagation(); + openFor(slot); + }); + document.addEventListener("keydown", (e) => { + if ((e.key === "Enter" || e.key === " ") && e.target.classList?.contains("photo-slot")) { + e.preventDefault(); + e.stopPropagation(); + openFor(e.target); + } + }); + + restoreSlotImages(); + } + + /* -------------------- THEME -------------------- */ + function initTheme() { + const root = document.documentElement; + $("#themeToggle").addEventListener("click", () => { + root.classList.toggle("dark"); + if (window.allocationChart && typeof window.allocationChart.update === "function") { + window.allocationChart.update(); + } + }); + } + + /* -------------------- MOBILE MENU -------------------- */ + function initMobileMenu() { + const btn = $("#menuToggle"), menu = $("#mobileMenu"); + btn.addEventListener("click", () => { + const open = menu.classList.toggle("hidden") === false; + btn.setAttribute("aria-expanded", String(open)); + }); + } + + /* -------------------- ROUTING -------------------- */ + const routes = ["home", "magic-mike", "nico", "paris", "casino"]; + let currentRoute = null; + + function setActiveNav(route) { + $$(".nav-link[data-route]").forEach((b) => b.classList.toggle("active", b.dataset.route === route)); + } + + function animateIn(pageEl) { + if (prefersReduced) return; + runSplitting(pageEl); + setupReveals(pageEl); + } + + function showPage(route, instant = false) { + if (!routes.includes(route)) route = "home"; + if (route === currentRoute) return; + + const next = $("#page-" + route); + const prev = currentRoute ? $("#page-" + currentRoute) : null; + + const swap = () => { + if (prev) prev.classList.remove("active"); + next.classList.add("active"); + window.scrollTo(0, 0); + setActiveNav(route); + currentRoute = route; + if (window.lucide) window.lucide.createIcons(); + restoreSlotImages(next); + animateIn(next); + if (route === "paris") refreshParisIcons(); + if (window.ScrollTrigger) ScrollTrigger.refresh(); + }; + + if (instant || prefersReduced || !prev) { + swap(); + if (next) gsap.set(next, { opacity: 1 }); + return; + } + + gsap.timeline() + .to(prev, { opacity: 0, duration: 0.25, ease: "power2.out" }) + .add(() => { swap(); gsap.set(next, { opacity: 0 }); }) + .to(next, { opacity: 1, duration: 0.35, ease: "power2.out" }); + } + + function routeFromHash() { + const h = (location.hash || "#home").replace("#", ""); + return routes.includes(h) ? h : "home"; + } + + function initRouting() { + // delegate data-route buttons + document.addEventListener("click", (e) => { + const btn = e.target.closest("[data-route]"); + if (!btn) return; + e.preventDefault(); + const r = btn.dataset.route; + $("#mobileMenu").classList.add("hidden"); + if ("#" + r === location.hash) showPage(r); + else location.hash = "#" + r; + }); + window.addEventListener("hashchange", () => showPage(routeFromHash())); + showPage(routeFromHash(), true); + } + + /* -------------------- SPLITTING + REVEALS -------------------- */ + function runSplitting(scope) { + if (typeof Splitting !== "function") return; + const targets = $$("[data-splitting]", scope).filter((el) => !el.dataset.split); + targets.forEach((el) => (el.dataset.split = "1")); + if (!targets.length) return; + Splitting({ target: targets, by: "chars" }); + targets.forEach((el) => { + const chars = $$(".char", el); + gsap.set(chars, { opacity: 0, y: 24 }); + gsap.to(chars, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out", stagger: 0.04 }); + }); + } + + function setupReveals(scope) { + const els = $$(".reveal", scope).filter((el) => !el.dataset.revealed && !el.classList.contains("photo-slot")); + els.forEach((el, i) => { + el.dataset.revealed = "1"; + const isCard = el.classList.contains("card-hover"); + gsap.fromTo(el, { opacity: 0, y: 30 }, { + opacity: 1, y: 0, duration: 0.8, ease: "power3.out", + // Stagger siblings that share the same parent for a polished cascade + delay: Math.min(0.18, (i % 4) * 0.06), + scrollTrigger: { trigger: el, start: "top 88%", once: true }, + // Hand transform control back to CSS so card tilt/hover keeps working + onComplete: () => { if (isCard) gsap.set(el, { clearProps: "transform" }); }, + }); + }); + } + + /* -------------------- COUNT-UP -------------------- */ + function formatCounter(el, value) { + const dec = parseInt(el.dataset.decimals || "0", 10); + const prefix = el.dataset.prefix || ""; + return prefix + value.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec }); + } + + function countUp(el) { + const target = parseFloat(el.dataset.counter); + if (Number.isNaN(target)) return; + + const setValue = (v) => { el.textContent = formatCounter(el, v); }; + + // Always show final value immediately as fallback + setValue(target); + + if (prefersReduced || typeof gsap === "undefined") return; + + const obj = { v: 0 }; + setValue(0); + gsap.to(obj, { + v: target, duration: 1.4, ease: "power2.out", + onUpdate: () => setValue(obj.v), + onComplete: () => setValue(target), + }); + } + + function updateDashboardKPIs(scope) { + $$("[data-counter]", scope || $("#parisDashboard")).forEach(countUp); + } + + /* -------------------- PARIS DASHBOARD -------------------- */ + let loggedIn = false; + let priceTimer = null; + + function refreshParisIcons() { if (window.lucide) window.lucide.createIcons(); } + + function renderWatchlist() { + const body = $("#watchlistBody"); + body.innerHTML = positions.map((p, i) => { + const pl = (p.price - p.avg) * p.shares; + const plPct = ((p.price - p.avg) / p.avg) * 100; + const cls = pl >= 0 ? "text-success" : "text-error"; + return ` + ${p.ticker} + ${p.company} + ${p.shares} + ${fmtEUR(p.avg)} + ${fmtEUR(p.price)} + ${fmtSigned(pl)} + ${(plPct >= 0 ? "+" : "") + plPct.toFixed(1)}% + `; + }).join(""); + } + + function renderTradeLog() { + $("#tradeLog").innerHTML = tradeLog.map((t) => { + const isBuy = t.startsWith("BUY"); + return `
+ ${isBuy ? "BUY" : "SELL"} + ${t} +
`; + }).join(""); + } + + const chartColors = ["#c9a84c", "#2f9e8f", "#5b7aa6", "#9a958a", "#7d8a4f"]; + function buildChart() { + const ctx = $("#allocationChart"); + if (!ctx || typeof Chart === "undefined") return; + const data = positions.map((p) => p.price * p.shares); + const labels = positions.map((p) => p.ticker); + const palette = labels.map((_, i) => chartColors[i % chartColors.length]); + + if (window.allocationChart) window.allocationChart.destroy(); + window.allocationChart = new Chart(ctx, { + type: "doughnut", + data: { labels, datasets: [{ data, backgroundColor: palette, borderColor: "transparent", borderWidth: 2, hoverOffset: 8 }] }, + options: { + cutout: "62%", responsive: true, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: (c) => ` ${c.label}: ${fmtEUR(c.parsed)}` } }, + }, + animation: { animateRotate: !prefersReduced, duration: prefersReduced ? 0 : 900 }, + }, + }); + + const total = data.reduce((a, b) => a + b, 0); + $("#chartLegend").innerHTML = labels.map((l, i) => ` +
+ ${l} + ${((data[i] / total) * 100).toFixed(1)}% +
`).join(""); + } + + function startLivePrices() { + if (priceTimer) clearInterval(priceTimer); + if (prefersReduced) return; + priceTimer = setInterval(() => { + const i = Math.floor(Math.random() * positions.length); + const p = positions[i]; + const pct = (Math.random() * 0.4 + 0.1) / 100; // 0.1–0.5% + const dir = Math.random() > 0.5 ? 1 : -1; + p.price = p.price * (1 + dir * pct); + + const priceCell = $(`[data-price="${i}"]`); + const plCell = $(`[data-pl="${i}"]`); + const plPctCell = $(`[data-plpct="${i}"]`); + if (!priceCell) return; + + const pl = (p.price - p.avg) * p.shares; + const plPct = ((p.price - p.avg) / p.avg) * 100; + priceCell.textContent = fmtEUR(p.price); + plCell.textContent = fmtSigned(pl); + plPctCell.textContent = (plPct >= 0 ? "+" : "") + plPct.toFixed(1) + "%"; + const cls = pl >= 0 ? "text-success" : "text-error"; + plCell.className = `py-3 px-4 text-right font-semibold ${cls}`; + plPctCell.className = `py-3 px-4 text-right font-semibold ${cls}`; + + const flash = dir > 0 ? "flash-up" : "flash-down"; + priceCell.classList.remove("flash-up", "flash-down"); + void priceCell.offsetWidth; + priceCell.classList.add(flash); + + if (window.allocationChart) { + window.allocationChart.data.datasets[0].data[i] = p.price * p.shares; + window.allocationChart.update("none"); + } + }, 8000); + } + + function revealDashboard() { + loggedIn = true; + $("#parisLogin").classList.add("hidden"); + $("#parisDashboard").classList.remove("hidden"); + renderWatchlist(); + renderTradeLog(); + refreshParisIcons(); + + // KPIs first — must not depend on chart init + updateDashboardKPIs(); + + // Chart after layout paint (canvas needs visible dimensions) + requestAnimationFrame(() => { + try { buildChart(); } catch (err) { console.warn("Chart init skipped:", err); } + if (window.ScrollTrigger) ScrollTrigger.refresh(); + }); + + startLivePrices(); + } + + function initParis() { + $("#loginForm").addEventListener("submit", (e) => { + e.preventDefault(); + const u = $("#username").value.trim(); + const pw = $("#password").value; + const err = $("#loginError"); + if (u === "paris" && pw === "trading123") { + err.textContent = ""; + revealDashboard(); + } else { + err.textContent = "Invalid credentials. Try paris / trading123."; + } + }); + $("#logoutBtn").addEventListener("click", () => { + loggedIn = false; + if (priceTimer) clearInterval(priceTimer); + $("#parisDashboard").classList.add("hidden"); + $("#parisLogin").classList.remove("hidden"); + $("#username").value = ""; $("#password").value = ""; + }); + } + + /* -------------------- LENIS SMOOTH SCROLL -------------------- */ + function initLenis() { + if (typeof Lenis === "undefined" || prefersReduced) return; + const lenis = new Lenis({ duration: 1.05, easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)) }); + function raf(time) { lenis.raf(time); requestAnimationFrame(raf); } + requestAnimationFrame(raf); + if (window.ScrollTrigger) lenis.on("scroll", ScrollTrigger.update); + } + + /* -------------------- MOTION micro-interactions -------------------- */ + function initMotion() { + // Subtle press feedback on primary buttons via Motion if available; CSS handles hover. + if (!window.Motion || prefersReduced) return; + } + + /* -------------------- SCROLL PROGRESS BAR -------------------- */ + function initScrollProgress() { + const bar = $("#scrollProgress"); + if (!bar || prefersReduced) return; + let ticking = false; + const update = () => { + const doc = document.documentElement; + const max = doc.scrollHeight - window.innerHeight; + const pct = max > 0 ? (window.scrollY / max) * 100 : 0; + bar.style.width = Math.min(100, Math.max(0, pct)) + "%"; + ticking = false; + }; + window.addEventListener("scroll", () => { + if (!ticking) { requestAnimationFrame(update); ticking = true; } + }, { passive: true }); + window.addEventListener("resize", update, { passive: true }); + update(); + } + + /* -------------------- PARALLAX (orbs + hero images) -------------------- */ + function initParallax() { + if (prefersReduced) return; + const isMobile = () => window.matchMedia("(max-width: 767px)").matches; + let ticking = false; + const update = () => { + const y = window.scrollY; + const mobile = isMobile(); + $$("[data-parallax]").forEach((el) => { + // Lighter parallax on phones to keep scrolling buttery. + const speed = (parseFloat(el.dataset.parallax) || 0.1) * (mobile ? 0.5 : 1); + el.style.transform = `translate3d(0, ${y * speed}px, 0)`; + }); + // Hero background images: drift slightly slower than scroll, kept scaled so edges never show. + $$("[data-parallax-img]").forEach((img) => { + if (!img.closest(".has-image")) return; + if (mobile) return; + const slot = img.closest(".photo-slot"); + const rect = img.getBoundingClientRect(); + const offset = (rect.top + rect.height / 2 - window.innerHeight / 2); + const speed = parseFloat(img.dataset.parallaxImg) || 0.15; + const parallaxY = -offset * speed; + if (slot && window.TrilogyImageEditor) { + const fw = slot.clientWidth; + const fh = slot.clientHeight; + if (fw > 0 && fh > 0 && img.naturalWidth) { + const layout = window.TrilogyImageEditor.computeLayout( + img.naturalWidth, img.naturalHeight, fw, fh, + { x: +slot.dataset.focusX || 0.5, y: +slot.dataset.focusY || 0.5 }, + +slot.dataset.zoom || 1, + ); + layout.top += parallaxY; + window.TrilogyImageEditor.applyLayout(img, layout); + return; + } + } + img.style.transform = `scale(1.14) translate3d(0, ${parallaxY.toFixed(1)}px, 0)`; + }); + ticking = false; + }; + const onScroll = () => { if (!ticking) { requestAnimationFrame(update); ticking = true; } }; + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll, { passive: true }); + update(); + } + + /* -------------------- 3D CARD TILT -------------------- */ + function initCardTilt() { + if (prefersReduced || !window.matchMedia("(pointer: fine)").matches) return; + const MAX = 6; // degrees — subtle + document.addEventListener("pointermove", (e) => { + const card = e.target.closest(".card-hover"); + if (!card) return; + const r = card.getBoundingClientRect(); + const px = (e.clientX - r.left) / r.width - 0.5; + const py = (e.clientY - r.top) / r.height - 0.5; + card.style.setProperty("--ry", (px * MAX).toFixed(2) + "deg"); + card.style.setProperty("--rx", (-py * MAX).toFixed(2) + "deg"); + }); + document.addEventListener("pointerout", (e) => { + const card = e.target.closest(".card-hover"); + if (!card) return; + card.style.setProperty("--rx", "0deg"); + card.style.setProperty("--ry", "0deg"); + }); + } + + /* -------------------- MAGNETIC BUTTONS -------------------- */ + function initMagneticButtons() { + if (prefersReduced || !window.matchMedia("(pointer: fine)").matches) return; + const STRENGTH = 0.28, MAX = 7; + document.addEventListener("pointermove", (e) => { + const btn = e.target.closest(".btn"); + if (!btn) return; + const r = btn.getBoundingClientRect(); + let dx = (e.clientX - (r.left + r.width / 2)) * STRENGTH; + let dy = (e.clientY - (r.top + r.height / 2)) * STRENGTH; + dx = Math.max(-MAX, Math.min(MAX, dx)); + dy = Math.max(-MAX, Math.min(MAX, dy)); + btn.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px) scale(1.03)`; + }); + document.addEventListener("pointerout", (e) => { + const btn = e.target.closest(".btn"); + if (!btn) return; + btn.style.transform = ""; + }); + } + + /* -------------------- INIT -------------------- */ + document.addEventListener("DOMContentLoaded", () => { + document.body.classList.add("js-ready"); + if (window.gsap && window.ScrollTrigger) gsap.registerPlugin(ScrollTrigger); + + renderMike(); + renderNico(); + if (window.lucide) window.lucide.createIcons(); + + initPhotoSlots(); + initTheme(); + initMobileMenu(); + initParis(); + initLenis(); + initMotion(); + initScrollProgress(); + initParallax(); + initCardTilt(); + initMagneticButtons(); + initRouting(); + + // Detect the upload server and restore any images already saved on disk. + loadServerImages().then(() => { + restoreSlotImages(document); + if (window.ScrollTrigger) ScrollTrigger.refresh(); + }); + + window.addEventListener("resize", () => { + restoreSlotImages(document); + }); + }); +})(); diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..9addea2 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,496 @@ +/* ============================================================ + PlayBull — Trilogy Hub Edition + Luxury gold-on-ink theme · mobile-first · animated arcade + ============================================================ */ +:root { + /* TrilogyHub palette */ + --bg: #0a0a0a; + --bg-2: #111111; + --bg-3: #161616; + --surface: #161616; + --surface-2: #1e1e1e; + --surface-3: #242424; + --line: rgba(201, 168, 76, 0.18); + --line-soft: rgba(255, 255, 255, 0.07); + --text: #f4f1e9; + --muted: #a8a39a; + --faint: #6e6a62; + + --gold: #c9a84c; + --gold-soft: #d8bd6e; + --gold-deep: #9a7c26; + --green: #46c98b; + --green-soft: rgba(70, 201, 139, 0.14); + --red: #e0625e; + --red-soft: rgba(224, 98, 94, 0.14); + + /* legacy aliases kept so existing inline styles stay on-brand */ + --accent: #c9a84c; + --accent-2: #d8bd6e; + --purple: #c9a84c; + + --radius: 16px; + --ease: cubic-bezier(0.16, 1, 0.3, 1); + --safe-b: env(safe-area-inset-bottom, 0px); + --font-display: 'Cabinet Grotesk', ui-sans-serif, system-ui, sans-serif; + --font-body: 'Satoshi', ui-sans-serif, system-ui, sans-serif; +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +html, body { margin: 0; padding: 0; } +html { -webkit-text-size-adjust: 100%; } +body { + font-family: var(--font-body); + background: + radial-gradient(1100px 560px at 50% -260px, rgba(201, 168, 76, 0.10), transparent 60%), + radial-gradient(800px 500px at 100% 110%, rgba(201, 168, 76, 0.05), transparent 60%), + var(--bg); + color: var(--text); + min-height: 100dvh; + padding-bottom: calc(76px + var(--safe-b)); + overscroll-behavior-y: none; + -webkit-font-smoothing: antialiased; + line-height: 1.5; +} +/* fine grain texture overlay for depth */ +body::before { + content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; + opacity: 0.025; mix-blend-mode: overlay; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} +#app, .topbar, .ticker, .bottom-nav, .modal-backdrop, .toast-wrap { position: relative; z-index: 1; } + +h1 { font-size: clamp(1.45rem, 5vw, 1.75rem); margin: 0; font-family: var(--font-display); font-weight: 800; letter-spacing: -0.01em; } +h2 { font-size: 1rem; margin: 18px 0 8px; color: var(--muted); font-weight: 700; letter-spacing: 0.01em; } +h2 small { color: var(--faint); font-weight: 500; } +small { font-weight: 400; } +button { font-family: inherit; cursor: pointer; border: none; } +.accent { color: var(--gold); } + +body.pb-embed { padding-bottom: calc(76px + var(--safe-b)); } +body.pb-embed main { padding-top: 10px; } + +/* ---------- Topbar ---------- */ +.topbar { + position: sticky; top: 0; z-index: 40; + display: flex; align-items: center; justify-content: space-between; + padding: 12px clamp(14px, 4vw, 22px); + padding-top: max(12px, env(safe-area-inset-top)); + background: linear-gradient(180deg, rgba(10, 10, 10, 0.92), rgba(10, 10, 10, 0.7)); + backdrop-filter: blur(16px) saturate(1.2); + border-bottom: 1px solid var(--line); +} +.brand { display: flex; align-items: center; gap: 10px; font-family: var(--font-display); font-weight: 800; font-size: 1.2rem; letter-spacing: -0.01em; } +.brand-logo { + font-size: 1.4rem; display: grid; place-items: center; width: 36px; height: 36px; + border-radius: 10px; background: radial-gradient(circle at 30% 25%, rgba(201,168,76,.3), rgba(201,168,76,.06)); + border: 1px solid var(--line); + filter: drop-shadow(0 0 10px rgba(201, 168, 76, 0.4)); +} +.brand .accent, .brand-name .accent { color: var(--gold); } +.topbar-right { display: flex; align-items: center; gap: 10px; } +.balance-chip { + display: flex; align-items: center; gap: 6px; + background: linear-gradient(135deg, rgba(201,168,76,.16), rgba(201,168,76,.04)); + border: 1px solid var(--line); padding: 7px 13px; border-radius: 999px; + font-weight: 800; font-variant-numeric: tabular-nums; color: var(--gold-soft); +} +.balance-chip .coin { filter: drop-shadow(0 0 6px var(--gold)); } +.btn-auth { + background: var(--gold); color: #0a0a0a; + padding: 9px 18px; border-radius: 999px; font-weight: 800; font-size: .9rem; + box-shadow: 0 8px 24px -8px rgba(201, 168, 76, 0.6); + transition: transform .3s var(--ease), box-shadow .3s var(--ease); +} +.btn-auth:active { transform: scale(.96); } + +/* ---------- Ticker ---------- */ +.ticker { overflow: hidden; border-bottom: 1px solid var(--line-soft); background: rgba(17,17,17,.6); height: 32px; } +.ticker-track { display: flex; gap: 30px; white-space: nowrap; align-items: center; height: 32px; padding-left: 100%; animation: scroll 40s linear infinite; } +.ticker-track:hover { animation-play-state: paused; } +.ticker-item { font-size: .76rem; color: var(--muted); letter-spacing: .01em; } +.ticker-item b { color: var(--gold-soft); } +@keyframes scroll { to { transform: translateX(-100%); } } + +/* ---------- Views ---------- */ +main { padding: clamp(14px, 4vw, 22px); max-width: 760px; margin: 0 auto; } +.view { display: none; } +.view.active { display: block; animation: viewIn .4s var(--ease); } +@keyframes viewIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } +.view-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.view-head h1 { display: flex; align-items: center; gap: 4px; } +.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--green); margin: 0 4px 0 8px; box-shadow: 0 0 0 0 var(--green); animation: pulse 1.6s infinite; } +@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(70,201,139,.6);} 70%{ box-shadow: 0 0 0 9px rgba(70,201,139,0);} 100%{ box-shadow:0 0 0 0 rgba(70,201,139,0);} } + +/* ---------- Segmented control ---------- */ +.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--line-soft); border-radius: 999px; padding: 3px; } +.seg-btn { background: transparent; color: var(--muted); padding: 7px 15px; border-radius: 999px; font-weight: 700; font-size: .82rem; transition: color .25s, background .25s; } +.seg-btn.active { background: var(--gold); color: #0a0a0a; box-shadow: 0 4px 12px -4px rgba(201,168,76,.6); } + +/* ---------- Asset list ---------- */ +.asset-list { display: flex; flex-direction: column; gap: 9px; } +.asset-row { + display: grid; grid-template-columns: 44px 1fr auto; gap: 13px; align-items: center; + background: var(--surface); border: 1px solid var(--line-soft); border-radius: 14px; padding: 13px 15px; + transition: transform .12s var(--ease), border-color .25s, box-shadow .25s; cursor: pointer; +} +.asset-row:hover { border-color: var(--line); box-shadow: 0 10px 30px -18px rgba(0,0,0,.8); } +.asset-row:active { transform: scale(.985); } +.asset-emoji { font-size: 1.45rem; width: 44px; height: 44px; display: grid; place-items: center; background: var(--surface-2); border-radius: 12px; border: 1px solid var(--line-soft); } +.asset-info { min-width: 0; } +.asset-info .sym { font-weight: 800; font-family: var(--font-display); letter-spacing: .01em; } +.asset-info .name { color: var(--muted); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-price { text-align: right; } +.asset-price .px { font-weight: 800; font-variant-numeric: tabular-nums; } +.asset-price .chg { font-size: .76rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.up { color: var(--green); } .down { color: var(--red); } +.flash-up { animation: fu .7s var(--ease); } .flash-down { animation: fd .7s var(--ease); } +@keyframes fu { 0% { background: var(--green-soft); border-color: var(--green);} 100%{ background: var(--surface); border-color: var(--line-soft);} } +@keyframes fd { 0% { background: var(--red-soft); border-color: var(--red);} 100%{ background: var(--surface); border-color: var(--line-soft);} } +.badge-halt { font-size: .6rem; background: var(--red-soft); color: var(--red); padding: 2px 6px; border-radius: 6px; margin-left: 6px; vertical-align: middle; font-weight: 700; } + +/* ---------- Games grid ---------- */ +.games-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 13px; } +.game-card { + position: relative; border-radius: 18px; padding: 16px; min-height: 150px; + display: flex; flex-direction: column; justify-content: space-between; + background: linear-gradient(165deg, var(--surface-2), var(--surface)); + border: 1px solid var(--line-soft); overflow: hidden; cursor: pointer; + transition: transform .25s var(--ease), border-color .3s, box-shadow .3s; +} +.game-card:hover { transform: translateY(-4px); border-color: var(--line); box-shadow: 0 22px 50px -26px rgba(0,0,0,.9); } +.game-card:active { transform: scale(.97); } +.game-card .g-emoji { font-size: 2.5rem; filter: drop-shadow(0 6px 14px rgba(0,0,0,.5)); transition: transform .4s var(--ease); } +.game-card:hover .g-emoji { transform: translateY(-3px) rotate(-6deg) scale(1.08); } +.game-card .g-name { font-weight: 800; font-size: 1.06rem; margin-top: 10px; font-family: var(--font-display); } +.game-card .g-desc { color: var(--muted); font-size: .73rem; margin-top: 2px; line-height: 1.35; } +.game-card .g-tag { position: absolute; top: 12px; right: 12px; font-size: .58rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--gold); border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px; background: rgba(201,168,76,.08); } +.game-card::after { content: ""; position: absolute; inset: 0; background: radial-gradient(150px 100px at 100% 0%, var(--glow, rgba(201,168,76,.22)), transparent 70%); pointer-events: none; } +.game-card::before { content: ""; position: absolute; inset: 0; background: linear-gradient(120deg, transparent 30%, rgba(255,255,255,.05) 50%, transparent 70%); transform: translateX(-120%); transition: transform .7s var(--ease); } +.game-card:hover::before { transform: translateX(120%); } + +/* ---------- Buttons ---------- */ +.btn { padding: 14px 16px; border-radius: 13px; font-weight: 800; font-size: .95rem; width: 100%; color: var(--text); background: var(--surface-2); border: 1px solid var(--line-soft); transition: transform .12s var(--ease), box-shadow .3s, filter .2s; } +.btn:active { transform: scale(.97); } +.btn-green { background: linear-gradient(135deg, #4fd99a, #2fa873); color: #052012; box-shadow: 0 8px 22px -8px rgba(70,201,139,.5); } +.btn-red { background: linear-gradient(135deg, #ec716d, #c44743); color: #fff; box-shadow: 0 8px 22px -8px rgba(224,98,94,.5); } +.btn-accent { background: var(--gold); color: #0a0a0a; box-shadow: 0 8px 22px -8px rgba(201,168,76,.6); } +.btn-gold { background: linear-gradient(135deg, var(--gold-soft), var(--gold-deep)); color: #1a1305; box-shadow: 0 8px 22px -8px rgba(201,168,76,.6); } +.btn-ghost { background: var(--surface); border-color: var(--line); color: var(--text); } +.btn-ghost:active { filter: brightness(1.15); } +.btn:disabled { opacity: .4; pointer-events: none; } +.btn-row { display: flex; gap: 10px; } +.link { background: none; color: var(--gold); text-decoration: underline; text-underline-offset: 2px; padding: 0; font-weight: 700; } + +/* ---------- Inputs ---------- */ +.field { margin-bottom: 13px; } +.field label { display: block; font-size: .76rem; color: var(--muted); margin-bottom: 6px; font-weight: 600; letter-spacing: .01em; } +input, select { + width: 100%; padding: 13px 14px; border-radius: 12px; background: var(--bg-2); + border: 1px solid var(--line-soft); color: var(--text); font-size: 1rem; font-family: inherit; + font-variant-numeric: tabular-nums; transition: border-color .25s, box-shadow .25s; +} +input:focus, select:focus { outline: none; border-color: var(--gold); box-shadow: 0 0 0 3px rgba(201,168,76,.15); } +.chips { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { background: var(--surface-2); border: 1px solid var(--line-soft); color: var(--text); padding: 8px 13px; border-radius: 999px; font-weight: 700; font-size: .82rem; transition: all .2s var(--ease); } +.chip:active { transform: scale(.94); } +.chip.active { border-color: var(--gold); background: rgba(201,168,76,.18); color: var(--gold-soft); } + +/* ---------- Cards ---------- */ +.card { background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; } +.stat-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--line-soft); gap: 10px; } +.stat-row:last-child { border: none; } +.stat-row .k { color: var(--muted); } +.stat-row .v { font-weight: 800; font-variant-numeric: tabular-nums; } +.big-num { font-size: clamp(1.9rem, 8vw, 2.3rem); font-weight: 900; font-family: var(--font-display); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; } + +/* ---------- Chart ---------- */ +.chart-wrap { background: var(--bg-2); border: 1px solid var(--line-soft); border-radius: 14px; padding: 8px; margin-bottom: 14px; } +canvas { display: block; width: 100%; } + +/* ---------- Modal ---------- */ +.modal-backdrop:not([hidden]) { + position: fixed; inset: 0; background: rgba(5,5,5,.78); backdrop-filter: blur(8px); z-index: 100; + display: flex; align-items: flex-end; justify-content: center; + animation: backIn .25s ease; +} +@keyframes backIn { from { opacity: 0; } to { opacity: 1; } } +.modal-backdrop[hidden] { display: none !important; } +.modal { + background: linear-gradient(180deg, var(--surface), var(--bg-2)); + border: 1px solid var(--line); border-top-color: var(--line); + border-radius: 24px 24px 0 0; width: 100%; max-width: 580px; max-height: 93dvh; overflow-y: auto; + padding: 20px; padding-bottom: calc(22px + var(--safe-b)); + box-shadow: 0 -20px 60px -20px rgba(0,0,0,.8); + animation: slideUp .34s var(--ease); +} +@keyframes slideUp { from { transform: translateY(100%); } to { transform: none; } } +@media (min-width: 600px) { .modal-backdrop { align-items: center; } .modal { border-radius: 22px; } } +.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; gap: 10px; } +.modal-head h2 { margin: 0; color: var(--text); font-size: 1.3rem; font-family: var(--font-display); font-weight: 800; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.modal-close { background: var(--surface-2); width: 34px; height: 34px; border-radius: 50%; color: var(--muted); font-size: 1.05rem; flex-shrink: 0; transition: background .2s, color .2s; } +.modal-close:hover { background: var(--red-soft); color: var(--red); } +.handle { width: 42px; height: 4px; background: var(--line); border-radius: 99px; margin: -8px auto 16px; } + +/* ---------- Bottom nav ---------- */ +.bottom-nav { + position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; + display: grid; grid-template-columns: repeat(5, 1fr); + background: linear-gradient(180deg, rgba(13,13,13,.86), rgba(10,10,10,.97)); + backdrop-filter: blur(18px); border-top: 1px solid var(--line); + padding-bottom: var(--safe-b); +} +.nav-btn { background: none; color: var(--faint); font-size: .64rem; font-weight: 700; padding: 10px 0 9px; display: flex; flex-direction: column; align-items: center; gap: 4px; letter-spacing: .02em; transition: color .2s; position: relative; } +.nav-btn span { font-size: 1.3rem; filter: grayscale(.6) opacity(.7); transition: transform .25s var(--ease), filter .25s; } +.nav-btn.active { color: var(--gold-soft); } +.nav-btn.active span { filter: none; transform: translateY(-3px) scale(1.12); } +.nav-btn.active::after { content: ""; position: absolute; top: 4px; width: 26px; height: 3px; border-radius: 99px; background: var(--gold); box-shadow: 0 0 10px var(--gold); } + +/* ---------- Toast ---------- */ +.toast-wrap { position: fixed; top: 70px; left: 0; right: 0; z-index: 200; display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; padding: 0 12px; } +.toast { background: var(--surface-2); border: 1px solid var(--line); padding: 12px 18px; border-radius: 13px; font-weight: 700; font-size: .9rem; box-shadow: 0 14px 40px rgba(0,0,0,.6); animation: toastIn .35s var(--ease); max-width: 92%; text-align: center; } +.toast.win { border-color: var(--green); background: linear-gradient(135deg, #123322, var(--surface-2)); color: #d6f7e6; } +.toast.lose { border-color: var(--red); background: linear-gradient(135deg, #331616, var(--surface-2)); color: #f7d6d6; } +@keyframes toastIn { from { transform: translateY(-18px) scale(.95); opacity: 0; } to { transform: none; opacity: 1; } } + +/* ---------- Feed ---------- */ +.feed-list { display: flex; flex-direction: column; gap: 6px; } +.feed-item { display: flex; gap: 9px; align-items: center; font-size: .85rem; padding: 10px 13px; background: var(--surface); border: 1px solid var(--line-soft); border-radius: 11px; } +.feed-item .ago { color: var(--faint); margin-left: auto; font-size: .7rem; white-space: nowrap; } + +/* ---------- Leaderboard ---------- */ +.lb-row { display: grid; grid-template-columns: 36px 1fr auto; gap: 11px; align-items: center; padding: 12px 14px; background: var(--surface); border: 1px solid var(--line-soft); border-radius: 13px; margin-bottom: 7px; } +.lb-rank { font-weight: 900; color: var(--faint); font-family: var(--font-display); font-size: 1.05rem; text-align: center; } +.lb-row.me { border-color: var(--gold); background: linear-gradient(135deg, rgba(201,168,76,.08), var(--surface)); } +.lb-user { display: flex; align-items: center; gap: 10px; min-width: 0; } +.lb-user b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ============================================================ + GAME: CHICKEN ROAD — animated scene + ============================================================ */ +.chicken-stage { + position: relative; border-radius: 18px; overflow: hidden; + border: 1px solid var(--line); margin-bottom: 14px; + background: linear-gradient(180deg, #0d1b12 0%, #0a0a0a 60%); + box-shadow: inset 0 0 60px rgba(0,0,0,.6); +} +.chicken-track { + display: flex; gap: 0; overflow-x: auto; padding: 0; scroll-behavior: smooth; + scrollbar-width: none; -webkit-overflow-scrolling: touch; +} +.chicken-track::-webkit-scrollbar { display: none; } +.lane { + min-width: 78px; height: 200px; position: relative; flex: 0 0 auto; + display: flex; flex-direction: column; align-items: center; justify-content: flex-end; + background: + repeating-linear-gradient(0deg, #1c1c1c 0 22px, #181818 22px 44px); + border-right: 2px dashed rgba(201,168,76,.22); +} +.lane:first-child { border-left: 2px solid rgba(201,168,76,.3); } +.lane.curb { background: repeating-linear-gradient(0deg, #15301f 0 14px, #112818 14px 28px); min-width: 60px; } +.lane.curb .mult-flag { color: var(--green); border-color: var(--green); background: var(--green-soft); } +.lane .mult-flag { + position: absolute; top: 10px; left: 50%; transform: translateX(-50%); + font-size: .72rem; font-weight: 800; color: var(--gold-soft); font-family: var(--font-display); + background: rgba(10,10,10,.7); border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px; + white-space: nowrap; transition: all .3s var(--ease); z-index: 3; +} +.lane.passed { background: repeating-linear-gradient(0deg, #16291d 0 22px, #122318 22px 44px); } +.lane.passed .mult-flag { background: var(--green-soft); border-color: var(--green); color: #8df0bd; } +.lane.current { background: repeating-linear-gradient(0deg, #2a2410 0 22px, #221d0c 22px 44px); } +.lane.current::after { content: ""; position: absolute; inset: 0; box-shadow: inset 0 0 0 2px var(--gold); border-radius: 2px; animation: laneGlow 1.2s ease-in-out infinite; } +@keyframes laneGlow { 0%,100% { opacity: .4; } 50% { opacity: 1; } } +.lane.dead { background: repeating-linear-gradient(0deg, #2a1212 0 22px, #221010 22px 44px); } +.lane .car { + position: absolute; top: -60px; left: 50%; transform: translateX(-50%); + font-size: 2rem; filter: drop-shadow(0 6px 8px rgba(0,0,0,.6)); +} +.lane.dead .car { animation: carSlam .5s var(--ease) forwards; } +@keyframes carSlam { 0% { top: -60px; } 60% { top: 78px; } 75% { top: 64px; } 100% { top: 72px; } } +/* the chicken hero rides on top of the stage */ +.chicken-hero { + position: absolute; bottom: 16px; left: 22px; font-size: 2.2rem; z-index: 5; + pointer-events: none; + filter: drop-shadow(0 8px 10px rgba(0,0,0,.6)); + will-change: transform, left; +} +.chicken-hero.hop { animation: hop .35s var(--ease); } +@keyframes hop { 0% { transform: translateY(0) scaleY(1); } 30% { transform: translateY(-46px) scaleY(1.15) scaleX(.9); } 70% { transform: translateY(-46px) scaleY(1.1); } 100% { transform: translateY(0) scaleY(1); } } +.chicken-hero.idle { animation: bob 1.4s ease-in-out infinite; } +@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } } +.chicken-hud { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 10px 0; } +.chicken-hud .mult-big { font-family: var(--font-display); font-weight: 900; color: var(--gold); font-size: 1.6rem; font-variant-numeric: tabular-nums; } +.stage-shake { animation: shake .5s var(--ease); } +@keyframes shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-8px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(4px); } } +.boom-flash { position: absolute; inset: 0; background: radial-gradient(circle, rgba(224,98,94,.5), transparent 70%); opacity: 0; pointer-events: none; z-index: 6; } +.boom-flash.go { animation: boomFlash .5s ease; } +@keyframes boomFlash { 0% { opacity: 1; } 100% { opacity: 0; } } + +/* ============================================================ + GAME: MINES 10x10 — 3D card flips + ============================================================ */ +.mines-grid { display: grid; grid-template-columns: repeat(10, 1fr); gap: 4px; margin: 12px 0; perspective: 700px; } +.tile { + aspect-ratio: 1; border-radius: 8px; position: relative; transform-style: preserve-3d; + transition: transform .4s var(--ease); cursor: pointer; +} +.tile:active { transform: scale(.9); } +.tile .face { position: absolute; inset: 0; border-radius: 8px; display: grid; place-items: center; font-size: clamp(.7rem, 2.6vw, 1rem); backface-visibility: hidden; } +.tile .face.back { + background: linear-gradient(160deg, var(--surface-3), var(--surface-2)); + border: 1px solid var(--line-soft); color: var(--faint); +} +.tile .face.back::before { content: ""; position: absolute; inset: 4px; border-radius: 5px; border: 1px solid rgba(201,168,76,.12); } +.tile .face.front { transform: rotateY(180deg); } +.tile.flipped { transform: rotateY(180deg); } +.tile.safe .face.front { background: radial-gradient(circle at 50% 35%, rgba(70,201,139,.35), var(--green-soft)); border: 1px solid var(--green); animation: gemPop .45s var(--ease); } +@keyframes gemPop { 0% { transform: rotateY(180deg) scale(.4); } 70% { transform: rotateY(180deg) scale(1.18); } 100% { transform: rotateY(180deg) scale(1); } } +.tile.bomb .face.front { background: radial-gradient(circle at 50% 35%, rgba(224,98,94,.45), var(--red-soft)); border: 1px solid var(--red); } +.tile.bomb.hit { animation: bombHit .5s var(--ease); z-index: 2; } +@keyframes bombHit { 0% { transform: rotateY(180deg) scale(1); } 40% { transform: rotateY(180deg) scale(1.4); } 100% { transform: rotateY(180deg) scale(1); } } +.tile.dim { opacity: .3; } + +/* ============================================================ + GAME: CRASH — canvas rocket + ============================================================ */ +.crash-stage { position: relative; border-radius: 18px; overflow: hidden; border: 1px solid var(--line); margin-bottom: 14px; background: linear-gradient(180deg, #0c1020, #0a0a0a); } +.crash-stage canvas { width: 100%; height: 240px; display: block; } +.crash-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; } +.crash-mult { font-size: clamp(2.6rem, 14vw, 3.6rem); font-weight: 900; font-family: var(--font-display); font-variant-numeric: tabular-nums; color: var(--text); text-shadow: 0 4px 30px rgba(0,0,0,.6); transition: color .2s, transform .15s; } +.crash-mult.boom { color: var(--red); animation: crashShake .5s var(--ease); } +@keyframes crashShake { 0%,100% { transform: translateX(0); } 25% { transform: translate(-6px,3px) rotate(-2deg); } 50% { transform: translate(6px,-2px) rotate(2deg); } 75% { transform: translate(-4px,2px); } } +.crash-sub { color: var(--muted); font-weight: 700; font-size: .85rem; margin-top: 6px; } + +/* legacy crash display fallback */ +.crash-display { text-align: center; padding: 28px 0; background: var(--bg-2); border-radius: 16px; border: 1px solid var(--line-soft); margin-bottom: 14px; } + +/* ============================================================ + GAME: DICE — animated roll bar + ============================================================ */ +.dice-bar-wrap { position: relative; margin: 18px 0 30px; padding: 0 6px; } +.dice-bar { position: relative; height: 14px; border-radius: 99px; overflow: hidden; background: var(--red); } +.dice-bar .win-zone { position: absolute; top: 0; bottom: 0; background: linear-gradient(90deg, #4fd99a, #2fa873); transition: all .25s var(--ease); } +.dice-marker { + position: absolute; top: 50%; width: 34px; height: 34px; border-radius: 12px; + background: var(--gold); color: #0a0a0a; display: grid; place-items: center; + font-weight: 900; font-size: .72rem; font-family: var(--font-display); + transform: translate(-50%, -50%); box-shadow: 0 6px 16px rgba(0,0,0,.5); z-index: 3; + transition: left .08s linear; +} +.dice-marker.settle { transition: left .5s cubic-bezier(.34,1.56,.64,1); } +.dice-scale { display: flex; justify-content: space-between; margin-top: 8px; color: var(--faint); font-size: .68rem; font-weight: 700; } +.dice-result { font-family: var(--font-display); font-weight: 900; font-size: 2.4rem; text-align: center; font-variant-numeric: tabular-nums; margin: 6px 0; transition: color .3s; } + +/* ============================================================ + GAME: COINFLIP — true 3D coin + ============================================================ */ +.coin-stage { display: grid; place-items: center; height: 200px; perspective: 900px; margin: 10px 0; } +.coin { position: relative; width: 120px; height: 120px; transform-style: preserve-3d; transition: transform .1s; } +.coin .side { + position: absolute; inset: 0; border-radius: 50%; display: grid; place-items: center; + font-size: 3rem; backface-visibility: hidden; + background: radial-gradient(circle at 35% 30%, var(--gold-soft), var(--gold-deep)); + border: 4px solid #8a6e1f; box-shadow: inset 0 0 18px rgba(0,0,0,.4), 0 14px 30px -10px rgba(0,0,0,.7); + color: #2a1d00; +} +.coin .heads { transform: translateZ(2px); } +.coin .tails { transform: rotateY(180deg) translateZ(2px); } +.coin.spin-heads { animation: spinHeads 2.4s cubic-bezier(.3,.1,.2,1) forwards; } +.coin.spin-tails { animation: spinTails 2.4s cubic-bezier(.3,.1,.2,1) forwards; } +@keyframes spinHeads { 0% { transform: rotateY(0) translateY(0); } 50% { transform: rotateY(1440deg) translateY(-50px); } 100% { transform: rotateY(2520deg) translateY(0); } } +@keyframes spinTails { 0% { transform: rotateY(0) translateY(0); } 50% { transform: rotateY(1440deg) translateY(-50px); } 100% { transform: rotateY(2700deg) translateY(0); } } + +/* ---------- misc ---------- */ +.login-hint { text-align: center; color: var(--muted); margin-top: 24px; padding: 22px; background: var(--surface); border: 1px solid var(--line-soft); border-radius: 14px; } +.muted { color: var(--muted); } +.center { text-align: center; } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.pill { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: .7rem; font-weight: 800; } +.pill.long { background: var(--green-soft); color: var(--green); } +.pill.short { background: var(--red-soft); color: var(--red); } +.spacer { height: 10px; } +.result-emoji { font-size: 3rem; text-align: center; margin: 8px 0; } +.stat-tile { background: var(--surface); border: 1px solid var(--line-soft); border-radius: 14px; padding: 12px; text-align: center; } +.stat-tile .lbl { color: var(--muted); font-size: .72rem; font-weight: 600; } +.stat-tile .val { font-weight: 900; font-family: var(--font-display); font-size: 1.15rem; margin-top: 2px; font-variant-numeric: tabular-nums; } + +/* ---------- Framed images (Avatar etc.) ---------- */ +.framed-image { overflow: hidden; position: relative; background: var(--surface-2); flex-shrink: 0; } +.framed-image--avatar { aspect-ratio: 1; border-radius: 50%; } +.framed-image img { position: absolute; pointer-events: none; user-select: none; max-width: none; } +.framed-image--avatar-sm { width: 36px; height: 36px; } +.framed-image--avatar-md { width: 48px; height: 48px; } +.framed-image--avatar-lg { width: 120px; height: 120px; } +.framed-image--avatar-xl { width: min(72vw, 260px); height: min(72vw, 260px); } + +.framed-image--gallery { aspect-ratio: 1; border-radius: 14px; } +.framed-image--gallery-sm { width: 80px; height: 80px; } +.framed-image--gallery-md { width: 100%; max-width: 140px; height: auto; aspect-ratio: 1; } +.framed-image--gallery-lg { width: 140px; height: 140px; } +.framed-image--gallery-xl { width: min(72vw, 280px); height: min(72vw, 280px); } + +.framed-image--banner { aspect-ratio: 3 / 1; border-radius: 14px; } +.framed-image--banner-sm { width: 120px; height: 40px; } +.framed-image--banner-md { width: 100%; max-width: 200px; height: auto; aspect-ratio: 3; } +.framed-image--banner-lg { width: 240px; height: 80px; } +.framed-image--banner-xl { width: min(92vw, 360px); height: auto; aspect-ratio: 3; } + +.framed-image--card { aspect-ratio: 2 / 3; border-radius: 12px; } +.framed-image--card-sm { width: 48px; height: 72px; } +.framed-image--card-md { width: 80px; height: 120px; } +.framed-image--card-lg { width: 100px; height: 150px; } +.framed-image--card-xl { width: min(50vw, 200px); height: auto; aspect-ratio: 2/3; } + +.avatar-emoji { display: inline-grid; place-items: center; line-height: 1; } +.avatar-emoji--sm { font-size: 1.35rem; width: 36px; height: 36px; } +.avatar-emoji--md { font-size: 1.6rem; width: 48px; height: 48px; } +.avatar-emoji--lg { font-size: 3.5rem; width: 120px; height: 120px; } + +.profile-avatar-row { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; } +.profile-avatar-actions { display: flex; flex-direction: column; gap: 8px; flex: 1; } +.profile-avatar-actions .btn { width: auto; font-size: .85rem; padding: 10px 14px; } + +.btn-auth--avatar { padding: 0; width: 38px; height: 38px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--line); font-size: 1.2rem; box-shadow: none; } +.btn-auth--avatar .framed-image--avatar-sm, +.btn-auth--avatar .avatar-emoji--sm { width: 38px; height: 38px; } + +.account-head { display: flex; align-items: center; gap: 12px; } +.account-head h2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + +/* ---------- Image position editor ---------- */ +.image-editor-backdrop { z-index: 110; } +.image-editor-modal { max-height: 94dvh; } +.image-editor-hint { font-size: .82rem; margin: 0 0 14px; } +.image-editor-preview-wrap { display: flex; justify-content: center; margin-bottom: 16px; } +.image-editor-preview { touch-action: none; cursor: grab; border: 2px solid var(--line); box-shadow: 0 12px 40px rgba(0,0,0,.35); } +.image-editor-preview:active { cursor: grabbing; } +.image-editor-zoom-field input[type="range"] { padding: 0; height: 6px; -webkit-appearance: none; appearance: none; background: var(--line); border: none; border-radius: 99px; } +.image-editor-zoom-field input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%; background: var(--gold); box-shadow: 0 2px 8px rgba(201,168,76,.5); } +.image-editor-zoom-field label { display: flex; justify-content: space-between; align-items: center; } + +/* ---------- Uploads Galerie ---------- */ +.uploads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; margin-top: 14px; } +.upload-item { display: flex; flex-direction: column; gap: 8px; align-items: stretch; } +.upload-item .framed-image--gallery-md, +.upload-item .framed-image--banner-md, +.upload-item .framed-image--card-md { width: 100%; max-width: none; } +.upload-item-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.upload-item-actions .chip { font-size: .72rem; padding: 5px 8px; } +.uploads-toolbar { display: flex; flex-direction: column; gap: 10px; } +.image-upload-wrap { display: contents; } + +/* range input global (dice etc.) */ +input[type="range"] { -webkit-appearance: none; appearance: none; height: 6px; padding: 0; background: var(--surface-3); border: none; border-radius: 99px; } +input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; border-radius: 50%; background: var(--gold); box-shadow: 0 2px 10px rgba(201,168,76,.5); cursor: pointer; } +input[type="range"]::-moz-range-thumb { width: 24px; height: 24px; border: none; border-radius: 50%; background: var(--gold); box-shadow: 0 2px 10px rgba(201,168,76,.5); cursor: pointer; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} + +/* very small phones */ +@media (max-width: 360px) { + .games-grid { gap: 10px; } + .game-card { min-height: 138px; padding: 13px; } + .lane { min-width: 70px; } +} diff --git a/public/image-editor.js b/public/image-editor.js new file mode 100644 index 0000000..61ce1bf --- /dev/null +++ b/public/image-editor.js @@ -0,0 +1,190 @@ +/* Trilogy Hub — Bild-Positions-Editor (gleicher Rahmen wie Slot-Anzeige) */ +(() => { + "use strict"; + + function computeLayout(imgW, imgH, frameW, frameH, focus, zoom) { + const fx = focus?.x ?? 0.5; + const fy = focus?.y ?? 0.5; + const z = Math.max(1, zoom ?? 1); + const cover = Math.max(frameW / imgW, frameH / imgH); + const scale = cover * z; + const w = imgW * scale; + const h = imgH * scale; + let left = frameW / 2 - fx * w; + let top = frameH / 2 - fy * h; + left = Math.min(0, Math.max(frameW - w, left)); + top = Math.min(0, Math.max(frameH - h, top)); + return { width: w, height: h, left, top }; + } + + function focusFromLayout(imgW, imgH, frameW, frameH, layout, zoom) { + const z = Math.max(1, zoom ?? 1); + const cover = Math.max(frameW / imgW, frameH / imgH); + const w = imgW * cover * z; + const h = imgH * cover * z; + return { + x: Math.min(1, Math.max(0, (frameW / 2 - layout.left) / w)), + y: Math.min(1, Math.max(0, (frameH / 2 - layout.top) / h)), + }; + } + + function applyLayout(img, layout) { + img.style.width = layout.width + "px"; + img.style.height = layout.height + "px"; + img.style.left = layout.left + "px"; + img.style.top = layout.top + "px"; + } + + function loadImage(src) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("Bild konnte nicht geladen werden")); + img.src = src; + }); + } + + function slotFrameSize(slot) { + const cs = getComputedStyle(slot); + let w = slot.offsetWidth; + let h = slot.offsetHeight; + if (w < 40) w = slot.parentElement?.offsetWidth || 320; + if (h < 40) { + const mh = cs.minHeight; + h = mh && mh !== "0px" ? parseFloat(mh) : 220; + } + const isHero = slot.classList.contains("!rounded-none") || slot.closest(".min-h-\\[72vh\\]"); + const maxPreview = isHero ? 400 : 300; + const ar = w / h; + if (w > maxPreview) { w = maxPreview; h = w / ar; } + return { width: Math.round(w), height: Math.round(h), radius: cs.borderRadius, label: slot.dataset.slot || "Bild" }; + } + + async function open({ file, slot, focus, zoom, imageSrc }) { + let src = imageSrc; + let revoke = null; + if (file) { src = URL.createObjectURL(file); revoke = src; } + + const frame = slotFrameSize(slot); + let img; + try { img = await loadImage(src); } + catch (e) { if (revoke) URL.revokeObjectURL(revoke); throw e; } + + const state = { + focus: { x: focus?.x ?? 0.5, y: focus?.y ?? 0.5 }, + zoom: Math.max(1, zoom ?? 1), + dragging: false, lastX: 0, lastY: 0, pinchDist: 0, pinchZoom: 1, + }; + + return new Promise((resolve) => { + const backdrop = document.createElement("div"); + backdrop.className = "img-editor-backdrop"; + backdrop.innerHTML = ` +
+
+

${frame.label} positionieren

+ +
+

So wird das Bild im Rahmen angezeigt — ziehen & zoomen

+
+
+ +
+
+ + +
+ + +
+
`; + + document.body.appendChild(backdrop); + document.body.style.overflow = "hidden"; + + const frameEl = backdrop.querySelector("#ieFrame"); + const imgEl = backdrop.querySelector("#ieImg"); + const zoomInput = backdrop.querySelector("#ieZoom"); + const zoomVal = backdrop.querySelector("#ieZoomVal"); + let layout = { left: 0, top: 0, width: 0, height: 0 }; + + const fw = () => frameEl.clientWidth; + const fh = () => frameEl.clientHeight; + + function syncFromFocus() { + layout = computeLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), state.focus, state.zoom); + applyLayout(imgEl, layout); + } + function syncFocusFromLayout() { + state.focus = focusFromLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), layout, state.zoom); + } + function finish(result) { + document.body.style.overflow = ""; + backdrop.remove(); + if (revoke) URL.revokeObjectURL(revoke); + resolve(result); + } + function setZoom(z) { + const old = { ...layout }; + state.zoom = Math.min(4, Math.max(1, z)); + layout = computeLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), state.focus, state.zoom); + const cx = fw() / 2, cy = fh() / 2; + const rx = (cx - old.left) / old.width; + const ry = (cy - old.top) / old.height; + layout.left = cx - rx * layout.width; + layout.top = cy - ry * layout.height; + layout.left = Math.min(0, Math.max(fw() - layout.width, layout.left)); + layout.top = Math.min(0, Math.max(fh() - layout.height, layout.top)); + syncFocusFromLayout(); + applyLayout(imgEl, layout); + zoomInput.value = state.zoom; + zoomVal.textContent = state.zoom.toFixed(1) + "×"; + } + + imgEl.addEventListener("load", syncFromFocus, { once: true }); + if (imgEl.complete) syncFromFocus(); + + frameEl.addEventListener("pointerdown", (e) => { + if (e.target.closest("button, input")) return; + state.dragging = true; + state.lastX = e.clientX; + state.lastY = e.clientY; + frameEl.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + frameEl.addEventListener("pointermove", (e) => { + if (!state.dragging) return; + layout.left = Math.min(0, Math.max(fw() - layout.width, layout.left + e.clientX - state.lastX)); + layout.top = Math.min(0, Math.max(fh() - layout.height, layout.top + e.clientY - state.lastY)); + state.lastX = e.clientX; + state.lastY = e.clientY; + applyLayout(imgEl, layout); + syncFocusFromLayout(); + }); + frameEl.addEventListener("pointerup", () => { state.dragging = false; }); + frameEl.addEventListener("pointercancel", () => { state.dragging = false; }); + frameEl.addEventListener("wheel", (e) => { e.preventDefault(); setZoom(state.zoom + (e.deltaY > 0 ? -0.08 : 0.08)); }, { passive: false }); + frameEl.addEventListener("touchstart", (e) => { + if (e.touches.length === 2) { + state.pinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); + state.pinchZoom = state.zoom; + } + }, { passive: true }); + frameEl.addEventListener("touchmove", (e) => { + if (e.touches.length === 2 && state.pinchDist > 0) { + e.preventDefault(); + const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); + setZoom(state.pinchZoom * (dist / state.pinchDist)); + } + }, { passive: false }); + + zoomInput.addEventListener("input", () => setZoom(+zoomInput.value)); + backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(null); }); + backdrop.querySelector(".img-editor-close").onclick = () => finish(null); + backdrop.querySelector('[data-act="reset"]').onclick = () => { state.focus = { x: 0.5, y: 0.5 }; state.zoom = 1; setZoom(1); }; + backdrop.querySelector('[data-act="save"]').onclick = () => { syncFocusFromLayout(); finish({ focus: { ...state.focus }, zoom: state.zoom }); }; + }); + } + + window.TrilogyImageEditor = { open, computeLayout, applyLayout }; +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..57b7d14 --- /dev/null +++ b/public/index.html @@ -0,0 +1,880 @@ + + + + + + Trilogy Hub — Three Hustlers. One Vision. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ +
+ + + + +
+ +
+ +
+
+

Trees · Properties · Markets

+

+ Three Hustlers.
One Vision. +

+

+ Trees. Properties. Markets. We don't just talk — we build. +

+
+ + +
+ + +
+
+
+
Magic Mike
+ Magic Mike + Magic Mike +
+
+

Magic Mike

+

Founder · Magic Tree Co.

+
+
+
+
+
Nico Gonzales
+ Nico Gonzales + Nico Gonzales +
+
+

Nico Gonzales

+

Broker · NovaTerra Immobilien

+
+
+
+
+
Paris
+ Paris + Paris +
+
+

Paris

+

Strategist · PG Trading Management

+
+
+
+
+
+ + +
+
+
+

Our Story

+

It started with a handshake and a vision.

+

+ Mike plants the seeds — literally. Nico turns land into legacy. Paris reads the market like a map. + Together, we invest in each other's ventures and grow as one unit. +

+

+ PG Trading Management finances stakes in Nico's real estate portfolio. Magic Tree supplies rare ornamental + trees to Nico's high-end property projects. Three businesses. One ecosystem. +

+
+
+
Team Photo
+ Team photo + Team Photo +
+
+
+ + +
+
+

The Trilogy

+

Three founders, three crafts.

+
+
+
+ +

Magic Mike

+

Magic Tree Co.

+

Rare and ornamental trees for luxury gardens — from Japanese Maples to 300-year-old olive trees.

+ +
+
+ +

Nico Gonzales

+

NovaTerra Immobilien

+

Premium real estate across Baden-Württemberg — buying, selling, and developing property into legacy.

+ +
+
+ +

Paris

+

PG Trading Management

+

Real markets, no risk, full thrill. A paper-trading dashboard powered by live-simulated prices.

+ +
+
+
+
+ + + + +
+ +
+
+
Hero Background
+ Magic Tree Co. hero +
+
+
+

Magic Tree Co.

+

Every Garden Tells a Story

+

Premium ornamental trees, delivered with craftsmanship and a guarantee.

+
+
+ + +
+
+
Mike's Portrait
+ Mike portrait + Mike's Portrait +
+
+

About Mike

+

Where nature meets craftsmanship.

+

+ Mike has been cultivating rare and ornamental trees for over a decade. From Japanese Maples to Giant + Sequoias, every tree he sells carries a story and a guarantee. Magic Tree Co. is more than a nursery — + it's where nature meets craftsmanship. +

+
+
+ + +
+
+
+

The Collection

+

Featured Trees

+
+
+
+
+ + +
+
+

Word of Mouth

+

Customer Reviews

+
+
+
+ + +
+
+
+

Happy Customers

+

Their Gardens, Transformed

+
+
+
+
+
+ + + + +
+ +
+
+
Hero Background
+ NovaTerra hero +
+
+
+

NovaTerra Immobilien · New Ground. New Possibilities.

+

Your Property. Your Legacy.

+

Premium real estate, brokered with discretion and results.

+
+
+ + +
+
+

About Nico

+

Closing premium deals across the region.

+

+ Nico Gonzales has been closing premium real estate deals across Baden-Württemberg for years. With a network + spanning developers, investors, and private buyers, NovaTerra Immobilien is your partner for buying, selling, + and developing property — backed by the investment muscle of PG Trading Management. +

+
+
+
Nico's Headshot
+ Nico headshot + Nico's Headshot +
+
+ + +
+
+
+

Portfolio

+

Featured Listings

+
+
+
+
+ + +
+
+

Trusted By

+

Client Testimonials

+
+
+
+ + +
+
+
+

Moments

+

Deals & Handshakes

+
+
+
+
+
+ + + + +
+ +
+
+ +

PG Trading Management

+

Your Portfolio. Your Game.

+

Real markets. No risk. Full thrill.

+
+
+

Member Login

+

Demo access — user: paris / pass: trading123

+ + + + + + +
+
+ + + +
+ + + + +
+ + + + +
+ +
+ + +
+
+
+ + TrilogyHub +
+ +
+
+ Trilogy Hub © 2025 — Built by three, grown together. +
+
+ + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..315bf88 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,1225 @@ +// ===================== PlayBull Frontend ===================== +import { renderAvatar, renderUploadItem, hydrateFramedImages, isImageAvatar, avatarEmoji } from './framed-image.js'; +import { uploadImageWithEditor, repositionImageWithEditor, appendPositionFields } from './upload-helper.js'; + +const $ = (s, r = document) => r.querySelector(s); +const $$ = (s, r = document) => [...r.querySelectorAll(s)]; + +const state = { + user: null, + assets: new Map(), // symbol -> asset obj + liveHistory: new Map(), // symbol -> [{t,p}] + filter: 'all', + selected: null, + socket: null, +}; + +// ---------- API ---------- +async function api(path, body, method = 'POST') { + const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin' }; + if (body && method !== 'GET') opts.body = JSON.stringify(body); + const res = await fetch(path, method === 'GET' ? { credentials: 'same-origin' } : opts); + const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' })); + if (!res.ok && !data.error) data.error = 'Fehler ' + res.status; + return data; +} +const get = (p) => api(p, null, 'GET'); + +async function apiForm(path, formData, method = 'POST') { + const res = await fetch(path, { method, body: formData, credentials: 'same-origin' }); + const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' })); + if (!res.ok && !data.error) data.error = 'Fehler ' + res.status; + return data; +} + +async function apiDelete(path) { + const res = await fetch(path, { method: 'DELETE', credentials: 'same-origin' }); + const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' })); + if (!res.ok && !data.error) data.error = 'Fehler ' + res.status; + return data; +} + +// ---------- Formatters ---------- +const nf = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const nf4 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 4 }); +function fmt(n) { return nf.format(Number(n) || 0); } +function coins(n) { return `🪙 ${fmt(n)}`; } +function px(n) { const v = Number(n); return v < 1 ? nf4.format(v) : nf.format(v); } +function timeAgo(ts) { + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 60) return s + 's'; + if (s < 3600) return Math.floor(s / 60) + 'm'; + if (s < 86400) return Math.floor(s / 3600) + 'h'; + return Math.floor(s / 86400) + 'd'; +} + +// ---------- Toast ---------- +function toast(msg, type = '') { + const el = document.createElement('div'); + el.className = 'toast ' + type; + el.innerHTML = msg; + $('#toastWrap').appendChild(el); + setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 2600); + setTimeout(() => el.remove(), 3000); +} + +// ---------- Celebration FX ---------- +const GOLD_CONFETTI = ['#c9a84c', '#d8bd6e', '#f4f1e9', '#46c98b', '#8a6e1f']; +function burstConfetti(opts = {}) { + if (typeof window.confetti !== 'function') return; + window.confetti({ particleCount: 110, spread: 75, startVelocity: 42, ticks: 180, origin: { y: 0.55 }, colors: GOLD_CONFETTI, disableForReducedMotion: true, ...opts }); +} +function bigWinFX(mult = 1) { + burstConfetti({ particleCount: Math.min(60 + mult * 18, 220), spread: 100 }); + if (mult >= 3) setTimeout(() => burstConfetti({ angle: 60, origin: { x: 0, y: 0.6 } }), 120); + if (mult >= 3) setTimeout(() => burstConfetti({ angle: 120, origin: { x: 1, y: 0.6 } }), 220); +} +function gsapOK() { return typeof window.gsap !== 'undefined'; } + +// ---------- Modal ---------- +function openModal(html) { + $('#modalBox').innerHTML = `
${html}`; + $('#modal').hidden = false; + document.body.style.overflow = 'hidden'; +} +function closeModal() { + $('#modal').hidden = true; + document.body.style.overflow = ''; + state.selected = null; +} +$('#modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); }); + +// ---------- Balance ---------- +function setBalance(b) { + if (!state.user) return; + state.user.balance = b; + const bal = $('#balanceValue'); + if (bal) bal.textContent = fmt(b); +} +function updateAuthUI() { + const btn = $('#authBtn'); + const chip = $('#balanceChip'); + const bal = $('#balanceValue'); + if (btn) { + if (state.user) { + if (chip) chip.hidden = false; + if (bal) bal.textContent = fmt(state.user.balance); + btn.className = 'btn-auth btn-auth--avatar'; + btn.innerHTML = renderAvatar(state.user.avatar, { size: 'sm' }); + hydrateFramedImages(btn); + btn.onclick = openAccountMenu; + } else { + if (chip) chip.hidden = true; + btn.className = 'btn-auth'; + btn.textContent = 'Login'; + btn.onclick = openAuthModal; + } + } + const hint = $('#gamesLoginHint'); + if (hint) hint.hidden = !!state.user; +} + +// ===================== AUTH ===================== +function openAuthModal(mode = 'login') { + const isLogin = mode === 'login'; + openModal(` + +
+
+
+ +

+ ${isLogin ? 'Noch kein Account?' : 'Schon registriert?'} + +

+ `); + $('#auSwitch').onclick = () => openAuthModal(isLogin ? 'register' : 'login'); + $('#auSubmit').onclick = async () => { + const username = $('#auUser').value.trim(); + const password = $('#auPass').value; + const r = await api(isLogin ? '/api/login' : '/api/register', { username, password }); + if (!r.ok) { $('#auErr').textContent = r.error; return; } + state.user = r.user; + updateAuthUI(); + closeModal(); + toast(`Willkommen, ${r.user.username}! 🎉`, 'win'); + renderActiveView(); + }; + $('#auPass').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#auSubmit').click(); }); +} +window._closeModal = closeModal; + +function openAccountMenu() { + const u = state.user; + openModal(` + +
Guthaben${coins(u.balance)}
+ +
+ + `); + hydrateFramedImages($('#modalBox')); + $('#goSettings').onclick = () => { closeModal(); switchView('settings'); }; + $('#logoutBtn').onclick = async () => { + await api('/api/logout', {}); + state.user = null; updateAuthUI(); closeModal(); renderActiveView(); + toast('Ausgeloggt'); + }; +} + +// ===================== NAVIGATION ===================== +function switchView(name) { + $$('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + name)); + $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === name)); + window.scrollTo(0, 0); + renderActiveView(); +} +$$('.nav-btn').forEach(b => b.onclick = () => switchView(b.dataset.view)); +$('#brandHome')?.addEventListener('click', () => switchView('trading')); +$$('[data-open-auth]').forEach(b => b.onclick = () => openAuthModal()); + +function activeView() { + const v = $$('.view').find(v => v.classList.contains('active')); + return v ? v.id.replace('view-', '') : 'trading'; +} +function renderActiveView() { + const v = activeView(); + if (v === 'trading') renderAssets(); + else if (v === 'games') renderGames(); + else if (v === 'portfolio') renderPortfolio(); + else if (v === 'social') renderSocial(); + else if (v === 'settings') renderSettings(); +} + +// ===================== TRADING ===================== +function renderAssets() { + const list = $('#assetList'); + const arr = [...state.assets.values()].filter(a => state.filter === 'all' || a.type === state.filter); + list.innerHTML = arr.map(a => assetRowHTML(a)).join(''); + $$('.asset-row', list).forEach(row => row.onclick = () => openAssetDetail(row.dataset.sym)); +} +function assetRowHTML(a) { + const cls = a.change >= 0 ? 'up' : 'down'; + const sign = a.change >= 0 ? '+' : ''; + return ` +
+
${a.emoji}
+
+
${a.symbol}${a.halted ? 'HALT' : ''}
+
${a.name}
+
+
+
${px(a.price)}
+
${sign}${fmt(a.changePct)}%
+
+
`; +} +$$('#marketFilter .seg-btn').forEach(b => b.onclick = () => { + $$('#marketFilter .seg-btn').forEach(x => x.classList.remove('active')); + b.classList.add('active'); + state.filter = b.dataset.f; + renderAssets(); +}); + +async function openAssetDetail(sym) { + state.selected = sym; + const a = state.assets.get(sym); + openModal(` + +
${px(a.price)}
+
${a.change>=0?'+':''}${fmt(a.change)} (${fmt(a.changePct)}%)
+
+ + ${state.user ? tradePanelHTML() : ''} + +

🎛️ Markt manipulieren (wirkt für ALLE)

+ ${state.user ? manipPanelHTML() : '
Login nötig
'} + `); + drawChartFor(sym); + if (state.user) wireTradePanel(sym), wireManipPanel(sym); +} +window._openAuth = () => openAuthModal(); + +function tradePanelHTML() { + return ` +
+
+ + + + + +
+
+
+ + +
`; +} +function wireTradePanel(sym) { + const qty = $('#tQty'); + const updEst = () => { + const a = state.assets.get(sym); + const cost = (parseFloat(qty.value) || 0) * a.price; + $('#tEst').textContent = `≈ ${coins(cost)}`; + }; + qty.addEventListener('input', updEst); updEst(); + $$('.chip[data-amt]').forEach(c => c.onclick = () => { qty.value = c.dataset.amt; updEst(); }); + $('.chip[data-max]')?.addEventListener('click', () => { + const a = state.assets.get(sym); + qty.value = (state.user.balance / a.price).toFixed(4); + updEst(); + }); + $('#btnBuy').onclick = () => doTrade(sym, 'buy', parseFloat(qty.value)); + $('#btnSell').onclick = () => doTrade(sym, 'sell', parseFloat(qty.value)); +} +async function doTrade(sym, side, qty) { + if (!(qty > 0)) return toast('Menge ungültig', 'lose'); + const r = await api('/api/trade', { symbol: sym, side, qty }); + if (!r.ok) return toast(r.error, 'lose'); + setBalance(r.balance); + toast(`${side === 'buy' ? 'Gekauft' : 'Verkauft'}: ${fmt(qty)} ${sym} @ ${px(r.price)}`, 'win'); +} + +function manipPanelHTML() { + return ` +
+ + +
+
+
+ + +
+
+
+ + + +
`; +} +function wireManipPanel(sym) { + $$('[data-mp]').forEach(b => b.onclick = async () => { + const action = b.dataset.mp; + const value = action === 'set' ? parseFloat($('#mSet').value) : (action === 'pump' || action === 'dump' ? 3 : 0); + const r = await api('/api/manipulate', { symbol: sym, action, value }); + if (!r.ok) return toast(r.error, 'lose'); + toast(`${sym}: ${action} ✅`); + }); +} + +// ---------- Chart ---------- +async function drawChartFor(sym) { + let hist = state.liveHistory.get(sym); + if (!hist || hist.length < 5) { + const r = await get(`/api/market/${sym}/history`); + if (r.ok) { hist = r.history; state.liveHistory.set(sym, hist.slice()); } + } + drawChart(); +} +function drawChart() { + const c = $('#chart'); if (!c) return; + const sym = state.selected; if (!sym) return; + const hist = state.liveHistory.get(sym) || []; + const dpr = window.devicePixelRatio || 1; + const w = c.clientWidth, h = 180; + c.width = w * dpr; c.height = h * dpr; + const ctx = c.getContext('2d'); ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + if (hist.length < 2) return; + const ys = hist.map(p => p.p); + let min = Math.min(...ys), max = Math.max(...ys); + if (min === max) { min *= 0.999; max *= 1.001; } + const pad = 10; + const X = i => pad + (i / (hist.length - 1)) * (w - 2 * pad); + const Y = v => h - pad - ((v - min) / (max - min)) * (h - 2 * pad); + const up = ys[ys.length - 1] >= ys[0]; + const col = up ? '#1fd17a' : '#ff4d61'; + // Fill + const grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, up ? 'rgba(31,209,122,.25)' : 'rgba(255,77,97,.25)'); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.beginPath(); ctx.moveTo(X(0), Y(ys[0])); + hist.forEach((p, i) => ctx.lineTo(X(i), Y(p.p))); + ctx.lineTo(X(hist.length - 1), h - pad); ctx.lineTo(X(0), h - pad); ctx.closePath(); + ctx.fillStyle = grad; ctx.fill(); + // Line + ctx.beginPath(); ctx.moveTo(X(0), Y(ys[0])); + hist.forEach((p, i) => ctx.lineTo(X(i), Y(p.p))); + ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); + // Last dot + const lx = X(hist.length - 1), ly = Y(ys[ys.length - 1]); + ctx.beginPath(); ctx.arc(lx, ly, 3.5, 0, 7); ctx.fillStyle = col; ctx.fill(); +} + +// ===================== GAMES ===================== +const GAMES = [ + { id: 'chicken', emoji: '🐔', name: 'Chicken Road', desc: 'Überquere die Spuren, cashe vor dem Crash', tag: 'Skill', glow: 'rgba(201,168,76,.28)' }, + { id: 'mines', emoji: '🃏', name: '10×10 Deck', desc: '100 Karten, finde die Diamanten', tag: 'Cards', glow: 'rgba(70,201,139,.24)' }, + { id: 'crash', emoji: '🚀', name: 'Crash', desc: 'Raus bevor die Rakete explodiert', tag: 'Live', glow: 'rgba(224,98,94,.26)' }, + { id: 'mine_dice', emoji: '🎲', name: 'Dice', desc: 'Über/Unter — wähle dein Risiko', tag: 'Classic', glow: 'rgba(201,168,76,.24)' }, + { id: 'coin', emoji: '🪙', name: 'Coinflip', desc: 'Kopf oder Zahl, 2×', tag: '50/50', glow: 'rgba(216,189,110,.26)' }, +]; +function renderGames() { + $('#gamesGrid').innerHTML = GAMES.map((g, i) => ` +
+ ${g.tag} +
${g.emoji}
+
+
${g.name}
+
${g.desc}
+
+
`).join(''); + const cards = $$('.game-card'); + cards.forEach(c => c.onclick = () => { + if (!state.user) return openAuthModal(); + openGame(c.dataset.game); + }); + if (gsapOK()) gsap.from(cards, { opacity: 0, y: 18, scale: .96, duration: .5, ease: 'power3.out', stagger: .07 }); + $('#gamesLoginHint').hidden = !!state.user; +} +function openGame(id) { + if (id === 'chicken') gameChicken(); + else if (id === 'mines') gameMines(); + else if (id === 'crash') gameCrash(); + else if (id === 'mine_dice') gameDice(); + else if (id === 'coin') gameCoin(); +} +function betHeader(title, emoji) { + return ` +
Guthaben: ${coins(state.user.balance)}
`; +} +function refreshGBal() { const el = $('#gBal'); if (el) el.textContent = `Guthaben: ${coins(state.user.balance)}`; } + +// ---------- Chicken Road ---------- +function gameChicken() { + openModal(`${betHeader('Chicken Road', '🐔')} +
+
+
+
+ + + + +
+
+ +
+ `); + let diff = 'easy'; + $$('#chDiff .chip').forEach(c => c.onclick = () => { $$('#chDiff .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); diff = c.dataset.d; }); + let round = null, busy = false; + + function buildStage(rd) { + const cells = ['
START
']; + rd.mults.forEach((m, i) => cells.push( + `
${m.toFixed(2)}×
🚗
`)); + $('#chTrack').innerHTML = cells.join(''); + } + function markLanes(rd) { + for (let i = 0; i <= rd.lanes; i++) { + const c = $(`#cell-${i}`); if (!c) continue; + c.classList.toggle('passed', i > 0 && i <= rd.lane); + c.classList.toggle('current', i === rd.lane + 1); + } + } + function scrollTo(lane) { + const cell = $(`#cell-${lane}`); if (!cell) return; + $('#chTrack').scrollTo({ left: Math.max(0, cell.offsetLeft - 24), behavior: 'smooth' }); + } + function hop() { + const hero = $('#chHero'); if (!hero) return; + hero.classList.remove('idle', 'hop'); void hero.offsetWidth; hero.classList.add('hop'); + setTimeout(() => hero && hero.classList.add('idle'), 360); + } + function setMult(v) { + const el = $('#chMult'); if (!el) return; + el.textContent = v.toFixed(2) + '×'; + if (gsapOK()) gsap.fromTo(el, { scale: 1.4 }, { scale: 1, duration: .4, ease: 'back.out(3)' }); + } + + $('#chStart').onclick = async () => { + if (busy) return; busy = true; + const bet = parseFloat($('#chBet').value); + const r = await api('/api/games/chicken/start', { bet, difficulty: diff }); + busy = false; + if (!r.ok) return toast(r.error, 'lose'); + setBalance(r.balance); refreshGBal(); + round = { id: r.roundId, mults: r.multipliers, lane: 0, lanes: r.lanes, bet }; + $('#chSetup').hidden = true; $('#chPlay').hidden = false; + buildStage(round); markLanes(round); scrollTo(0); + $('#chHero').textContent = '🐔'; $('#chHero').className = 'chicken-hero idle'; + $('#chMult').textContent = '1.00×'; + }; + + $('#chWalk').onclick = async () => { + if (!round || busy) return; busy = true; + const r = await api('/api/games/chicken/step', { roundId: round.id }); + if (!r.ok) { busy = false; return toast(r.error, 'lose'); } + setBalance(r.balance ?? state.user.balance); refreshGBal(); + if (r.dead) { + hop(); + const cell = $(`#cell-${r.lane}`); + if (cell) cell.classList.add('dead'); + scrollTo(r.lane); + setTimeout(() => { + const stage = $('#chStage'), hero = $('#chHero'), boom = $('#chBoom'); + if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); } + if (boom) { boom.classList.remove('go'); void boom.offsetWidth; boom.classList.add('go'); } + if (hero) { hero.classList.remove('idle'); hero.textContent = '💥'; } + toast('💥 Erwischt! Verloren.', 'lose'); + }, 280); + endChicken(); + } else { + round.lane = r.lane; + hop(); markLanes(round); scrollTo(round.lane); + setMult(r.multiplier); + if (r.maxed) { bigWinFX(r.multiplier); toast(`🏁 Ende erreicht! +${coins(r.payout)}`, 'win'); endChicken(); } + else busy = false; + } + }; + $('#chCash').onclick = async () => { + if (!round || busy) return; busy = true; + const r = await api('/api/games/chicken/cashout', { roundId: round.id }); + if (!r.ok) { busy = false; return toast(r.error, 'lose'); } + setBalance(r.balance); refreshGBal(); + bigWinFX(r.multiplier); + toast(`💰 Cashout ${r.multiplier.toFixed(2)}× → +${coins(r.payout)}`, 'win'); + endChicken(); + }; + function endChicken() { + round = null; + setTimeout(() => { busy = false; if ($('#chPlay')) { $('#chSetup').hidden = false; $('#chPlay').hidden = true; } }, 1600); + } +} + +// ---------- Mines 10x10 ---------- +function gameMines() { + openModal(`${betHeader('10×10 Karten-Deck', '🃏')} +
+
+
+
+ + + + +
+
+ +
+ `); + let bombs = 10; + $$('#mBombs .chip').forEach(c => c.onclick = () => { $$('#mBombs .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); bombs = +c.dataset.b; }); + let round = null, busy = false; + const tileHTML = (i) => `
`; + function flip(t, kind, sym) { + t.classList.add('flipped', kind); + const f = t.querySelector('.front'); if (f) f.textContent = sym; + } + function setStats(mult) { + const mEl = $('#mMult'), pEl = $('#mPay'); + if (mEl) mEl.textContent = mult.toFixed(2) + '×'; + if (pEl) pEl.textContent = fmt(round.bet * mult); + if (gsapOK() && mEl) gsap.fromTo([mEl, pEl], { scale: 1.25 }, { scale: 1, duration: .35, ease: 'back.out(2.5)' }); + } + $('#mStart').onclick = async () => { + if (busy) return; busy = true; + const bet = parseFloat($('#mBet').value); + const r = await api('/api/games/mines/start', { bet, bombs }); + busy = false; + if (!r.ok) return toast(r.error, 'lose'); + setBalance(r.balance); refreshGBal(); + round = { id: r.roundId, bet, picks: 0, mult: 1, over: false }; + $('#mSetup').hidden = true; $('#mPlay').hidden = false; + $('#mMult').textContent = '1.00×'; $('#mPay').textContent = '0'; + $('#mGrid').innerHTML = Array.from({ length: 100 }, (_, i) => tileHTML(i)).join(''); + $$('#mGrid .tile').forEach(t => t.onclick = () => revealTile(+t.dataset.i, t)); + if (gsapOK()) gsap.from('#mGrid .tile', { opacity: 0, scale: .5, duration: .35, ease: 'power2.out', stagger: { each: .004, from: 'center', grid: [10, 10] } }); + }; + async function revealTile(i, el) { + if (!round || round.over || el.classList.contains('flipped') || busy) return; + busy = true; + const r = await api('/api/games/mines/reveal', { roundId: round.id, index: i }); + if (!r.ok) { busy = false; return toast(r.error, 'lose'); } + if (r.bomb) { + round.over = true; + flip(el, 'bomb', '💣'); el.classList.add('hit'); + const stage = $('#mGrid'); + if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); } + r.mines.forEach((m, idx) => { + if (m === i) return; + const t = $(`#mGrid .tile[data-i="${m}"]`); + if (t) setTimeout(() => flip(t, 'bomb', '💣'), 60 + idx * 18); + }); + $$('#mGrid .tile').forEach(t => { if (!t.classList.contains('flipped')) t.classList.add('dim'); }); + setBalance(r.balance); refreshGBal(); + toast('💥 Bombe! Verloren.', 'lose'); + endMines(); + } else { + flip(el, 'safe', '💎'); + round.picks = r.picks; round.mult = r.multiplier; + setStats(r.multiplier); + if (r.cleared) { setBalance(r.balance); refreshGBal(); bigWinFX(r.multiplier); toast(`🏆 Deck geknackt! +${coins(r.payout)}`, 'win'); endMines(); } + else busy = false; + } + } + $('#mCash').onclick = async () => { + if (!round || round.over || busy) return; busy = true; + const r = await api('/api/games/mines/cashout', { roundId: round.id }); + if (!r.ok) { busy = false; return toast(r.error, 'lose'); } + round.over = true; + setBalance(r.balance); refreshGBal(); + r.mines.forEach((m, idx) => { const t = $(`#mGrid .tile[data-i="${m}"]`); if (t && !t.classList.contains('flipped')) setTimeout(() => flip(t, 'bomb', '💣'), idx * 14); }); + bigWinFX(r.multiplier); + toast(`💰 +${coins(r.payout)} (${r.multiplier.toFixed(2)}×)`, 'win'); + endMines(); + }; + function endMines() { if (round) round.over = true; setTimeout(() => { busy = false; if ($('#mPlay')) { $('#mSetup').hidden = false; $('#mPlay').hidden = true; } }, 1800); } +} + +// ---------- Crash ---------- +function gameCrash() { + openModal(`${betHeader('Crash', '🚀')} +
+
+ +
+ `); + let round = null, raf = null, poll = null, particles = []; + const stars = Array.from({ length: 46 }, () => ({ x: Math.random(), y: Math.random(), r: Math.random() * 1.3 + 0.3, tw: Math.random() * 6 })); + + function cv() { + const c = $('#crCanvas'); if (!c) return null; + const dpr = window.devicePixelRatio || 1; + const w = c.clientWidth || 320, h = 240; + if (c.width !== Math.round(w * dpr)) { c.width = w * dpr; c.height = h * dpr; } + const ctx = c.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { ctx, w, h }; + } + function rocketPos(elapsed, m, w, h) { + const curMax = Math.max(1.4, m * 1.12), pad = 14; + const x = pad + Math.min(1, elapsed / Math.max(elapsed, 1)) * (w - 2 * pad); + const y = h - pad - ((m - 1) / (curMax - 1)) * (h - 2 * pad); + return { x, y, curMax, pad }; + } + function draw(elapsed, m, color) { + const ctxv = cv(); if (!ctxv) return; const { ctx, w, h } = ctxv; + ctx.clearRect(0, 0, w, h); + // stars + stars.forEach(s => { ctx.globalAlpha = .35 + .45 * Math.abs(Math.sin(elapsed / 700 + s.tw)); ctx.fillStyle = '#d8bd6e'; ctx.beginPath(); ctx.arc(s.x * w, s.y * h, s.r, 0, 7); ctx.fill(); }); + ctx.globalAlpha = 1; + const { x: rx, y: ry, curMax, pad } = rocketPos(elapsed, m, w, h); + const N = 64; + // fill under curve + const grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, color === 'red' ? 'rgba(224,98,94,.30)' : 'rgba(201,168,76,.30)'); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.beginPath(); ctx.moveTo(pad, h - pad); + for (let i = 0; i <= N; i++) { const t = elapsed * i / N; const mm = Math.exp(round.k * t); const x = pad + (i / N) * (rx - pad); const y = h - pad - ((mm - 1) / (curMax - 1)) * (h - 2 * pad); ctx.lineTo(x, y); } + ctx.lineTo(rx, h - pad); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); + // curve line + ctx.beginPath(); + for (let i = 0; i <= N; i++) { const t = elapsed * i / N; const mm = Math.exp(round.k * t); const x = pad + (i / N) * (rx - pad); const y = h - pad - ((mm - 1) / (curMax - 1)) * (h - 2 * pad); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } + ctx.strokeStyle = color === 'red' ? '#e0625e' : '#d8bd6e'; ctx.lineWidth = 3; ctx.lineJoin = 'round'; + ctx.shadowColor = color === 'red' ? 'rgba(224,98,94,.7)' : 'rgba(201,168,76,.6)'; ctx.shadowBlur = 12; ctx.stroke(); ctx.shadowBlur = 0; + if (color !== 'red') { + // flame + const fl = 8 + Math.random() * 8; + ctx.fillStyle = 'rgba(255,180,60,.8)'; ctx.beginPath(); ctx.moveTo(rx - 6, ry + 6); ctx.lineTo(rx - 16 - fl, ry + 12); ctx.lineTo(rx - 6, ry + 14); ctx.closePath(); ctx.fill(); + // rocket + ctx.font = '22px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.save(); ctx.translate(rx, ry); ctx.rotate(-Math.PI / 5); ctx.fillText('🚀', 0, 0); ctx.restore(); + } + // particles (explosion) + particles.forEach(p => { + p.x += p.vx; p.y += p.vy; p.vy += 0.12; p.life -= 0.022; p.vx *= 0.99; + ctx.globalAlpha = Math.max(0, p.life); ctx.fillStyle = p.c; ctx.beginPath(); ctx.arc(p.x, p.y, p.s, 0, 7); ctx.fill(); + }); + particles = particles.filter(p => p.life > 0); + ctx.globalAlpha = 1; + } + function explode(x, y) { + const cols = ['#e0625e', '#ffb43c', '#d8bd6e', '#f4f1e9']; + for (let i = 0; i < 46; i++) { const a = Math.random() * Math.PI * 2, sp = 1 + Math.random() * 5; particles.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1, s: 1.5 + Math.random() * 3, life: 1, c: cols[i % cols.length] }); } + } + + $('#crStart').onclick = async () => { + const bet = parseFloat($('#crBet').value); + const r = await api('/api/games/crash/start', { bet }); + if (!r.ok) return toast(r.error, 'lose'); + setBalance(r.balance); refreshGBal(); + round = { id: r.roundId, k: r.k, start: Date.now(), over: false, bet }; + particles = []; + $('#crSetup').hidden = true; $('#crPlay').hidden = false; + $('#crMult').classList.remove('boom'); $('#crMult').style.color = ''; $('#crSub').textContent = '🚀 steigt…'; + animate(); + poll = setInterval(checkStatus, 280); + }; + function animate() { + if (!round || round.over) return; + const elapsed = Date.now() - round.start; + const m = Math.exp(round.k * elapsed); + $('#crMult').textContent = m.toFixed(2) + '×'; + $('#crMult').style.color = m >= 2 ? 'var(--gold-soft)' : ''; + draw(elapsed, m, 'gold'); + raf = requestAnimationFrame(animate); + } + async function checkStatus() { + if (!round || round.over) return; + const r = await api('/api/games/crash/status', { roundId: round.id }); + if (r.ok && r.crashed) boom(r.multiplier); + } + function boom(at) { + if (!round) return; + round.over = true; cancelAnimationFrame(raf); clearInterval(poll); + const elapsed = Date.now() - round.start; + const ctxv = cv(); + if (ctxv) { const p = rocketPos(elapsed, at, ctxv.w, ctxv.h); explode(p.x, p.y); } + $('#crMult').textContent = at.toFixed(2) + '×'; $('#crMult').classList.add('boom'); + $('#crSub').textContent = '💥 CRASHED!'; + const stage = $('#crStage'); if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); } + boomLoop(elapsed, at); + toast('💥 Crashed! Verloren.', 'lose'); + endCrash(); + } + function boomLoop(elapsed, at) { + if (!particles.length) return; + draw(elapsed, at, 'red'); + requestAnimationFrame(() => boomLoop(elapsed, at)); + } + $('#crCash').onclick = async () => { + if (!round || round.over) return; + const r = await api('/api/games/crash/cashout', { roundId: round.id }); + if (!r.ok) return toast(r.error, 'lose'); + round.over = true; cancelAnimationFrame(raf); clearInterval(poll); + setBalance(r.balance); refreshGBal(); + if (r.crashed) { + const elapsed = Date.now() - round.start; const ctxv = cv(); + if (ctxv) { const p = rocketPos(elapsed, r.crashPoint || 1, ctxv.w, ctxv.h); explode(p.x, p.y); boomLoop(elapsed, r.crashPoint || 1); } + $('#crMult').classList.add('boom'); $('#crSub').textContent = '💥 zu spät!'; toast('💥 Zu spät!', 'lose'); + } else { + $('#crMult').textContent = r.multiplier.toFixed(2) + '×'; $('#crMult').style.color = 'var(--green)'; $('#crSub').textContent = '💰 Cashout!'; + bigWinFX(r.multiplier); toast(`💰 ${r.multiplier.toFixed(2)}× → +${coins(r.payout)}`, 'win'); + } + endCrash(); + }; + function endCrash() { setTimeout(() => { if ($('#crPlay')) { $('#crSetup').hidden = false; $('#crPlay').hidden = true; } round = null; }, 2200); } +} + +// ---------- Dice ---------- +function gameDice() { + openModal(`${betHeader('Dice', '🎲')} +
+
50
+
0255075100
+
+
Bereit?
+
+
+
+
+
+
+
Gewinnchance
50%
+
Multiplikator
1.98×
+
+ `); + let dir = 'under', rolling = false; + const upd = () => { + const t = +$('#dTarget').value; $('#dTval').textContent = t; + const chance = dir === 'under' ? t : 100 - t; + $('#dChance').textContent = chance + '%'; + $('#dMult').textContent = ((100 / chance) * 0.99).toFixed(2) + '×'; + const zone = $('#dZone'); + if (dir === 'under') { zone.style.left = '0%'; zone.style.right = 'auto'; zone.style.width = t + '%'; } + else { zone.style.left = t + '%'; zone.style.right = '0'; zone.style.width = (100 - t) + '%'; } + }; + function moveMarker(v, settle) { + const m = $('#dMark'); if (!m) return; + m.classList.toggle('settle', !!settle); + m.style.left = v + '%'; m.textContent = (Math.round(v * 10) / 10).toFixed(1); + } + $('#dTarget').addEventListener('input', upd); + $$('#dDir .chip').forEach(c => c.onclick = () => { $$('#dDir .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); dir = c.dataset.d; upd(); }); + upd(); + $('#dRoll').onclick = async () => { + if (rolling) return; rolling = true; + const btn = $('#dRoll'); btn.disabled = true; + $('#dRes').textContent = '🎲'; $('#dRes').className = 'dice-result muted'; + const r = await api('/api/games/dice', { bet: parseFloat($('#dBet').value), target: +$('#dTarget').value, direction: dir }); + if (!r.ok) { rolling = false; btn.disabled = false; return toast(r.error, 'lose'); } + setBalance(r.balance); refreshGBal(); + // spin the marker, then settle on the real roll + let ticks = 0; const spin = setInterval(() => { moveMarker(2 + Math.random() * 96, false); ticks++; }, 55); + setTimeout(() => { + clearInterval(spin); + moveMarker(r.roll, true); + const res = $('#dRes'); res.textContent = r.roll.toFixed(2); + res.className = 'dice-result ' + (r.win ? 'up' : 'down'); + if (r.win) { bigWinFX(r.multiplier); toast(`🎉 Gewonnen! +${coins(r.payout)}`, 'win'); } + else toast('😢 Verloren', 'lose'); + rolling = false; btn.disabled = false; + }, 760); + }; +} + +// ---------- Coinflip ---------- +function gameCoin() { + openModal(`${betHeader('Coinflip', '🪙')} +
👑
🔢
+
+
+ + +
`); + let busy = false; + async function flip(choice) { + if (busy) return; busy = true; + $('#cHeads').disabled = $('#cTails').disabled = true; + const coin = $('#cCoin'); + coin.classList.remove('spin-heads', 'spin-tails'); coin.style.transform = 'none'; void coin.offsetWidth; + const r = await api('/api/games/coinflip', { bet: parseFloat($('#cBet').value), choice }); + if (!r.ok) { busy = false; $('#cHeads').disabled = $('#cTails').disabled = false; return toast(r.error, 'lose'); } + setBalance(r.balance); refreshGBal(); + coin.style.transform = ''; + coin.classList.add(r.flip === 'heads' ? 'spin-heads' : 'spin-tails'); + setTimeout(() => { + if (r.win) { bigWinFX(2); toast(`🎉 ${r.flip === 'heads' ? 'Kopf' : 'Zahl'}! +${coins(r.payout)}`, 'win'); } + else toast(`😢 ${r.flip === 'heads' ? 'Kopf' : 'Zahl'} — verloren`, 'lose'); + busy = false; $('#cHeads').disabled = $('#cTails').disabled = false; + }, 2400); + } + $('#cHeads').onclick = () => flip('heads'); + $('#cTails').onclick = () => flip('tails'); +} + +// ===================== PORTFOLIO ===================== +async function renderPortfolio() { + const el = $('#portfolioContent'); + if (!state.user) { el.innerHTML = ''; return; } + const r = await get('/api/portfolio'); + if (!r.ok) { el.innerHTML = `
${r.error}
`; return; } + const p = r.portfolio; + const pnlTotal = p.holdings.reduce((s, h) => s + h.pnl, 0); + el.innerHTML = ` +
+
Gesamtwert (Net Worth)
+
${coins(p.netWorth)}
+
💵 Cash${coins(p.balance)}
+
📊 Positionen${coins(p.holdingsValue)}
+
P&L offen${pnlTotal>=0?'+':''}${fmt(pnlTotal)}
+
+

Positionen

+ ${p.holdings.length ? p.holdings.map(h => ` +
+
${h.emoji}
+
+
${h.symbol} ${h.side.toUpperCase()}
+
${fmt(h.quantity)} @ ${px(h.avgPrice)}
+
+
+
${coins(h.value)}
+
${h.pnl>=0?'+':''}${fmt(h.pnl)} (${fmt(h.pnlPct)}%)
+
+
`).join('') : '
Noch keine Positionen. Geh ins Trading! 📈
'} +

Letzte Trades

+
`; + $$('#portfolioContent .asset-row').forEach(row => row.onclick = () => { switchView('trading'); openAssetDetail(row.dataset.sym); }); + const tr = await get('/api/trades'); + if (tr.ok) $('#tradeHist').innerHTML = tr.trades.slice(0, 15).map(t => ` +
${t.side === 'buy' ? '🟢' : '🔴'}${t.side === 'buy' ? 'Kauf' : 'Verkauf'} ${fmt(t.qty)} ${t.symbol} @ ${px(t.price)}${timeAgo(t.ts)}
`).join('') || '
'; +} + +// ===================== SOCIAL ===================== +async function renderSocial() { + const r = await get('/api/leaderboard'); + const lb = $('#leaderboardContent'); + if (r.ok) { + lb.innerHTML = r.leaderboard.map((u, i) => ` +
+
${['🥇','🥈','🥉'][i] || '#' + (i + 1)}
+
${renderAvatar(u.avatar, { size: 'sm' })}${u.username}
+
${coins(u.netWorth)}
+
`).join(''); + hydrateFramedImages(lb); + } + const f = await get('/api/feed'); + if (f.ok) renderFeedList(f.feed); +} +function renderFeedList(feed) { + const el = $('#feedList'); if (!el) return; + const icon = { win: '🏆', trade: '💱', manip: '🎛️', admin: '💰', join: '🎉' }; + el.innerHTML = feed.map(f => `
${icon[f.kind] || '•'}${f.text}${timeAgo(f.ts)}
`).join(''); +} + +// ===================== SETTINGS / ADMIN ===================== +async function handleAvatarFile(file) { + if (!file) return; + try { + await uploadImageWithEditor(file, { + frame: 'avatar', + uploadFn: async (f, result) => { + const fd = new FormData(); + fd.append('file', f); + appendPositionFields(fd, result); + const r = await apiForm('/api/settings/avatar/image', fd); + if (!r.ok) { toast(r.error, 'lose'); return null; } + state.user.avatar = r.avatar; + updateAuthUI(); + renderSettings(); + toast('Profilbild gespeichert ✅', 'win'); + return r; + }, + }); + } catch (e) { + toast(e.message || 'Upload fehlgeschlagen', 'lose'); + } +} + +async function adjustAvatarPosition() { + const av = state.user?.avatar; + if (!isImageAvatar(av)) return; + try { + await repositionImageWithEditor(av, { + frame: 'avatar', + saveFn: async (result) => { + const r = await api('/api/settings/avatar/image/position', { + focusX: result.focus.x, + focusY: result.focus.y, + zoom: result.zoom, + }, 'PATCH'); + if (!r.ok) { toast(r.error, 'lose'); return null; } + state.user.avatar = r.avatar; + updateAuthUI(); + renderSettings(); + toast('Position gespeichert ✅', 'win'); + return r; + }, + }); + } catch (e) { + toast(e.message || 'Fehler', 'lose'); + } +} + +async function removeAvatarImage() { + const r = await apiDelete('/api/settings/avatar/image'); + if (!r.ok) return toast(r.error, 'lose'); + state.user.avatar = r.avatar; + updateAuthUI(); + renderSettings(); + toast('Profilbild entfernt'); +} + +let selectedUploadFrame = 'gallery'; + +async function handleGalleryUpload(file, frame) { + if (!file) return; + try { + await uploadImageWithEditor(file, { + frame, + uploadFn: async (f, result) => { + const fd = new FormData(); + fd.append('file', f); + appendPositionFields(fd, result, frame); + const r = await apiForm('/api/uploads', fd); + if (!r.ok) { toast(r.error, 'lose'); return null; } + toast('Bild hochgeladen ✅', 'win'); + renderSettings(); + return r; + }, + }); + } catch (e) { + toast(e.message || 'Upload fehlgeschlagen', 'lose'); + } +} + +async function repositionGalleryItem(item) { + try { + await repositionImageWithEditor(item, { + frame: item.frame, + saveFn: async (result) => { + const r = await api(`/api/uploads/${item.id}/position`, { + focusX: result.focus.x, + focusY: result.focus.y, + zoom: result.zoom, + }, 'PATCH'); + if (!r.ok) { toast(r.error, 'lose'); return null; } + toast('Position gespeichert ✅', 'win'); + renderSettings(); + return r; + }, + }); + } catch (e) { + toast(e.message || 'Fehler', 'lose'); + } +} + +async function deleteGalleryItem(id) { + const r = await apiDelete(`/api/uploads/${id}`); + if (!r.ok) return toast(r.error, 'lose'); + toast('Bild gelöscht'); + renderSettings(); +} + +async function renderSettings() { + const el = $('#settingsContent'); + if (!state.user) { el.innerHTML = ''; return; } + const av = state.user.avatar; + const hasImage = isImageAvatar(av); + const currentEmoji = avatarEmoji(av); + const uploadsRes = await get('/api/uploads'); + const uploads = uploadsRes.ok ? uploadsRes.uploads : []; + el.innerHTML = ` +
+

👤 Profil

+
+ ${renderAvatar(av, { size: 'lg' })} +
+ + + ${hasImage ? '' : ''} + ${hasImage ? '' : ''} +
+
+
+
${['🐂','🚀','💎','🔥','🤑','👑','🦍','🐳','🃏','🎰'].map(e => ``).join('')}
+
+
+ +
+

🖼️ Uploads

+

Bilder hochladen — Editor zeigt den Rahmen wie auf der Seite.

+
+
+ +
+ + + +
+
+ + +
+
+ ${uploads.length ? uploads.map(renderUploadItem).join('') : '

Noch keine Uploads

'} +
+
+ +
+

💰 Geld bearbeiten

+

Spaßmodus: jeder darf bei sich & anderen Geld ändern.

+
+
+
+ + +
+
+
+ + + + +
+
+ +
+

🎛️ Markt-Kontrolle

+

Pumpe/dumpe Kurse für ALLE Spieler gleichzeitig.

+
+
+ + + +
+
+ +
+

👥 Alle Spieler

+
+
`; + + hydrateFramedImages(el); + + const fileInput = $('#avFile'); + $('#avUploadBtn').onclick = () => fileInput.click(); + fileInput.onchange = () => { + const f = fileInput.files?.[0]; + fileInput.value = ''; + handleAvatarFile(f); + }; + $('#avReposition')?.addEventListener('click', adjustAvatarPosition); + $('#avRemoveImg')?.addEventListener('click', removeAvatarImage); + + $$('#uploadFrameChips .chip').forEach(c => c.onclick = () => { + selectedUploadFrame = c.dataset.uframe; + $$('#uploadFrameChips .chip').forEach(x => x.classList.toggle('active', x.dataset.uframe === selectedUploadFrame)); + }); + const galleryInput = $('#galleryFile'); + $('#galleryUploadBtn').onclick = () => galleryInput.click(); + galleryInput.onchange = () => { + const f = galleryInput.files?.[0]; + galleryInput.value = ''; + handleGalleryUpload(f, selectedUploadFrame); + }; + + const uploadMap = new Map(uploads.map(u => [u.id, u])); + $$('[data-upload-repos]').forEach(btn => btn.onclick = () => { + const item = uploadMap.get(+btn.dataset.uploadRepos); + if (item) repositionGalleryItem(item); + }); + $$('[data-upload-del]').forEach(btn => btn.onclick = () => deleteGalleryItem(+btn.dataset.uploadDel)); + + $$('#avChips .chip').forEach(c => c.onclick = async () => { + const r = await api('/api/settings/avatar', { avatar: c.dataset.av }); + if (r.ok) { state.user.avatar = r.avatar; updateAuthUI(); renderSettings(); } + }); + + const users = (await get('/api/users')).users || []; + const sel = $('#adUser'); + sel.innerHTML = users.map(u => ``).join(''); + $('#userList').innerHTML = users.map(u => `
${renderAvatar(u.avatar, { size: 'sm' })} ${u.username}${coins(u.balance)}
`).join(''); + hydrateFramedImages($('#userList')); + + const doBalance = async (mode, amount) => { + const r = await api('/api/admin/balance', { userId: +sel.value, mode, amount }); + if (!r.ok) return toast(r.error, 'lose'); + toast('Guthaben aktualisiert ✅', 'win'); + if (+sel.value === state.user.id) setBalance(r.balance); + renderSettings(); + }; + $('#adAdd').onclick = () => doBalance('add', parseFloat($('#adAmount').value)); + $('#adSet').onclick = () => doBalance('set', parseFloat($('#adAmount').value)); + $$('[data-quick]').forEach(c => c.onclick = () => doBalance('add', +c.dataset.quick)); + + $$('[data-mc]').forEach(b => b.onclick = async () => { + const action = b.dataset.mc; + const r = await api('/api/manipulate', { symbol: $('#mkSym').value, action, value: action === 'reset' ? 0 : 4 }); + if (!r.ok) return toast(r.error, 'lose'); + toast(`${$('#mkSym').value}: ${action} ✅`); + }); +} + +// ===================== LIVE (Socket.IO) ===================== +function connectSocket() { + const socket = io(); + state.socket = socket; + socket.on('hello', ({ assets, feed }) => { + assets.forEach(a => state.assets.set(a.symbol, a)); + if (activeView() === 'trading') renderAssets(); + if (feed) buildTicker(feed); + }); + socket.on('prices', ({ data }) => { + data.forEach(d => { + const a = state.assets.get(d.s); + if (!a) return; + const prev = a.price; + a.price = d.p; a.change = d.c; a.changePct = d.cp; a.halted = !!d.h; + // live history + let h = state.liveHistory.get(d.s); if (!h) { h = []; state.liveHistory.set(d.s, h); } + h.push({ t: Date.now(), p: d.p }); if (h.length > 120) h.shift(); + updateAssetRow(d.s, prev); + if (state.selected === d.s) updateDetail(a); + }); + if (state.selected) drawChart(); + }); + socket.on('feed', (f) => { + addTickerItem(f); + if (activeView() === 'social') get('/api/feed').then(r => r.ok && renderFeedList(r.feed)); + }); + socket.on('balance_changed', ({ userId, balance }) => { + if (state.user && userId === state.user.id) setBalance(balance); + }); + socket.on('manip', ({ symbol, action, by }) => { /* feed handles UI */ }); +} +function updateAssetRow(sym, prevPrice) { + const row = $(`#row-${sym}`); if (!row) return; + const a = state.assets.get(sym); + const pxEl = row.querySelector('[data-px]'); const chgEl = row.querySelector('[data-chg]'); + if (pxEl) pxEl.textContent = px(a.price); + if (chgEl) { chgEl.textContent = (a.change >= 0 ? '+' : '') + fmt(a.changePct) + '%'; chgEl.className = 'chg ' + (a.change >= 0 ? 'up' : 'down'); } + if (a.price !== prevPrice) { + row.classList.remove('flash-up', 'flash-down'); + void row.offsetWidth; + row.classList.add(a.price > prevPrice ? 'flash-up' : 'flash-down'); + } +} +function updateDetail(a) { + const p = $('#dPrice'); if (p) p.textContent = px(a.price); + const c = $('#dChg'); if (c) { c.textContent = `${a.change>=0?'+':''}${fmt(a.change)} (${fmt(a.changePct)}%)`; c.className = a.change >= 0 ? 'up' : 'down'; } +} + +// ---------- Ticker ---------- +function buildTicker(feed) { + const t = $('#tickerTrack'); + if (!t) return; + const items = (feed && feed.length) ? feed.map(f => f.text) : ['Willkommen bei PlayBull 🐂', 'Echte Kurse · Live · Spielgeld']; + t.innerHTML = items.map(x => `${x}`).join('') + items.map(x => `${x}`).join(''); +} +function addTickerItem(f) { + const t = $('#tickerTrack'); if (!t) return; + const s = document.createElement('span'); s.className = 'ticker-item'; s.innerHTML = f.text; + t.prepend(s); + while (t.children.length > 60) t.lastChild.remove(); +} + +// ===================== INIT ===================== +async function init() { + const embed = new URLSearchParams(location.search).has('embed'); + if (embed) { + document.body.classList.add('pb-embed'); + $('#pbTopbar')?.remove(); + $('#feedTicker')?.remove(); + } + const me = await get('/api/me'); + if (me.ok && me.user) state.user = me.user; + updateAuthUI(); + const m = await get('/api/market'); + if (m.ok) m.assets.forEach(a => state.assets.set(a.symbol, a)); + connectSocket(); + if (embed) switchView('games'); + else renderActiveView(); + setInterval(() => { // Ticker-Zeiten & Feed leichtgewichtig aktualisieren + if (activeView() === 'social') { /* live via socket */ } + }, 1000); +} +const authBtn = $('#authBtn'); +if (authBtn) authBtn.onclick = openAuthModal; +init(); diff --git a/public/js/framed-image.js b/public/js/framed-image.js new file mode 100644 index 0000000..4104c26 --- /dev/null +++ b/public/js/framed-image.js @@ -0,0 +1,156 @@ +// Gemeinsame Transform-Logik für Editor-Vorschau und finale Anzeige + +export const FRAMES = { + avatar: { + id: 'avatar', + aspectRatio: 1, + label: 'Profilbild', + cssClass: 'framed-image framed-image--avatar', + previewClass: 'framed-image--avatar-xl', + sizes: { sm: 'framed-image--avatar-sm', md: 'framed-image--avatar-md', lg: 'framed-image--avatar-lg' }, + }, + gallery: { + id: 'gallery', + aspectRatio: 1, + label: 'Quadrat', + cssClass: 'framed-image framed-image--gallery', + previewClass: 'framed-image--gallery-xl', + sizes: { sm: 'framed-image--gallery-sm', md: 'framed-image--gallery-md', lg: 'framed-image--gallery-lg' }, + }, + banner: { + id: 'banner', + aspectRatio: 3, + label: 'Banner', + cssClass: 'framed-image framed-image--banner', + previewClass: 'framed-image--banner-xl', + sizes: { sm: 'framed-image--banner-sm', md: 'framed-image--banner-md', lg: 'framed-image--banner-lg' }, + }, + card: { + id: 'card', + aspectRatio: 2 / 3, + label: 'Karte', + cssClass: 'framed-image framed-image--card', + previewClass: 'framed-image--card-xl', + sizes: { sm: 'framed-image--card-sm', md: 'framed-image--card-md', lg: 'framed-image--card-lg' }, + }, +}; + +export function computeFramedLayout(imgW, imgH, frameW, frameH, focus, zoom) { + const fx = focus?.x ?? 0.5; + const fy = focus?.y ?? 0.5; + const z = Math.max(1, zoom ?? 1); + const cover = Math.max(frameW / imgW, frameH / imgH); + const scale = cover * z; + const w = imgW * scale; + const h = imgH * scale; + let left = frameW / 2 - fx * w; + let top = frameH / 2 - fy * h; + left = Math.min(0, Math.max(frameW - w, left)); + top = Math.min(0, Math.max(frameH - h, top)); + return { width: w, height: h, left, top, scale }; +} + +export function focusFromLayout(imgW, imgH, frameW, frameH, layout, zoom) { + const z = Math.max(1, zoom ?? 1); + const cover = Math.max(frameW / imgW, frameH / imgH); + const scale = cover * z; + const w = imgW * scale; + const h = imgH * scale; + return { + x: Math.min(1, Math.max(0, (frameW / 2 - layout.left) / w)), + y: Math.min(1, Math.max(0, (frameH / 2 - layout.top) / h)), + }; +} + +export function applyFramedLayout(imgEl, layout) { + imgEl.style.width = `${layout.width}px`; + imgEl.style.height = `${layout.height}px`; + imgEl.style.left = `${layout.left}px`; + imgEl.style.top = `${layout.top}px`; +} + +export function loadImage(src) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Bild konnte nicht geladen werden')); + img.src = src; + }); +} + +function sizeClass(frame, size) { + const preset = FRAMES[frame] || FRAMES.gallery; + return preset.sizes[size] || preset.sizes.sm; +} + +export function isImageAvatar(avatar) { + return avatar && typeof avatar === 'object' && avatar.type === 'image' && avatar.url; +} + +export function avatarEmoji(avatar) { + if (!avatar) return '🐂'; + if (typeof avatar === 'string') return avatar; + if (isImageAvatar(avatar)) return avatar.emoji || '🐂'; + return avatar.value || '🐂'; +} + +export function renderFramedImage({ url, focus, zoom, frame = 'gallery', size = 'sm', alt = '' }) { + const preset = FRAMES[frame] || FRAMES.gallery; + const fx = focus?.x ?? 0.5; + const fy = focus?.y ?? 0.5; + const z = zoom ?? 1; + return `
+ ${escapeAttr(alt)} +
`; +} + +export function renderUploadItem(item) { + return `
+ ${renderFramedImage({ url: item.url, focus: item.focus, zoom: item.zoom, frame: item.frame, size: 'md' })} +
+ + +
+
`; +} + +export function renderAvatar(avatar, { size = 'sm', alt = '' } = {}) { + if (isImageAvatar(avatar)) { + return renderFramedImage({ + url: avatar.url, + focus: avatar.focus, + zoom: avatar.zoom, + frame: 'avatar', + size, + alt, + }); + } + const emoji = avatarEmoji(avatar); + const cls = size === 'lg' ? 'avatar-emoji avatar-emoji--lg' : size === 'md' ? 'avatar-emoji avatar-emoji--md' : 'avatar-emoji avatar-emoji--sm'; + return ``; +} + +export function hydrateFramedImages(root = document) { + root.querySelectorAll('.framed-image-host[data-framed]').forEach((host) => { + const img = host.querySelector('img'); + if (!img) return; + const apply = () => { + const layout = computeFramedLayout( + img.naturalWidth, + img.naturalHeight, + host.clientWidth, + host.clientHeight, + { x: +host.dataset.fx, y: +host.dataset.fy }, + +host.dataset.zoom, + ); + applyFramedLayout(img, layout); + }; + if (img.complete && img.naturalWidth) apply(); + else img.addEventListener('load', apply, { once: true }); + }); +} + +function escapeAttr(s) { + return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/} + */ +export async function openImagePositionEditor({ file, url, focus, zoom, frame = 'avatar' } = {}) { + const preset = FRAMES[frame] || FRAMES.avatar; + let src = url; + if (file) src = URL.createObjectURL(file); + + let img; + try { + img = await loadImage(src); + } catch (e) { + if (file) URL.revokeObjectURL(src); + throw e; + } + + const state = { + focus: { x: focus?.x ?? 0.5, y: focus?.y ?? 0.5 }, + zoom: Math.max(1, zoom ?? 1), + img, + dragging: false, + lastX: 0, + lastY: 0, + pinchDist: 0, + pinchZoom: 1, + }; + + return new Promise((resolve) => { + const backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop image-editor-backdrop'; + backdrop.innerHTML = ` + `; + + document.body.appendChild(backdrop); + document.body.style.overflow = 'hidden'; + + const frameEl = backdrop.querySelector('#ieFrame'); + const imgEl = backdrop.querySelector('#ieImg'); + const zoomInput = backdrop.querySelector('#ieZoom'); + const zoomVal = backdrop.querySelector('#ieZoomVal'); + + let layout = { left: 0, top: 0, width: 0, height: 0, scale: 1 }; + + function syncFromFocus() { + const fw = frameEl.clientWidth; + const fh = frameEl.clientHeight; + layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom); + applyFramedLayout(imgEl, layout); + } + + function syncFocusFromLayout() { + const fw = frameEl.clientWidth; + const fh = frameEl.clientHeight; + state.focus = focusFromLayout(img.naturalWidth, img.naturalHeight, fw, fh, layout, state.zoom); + } + + function finish(result) { + document.body.style.overflow = ''; + backdrop.remove(); + if (file) URL.revokeObjectURL(src); + resolve(result); + } + + function onPointerDown(e) { + if (e.target.closest('button, input')) return; + state.dragging = true; + state.lastX = e.clientX; + state.lastY = e.clientY; + frameEl.setPointerCapture(e.pointerId); + e.preventDefault(); + } + + function onPointerMove(e) { + if (!state.dragging) return; + const dx = e.clientX - state.lastX; + const dy = e.clientY - state.lastY; + state.lastX = e.clientX; + state.lastY = e.clientY; + const fw = frameEl.clientWidth; + const fh = frameEl.clientHeight; + layout.left = Math.min(0, Math.max(fw - layout.width, layout.left + dx)); + layout.top = Math.min(0, Math.max(fh - layout.height, layout.top + dy)); + applyFramedLayout(imgEl, layout); + syncFocusFromLayout(); + } + + function onPointerUp(e) { + state.dragging = false; + try { frameEl.releasePointerCapture(e.pointerId); } catch { /* ignore */ } + } + + function onWheel(e) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.08 : 0.08; + setZoom(state.zoom + delta); + } + + function setZoom(z) { + const fw = frameEl.clientWidth; + const fh = frameEl.clientHeight; + const oldZoom = state.zoom; + state.zoom = Math.min(4, Math.max(1, z)); + const oldLayout = { ...layout }; + layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom); + // Fokuspunkt in der Mitte halten beim Zoomen + const cx = fw / 2; + const cy = fh / 2; + const relX = (cx - oldLayout.left) / oldLayout.width; + const relY = (cy - oldLayout.top) / oldLayout.height; + layout.left = cx - relX * layout.width; + layout.top = cy - relY * layout.height; + layout.left = Math.min(0, Math.max(fw - layout.width, layout.left)); + layout.top = Math.min(0, Math.max(fh - layout.height, layout.top)); + syncFocusFromLayout(); + applyFramedLayout(imgEl, layout); + zoomInput.value = state.zoom; + zoomVal.textContent = `${state.zoom.toFixed(1)}×`; + } + + imgEl.addEventListener('load', syncFromFocus, { once: true }); + if (imgEl.complete) syncFromFocus(); + else requestAnimationFrame(syncFromFocus); + + frameEl.addEventListener('pointerdown', onPointerDown); + frameEl.addEventListener('pointermove', onPointerMove); + frameEl.addEventListener('pointerup', onPointerUp); + frameEl.addEventListener('pointercancel', onPointerUp); + frameEl.addEventListener('wheel', onWheel, { passive: false }); + + // Pinch-Zoom auf Touch + frameEl.addEventListener('touchstart', (e) => { + if (e.touches.length === 2) { + state.pinchDist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY, + ); + state.pinchZoom = state.zoom; + } + }, { passive: true }); + frameEl.addEventListener('touchmove', (e) => { + if (e.touches.length === 2 && state.pinchDist > 0) { + e.preventDefault(); + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY, + ); + setZoom(state.pinchZoom * (dist / state.pinchDist)); + } + }, { passive: false }); + + zoomInput.addEventListener('input', () => setZoom(+zoomInput.value)); + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) finish(null); + }); + backdrop.querySelector('[data-act="cancel"]').onclick = () => finish(null); + backdrop.querySelector('[data-act="reset"]').onclick = () => { + state.focus = { x: 0.5, y: 0.5 }; + state.zoom = 1; + zoomInput.value = 1; + zoomVal.textContent = '1.0×'; + syncFromFocus(); + }; + backdrop.querySelector('[data-act="save"]').onclick = () => { + syncFocusFromLayout(); + finish({ focus: { ...state.focus }, zoom: state.zoom }); + }; + }); +} diff --git a/public/js/upload-helper.js b/public/js/upload-helper.js new file mode 100644 index 0000000..7e90faa --- /dev/null +++ b/public/js/upload-helper.js @@ -0,0 +1,72 @@ +import { openImagePositionEditor } from './image-editor.js'; + +/** + * Öffnet Editor und lädt Bild hoch. Für Avatar, Galerie und alle anderen Uploads. + */ +export async function uploadImageWithEditor(file, { frame, uploadFn }) { + if (!file) return null; + const result = await openImagePositionEditor({ file, frame }); + if (!result) return null; + return uploadFn(file, result); +} + +/** + * Editor für bestehendes Bild, dann Position speichern. + */ +export async function repositionImageWithEditor(item, { frame, saveFn }) { + const result = await openImagePositionEditor({ + url: item.url, + focus: item.focus, + zoom: item.zoom, + frame: frame || item.frame || 'gallery', + }); + if (!result) return null; + return saveFn(result); +} + +/** + * Verbindet einen File-Input mit Editor + Upload-Callback. + */ +export function wireImageFileInput(input, { frame, onPick }) { + input.addEventListener('change', async () => { + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + try { + await onPick(file, frame); + } catch (e) { + throw e; + } + }); +} + +/** + * Erstellt versteckten File-Input + Button — Editor öffnet sich immer vor Upload. + */ +export function createImageUploadButton({ label, frame, onPick, className = 'btn btn-accent' }) { + const wrap = document.createElement('div'); + wrap.className = 'image-upload-wrap'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + btn.textContent = label; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.hidden = true; + btn.onclick = () => input.click(); + wireImageFileInput(input, { + frame, + onPick: async (file) => onPick(file, frame), + }); + wrap.append(btn, input); + return wrap; +} + +export function appendPositionFields(fd, result, frame) { + fd.append('focusX', result.focus.x); + fd.append('focusY', result.focus.y); + fd.append('zoom', result.zoom); + if (frame) fd.append('frame', frame); + return fd; +} diff --git a/public/play.html b/public/play.html new file mode 100644 index 0000000..7d0495d --- /dev/null +++ b/public/play.html @@ -0,0 +1,98 @@ + + + + + + + + PlayBull — Trilogy Hub Trading & Arcade + + + + + + + + + +
+
+ + PlayBull +
+
+ + +
+
+ + +
+ +
+ +
+
+

Markt live

+
+ + + +
+
+
+
+ + +
+

Casino 🎰

+
+ +
+ + +
+

Portfolio 💼

+
+
+ + +
+

Rangliste 🏆

+
+

Aktivität

+
+
+ + +
+

Einstellungen ⚙️

+
+
+
+ + + + + + + + +
+ + + + + diff --git a/public/playbull.js b/public/playbull.js new file mode 100644 index 0000000..0b0e97e --- /dev/null +++ b/public/playbull.js @@ -0,0 +1,460 @@ +/* ============================================================ + PlayBull Integration — verbindet Trilogy Hub mit dem + PlayBull-Backend (echtes Login, Live-Trading, Casino). + Additiv: laeuft NACH app.js, ohne dessen Logik zu entfernen. + ============================================================ */ +(() => { + "use strict"; + const $ = (s, c = document) => c.querySelector(s); + const $$ = (s, c = document) => Array.from(c.querySelectorAll(s)); + + const PB = { + user: null, + assets: new Map(), + portfolio: null, + socket: null, + chart: null, + parisLive: false, + casinoLoaded: false, + }; + window.PlayBull = PB; + + /* -------------------- API -------------------- */ + async function api(path, body, method = "POST") { + const opts = { method, headers: { "Content-Type": "application/json" }, credentials: "same-origin" }; + if (body && method !== "GET") opts.body = JSON.stringify(body); + const res = await fetch(path, method === "GET" ? { credentials: "same-origin" } : opts); + const data = await res.json().catch(() => ({ ok: false, error: "Netzwerkfehler" })); + return data; + } + const get = (p) => api(p, null, "GET"); + + /* -------------------- Format -------------------- */ + const eur = (n, d = 2) => "€" + (Number(n) || 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d }); + const px = (n) => (Number(n) < 1 ? (Number(n)).toLocaleString("de-DE", { maximumFractionDigits: 4 }) : eur(n)); + const signed = (n) => (n >= 0 ? "+" : "-") + "€" + Math.abs(Number(n) || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + /* -------------------- Toast -------------------- */ + function toast(msg, type = "") { + let wrap = $("#pbToasts"); + if (!wrap) { + wrap = document.createElement("div"); + wrap.id = "pbToasts"; + wrap.style.cssText = "position:fixed;top:80px;left:0;right:0;z-index:200;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none"; + document.body.appendChild(wrap); + } + const el = document.createElement("div"); + const border = type === "win" ? "var(--success)" : type === "lose" ? "var(--error)" : "var(--border)"; + el.style.cssText = `pointer-events:auto;background:var(--surface);border:1px solid ${border};color:var(--text);padding:11px 18px;border-radius:12px;font-weight:600;font-size:.9rem;box-shadow:0 10px 30px rgba(0,0,0,.45);max-width:90%`; + el.innerHTML = msg; + wrap.appendChild(el); + setTimeout(() => { el.style.transition = "opacity .3s"; el.style.opacity = "0"; }, 2600); + setTimeout(() => el.remove(), 3000); + } + + /* -------------------- Modal -------------------- */ + function openModal(html, maxw = "440px") { + closeModal(); + const back = document.createElement("div"); + back.className = "modal-backdrop"; + back.id = "pbModal"; + back.innerHTML = `
${html}
`; + document.body.appendChild(back); + back.addEventListener("click", (e) => { if (e.target === back) closeModal(); }); + if (window.lucide) window.lucide.createIcons(); + return back; + } + function closeModal() { const m = $("#pbModal"); if (m) m.remove(); } + PB.closeModal = closeModal; + + /* -------------------- Auth -------------------- */ + function openAuthModal(mode = "login") { + const isLogin = mode === "login"; + openModal(` +
+

${isLogin ? "Login" : "Registrieren"} 🐂

+ +
+ + + + +

+ +

+ ${isLogin ? "Noch kein Account?" : "Schon registriert?"} + +

`); + $("#pbModal [data-close]").onclick = closeModal; + $("#pbAuthSwitch").onclick = () => openAuthModal(isLogin ? "register" : "login"); + const submit = async () => { + const username = $("#pbUser").value.trim(); + const password = $("#pbPass").value; + const r = await api(isLogin ? "/api/login" : "/api/register", { username, password }); + if (!r.ok) { $("#pbAuthErr").textContent = r.error || "Fehler"; return; } + PB.user = r.user; + closeModal(); + toast(`Willkommen, ${r.user.username}! 🎉`, "win"); + onAuthChange(); + }; + $("#pbAuthSubmit").onclick = submit; + $("#pbPass").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); + setTimeout(() => $("#pbUser")?.focus(), 50); + } + PB.openAuthModal = openAuthModal; + + async function logout() { + await api("/api/logout", {}); + PB.user = null; + PB.parisLive = false; + onAuthChange(); + toast("Ausgeloggt"); + } + + function onAuthChange() { + // Members-only Navigation ein/ausblenden + $$("[data-members]").forEach((el) => { el.hidden = !PB.user; }); + // aktuelle Seite aktualisieren + const route = (location.hash || "#home").replace("#", ""); + if (route === "paris") setupParis(); + if (route === "casino") setupCasino(); + // Falls eingeloggt: Portfolio laden + if (PB.user) loadPortfolio(); + } + + /* -------------------- Portfolio / Markt -------------------- */ + async function loadMarket() { + const r = await get("/api/market"); + if (r.ok) r.assets.forEach((a) => PB.assets.set(a.symbol, a)); + } + async function loadPortfolio() { + if (!PB.user) return; + const r = await get("/api/portfolio"); + if (r.ok) { PB.portfolio = r.portfolio; if (PB.user) PB.user.balance = r.portfolio.balance; } + } + + /* ==================== PARIS — LIVE TRADING ==================== */ + function setupParis() { + // Demo-Handler von app.js abklemmen: Login-Form + Logout klonen + const form = $("#loginForm"); + if (form && !form.dataset.pbWired) { + const clone = form.cloneNode(true); + form.replaceWith(clone); + clone.dataset.pbWired = "1"; + clone.addEventListener("submit", async (e) => { + e.preventDefault(); + const u = clone.querySelector("#username").value.trim(); + const pw = clone.querySelector("#password").value; + const err = clone.querySelector("#loginError"); + // 1) Login versuchen, 2) sonst automatisch registrieren + let r = await api("/api/login", { username: u, password: pw }); + if (!r.ok) { + const reg = await api("/api/register", { username: u, password: pw }); + if (reg.ok) r = reg; + } + if (!r.ok) { err.textContent = r.error || "Login fehlgeschlagen"; return; } + err.textContent = ""; + PB.user = r.user; + onAuthChange(); + revealDashboard(); + }); + // Hinweis-Text anpassen + const hint = clone.querySelector("p.text-faint"); + if (hint) hint.innerHTML = 'Dein PlayBull-Konto — neu? Einfach Name + Passwort eingeben, der Account wird automatisch erstellt.'; + } + const logoutBtn = $("#logoutBtn"); + if (logoutBtn && !logoutBtn.dataset.pbWired) { + const lc = logoutBtn.cloneNode(true); + logoutBtn.replaceWith(lc); + lc.dataset.pbWired = "1"; + lc.addEventListener("click", () => { logout(); $("#parisDashboard").classList.add("hidden"); $("#parisLogin").classList.remove("hidden"); }); + } + if (PB.user) revealDashboard(); + } + + async function revealDashboard() { + if (!PB.user) return; + $("#parisLogin")?.classList.add("hidden"); + $("#parisDashboard")?.classList.remove("hidden"); + await loadPortfolio(); + injectDashboardExtras(); + renderMarketTable(); + renderTradeLog(); + renderHoldings(); + updateKpis(); + requestAnimationFrame(() => { try { buildAllocationChart(); } catch {} }); + PB.parisLive = true; + if (window.lucide) window.lucide.createIcons(); + } + + // Logged-in-as Anzeige + Casino-CTA in die Navbar setzen + function injectDashboardExtras() { + const chip = $$("#parisDashboard .chip").find((c) => /Logged in as|Eingeloggt/.test(c.textContent)); + if (chip) chip.textContent = `Eingeloggt: ${PB.user.username}`; + const bar = $("#parisDashboard .wrap.flex"); + if (bar && !$("#pbCasinoCta")) { + const a = document.createElement("button"); + a.id = "pbCasinoCta"; + a.className = "btn btn-ghost !min-h-0 !py-2.5"; + a.innerHTML = 'Casino & Games '; + a.onclick = () => { location.hash = "#casino"; }; + const logout = $("#parisDashboard #logoutBtn"); + logout?.parentElement?.insertBefore(a, logout); + } + } + + function updateKpis() { + const p = PB.portfolio; if (!p) return; + const pnl = (p.holdings || []).reduce((s, h) => s + h.pnl, 0); + // Reihenfolge im KPI-Grid: Portfolio Value, Today's P&L, Open Positions, Buying Power + const grid = $("#parisDashboard .grid.grid-cols-2"); + if (grid) { + const vals = $$("[data-counter]", grid); + if (vals[0]) vals[0].textContent = eur(p.netWorth); + if (vals[1]) { vals[1].textContent = signed(pnl); vals[1].style.color = pnl >= 0 ? "var(--success)" : "var(--error)"; } + if (vals[2]) vals[2].textContent = String((p.holdings || []).length); + if (vals[3]) vals[3].textContent = eur(p.balance); + // statische "(+0.66%)" Klammer hinter P&L entfernen, falls vorhanden + const pnlExtra = vals[1]?.parentElement?.querySelector("span.text-sm"); + if (pnlExtra) pnlExtra.textContent = ""; + } + // Portfolio-Wert oben in der Navbar + const navPv = $("#parisDashboard .text-right [data-counter]"); + if (navPv) navPv.textContent = eur(p.netWorth); + } + + function renderMarketTable() { + const table = $("#watchlistTable"); + if (!table) return; + const assets = [...PB.assets.values()]; + const posBySym = {}; + (PB.portfolio?.holdings || []).forEach((h) => (posBySym[h.symbol] = h)); + table.innerHTML = ` + + + Asset + Kurs + 24h + Position + Aktionen + + + + ${assets.map((a) => { + const pos = posBySym[a.symbol]; + const cls = a.changePct >= 0 ? "text-success" : "text-error"; + return ` + ${a.emoji || ""} ${a.symbol}
${a.name}
+ ${px(a.price)} + ${a.changePct >= 0 ? "+" : ""}${(a.changePct).toFixed(2)}% + ${pos ? pos.quantity + (pos.side === "short" ? " (S)" : "") : "—"} + + + + + + + `; + }).join("")} + `; + $$("#watchlistTable tr[data-sym]").forEach((tr) => { + const sym = tr.dataset.sym; + tr.querySelector("[data-buy]").onclick = () => openTradeModal(sym, "buy"); + tr.querySelector("[data-sell]").onclick = () => openTradeModal(sym, "sell"); + tr.querySelector("[data-pump]").onclick = () => manipulate(sym, "pump"); + tr.querySelector("[data-dump]").onclick = () => manipulate(sym, "dump"); + }); + } + + async function manipulate(sym, action) { + const r = await api("/api/manipulate", { symbol: sym, action, value: action === "pump" || action === "dump" ? 3 : 0 }); + if (!r.ok) return toast(r.error, "lose"); + toast(`${sym}: ${action === "pump" ? "🚀 gepumpt" : "📉 gedumpt"} (für alle)`); + } + + function openTradeModal(sym, side) { + const a = PB.assets.get(sym); + openModal(` +
+

${a.emoji || ""} ${sym} ${a.name}

+ +
+
+ Kurs + ${px(a.price)} +
+
+ + +
+ + +
+ ${[0.1, 1, 5, 10].map((v) => ``).join("")} + +
+

+ +

Guthaben: ${eur(PB.user.balance)}

`); + let mode = side; + const qty = $("#tmQty"); + const refresh = () => { + const cur = PB.assets.get(sym); + $("#tmPx").textContent = px(cur.price); + const cost = (parseFloat(qty.value) || 0) * cur.price; + $("#tmEst").textContent = `≈ ${eur(cost)}`; + $("#tmGo").textContent = mode === "buy" ? "Jetzt kaufen" : "Jetzt verkaufen"; + $("#tmGo").className = "btn w-full " + (mode === "buy" ? "btn-primary" : "btn-ghost"); + }; + $("#pbModal [data-close]").onclick = closeModal; + $$("#pbModal [data-mode]").forEach((b) => b.onclick = () => { + mode = b.dataset.mode; + $$("#pbModal [data-mode]").forEach((x) => x.className = "btn flex-1 " + (x.dataset.mode === mode ? "btn-primary" : "btn-ghost")); + refresh(); + }); + $$("#pbModal [data-amt]").forEach((c) => c.onclick = () => { qty.value = c.dataset.amt; refresh(); }); + $("#pbModal [data-max]").onclick = () => { qty.value = (PB.user.balance / PB.assets.get(sym).price).toFixed(4); refresh(); }; + qty.addEventListener("input", refresh); + $("#tmGo").onclick = async () => { + const r = await api("/api/trade", { symbol: sym, side: mode, qty: parseFloat(qty.value) }); + if (!r.ok) return toast(r.error, "lose"); + const tradedQty = parseFloat(qty.value); + PB.user.balance = r.balance; + closeModal(); + toast(`${mode === "buy" ? "Gekauft" : "Verkauft"}: ${tradedQty} ${sym} @ ${px(r.price)}`, "win"); + await loadPortfolio(); + updateKpis(); renderMarketTable(); renderTradeLog(); buildAllocationChart(); + }; + refresh(); + } + + async function renderTradeLog() { + const log = $("#tradeLog"); + if (!log) return; + const r = await get("/api/trades"); + const trades = r.ok ? r.trades : []; + log.innerHTML = trades.length ? trades.slice(0, 12).map((t) => { + const isBuy = t.side === "buy"; + const d = new Date(t.ts).toLocaleString("de-DE", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); + return `
+ ${isBuy ? "BUY" : "SELL"} + ${t.qty} ${t.symbol} @ ${px(t.price)} + ${d} +
`; + }).join("") : '
Noch keine Trades.
'; + } + + function renderHoldings() { updateKpis(); } + + const chartColors = ["#c9a84c", "#2f9e8f", "#5b7aa6", "#9a958a", "#7d8a4f", "#b8763a", "#6a8caf", "#a85f7a"]; + function buildAllocationChart() { + const ctx = $("#allocationChart"); + if (!ctx || typeof Chart === "undefined") return; + const holds = (PB.portfolio?.holdings || []).filter((h) => Math.abs(h.value) > 0.01); + const data = holds.map((h) => Math.abs(h.value)); + const labels = holds.map((h) => h.symbol); + if (PB.chart) PB.chart.destroy(); + if (!data.length) { + const c2 = ctx.getContext("2d"); c2.clearRect(0, 0, ctx.width, ctx.height); + $("#chartLegend").innerHTML = '

Noch keine Positionen.

'; + return; + } + PB.chart = new Chart(ctx, { + type: "doughnut", + data: { labels, datasets: [{ data, backgroundColor: labels.map((_, i) => chartColors[i % chartColors.length]), borderColor: "transparent", borderWidth: 2, hoverOffset: 8 }] }, + options: { cutout: "62%", responsive: true, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => ` ${c.label}: ${eur(c.parsed)}` } } } }, + }); + const total = data.reduce((a, b) => a + b, 0); + $("#chartLegend").innerHTML = labels.map((l, i) => ` +
+ ${l} + ${((data[i] / total) * 100).toFixed(1)}% +
`).join(""); + } + + /* ==================== CASINO (eingebettete Arcade) ==================== */ + function setupCasino() { + const gate = $("#casinoGate"), wrap = $("#casinoFrameWrap"); + if (!gate || !wrap) return; + if (PB.user) { + gate.classList.add("hidden"); + wrap.classList.remove("hidden"); + const frame = $("#casinoFrame"); + if (frame) { + const url = "/play.html?embed=1"; + if (!frame.src.includes("embed=1")) frame.src = url; + PB.casinoLoaded = true; + } + } else { + gate.classList.remove("hidden"); + wrap.classList.add("hidden"); + } + const btn = $("#casinoLoginBtn"); + if (btn && !btn.dataset.pbWired) { btn.dataset.pbWired = "1"; btn.onclick = () => openAuthModal(); } + if (window.lucide) window.lucide.createIcons(); + } + + /* -------------------- Live (Socket.IO) -------------------- */ + function connectSocket() { + if (typeof io === "undefined") return; + const socket = io(); + PB.socket = socket; + socket.on("hello", ({ assets }) => { assets && assets.forEach((a) => PB.assets.set(a.symbol, a)); }); + socket.on("prices", ({ data }) => { + data.forEach((d) => { + const a = PB.assets.get(d.s); + if (!a) return; + const prev = a.price; + a.price = d.p; a.change = d.c; a.changePct = d.cp; a.halted = !!d.h; + if ((location.hash || "#home").replace("#", "") === "paris" && PB.parisLive) { + const row = $(`#watchlistTable tr[data-sym="${d.s}"]`); + if (row) { + const pxc = row.querySelector("[data-px]"), chc = row.querySelector("[data-chg]"); + if (pxc) { pxc.textContent = px(d.p); pxc.classList.remove("flash-up", "flash-down"); void pxc.offsetWidth; pxc.classList.add(d.p >= prev ? "flash-up" : "flash-down"); } + if (chc) { chc.textContent = (d.cp >= 0 ? "+" : "") + d.cp.toFixed(2) + "%"; chc.className = "py-3 px-3 text-right " + (d.cp >= 0 ? "text-success" : "text-error"); } + } + } + }); + // Portfolio-Wert live nachrechnen + if (PB.portfolio && PB.parisLive) recomputeLive(); + }); + socket.on("balance_changed", ({ userId, balance }) => { + if (PB.user && userId === PB.user.id) { PB.user.balance = balance; if (PB.portfolio) PB.portfolio.balance = balance; updateKpis(); } + }); + } + function recomputeLive() { + const p = PB.portfolio; if (!p) return; + let hv = 0; + (p.holdings || []).forEach((h) => { + const a = PB.assets.get(h.symbol); + if (a) { h.price = a.price; h.value = h.quantity * a.price; h.pnl = h.value - h.quantity * h.avgPrice; } + hv += h.value; + }); + p.holdingsValue = hv; p.netWorth = p.balance + hv; + updateKpis(); + if (PB.chart) { PB.chart.data.datasets[0].data = (p.holdings || []).filter(h => Math.abs(h.value) > 0.01).map(h => Math.abs(h.value)); PB.chart.update("none"); } + } + + /* -------------------- Route Hooks -------------------- */ + function onRoute() { + const route = (location.hash || "#home").replace("#", ""); + if (route === "paris") setupParis(); + if (route === "casino") setupCasino(); + } + PB.onRoute = onRoute; + + /* -------------------- Init -------------------- */ + async function init() { + await loadMarket(); + const me = await get("/api/me"); + if (me.ok && me.user) { PB.user = me.user; await loadPortfolio(); } + $$("[data-members]").forEach((el) => { el.hidden = !PB.user; }); + connectSocket(); + window.addEventListener("hashchange", onRoute); + // Erststart-Route behandeln (z.B. direkt auf #paris) + onRoute(); + } + + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); + else init(); +})(); diff --git a/public/uploads/manifest.json b/public/uploads/manifest.json new file mode 100644 index 0000000..ef53726 --- /dev/null +++ b/public/uploads/manifest.json @@ -0,0 +1,7 @@ +{ + "page-home::Magic Mike": "/uploads/page-home_Magic_Mike.jpg", + "page-home::Nico Gonzales": "/uploads/page-home_Nico_Gonzales.jpg", + "page-home::Paris": "/uploads/page-home_Paris.jpg", + "page-home::Team Photo": "/uploads/page-home_Team_Photo.jpg", + "page-nico::Hero Background": "/uploads/page-nico_Hero_Background.jpg" +} \ No newline at end of file diff --git a/public/uploads/page-home_Magic_Mike.jpg b/public/uploads/page-home_Magic_Mike.jpg new file mode 100644 index 0000000..b1ec2b1 Binary files /dev/null and b/public/uploads/page-home_Magic_Mike.jpg differ diff --git a/public/uploads/page-home_Nico_Gonzales.jpg b/public/uploads/page-home_Nico_Gonzales.jpg new file mode 100644 index 0000000..8ebeaa8 Binary files /dev/null and b/public/uploads/page-home_Nico_Gonzales.jpg differ diff --git a/public/uploads/page-home_Paris.jpg b/public/uploads/page-home_Paris.jpg new file mode 100644 index 0000000..92db1e6 Binary files /dev/null and b/public/uploads/page-home_Paris.jpg differ diff --git a/public/uploads/page-home_Team_Photo.jpg b/public/uploads/page-home_Team_Photo.jpg new file mode 100644 index 0000000..bdc280f Binary files /dev/null and b/public/uploads/page-home_Team_Photo.jpg differ diff --git a/public/uploads/page-nico_Hero_Background.jpg b/public/uploads/page-nico_Hero_Background.jpg new file mode 100644 index 0000000..57d1ae7 Binary files /dev/null and b/public/uploads/page-nico_Hero_Background.jpg differ diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..f5c3cec --- /dev/null +++ b/server/auth.js @@ -0,0 +1,70 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { config } from './config.js'; +import { Users } from './db.js'; + +const COOKIE = 'pb_token'; + +export function signToken(user) { + return jwt.sign({ id: user.id, username: user.username }, config.JWT_SECRET, { expiresIn: '30d' }); +} + +export function setAuthCookie(res, token) { + res.cookie(COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60 * 1000, + }); +} + +export function clearAuthCookie(res) { + res.clearCookie(COOKIE); +} + +function readToken(req) { + const c = req.cookies?.[COOKIE]; + if (c) return c; + const h = req.headers.authorization; + if (h && h.startsWith('Bearer ')) return h.slice(7); + return null; +} + +// Haengt req.user an, wenn eingeloggt (sonst null). Blockiert nicht. +export function optionalAuth(req, _res, next) { + req.user = null; + const token = readToken(req); + if (token) { + try { + const decoded = jwt.verify(token, config.JWT_SECRET); + const u = Users.byId(decoded.id); + if (u) req.user = u; + } catch { /* ungueltig -> Gast */ } + } + next(); +} + +// Erzwingt Login (fuer Gamble & Trades). +export function requireAuth(req, res, next) { + if (!req.user) return res.status(401).json({ error: 'Login erforderlich' }); + next(); +} + +export async function registerUser(username, password) { + username = String(username || '').trim(); + if (username.length < 3 || username.length > 20) throw new Error('Username: 3-20 Zeichen'); + if (!/^[a-zA-Z0-9_]+$/.test(username)) throw new Error('Nur Buchstaben, Zahlen, _'); + if (String(password || '').length < 4) throw new Error('Passwort: min. 4 Zeichen'); + if (Users.byName(username)) throw new Error('Username bereits vergeben'); + const hash = await bcrypt.hash(password, 10); + return Users.create(username, hash, config.START_BALANCE); +} + +export async function loginUser(username, password) { + const u = Users.byName(String(username || '').trim()); + if (!u) throw new Error('Falscher Username oder Passwort'); + const ok = await bcrypt.compare(String(password || ''), u.password_hash); + if (!ok) throw new Error('Falscher Username oder Passwort'); + return u; +} + +export { COOKIE }; diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..bbdae5a --- /dev/null +++ b/server/config.js @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +// Minimaler .env Parser (keine externe Abhaengigkeit noetig) +function loadEnv() { + const file = path.join(ROOT, '.env'); + if (!fs.existsSync(file)) return; + const txt = fs.readFileSync(file, 'utf8'); + for (const line of txt.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + const val = trimmed.slice(idx + 1).trim(); + if (!(key in process.env)) process.env[key] = val; + } +} + +loadEnv(); + +export const config = { + ROOT, + PORT: Number(process.env.PORT || 3000), + JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-change-me', + FINNHUB_API_KEY: process.env.FINNHUB_API_KEY || '', + START_BALANCE: Number(process.env.START_BALANCE || 10000), + DATA_DIR: path.join(ROOT, 'data'), + PUBLIC_DIR: path.join(ROOT, 'public'), + UPLOADS_DIR: path.join(ROOT, 'data', 'uploads', 'avatars'), + FILES_DIR: path.join(ROOT, 'data', 'uploads', 'files'), +}; + +if (!fs.existsSync(config.DATA_DIR)) fs.mkdirSync(config.DATA_DIR, { recursive: true }); +if (!fs.existsSync(config.UPLOADS_DIR)) fs.mkdirSync(config.UPLOADS_DIR, { recursive: true }); +if (!fs.existsSync(config.FILES_DIR)) fs.mkdirSync(config.FILES_DIR, { recursive: true }); diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..06c9795 --- /dev/null +++ b/server/db.js @@ -0,0 +1,208 @@ +import { DatabaseSync } from 'node:sqlite'; +import path from 'node:path'; +import { config } from './config.js'; + +const db = new DatabaseSync(path.join(config.DATA_DIR, 'playbull.db')); + +db.exec(` + PRAGMA journal_mode = WAL; + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + balance REAL NOT NULL DEFAULT 0, + avatar TEXT DEFAULT '🐂', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS holdings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + symbol TEXT NOT NULL, + quantity REAL NOT NULL DEFAULT 0, + avg_price REAL NOT NULL DEFAULT 0, + UNIQUE(user_id, symbol) + ); + + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + qty REAL NOT NULL, + price REAL NOT NULL, + ts INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + game TEXT NOT NULL, + bet REAL NOT NULL, + payout REAL NOT NULL, + result TEXT, + ts INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS feed ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + text TEXT NOT NULL, + ts INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + url TEXT NOT NULL, + frame TEXT NOT NULL DEFAULT 'gallery', + focus_x REAL DEFAULT 0.5, + focus_y REAL DEFAULT 0.5, + zoom REAL DEFAULT 1.0, + created_at INTEGER NOT NULL + ); +`); + +// Migration: Avatar-Bild + Positions-Metadaten +const userCols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name); +if (!userCols.includes('avatar_image')) db.exec('ALTER TABLE users ADD COLUMN avatar_image TEXT'); +if (!userCols.includes('avatar_focus_x')) db.exec('ALTER TABLE users ADD COLUMN avatar_focus_x REAL DEFAULT 0.5'); +if (!userCols.includes('avatar_focus_y')) db.exec('ALTER TABLE users ADD COLUMN avatar_focus_y REAL DEFAULT 0.5'); +if (!userCols.includes('avatar_zoom')) db.exec('ALTER TABLE users ADD COLUMN avatar_zoom REAL DEFAULT 1.0'); + +export default db; + +// ---- User Helpers ---- +export const Users = { + create(username, hash, balance) { + const info = db.prepare( + 'INSERT INTO users (username, password_hash, balance, created_at) VALUES (?,?,?,?)' + ).run(username, hash, balance, Date.now()); + return this.byId(info.lastInsertRowid); + }, + byId(id) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id); + }, + byName(name) { + return db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(name); + }, + all() { + return db.prepare(` + SELECT id, username, balance, avatar, avatar_image, avatar_focus_x, avatar_focus_y, avatar_zoom, created_at + FROM users ORDER BY balance DESC + `).all(); + }, + setBalance(id, balance) { + db.prepare('UPDATE users SET balance = ? WHERE id = ?').run(Math.max(0, balance), id); + }, + addBalance(id, delta) { + const u = this.byId(id); + if (!u) return null; + const nb = Math.max(0, u.balance + delta); + db.prepare('UPDATE users SET balance = ? WHERE id = ?').run(nb, id); + return nb; + }, + setAvatar(id, avatar) { + db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(avatar, id); + }, + setAvatarImage(id, imagePath, focusX, focusY, zoom) { + db.prepare(` + UPDATE users SET avatar_image = ?, avatar_focus_x = ?, avatar_focus_y = ?, avatar_zoom = ? + WHERE id = ? + `).run(imagePath, focusX, focusY, zoom, id); + }, + setAvatarPosition(id, focusX, focusY, zoom) { + db.prepare(` + UPDATE users SET avatar_focus_x = ?, avatar_focus_y = ?, avatar_zoom = ? + WHERE id = ? + `).run(focusX, focusY, zoom, id); + }, + clearAvatarImage(id) { + db.prepare(` + UPDATE users SET avatar_image = NULL, avatar_focus_x = 0.5, avatar_focus_y = 0.5, avatar_zoom = 1.0 + WHERE id = ? + `).run(id); + }, + count() { + return db.prepare('SELECT COUNT(*) c FROM users').get().c; + }, +}; + +// ---- Holdings Helpers ---- +export const Holdings = { + forUser(userId) { + return db.prepare('SELECT * FROM holdings WHERE user_id = ?').all(userId); + }, + get(userId, symbol) { + return db.prepare('SELECT * FROM holdings WHERE user_id = ? AND symbol = ?').get(userId, symbol); + }, + upsert(userId, symbol, quantity, avgPrice) { + db.prepare(` + INSERT INTO holdings (user_id, symbol, quantity, avg_price) VALUES (?,?,?,?) + ON CONFLICT(user_id, symbol) DO UPDATE SET quantity = excluded.quantity, avg_price = excluded.avg_price + `).run(userId, symbol, quantity, avgPrice); + }, + remove(userId, symbol) { + db.prepare('DELETE FROM holdings WHERE user_id = ? AND symbol = ?').run(userId, symbol); + }, +}; + +export const Trades = { + add(userId, symbol, side, qty, price) { + db.prepare('INSERT INTO trades (user_id, symbol, side, qty, price, ts) VALUES (?,?,?,?,?,?)') + .run(userId, symbol, side, qty, price, Date.now()); + }, + forUser(userId, limit = 50) { + return db.prepare('SELECT * FROM trades WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit); + }, +}; + +export const Games = { + add(userId, game, bet, payout, result) { + db.prepare('INSERT INTO games (user_id, game, bet, payout, result, ts) VALUES (?,?,?,?,?,?)') + .run(userId, game, bet, payout, JSON.stringify(result || {}), Date.now()); + }, + forUser(userId, limit = 50) { + return db.prepare('SELECT * FROM games WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit); + }, +}; + +export const Feed = { + add(kind, text) { + db.prepare('INSERT INTO feed (kind, text, ts) VALUES (?,?,?)').run(kind, text, Date.now()); + // alte Eintraege aufraeumen + db.prepare('DELETE FROM feed WHERE id NOT IN (SELECT id FROM feed ORDER BY ts DESC LIMIT 100)').run(); + }, + recent(limit = 30) { + return db.prepare('SELECT * FROM feed ORDER BY ts DESC LIMIT ?').all(limit); + }, +}; + +export const Uploads = { + forUser(userId) { + return db.prepare('SELECT * FROM uploads WHERE user_id = ? ORDER BY created_at DESC').all(userId); + }, + byId(id) { + return db.prepare('SELECT * FROM uploads WHERE id = ?').get(id); + }, + create(userId, frame, focusX, focusY, zoom) { + const info = db.prepare(` + INSERT INTO uploads (user_id, url, frame, focus_x, focus_y, zoom, created_at) + VALUES (?,?,?,?,?,?,?) + `).run(userId, 'pending', frame, focusX, focusY, zoom, Date.now()); + return this.byId(info.lastInsertRowid); + }, + setUrl(id, url) { + db.prepare('UPDATE uploads SET url = ? WHERE id = ?').run(url, id); + return this.byId(id); + }, + setPosition(id, focusX, focusY, zoom) { + db.prepare('UPDATE uploads SET focus_x = ?, focus_y = ?, zoom = ? WHERE id = ?') + .run(focusX, focusY, zoom, id); + return this.byId(id); + }, + remove(id) { + db.prepare('DELETE FROM uploads WHERE id = ?').run(id); + }, +}; diff --git a/server/games.js b/server/games.js new file mode 100644 index 0000000..b40715d --- /dev/null +++ b/server/games.js @@ -0,0 +1,247 @@ +import crypto from 'node:crypto'; +import { Users, Games } from './db.js'; +import { pushFeed } from './realtime.js'; + +const HOUSE_EDGE = 0.99; // 1% Hausvorteil +const rounds = new Map(); // roundId -> state + +function rid() { return crypto.randomBytes(12).toString('hex'); } +function rand() { return crypto.randomBytes(4).readUInt32BE(0) / 0xffffffff; } +function round(n, d = 2) { const f = Math.pow(10, d); return Math.round(n * f) / f; } + +function debit(userId, amount) { + const u = Users.byId(userId); + if (!u) return { ok: false, error: 'User fehlt' }; + if (amount <= 0) return { ok: false, error: 'Einsatz muss > 0 sein' }; + if (u.balance < amount) return { ok: false, error: 'Nicht genug Guthaben' }; + Users.setBalance(userId, u.balance - amount); + return { ok: true, balance: u.balance - amount }; +} + +function credit(userId, amount) { + return Users.addBalance(userId, amount); +} + +function settle(userId, game, bet, payout, result) { + Games.add(userId, game, bet, payout, result); + const u = Users.byId(userId); + if (payout >= bet * 5 && payout > 0) { + pushFeed('win', `${u?.username || 'Spieler'} gewinnt ${round(payout)} 🪙 bei ${game} (${round(payout / bet, 2)}x)`); + } + return Users.byId(userId)?.balance; +} + +// ============ CHICKEN ROAD ============ +// Huhn ueberquert Fahrbahnen. Jede Spur erhoeht den Multiplikator, +// aber es droht ein Crash (Auto). Cashout jederzeit moeglich. +const CHICKEN_DIFF = { + easy: { p: 0.04, lanes: 24 }, + medium: { p: 0.10, lanes: 20 }, + hard: { p: 0.20, lanes: 16 }, + insane: { p: 0.35, lanes: 12 }, +}; + +function chickenMultipliers(p, lanes) { + // Faire Multiplikatoren: 1/(1-p)^k * HouseEdge + const arr = []; + for (let k = 1; k <= lanes; k++) { + arr.push(round(HOUSE_EDGE / Math.pow(1 - p, k), 2)); + } + return arr; +} + +export function chickenStart(userId, bet, difficulty = 'medium') { + bet = Number(bet); + const diff = CHICKEN_DIFF[difficulty] || CHICKEN_DIFF.medium; + const d = debit(userId, bet); + if (!d.ok) return d; + const mults = chickenMultipliers(diff.p, diff.lanes); + const id = rid(); + rounds.set(id, { type: 'chicken', userId, bet, p: diff.p, lanes: diff.lanes, mults, lane: 0, alive: true, difficulty }); + return { ok: true, roundId: id, lanes: diff.lanes, multipliers: mults, balance: d.balance }; +} + +export function chickenStep(userId, roundId) { + const r = rounds.get(roundId); + if (!r || r.type !== 'chicken' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' }; + // Crash-Check auf der naechsten Spur + if (rand() < r.p) { + r.alive = false; + rounds.delete(roundId); + const bal = settle(userId, 'chicken', r.bet, 0, { crashedAt: r.lane + 1, difficulty: r.difficulty }); + return { ok: true, dead: true, lane: r.lane + 1, balance: bal }; + } + r.lane += 1; + const multiplier = r.mults[r.lane - 1]; + if (r.lane >= r.lanes) { + // Maximum erreicht -> automatischer Cashout + const payout = round(r.bet * multiplier); + credit(userId, payout); + rounds.delete(roundId); + const bal = settle(userId, 'chicken', r.bet, payout, { lane: r.lane, multiplier, maxed: true }); + return { ok: true, dead: false, maxed: true, lane: r.lane, multiplier, payout, balance: bal }; + } + return { ok: true, dead: false, lane: r.lane, multiplier, nextMultiplier: r.mults[r.lane] }; +} + +export function chickenCashout(userId, roundId) { + const r = rounds.get(roundId); + if (!r || r.type !== 'chicken' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' }; + if (r.lane === 0) return { ok: false, error: 'Erst mind. 1 Schritt gehen' }; + const multiplier = r.mults[r.lane - 1]; + const payout = round(r.bet * multiplier); + credit(userId, payout); + rounds.delete(roundId); + const bal = settle(userId, 'chicken', r.bet, payout, { lane: r.lane, multiplier }); + return { ok: true, payout, multiplier, lane: r.lane, balance: bal }; +} + +// ============ 10x10 MINES KARTEN-DECK ============ +const GRID = 100; +function minesMultiplier(bombs, picks) { + let m = 1; + for (let i = 0; i < picks; i++) { + m *= (GRID - i) / (GRID - bombs - i); + } + return round(m * HOUSE_EDGE, 4); +} + +export function minesStart(userId, bet, bombs = 10) { + bet = Number(bet); + bombs = Math.max(1, Math.min(95, Math.floor(Number(bombs)))); + const d = debit(userId, bet); + if (!d.ok) return d; + // Bomben-Positionen geheim ziehen + const positions = new Set(); + while (positions.size < bombs) positions.add(Math.floor(rand() * GRID)); + const id = rid(); + rounds.set(id, { type: 'mines', userId, bet, bombs, mines: positions, revealed: new Set(), alive: true }); + return { ok: true, roundId: id, bombs, grid: GRID, balance: d.balance, nextMultiplier: minesMultiplier(bombs, 1) }; +} + +export function minesReveal(userId, roundId, index) { + const r = rounds.get(roundId); + if (!r || r.type !== 'mines' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' }; + index = Math.floor(Number(index)); + if (index < 0 || index >= GRID) return { ok: false, error: 'Feld ungueltig' }; + if (r.revealed.has(index)) return { ok: false, error: 'Feld schon offen' }; + + if (r.mines.has(index)) { + r.alive = false; + rounds.delete(roundId); + const bal = settle(userId, 'mines', r.bet, 0, { hitBomb: index, bombs: r.bombs }); + return { ok: true, bomb: true, index, mines: [...r.mines], balance: bal }; + } + r.revealed.add(index); + const picks = r.revealed.size; + const safeLeft = GRID - r.bombs - picks; + const multiplier = minesMultiplier(r.bombs, picks); + const next = safeLeft > 0 ? minesMultiplier(r.bombs, picks + 1) : null; + if (safeLeft === 0) { + // Alle sicheren Felder gefunden -> Auto-Cashout + const payout = round(r.bet * multiplier); + credit(userId, payout); + rounds.delete(roundId); + const bal = settle(userId, 'mines', r.bet, payout, { picks, multiplier, cleared: true }); + return { ok: true, bomb: false, index, picks, multiplier, payout, cleared: true, balance: bal }; + } + return { ok: true, bomb: false, index, picks, multiplier, nextMultiplier: next }; +} + +export function minesCashout(userId, roundId) { + const r = rounds.get(roundId); + if (!r || r.type !== 'mines' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' }; + const picks = r.revealed.size; + if (picks === 0) return { ok: false, error: 'Erst mind. 1 Karte aufdecken' }; + const multiplier = minesMultiplier(r.bombs, picks); + const payout = round(r.bet * multiplier); + credit(userId, payout); + rounds.delete(roundId); + const bal = settle(userId, 'mines', r.bet, payout, { picks, multiplier }); + return { ok: true, payout, multiplier, picks, mines: [...r.mines], balance: bal }; +} + +// ============ COINFLIP ============ +export function coinflip(userId, bet, choice) { + bet = Number(bet); + const d = debit(userId, bet); + if (!d.ok) return d; + const flip = rand() < 0.5 ? 'heads' : 'tails'; + const win = flip === choice; + const mult = 2 * HOUSE_EDGE; + const payout = win ? round(bet * mult) : 0; + if (win) credit(userId, payout); + const bal = settle(userId, 'coinflip', bet, payout, { flip, choice }); + return { ok: true, win, flip, payout, multiplier: win ? mult : 0, balance: bal }; +} + +// ============ DICE (roll under) ============ +export function dice(userId, bet, target, direction = 'under') { + bet = Number(bet); + target = Math.max(2, Math.min(98, Number(target))); + const d = debit(userId, bet); + if (!d.ok) return d; + const roll = round(rand() * 100, 2); + const win = direction === 'under' ? roll < target : roll > target; + const chance = direction === 'under' ? target : 100 - target; + const mult = round((100 / chance) * HOUSE_EDGE, 4); + const payout = win ? round(bet * mult) : 0; + if (win) credit(userId, payout); + const bal = settle(userId, 'dice', bet, payout, { roll, target, direction }); + return { ok: true, win, roll, target, direction, multiplier: mult, payout, balance: bal }; +} + +// ============ CRASH ============ +// Server-autoritativ: Multiplikator = e^(k*t). Crashpunkt geheim. +const CRASH_K = 0.00006; // Wachstum pro ms +function genCrashPoint() { + const r = rand(); + if (r < 0.03) return 1.0; // Instant-Crash 3% + // Schwerer Tail, Median ~2x + return round(Math.max(1, (1 - 0.01) / (1 - r)), 2); +} +function crashMultAt(ms) { + return round(Math.exp(CRASH_K * ms), 2); +} + +export function crashStart(userId, bet) { + bet = Number(bet); + const d = debit(userId, bet); + if (!d.ok) return d; + const id = rid(); + const crashPoint = genCrashPoint(); + rounds.set(id, { type: 'crash', userId, bet, crashPoint, start: Date.now(), done: false }); + return { ok: true, roundId: id, balance: d.balance, k: CRASH_K }; +} + +export function crashCashout(userId, roundId) { + const r = rounds.get(roundId); + if (!r || r.type !== 'crash' || r.userId !== userId || r.done) return { ok: false, error: 'Runde ungueltig' }; + const elapsed = Date.now() - r.start; + const current = crashMultAt(elapsed); + r.done = true; + rounds.delete(roundId); + if (current >= r.crashPoint) { + const bal = settle(userId, 'crash', r.bet, 0, { crashPoint: r.crashPoint, cashoutAt: current }); + return { ok: true, crashed: true, crashPoint: r.crashPoint, balance: bal }; + } + const payout = round(r.bet * current); + credit(userId, payout); + const bal = settle(userId, 'crash', r.bet, payout, { multiplier: current, crashPoint: r.crashPoint }); + return { ok: true, crashed: false, multiplier: current, payout, balance: bal }; +} + +// Status-Abfrage fuer die Crash-Animation (autoritativ). +export function crashStatus(userId, roundId) { + const r = rounds.get(roundId); + if (!r || r.type !== 'crash' || r.userId !== userId) return { ok: false, error: 'Runde ungueltig' }; + const elapsed = Date.now() - r.start; + const current = crashMultAt(elapsed); + if (current >= r.crashPoint && !r.done) { + r.done = true; + rounds.delete(roundId); + settle(userId, 'crash', r.bet, 0, { crashPoint: r.crashPoint, busted: true }); + return { ok: true, crashed: true, crashPoint: r.crashPoint, multiplier: r.crashPoint }; + } + return { ok: true, crashed: false, multiplier: current }; +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..3efc3ac --- /dev/null +++ b/server/index.js @@ -0,0 +1,399 @@ +import express from 'express'; +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs'; +import cookieParser from 'cookie-parser'; +import multer from 'multer'; +import sharp from 'sharp'; +import { Server as SocketServer } from 'socket.io'; + +import { config } from './config.js'; +import { Users, Trades, Games, Feed, Uploads } from './db.js'; +import { + registerUser, loginUser, signToken, setAuthCookie, clearAuthCookie, + optionalAuth, requireAuth, +} from './auth.js'; +import { initMarket, listAssets, getHistory, manipulate, getAsset } from './market.js'; +import { executeTrade, getPortfolio } from './trading.js'; +import * as G from './games.js'; +import { setIO, pushFeed } from './realtime.js'; + +const app = express(); +const server = http.createServer(app); +const io = new SocketServer(server, { cors: { origin: true } }); +setIO(io); + +app.use(express.json({ limit: '30mb' })); +app.use(cookieParser()); +app.use(optionalAuth); + +// ---------- Trilogy Hub: Foto-Slot Persistenz (Bilder als Dateien) ---------- +const SITE_UPLOAD_DIR = path.join(config.PUBLIC_DIR, 'uploads'); +const SITE_MANIFEST = path.join(SITE_UPLOAD_DIR, 'manifest.json'); +const SITE_EXT_BY_MIME = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp' }; +fs.mkdirSync(SITE_UPLOAD_DIR, { recursive: true }); +if (!fs.existsSync(SITE_MANIFEST)) fs.writeFileSync(SITE_MANIFEST, '{}'); +function readSiteManifest() { try { return JSON.parse(fs.readFileSync(SITE_MANIFEST, 'utf8') || '{}'); } catch { return {}; } } +function writeSiteManifest(o) { fs.writeFileSync(SITE_MANIFEST, JSON.stringify(o, null, 2)); } +function sanitizeSlot(id) { return String(id).replace(/[^a-z0-9_-]+/gi, '_').slice(0, 120) || 'slot'; } + +app.get('/api/images', (req, res) => res.json(readSiteManifest())); +app.post('/api/upload', (req, res) => { + try { + const { id, dataUrl, focusX, focusY, zoom } = req.body || {}; + if (!id || !dataUrl) return res.status(400).json({ error: 'Missing id or dataUrl' }); + const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl); + if (!m) return res.status(400).json({ error: 'Invalid dataUrl' }); + const ext = SITE_EXT_BY_MIME[m[1].toLowerCase()] || '.png'; + const buf = Buffer.from(m[2], 'base64'); + const filename = sanitizeSlot(id) + ext; + const manifest = readSiteManifest(); + if (manifest[id]) { + const oldUrl = typeof manifest[id] === 'string' ? manifest[id] : manifest[id].url; + if (oldUrl) { + const old = path.join(config.PUBLIC_DIR, oldUrl.replace(/^\//, '')); + if (old.startsWith(SITE_UPLOAD_DIR) && fs.existsSync(old)) { try { fs.unlinkSync(old); } catch {} } + } + } + fs.writeFileSync(path.join(SITE_UPLOAD_DIR, filename), buf); + const publicUrl = '/uploads/' + filename; + const fx = Number(focusX); + const fy = Number(focusY); + const z = Number(zoom); + manifest[id] = { + url: publicUrl, + focus: { + x: Number.isFinite(fx) ? Math.min(1, Math.max(0, fx)) : 0.5, + y: Number.isFinite(fy) ? Math.min(1, Math.max(0, fy)) : 0.5, + }, + zoom: Number.isFinite(z) ? Math.min(4, Math.max(1, z)) : 1, + }; + writeSiteManifest(manifest); + res.json({ ok: true, url: publicUrl, focus: manifest[id].focus, zoom: manifest[id].zoom }); + } catch (e) { res.status(500).json({ error: String(e?.message || e) }); } +}); + +// ---------- Helper ---------- +function formatAvatar(u) { + if (u.avatar_image) { + return { + type: 'image', + url: u.avatar_image, + focus: { x: u.avatar_focus_x ?? 0.5, y: u.avatar_focus_y ?? 0.5 }, + zoom: u.avatar_zoom ?? 1, + emoji: u.avatar || '🐂', + }; + } + return { type: 'emoji', value: u.avatar || '🐂' }; +} +function publicUser(u) { + if (!u) return null; + return { id: u.id, username: u.username, balance: round(u.balance), avatar: formatAvatar(u) }; +} +function parseFocus(val, fallback = 0.5) { + const n = Number(val); + return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback; +} +function parseZoom(val) { + const n = Number(val); + return Number.isFinite(n) ? Math.min(4, Math.max(1, n)) : 1; +} +function avatarFilePath(userId) { + return path.join(config.UPLOADS_DIR, `${userId}.webp`); +} +function avatarPublicPath(userId) { + return `/uploads/avatars/${userId}.webp`; +} +async function saveAvatarImage(userId, buffer) { + const out = avatarFilePath(userId); + await sharp(buffer) + .rotate() + .resize(512, 512, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 85 }) + .toFile(out); + return avatarPublicPath(userId); +} +function deleteAvatarFile(userId) { + const p = avatarFilePath(userId); + if (fs.existsSync(p)) fs.unlinkSync(p); +} + +const VALID_FRAMES = new Set(['gallery', 'banner', 'card', 'avatar']); + +function formatUpload(row) { + if (!row) return null; + return { + id: row.id, + url: row.url, + frame: row.frame, + focus: { x: row.focus_x ?? 0.5, y: row.focus_y ?? 0.5 }, + zoom: row.zoom ?? 1, + createdAt: row.created_at, + }; +} +function uploadFilePath(id) { + return path.join(config.FILES_DIR, `${id}.webp`); +} +function uploadPublicPath(id) { + return `/uploads/files/${id}.webp`; +} +async function saveUploadImage(id, buffer, frame) { + const maxW = frame === 'banner' ? 1200 : frame === 'card' ? 600 : 800; + const maxH = frame === 'banner' ? 400 : frame === 'card' ? 900 : 800; + await sharp(buffer) + .rotate() + .resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 85 }) + .toFile(uploadFilePath(id)); + return uploadPublicPath(id); +} +function deleteUploadFile(id) { + const p = uploadFilePath(id); + if (fs.existsSync(p)) fs.unlinkSync(p); +} +function parseFrame(val) { + const f = String(val || 'gallery'); + return VALID_FRAMES.has(f) ? f : 'gallery'; +} + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ok = /^image\/(jpeg|png|webp|gif)$/.test(file.mimetype); + cb(ok ? null : new Error('Nur JPEG, PNG, WebP oder GIF'), ok); + }, +}); +function round(n, d = 2) { const f = Math.pow(10, d); return Math.round(n * f) / f; } +const ok = (res, data) => res.json({ ok: true, ...data }); +const fail = (res, msg, code = 400) => res.status(code).json({ ok: false, error: msg }); + +// ========== AUTH ========== +app.post('/api/register', async (req, res) => { + try { + const u = await registerUser(req.body.username, req.body.password); + const token = signToken(u); + setAuthCookie(res, token); + pushFeed('join', `🎉 ${u.username} ist PlayBull beigetreten!`); + ok(res, { user: publicUser(u) }); + } catch (e) { fail(res, e.message); } +}); + +app.post('/api/login', async (req, res) => { + try { + const u = await loginUser(req.body.username, req.body.password); + const token = signToken(u); + setAuthCookie(res, token); + ok(res, { user: publicUser(u) }); + } catch (e) { fail(res, e.message, 401); } +}); + +app.post('/api/logout', (req, res) => { clearAuthCookie(res); ok(res, {}); }); + +app.get('/api/me', (req, res) => { + ok(res, { user: publicUser(req.user) }); +}); + +// ========== MARKT (oeffentlich) ========== +app.get('/api/market', (req, res) => { + ok(res, { assets: listAssets() }); +}); + +app.get('/api/market/:symbol/history', (req, res) => { + const sym = req.params.symbol.toUpperCase(); + if (!getAsset(sym)) return fail(res, 'Unbekanntes Symbol', 404); + ok(res, { symbol: sym, history: getHistory(sym) }); +}); + +// ========== TRADING (Login noetig) ========== +app.get('/api/portfolio', requireAuth, (req, res) => { + ok(res, { portfolio: getPortfolio(req.user.id) }); +}); + +app.post('/api/trade', requireAuth, (req, res) => { + const { symbol, side, qty } = req.body; + if (!['buy', 'sell'].includes(side)) return fail(res, 'side muss buy/sell sein'); + const r = executeTrade(req.user.id, String(symbol).toUpperCase(), side, qty); + if (!r.ok) return fail(res, r.error); + const a = getAsset(String(symbol).toUpperCase()); + pushFeed('trade', `${req.user.username} ${side === 'buy' ? 'kauft' : 'verkauft'} ${round(qty,4)} ${a?.emoji||''}${symbol.toUpperCase()} @ ${r.price}`); + io.emit('portfolio_dirty', { userId: req.user.id }); + ok(res, r); +}); + +app.get('/api/trades', requireAuth, (req, res) => { + ok(res, { trades: Trades.forUser(req.user.id) }); +}); + +// ========== MANIPULATION (jeder eingeloggte User - Spassmodus) ========== +app.post('/api/manipulate', requireAuth, (req, res) => { + const { symbol, action, value } = req.body; + const r = manipulate(String(symbol).toUpperCase(), action, Number(value), req.user.username); + if (!r.ok) return fail(res, r.error); + const verb = { pump: '🚀 pumpt', dump: '📉 dumpt', set: '✏️ setzt', halt: '⏸️ pausiert', resume: '▶️ aktiviert', reset: '♻️ resettet' }[action] || action; + pushFeed('manip', `${req.user.username} ${verb} ${symbol.toUpperCase()}`); + ok(res, r); +}); + +// ========== SETTINGS / ADMIN (Spassmodus: jeder darf alles) ========== +app.get('/api/users', requireAuth, (req, res) => { + ok(res, { users: Users.all().map(u => ({ ...u, avatar: formatAvatar(u) })) }); +}); + +// Geld bearbeiten - bei sich selbst oder anderen +app.post('/api/admin/balance', requireAuth, (req, res) => { + const { userId, mode, amount } = req.body; // mode: 'set' | 'add' + const target = Users.byId(Number(userId)); + if (!target) return fail(res, 'User nicht gefunden', 404); + const amt = Number(amount); + if (!Number.isFinite(amt)) return fail(res, 'Betrag ungueltig'); + let nb; + if (mode === 'set') { Users.setBalance(target.id, amt); nb = amt; } + else { nb = Users.addBalance(target.id, amt); } + pushFeed('admin', `${req.user.username} ${mode === 'set' ? 'setzt' : 'aendert'} Guthaben von ${target.username} → ${round(nb)} 🪙`); + io.emit('balance_changed', { userId: target.id, balance: round(nb) }); + ok(res, { userId: target.id, balance: round(nb) }); +}); + +app.post('/api/settings/avatar', requireAuth, (req, res) => { + const avatar = String(req.body.avatar || '🐂').slice(0, 4); + Users.setAvatar(req.user.id, avatar); + ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) }); +}); + +app.post('/api/settings/avatar/image', requireAuth, (req, res, next) => { + upload.single('file')(req, res, (err) => { + if (err) return fail(res, err.message || 'Upload fehlgeschlagen'); + next(); + }); +}, async (req, res) => { + try { + if (!req.file) return fail(res, 'Keine Bilddatei'); + const focusX = parseFocus(req.body.focusX); + const focusY = parseFocus(req.body.focusY); + const zoom = parseZoom(req.body.zoom); + deleteAvatarFile(req.user.id); + const url = await saveAvatarImage(req.user.id, req.file.buffer); + Users.setAvatarImage(req.user.id, url, focusX, focusY, zoom); + ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) }); + } catch (e) { + fail(res, e.message || 'Upload fehlgeschlagen'); + } +}); + +app.patch('/api/settings/avatar/image/position', requireAuth, (req, res) => { + const u = Users.byId(req.user.id); + if (!u?.avatar_image) return fail(res, 'Kein Profilbild vorhanden', 404); + const focusX = parseFocus(req.body.focusX); + const focusY = parseFocus(req.body.focusY); + const zoom = parseZoom(req.body.zoom); + Users.setAvatarPosition(req.user.id, focusX, focusY, zoom); + ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) }); +}); + +app.delete('/api/settings/avatar/image', requireAuth, (req, res) => { + deleteAvatarFile(req.user.id); + Users.clearAvatarImage(req.user.id); + ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) }); +}); + +// ========== UPLOADS (Galerie) ========== +app.get('/api/uploads', requireAuth, (req, res) => { + ok(res, { uploads: Uploads.forUser(req.user.id).map(formatUpload) }); +}); + +app.post('/api/uploads', requireAuth, (req, res, next) => { + upload.single('file')(req, res, (err) => { + if (err) return fail(res, err.message || 'Upload fehlgeschlagen'); + next(); + }); +}, async (req, res) => { + let row = null; + try { + if (!req.file) return fail(res, 'Keine Bilddatei'); + const frame = parseFrame(req.body.frame); + const focusX = parseFocus(req.body.focusX); + const focusY = parseFocus(req.body.focusY); + const zoom = parseZoom(req.body.zoom); + row = Uploads.create(req.user.id, frame, focusX, focusY, zoom); + const url = await saveUploadImage(row.id, req.file.buffer, frame); + Uploads.setUrl(row.id, url); + ok(res, { upload: formatUpload(Uploads.byId(row.id)) }); + } catch (e) { + if (row) { deleteUploadFile(row.id); Uploads.remove(row.id); } + fail(res, e.message || 'Upload fehlgeschlagen'); + } +}); + +app.patch('/api/uploads/:id/position', requireAuth, (req, res) => { + const row = Uploads.byId(+req.params.id); + if (!row || row.user_id !== req.user.id) return fail(res, 'Upload nicht gefunden', 404); + const focusX = parseFocus(req.body.focusX); + const focusY = parseFocus(req.body.focusY); + const zoom = parseZoom(req.body.zoom); + Uploads.setPosition(row.id, focusX, focusY, zoom); + ok(res, { upload: formatUpload(Uploads.byId(row.id)) }); +}); + +app.delete('/api/uploads/:id', requireAuth, (req, res) => { + const row = Uploads.byId(+req.params.id); + if (!row || row.user_id !== req.user.id) return fail(res, 'Upload nicht gefunden', 404); + deleteUploadFile(row.id); + Uploads.remove(row.id); + ok(res, {}); +}); + +// ========== FEED / LEADERBOARD ========== +app.get('/api/feed', (req, res) => { ok(res, { feed: Feed.recent(30) }); }); +app.get('/api/leaderboard', (req, res) => { + const users = Users.all().slice(0, 20).map(u => { + const p = getPortfolio(u.id); + return { username: u.username, avatar: formatAvatar(u), netWorth: p ? p.netWorth : u.balance }; + }).sort((a, b) => b.netWorth - a.netWorth); + ok(res, { leaderboard: users }); +}); + +// ========== GAMES (Login noetig) ========== +const gameRoute = (fn) => (req, res) => { + const r = fn(req.user.id, req.body); + if (!r || !r.ok) return fail(res, r?.error || 'Fehler'); + ok(res, r); +}; + +app.post('/api/games/chicken/start', requireAuth, gameRoute((id, b) => G.chickenStart(id, b.bet, b.difficulty))); +app.post('/api/games/chicken/step', requireAuth, gameRoute((id, b) => G.chickenStep(id, b.roundId))); +app.post('/api/games/chicken/cashout', requireAuth, gameRoute((id, b) => G.chickenCashout(id, b.roundId))); + +app.post('/api/games/mines/start', requireAuth, gameRoute((id, b) => G.minesStart(id, b.bet, b.bombs))); +app.post('/api/games/mines/reveal', requireAuth, gameRoute((id, b) => G.minesReveal(id, b.roundId, b.index))); +app.post('/api/games/mines/cashout', requireAuth, gameRoute((id, b) => G.minesCashout(id, b.roundId))); + +app.post('/api/games/coinflip', requireAuth, gameRoute((id, b) => G.coinflip(id, b.bet, b.choice))); +app.post('/api/games/dice', requireAuth, gameRoute((id, b) => G.dice(id, b.bet, b.target, b.direction))); + +app.post('/api/games/crash/start', requireAuth, gameRoute((id, b) => G.crashStart(id, b.bet))); +app.post('/api/games/crash/status', requireAuth, gameRoute((id, b) => G.crashStatus(id, b.roundId))); +app.post('/api/games/crash/cashout', requireAuth, gameRoute((id, b) => G.crashCashout(id, b.roundId))); + +app.get('/api/games/history', requireAuth, (req, res) => { + ok(res, { history: Games.forUser(req.user.id) }); +}); + +// ---------- Static Frontend ---------- +app.use('/uploads/avatars', express.static(config.UPLOADS_DIR)); +app.use('/uploads/files', express.static(config.FILES_DIR)); +app.use(express.static(config.PUBLIC_DIR)); +app.get('*', (req, res) => res.sendFile(path.join(config.PUBLIC_DIR, 'index.html'))); + +// ---------- Socket.IO ---------- +io.on('connection', (socket) => { + socket.emit('hello', { assets: listAssets(), feed: Feed.recent(20) }); +}); + +// ---------- Start ---------- +initMarket(io).then(() => { + server.listen(config.PORT, () => { + console.log(`\n 🐂 PlayBull laeuft auf http://localhost:${config.PORT}\n`); + console.log(` Finnhub-Key: ${config.FINNHUB_API_KEY ? 'aktiv (echte Aktienkurse)' : 'fehlt (nur Simulation)'}`); + }); +}); diff --git a/server/market.js b/server/market.js new file mode 100644 index 0000000..03f28f3 --- /dev/null +++ b/server/market.js @@ -0,0 +1,184 @@ +import { config } from './config.js'; + +// Definierte handelbare Assets. emoji + sector nur fuer die UI. +const ASSETS = [ + { symbol: 'AAPL', name: 'Apple', emoji: '🍎', sector: 'Tech', type: 'stock', fallback: 195 }, + { symbol: 'TSLA', name: 'Tesla', emoji: '🚗', sector: 'Auto', type: 'stock', fallback: 250 }, + { symbol: 'NVDA', name: 'Nvidia', emoji: '🎮', sector: 'Chips', type: 'stock', fallback: 120 }, + { symbol: 'AMZN', name: 'Amazon', emoji: '📦', sector: 'Retail', type: 'stock', fallback: 185 }, + { symbol: 'MSFT', name: 'Microsoft', emoji: '🪟', sector: 'Tech', type: 'stock', fallback: 420 }, + { symbol: 'GOOGL', name: 'Alphabet', emoji: '🔍', sector: 'Tech', type: 'stock', fallback: 175 }, + { symbol: 'META', name: 'Meta', emoji: '👓', sector: 'Social', type: 'stock', fallback: 500 }, + { symbol: 'AMD', name: 'AMD', emoji: '⚡', sector: 'Chips', type: 'stock', fallback: 160 }, + { symbol: 'NFLX', name: 'Netflix', emoji: '🎬', sector: 'Media', type: 'stock', fallback: 650 }, + { symbol: 'GME', name: 'GameStop', emoji: '🕹️', sector: 'Meme', type: 'stock', fallback: 25 }, + // Krypto + Meme (simuliert, hohe Volatilitaet) + { symbol: 'BTC', name: 'Bitcoin', emoji: '₿', sector: 'Krypto', type: 'crypto', fallback: 67000, vol: 2.2 }, + { symbol: 'ETH', name: 'Ethereum', emoji: '💎', sector: 'Krypto', type: 'crypto', fallback: 3500, vol: 2.4 }, + { symbol: 'DOGE', name: 'Dogecoin', emoji: '🐕', sector: 'Meme', type: 'crypto', fallback: 0.16, vol: 4 }, + { symbol: 'BULL', name: 'PlayBull Coin',emoji: '🐂', sector: 'Haus', type: 'crypto', fallback: 1.00, vol: 5 }, +]; + +const HISTORY_LEN = 120; + +/** @type {Map} */ +const market = new Map(); +let io = null; + +function rnd(min, max) { return min + Math.random() * (max - min); } + +function initAsset(a) { + const price = a.fallback; + return { + symbol: a.symbol, + name: a.name, + emoji: a.emoji, + sector: a.sector, + type: a.type, + price, + prevClose: price, + open: price, + high: price, + low: price, + change: 0, + changePct: 0, + momentum: 0, // treibt die naechsten Ticks (Manipulation haengt sich hier rein) + baseVol: a.vol || (a.type === 'crypto' ? 2 : 1), // Grund-Volatilitaet in % + halted: false, + history: Array.from({ length: HISTORY_LEN }, () => ({ t: Date.now(), p: price })), + }; +} + +async function fetchRealPrice(symbol) { + if (!config.FINNHUB_API_KEY) return null; + try { + const url = `https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(symbol)}&token=${config.FINNHUB_API_KEY}`; + const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); + if (!res.ok) return null; + const j = await res.json(); + // c = current, pc = previous close + if (j && typeof j.c === 'number' && j.c > 0) { + return { price: j.c, prevClose: j.pc > 0 ? j.pc : j.c, high: j.h || j.c, low: j.l || j.c, open: j.o || j.c }; + } + } catch { /* ignore -> fallback */ } + return null; +} + +export async function initMarket(socketServer) { + io = socketServer; + for (const a of ASSETS) market.set(a.symbol, initAsset(a)); + + // Echte Startkurse fuer Aktien von Finnhub laden (Krypto bleibt simuliert) + const stockSymbols = ASSETS.filter(a => a.type === 'stock').map(a => a.symbol); + await Promise.all(stockSymbols.map(async (sym) => { + const real = await fetchRealPrice(sym); + if (real) { + const m = market.get(sym); + m.price = real.price; + m.prevClose = real.prevClose; + m.open = real.open; + m.high = real.high; + m.low = real.low; + m.history = Array.from({ length: HISTORY_LEN }, () => ({ t: Date.now(), p: real.price })); + recalc(m); + } + })); + + // Live-Tick alle 1s + setInterval(tick, 1000); + return market; +} + +function recalc(m) { + m.change = m.price - m.prevClose; + m.changePct = m.prevClose ? (m.change / m.prevClose) * 100 : 0; + if (m.price > m.high) m.high = m.price; + if (m.price < m.low) m.low = m.price; +} + +function tick() { + const payload = []; + for (const m of market.values()) { + if (!m.halted) { + // Random walk + Momentum (Manipulation) + leichte Rueckkehr zur Mitte + const noise = rnd(-1, 1) * m.baseVol * 0.4; // % pro Tick + const drift = m.momentum; // % pro Tick aus Manipulation + const pctMove = (noise + drift) / 100; + m.price = Math.max(0.0001, m.price * (1 + pctMove)); + m.momentum *= 0.85; // Momentum klingt ab + if (Math.abs(m.momentum) < 0.0005) m.momentum = 0; + recalc(m); + m.history.push({ t: Date.now(), p: m.price }); + if (m.history.length > HISTORY_LEN) m.history.shift(); + } + payload.push({ + s: m.symbol, + p: round(m.price), + c: round(m.change), + cp: round(m.changePct, 2), + h: round(m.halted ? 1 : 0), + }); + } + if (io) io.emit('prices', { t: Date.now(), data: payload }); +} + +function round(n, d = 4) { + const f = Math.pow(10, d); + return Math.round(n * f) / f; +} + +// ---- Oeffentliche API ---- +export function getAsset(symbol) { return market.get(symbol); } +export function getPrice(symbol) { const m = market.get(symbol); return m ? m.price : null; } + +export function listAssets() { + return [...market.values()].map(m => ({ + symbol: m.symbol, name: m.name, emoji: m.emoji, sector: m.sector, type: m.type, + price: round(m.price), change: round(m.change), changePct: round(m.changePct, 2), + high: round(m.high), low: round(m.low), prevClose: round(m.prevClose), halted: m.halted, + })); +} + +export function getHistory(symbol) { + const m = market.get(symbol); + if (!m) return []; + return m.history.map(h => ({ t: h.t, p: round(h.p) })); +} + +/** + * Globale Markt-Manipulation. Wirkt fuer ALLE Nutzer (geteilter Live-Status). + * action: 'pump' | 'dump' | 'set' | 'halt' | 'resume' | 'reset' + */ +export function manipulate(symbol, action, value, byUser = 'Jemand') { + const m = market.get(symbol); + if (!m) return { ok: false, error: 'Unbekanntes Symbol' }; + switch (action) { + case 'pump': + m.momentum += (value && value > 0 ? value : 2); // % Boost pro Tick + break; + case 'dump': + m.momentum -= (value && value > 0 ? value : 2); + break; + case 'set': { + const v = Number(value); + if (!(v > 0)) return { ok: false, error: 'Ungueltiger Preis' }; + m.price = v; + recalc(m); + m.history.push({ t: Date.now(), p: m.price }); + break; + } + case 'halt': m.halted = true; break; + case 'resume': m.halted = false; break; + case 'reset': + m.momentum = 0; m.halted = false; + m.price = m.prevClose; + recalc(m); + break; + default: + return { ok: false, error: 'Unbekannte Aktion' }; + } + if (io) io.emit('manip', { symbol, action, by: byUser, price: round(m.price) }); + return { ok: true, symbol, price: round(m.price), momentum: m.momentum, halted: m.halted }; +} + +export { ASSETS }; diff --git a/server/realtime.js b/server/realtime.js new file mode 100644 index 0000000..33a4a39 --- /dev/null +++ b/server/realtime.js @@ -0,0 +1,15 @@ +import { Feed } from './db.js'; + +let io = null; +export function setIO(server) { io = server; } +export function getIO() { return io; } + +export function broadcast(event, payload) { + if (io) io.emit(event, payload); +} + +// Globaler Aktivitaets-Feed (Wins, Trades, Manipulationen) fuer alle sichtbar. +export function pushFeed(kind, text) { + Feed.add(kind, text); + broadcast('feed', { kind, text, ts: Date.now() }); +} diff --git a/server/trading.js b/server/trading.js new file mode 100644 index 0000000..397c907 --- /dev/null +++ b/server/trading.js @@ -0,0 +1,98 @@ +import { Users, Holdings, Trades } from './db.js'; +import { getAsset } from './market.js'; + +// Position-Netting: kombiniert bestehende Position mit neuem Trade. +function applyPosition(q0, a0, delta, price) { + if (q0 === 0 || Math.sign(q0) === Math.sign(delta)) { + const newQ = q0 + delta; + const newA = (Math.abs(q0) * a0 + Math.abs(delta) * price) / (Math.abs(q0) + Math.abs(delta)); + return { q: newQ, a: newA }; + } + // Gegenrichtung + if (Math.abs(delta) < Math.abs(q0)) return { q: q0 + delta, a: a0 }; + if (Math.abs(delta) === Math.abs(q0)) return { q: 0, a: 0 }; + return { q: q0 + delta, a: price }; // Flip +} + +/** + * side: 'buy' | 'sell'. qty > 0. 'sell' kann Short eroeffnen. + */ +export function executeTrade(userId, symbol, side, qty) { + const asset = getAsset(symbol); + if (!asset) return { ok: false, error: 'Unbekanntes Symbol' }; + if (asset.halted) return { ok: false, error: `${symbol} ist pausiert (halted)` }; + qty = Number(qty); + if (!(qty > 0)) return { ok: false, error: 'Menge muss > 0 sein' }; + + const user = Users.byId(userId); + if (!user) return { ok: false, error: 'User nicht gefunden' }; + + const price = asset.price; + const notional = qty * price; + const delta = side === 'buy' ? qty : -qty; + + const h = Holdings.get(userId, symbol) || { quantity: 0, avg_price: 0 }; + + let newBalance; + if (side === 'buy') { + if (user.balance < notional) return { ok: false, error: 'Nicht genug Guthaben' }; + newBalance = user.balance - notional; + } else { + // Verkauf bringt Cash. Short-Eroeffnung erlaubt, aber Sicherheit pruefen. + const result = applyPosition(h.quantity, h.avg_price, delta, price); + if (result.q < 0 && user.balance < Math.abs(result.q) * price * 0.5) { + return { ok: false, error: 'Nicht genug Sicherheit fuer Short (50% Margin)' }; + } + newBalance = user.balance + notional; + } + + const pos = applyPosition(h.quantity, h.avg_price, delta, price); + Users.setBalance(userId, newBalance); + if (Math.abs(pos.q) < 1e-9) Holdings.remove(userId, symbol); + else Holdings.upsert(userId, symbol, pos.q, pos.a); + Trades.add(userId, symbol, side, qty, price); + + return { + ok: true, + symbol, side, qty, price: round(price), + balance: round(newBalance), + position: { quantity: round(pos.q, 6), avgPrice: round(pos.a) }, + }; +} + +export function getPortfolio(userId) { + const user = Users.byId(userId); + if (!user) return null; + const holdings = Holdings.forUser(userId).map(h => { + const a = getAsset(h.symbol); + const price = a ? a.price : h.avg_price; + const value = h.quantity * price; + const cost = h.quantity * h.avg_price; + const pnl = value - cost; // funktioniert fuer Long & Short + const pnlPct = cost !== 0 ? (pnl / Math.abs(cost)) * 100 : 0; + return { + symbol: h.symbol, + name: a?.name || h.symbol, + emoji: a?.emoji || '', + quantity: round(h.quantity, 6), + avgPrice: round(h.avg_price), + price: round(price), + value: round(value), + pnl: round(pnl), + pnlPct: round(pnlPct, 2), + side: h.quantity >= 0 ? 'long' : 'short', + }; + }); + const holdingsValue = holdings.reduce((s, h) => s + h.value, 0); + return { + balance: round(user.balance), + holdingsValue: round(holdingsValue), + netWorth: round(user.balance + holdingsValue), + holdings, + }; +} + +function round(n, d = 2) { + const f = Math.pow(10, d); + return Math.round(n * f) / f; +}