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 <cursoragent@cursor.com>
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# PlayBull — kopiere nach .env und fuelle aus
|
||||
PORT=3080
|
||||
JWT_SECRET=
|
||||
FINNHUB_API_KEY=
|
||||
START_BALANCE=10000
|
||||
TRUST_PROXY=1
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.cursor/
|
||||
.env
|
||||
deploy/env.homelab
|
||||
deploy/homelab.credentials
|
||||
80
README.md
Normal file
80
README.md
Normal file
@@ -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
|
||||
```
|
||||
21
deploy/caddy-trilogyhub.snippet
Normal file
21
deploy/caddy-trilogyhub.snippet
Normal file
@@ -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
|
||||
}
|
||||
74
deploy/cloudflare-ddns-update.sh
Normal file
74
deploy/cloudflare-ddns-update.sh
Normal file
@@ -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"
|
||||
26
deploy/cloudflared/config.yml
Normal file
26
deploy/cloudflared/config.yml
Normal file
@@ -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
|
||||
114
deploy/connect-trilogyhub.sh
Normal file
114
deploy/connect-trilogyhub.sh
Normal file
@@ -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 <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${DOMAIN} www.${DOMAIN};
|
||||
|
||||
client_max_body_size 30m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:${PORT};
|
||||
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;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
if [[ -f "${APP_DIR}/deploy/nginx/bestewebsite-lan.conf" ]]; then
|
||||
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
|
||||
fi
|
||||
|
||||
ln -sf /etc/nginx/sites-available/trilogyhub /etc/nginx/sites-enabled/trilogyhub
|
||||
# alte DuckDNS-Sites nicht loeschen — nur Redirect optional
|
||||
if [[ -f /etc/nginx/sites-available/bestewebsite ]]; then
|
||||
cat > /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)"
|
||||
15
deploy/duckdns-update.bat
Normal file
15
deploy/duckdns-update.bat
Normal file
@@ -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%'"
|
||||
32
deploy/duckdns-update.sh
Normal file
32
deploy/duckdns-update.sh
Normal file
@@ -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
|
||||
33
deploy/env.homelab.example
Normal file
33
deploy/env.homelab.example
Normal file
@@ -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=
|
||||
16
deploy/finish-on-server.sh
Normal file
16
deploy/finish-on-server.sh
Normal file
@@ -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
|
||||
135
deploy/fix-domain.sh
Normal file
135
deploy/fix-domain.sh
Normal file
@@ -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 <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${NEW_DOMAIN};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
ln -sf /etc/nginx/sites-available/bestewebsite /etc/nginx/sites-enabled/bestewebsite
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
# Let's Encrypt Zertifikat
|
||||
echo "==> 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 <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${OLD_DOMAIN};
|
||||
return 301 https://${NEW_DOMAIN}\$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name ${OLD_DOMAIN};
|
||||
ssl_certificate /etc/letsencrypt/live/${OLD_DOMAIN}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/${OLD_DOMAIN}/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
return 301 https://${NEW_DOMAIN}\$request_uri;
|
||||
}
|
||||
EOF
|
||||
if [[ -f "/etc/letsencrypt/live/${OLD_DOMAIN}/fullchain.pem" ]]; then
|
||||
ln -sf /etc/nginx/sites-available/bestewebsite-legacy /etc/nginx/sites-enabled/bestewebsite-legacy
|
||||
nginx -t && systemctl reload nginx
|
||||
echo " Redirect ${OLD_DOMAIN} -> ${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"
|
||||
33
deploy/homelab-exec.ps1
Normal file
33
deploy/homelab-exec.ps1
Normal file
@@ -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
|
||||
}
|
||||
13
deploy/homelab-git-check.ps1
Normal file
13
deploy/homelab-git-check.ps1
Normal file
@@ -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
|
||||
}
|
||||
130
deploy/homelab-lib.ps1
Normal file
130
deploy/homelab-lib.ps1
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
11
deploy/homelab-safe-pull.sh
Normal file
11
deploy/homelab-safe-pull.sh
Normal file
@@ -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
|
||||
13
deploy/homelab.credentials.example
Normal file
13
deploy/homelab.credentials.example
Normal file
@@ -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
|
||||
212
deploy/install-homelab.sh
Normal file
212
deploy/install-homelab.sh
Normal file
@@ -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"
|
||||
21
deploy/nginx/bestewebsite-lan.conf
Normal file
21
deploy/nginx/bestewebsite-lan.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
35
deploy/nginx/bestewebsite.conf
Normal file
35
deploy/nginx/bestewebsite.conf
Normal file
@@ -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;
|
||||
}
|
||||
43
deploy/nginx/playbull.conf
Normal file
43
deploy/nginx/playbull.conf
Normal file
@@ -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;
|
||||
}
|
||||
33
deploy/nginx/trilogyhub.conf
Normal file
33
deploy/nginx/trilogyhub.conf
Normal file
@@ -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;
|
||||
}
|
||||
139
deploy/push-homelab.ps1
Normal file
139
deploy/push-homelab.ps1
Normal file
@@ -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"
|
||||
28
deploy/quick-start-homelab.sh
Normal file
28
deploy/quick-start-homelab.sh
Normal file
@@ -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 ""
|
||||
4
deploy/setup-all.bat
Normal file
4
deploy/setup-all.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0.."
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-all.ps1"
|
||||
pause
|
||||
11
deploy/setup-all.ps1
Normal file
11
deploy/setup-all.ps1
Normal file
@@ -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/=.*/=***/'"
|
||||
32
deploy/sync-user.sh
Normal file
32
deploy/sync-user.sh
Normal file
@@ -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 &"
|
||||
12
deploy/systemd/cloudflare-ddns-update.service
Normal file
12
deploy/systemd/cloudflare-ddns-update.service
Normal file
@@ -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
|
||||
10
deploy/systemd/cloudflare-ddns-update.timer
Normal file
10
deploy/systemd/cloudflare-ddns-update.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Cloudflare DDNS alle 5 Minuten pruefen
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
17
deploy/systemd/cloudflared-playbull.service
Normal file
17
deploy/systemd/cloudflared-playbull.service
Normal file
@@ -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
|
||||
12
deploy/systemd/duckdns-update.service
Normal file
12
deploy/systemd/duckdns-update.service
Normal file
@@ -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
|
||||
11
deploy/systemd/duckdns-update.timer
Normal file
11
deploy/systemd/duckdns-update.timer
Normal file
@@ -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
|
||||
18
deploy/systemd/playbull.service
Normal file
18
deploy/systemd/playbull.service
Normal file
@@ -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
|
||||
5
deploy/test-ports.ps1
Normal file
5
deploy/test-ports.ps1
Normal file
@@ -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)"
|
||||
}
|
||||
1939
package-lock.json
generated
Normal file
1939
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
797
public/app.js
Normal file
797
public/app.js
Normal file
@@ -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 `<div class="photo-slot" style="min-height:${minH}" data-slot="${label}" tabindex="0" role="button" aria-label="Upload ${label}">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-6 h-6"></i><span class="slot-label">${label}</span></div>
|
||||
<img class="preview" alt="${label}" /><span class="slot-hover-name">${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMike() {
|
||||
$("#mikeProducts").innerHTML = mikeProducts.map((p, i) => `
|
||||
<article class="reveal card card-gold card-hover overflow-hidden flex flex-col">
|
||||
${slotMarkup("Product Photo " + (i + 1), "190px")}
|
||||
<div class="p-5 flex flex-col flex-1">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 class="font-display font-bold text-base leading-tight">${p.name}</h3>
|
||||
<span class="badge shrink-0">${p.price}</span>
|
||||
</div>
|
||||
<p class="text-dim text-sm leading-relaxed flex-1">${p.desc}</p>
|
||||
<button class="btn btn-ghost mt-4 !min-h-0 !py-2.5 self-start">Request Info <i data-lucide="mail" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</article>`).join("");
|
||||
|
||||
$("#mikeReviews").innerHTML = mikeReviews.map((r) => `
|
||||
<figure class="reveal card card-hover p-6">
|
||||
<div class="star mb-3">★★★★★</div>
|
||||
<blockquote class="text-dim leading-relaxed">"${r.text}"</blockquote>
|
||||
<figcaption class="text-gold text-sm font-semibold mt-4">— ${r.who}</figcaption>
|
||||
</figure>`).join("");
|
||||
|
||||
$("#mikeGallery").innerHTML = Array.from({ length: 6 }, (_, i) =>
|
||||
`<div class="reveal">${slotMarkup("Customer Photo " + (i + 1), "200px")}</div>`).join("");
|
||||
restoreSlotImages($("#page-magic-mike"));
|
||||
}
|
||||
|
||||
function renderNico() {
|
||||
$("#nicoListings").innerHTML = nicoListings.map((p, i) => `
|
||||
<article class="reveal card card-gold card-hover overflow-hidden flex flex-col">
|
||||
${slotMarkup("Listing Photo " + (i + 1), "230px")}
|
||||
<div class="p-6 flex flex-col flex-1">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="badge">${p.price}</span><span class="chip">${p.size}</span>
|
||||
</div>
|
||||
<h3 class="font-display font-bold text-lg leading-tight mb-2">${p.name}</h3>
|
||||
<p class="text-dim text-sm leading-relaxed flex-1">${p.desc}</p>
|
||||
<button class="btn btn-ghost mt-5 !min-h-0 !py-2.5 self-start">Enquire Now <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</article>`).join("");
|
||||
|
||||
$("#nicoTestimonials").innerHTML = nicoTestimonials.map((r) => `
|
||||
<figure class="reveal card card-hover p-7">
|
||||
<i data-lucide="quote" class="w-7 h-7 text-gold mb-4"></i>
|
||||
<blockquote class="text-dim text-lg leading-relaxed">"${r.text}"</blockquote>
|
||||
<figcaption class="text-gold text-sm font-semibold mt-4">— ${r.who}</figcaption>
|
||||
</figure>`).join("");
|
||||
|
||||
const galleryLabels = ["Client Handshake", "Property Visit", "Signing Moment", "Property Visit 2", "Client Handshake 2", "Signing Moment 2"];
|
||||
$("#nicoGallery").innerHTML = galleryLabels.map((l) => `<div class="reveal">${slotMarkup(l, "200px")}</div>`).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 `<tr class="border-b border-soft" data-row="${i}">
|
||||
<td class="py-3 px-4 font-bold">${p.ticker}</td>
|
||||
<td class="py-3 px-4 text-dim">${p.company}</td>
|
||||
<td class="py-3 px-4 text-right">${p.shares}</td>
|
||||
<td class="py-3 px-4 text-right text-dim">${fmtEUR(p.avg)}</td>
|
||||
<td class="py-3 px-4 text-right font-semibold" data-price="${i}">${fmtEUR(p.price)}</td>
|
||||
<td class="py-3 px-4 text-right font-semibold ${cls}" data-pl="${i}">${fmtSigned(pl)}</td>
|
||||
<td class="py-3 px-4 text-right font-semibold ${cls}" data-plpct="${i}">${(plPct >= 0 ? "+" : "") + plPct.toFixed(1)}%</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderTradeLog() {
|
||||
$("#tradeLog").innerHTML = tradeLog.map((t) => {
|
||||
const isBuy = t.startsWith("BUY");
|
||||
return `<div class="flex items-center gap-3 p-4 border-b border-soft">
|
||||
<span class="chip ${isBuy ? "text-success" : "text-error"}">${isBuy ? "BUY" : "SELL"}</span>
|
||||
<span class="text-dim text-sm">${t}</span>
|
||||
</div>`;
|
||||
}).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) => `
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2"><span style="width:10px;height:10px;border-radius:3px;background:${palette[i]};display:inline-block"></span>${l}</span>
|
||||
<span class="text-faint">${((data[i] / total) * 100).toFixed(1)}%</span>
|
||||
</div>`).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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
496
public/css/styles.css
Normal file
496
public/css/styles.css
Normal file
@@ -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; }
|
||||
}
|
||||
190
public/image-editor.js
Normal file
190
public/image-editor.js
Normal file
@@ -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 = `
|
||||
<div class="img-editor-modal card card-gold">
|
||||
<div class="img-editor-head">
|
||||
<h2 class="font-display font-bold text-xl">${frame.label} positionieren</h2>
|
||||
<button type="button" class="btn btn-ghost !min-h-0 !p-2.5 img-editor-close" aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
<p class="text-dim text-sm text-center mb-5">So wird das Bild im Rahmen angezeigt — ziehen & zoomen</p>
|
||||
<div class="img-editor-preview-outer">
|
||||
<div class="img-editor-preview" id="ieFrame" style="width:${frame.width}px;height:${frame.height}px;border-radius:${frame.radius}">
|
||||
<img id="ieImg" src="${src}" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-sm font-semibold mb-2 mt-5">Zoom <span id="ieZoomVal">${state.zoom.toFixed(1)}×</span></label>
|
||||
<input type="range" id="ieZoom" class="img-editor-range" min="1" max="4" step="0.05" value="${state.zoom}" />
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="button" class="btn btn-ghost flex-1" data-act="reset">Zurücksetzen</button>
|
||||
<button type="button" class="btn btn-primary flex-1" data-act="save">Speichern</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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 };
|
||||
})();
|
||||
880
public/index.html
Normal file
880
public/index.html
Normal file
@@ -0,0 +1,880 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Trilogy Hub — Three Hustlers. One Vision.</title>
|
||||
<meta name="description" content="Trilogy Hub — the personal brand home of three business partners: Magic Tree Co., NovaTerra Immobilien, and PG Trading Management." />
|
||||
|
||||
<!-- Favicon (custom SVG: three interlocking triangles) -->
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230a0a0a'/%3E%3Cg fill='none' stroke='%23c9a84c' stroke-width='1.6' stroke-linejoin='round'%3E%3Cpath d='M16 6 L23 18 L9 18 Z'/%3E%3Cpath d='M10 14 L17 26 L3 26 Z' opacity='0.55'/%3E%3Cpath d='M22 14 L29 26 L15 26 Z' opacity='0.55'/%3E%3C/g%3E%3C/svg%3E" />
|
||||
|
||||
<!-- Fonts: Cabinet Grotesk (display) + Satoshi (body) via Fontshare -->
|
||||
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,700,800,900&f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Tailwind CSS v4 (CDN browser build) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
|
||||
<!-- GSAP + ScrollTrigger -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
||||
|
||||
<!-- Splitting.js -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/splitting/dist/splitting.css" />
|
||||
<script src="https://unpkg.com/splitting/dist/splitting.min.js"></script>
|
||||
|
||||
<!-- Lenis smooth scroll -->
|
||||
<script src="https://unpkg.com/lenis@1.1.13/dist/lenis.min.js"></script>
|
||||
|
||||
<!-- Motion.js (Framer Motion vanilla) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
|
||||
|
||||
<!-- Lucide icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
--color-ink-900: #0a0a0a;
|
||||
--color-ink-800: #111111;
|
||||
--color-ink-700: #1a1a1a;
|
||||
--color-ink-600: #242424;
|
||||
--color-gold: #c9a84c;
|
||||
--color-gold-soft: #d8bd6e;
|
||||
--color-cream: #f4f1e9;
|
||||
--color-success: #46c98b;
|
||||
--color-error: #e0625e;
|
||||
--font-display: "Cabinet Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: "Satoshi", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-2: #111111;
|
||||
--bg-3: #1a1a1a;
|
||||
--surface: #161616;
|
||||
--border: rgba(201, 168, 76, 0.18);
|
||||
--border-soft: rgba(255, 255, 255, 0.08);
|
||||
--text: #f4f1e9;
|
||||
--text-dim: #a8a39a;
|
||||
--text-faint: #6e6a62;
|
||||
--gold: #c9a84c;
|
||||
--gold-soft: #d8bd6e;
|
||||
--success: #46c98b;
|
||||
--error: #e0625e;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
html:not(.dark) {
|
||||
--bg: #f6f4ee;
|
||||
--bg-2: #efece3;
|
||||
--bg-3: #e7e3d8;
|
||||
--surface: #ffffff;
|
||||
--border: rgba(154, 124, 38, 0.28);
|
||||
--border-soft: rgba(10, 10, 10, 0.1);
|
||||
--text: #1a1712;
|
||||
--text-dim: #4f4a40;
|
||||
--text-faint: #7d7668;
|
||||
--gold: #9a7c26;
|
||||
--gold-soft: #b89537;
|
||||
}
|
||||
|
||||
* { -webkit-font-smoothing: antialiased; }
|
||||
|
||||
html { scroll-behavior: auto; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
overflow-x: hidden;
|
||||
transition: background-color 0.5s var(--ease), color 0.5s var(--ease);
|
||||
}
|
||||
|
||||
.font-display { font-family: var(--font-display); }
|
||||
|
||||
::selection { background: var(--gold); color: #0a0a0a; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 8px; border: 2px solid var(--bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--gold); }
|
||||
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-faint { color: var(--text-faint); }
|
||||
.text-gold { color: var(--gold); }
|
||||
.bg-surface { background-color: var(--surface); }
|
||||
.bg-ink2 { background-color: var(--bg-2); }
|
||||
.bg-ink3 { background-color: var(--bg-3); }
|
||||
.border-soft { border-color: var(--border-soft); }
|
||||
.border-gold { border-color: var(--border); }
|
||||
|
||||
/* Section container */
|
||||
.wrap { width: 100%; max-width: 1200px; margin-inline: auto; padding-inline: clamp(1.25rem, 4vw, 3rem); }
|
||||
|
||||
/* Pages */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
/* Eyebrow label */
|
||||
.eyebrow {
|
||||
font-size: 0.75rem; letter-spacing: 0.22em; text-transform: uppercase;
|
||||
color: var(--gold); font-weight: 700;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
min-height: 44px; padding: 0.75rem 1.4rem; border-radius: 0.7rem;
|
||||
font-weight: 700; font-size: 0.95rem; cursor: pointer;
|
||||
transition: transform 0.4s var(--ease), box-shadow 0.4s var(--ease), background-color 0.3s var(--ease), color 0.3s var(--ease);
|
||||
border: 1px solid transparent; text-decoration: none; line-height: 1;
|
||||
}
|
||||
.btn-primary { background: var(--gold); color: #0a0a0a; }
|
||||
.btn-primary:hover { transform: scale(1.03); box-shadow: 0 10px 34px -8px rgba(201,168,76,0.55); }
|
||||
.btn-ghost { background: transparent; color: var(--text); border-color: var(--border); }
|
||||
.btn-ghost:hover { transform: scale(1.03); border-color: var(--gold); box-shadow: 0 10px 30px -12px rgba(201,168,76,0.4); }
|
||||
.btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border-soft);
|
||||
border-radius: 1.1rem; transition: transform 0.45s var(--ease), box-shadow 0.45s var(--ease), border-color 0.45s var(--ease);
|
||||
}
|
||||
.card-gold { border-color: var(--border); }
|
||||
.card-hover:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 24px 60px -24px rgba(0,0,0,0.7), 0 0 0 1px var(--border);
|
||||
}
|
||||
html:not(.dark) .card-hover:hover { box-shadow: 0 24px 50px -24px rgba(0,0,0,0.25), 0 0 0 1px var(--border); }
|
||||
|
||||
/* Photo upload slot */
|
||||
.photo-slot {
|
||||
position: relative; display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column; gap: 0.6rem; cursor: pointer; overflow: hidden;
|
||||
background: var(--bg-3); border: 1.5px dashed var(--gold);
|
||||
border-radius: 1rem; color: var(--text-dim); text-align: center; padding: 1.5rem;
|
||||
transition: border-color 0.4s var(--ease), background-color 0.4s var(--ease);
|
||||
min-height: 180px;
|
||||
}
|
||||
.photo-slot:hover { border-color: var(--gold-soft); background: var(--bg-2); }
|
||||
.photo-slot:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
|
||||
.photo-slot .slot-icon { color: var(--gold); transition: transform 0.4s var(--ease); }
|
||||
.photo-slot:hover .slot-icon { transform: translateY(-3px) scale(1.08); }
|
||||
.photo-slot .slot-label { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.02em; }
|
||||
.photo-slot img.preview {
|
||||
position: absolute; max-width: none; pointer-events: none;
|
||||
opacity: 0; transition: opacity 0.6s var(--ease), filter 0.6s var(--ease);
|
||||
border-radius: inherit; z-index: 2;
|
||||
}
|
||||
.photo-slot.has-image img.preview { opacity: 1; }
|
||||
/* Subtle brightness lift on hover for uploaded images */
|
||||
.photo-slot.has-image:hover img.preview { filter: saturate(1.08) brightness(1.04); }
|
||||
/* Diagonal glossy shine that sweeps across images on hover */
|
||||
.photo-slot.has-image::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
|
||||
background: linear-gradient(115deg, transparent 30%, rgba(255,255,255,0.18) 48%, transparent 60%);
|
||||
transform: translateX(-130%); transition: transform 0.85s var(--ease); border-radius: inherit;
|
||||
}
|
||||
.photo-slot.has-image:hover::before { transform: translateX(130%); }
|
||||
/* Gentle gold ring glow when hovering an image */
|
||||
.photo-slot.has-image:hover { box-shadow: 0 22px 55px -28px rgba(0,0,0,0.85), 0 0 0 1px var(--border); }
|
||||
.photo-slot.has-image {
|
||||
border-style: solid;
|
||||
border-color: var(--border);
|
||||
padding: 0;
|
||||
}
|
||||
.photo-slot .slot-default { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; transition: opacity 0.4s var(--ease); z-index: 1; position: relative; }
|
||||
.photo-slot .slot-hover-name {
|
||||
position: absolute; inset: auto 0 0 0; padding: 0.6rem 0.9rem; z-index: 2;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.82), transparent);
|
||||
color: #fff; font-size: 0.78rem; font-weight: 600; opacity: 0;
|
||||
transform: translateY(8px); transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
|
||||
text-align: left;
|
||||
}
|
||||
.photo-slot.has-image:hover .slot-hover-name { opacity: 1; transform: translateY(0); }
|
||||
|
||||
/* Image position editor */
|
||||
.img-editor-backdrop {
|
||||
position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.82); backdrop-filter: blur(10px); padding: 1.25rem;
|
||||
}
|
||||
.img-editor-modal { width: 100%; max-width: 420px; padding: 1.5rem; max-height: 92vh; overflow-y: auto; }
|
||||
.img-editor-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||
.img-editor-preview-outer { display: flex; justify-content: center; }
|
||||
.img-editor-preview {
|
||||
position: relative; overflow: hidden; background: var(--bg-3);
|
||||
border: 2px solid var(--border); touch-action: none; cursor: grab;
|
||||
}
|
||||
.img-editor-preview:active { cursor: grabbing; }
|
||||
.img-editor-preview img { position: absolute; max-width: none; pointer-events: none; user-select: none; }
|
||||
.img-editor-range { width: 100%; accent-color: var(--gold); height: 6px; cursor: pointer; }
|
||||
|
||||
/* Nav */
|
||||
.nav-link {
|
||||
position: relative; font-weight: 500; color: var(--text-dim); font-size: 0.95rem;
|
||||
padding: 0.4rem 0; transition: color 0.3s var(--ease); cursor: pointer; background: none; border: none;
|
||||
}
|
||||
.nav-link:hover, .nav-link.active { color: var(--text); }
|
||||
.nav-link::after {
|
||||
content: ""; position: absolute; left: 0; bottom: -2px; height: 2px; width: 0;
|
||||
background: var(--gold); transition: width 0.4s var(--ease);
|
||||
}
|
||||
.nav-link:hover::after, .nav-link.active::after { width: 100%; }
|
||||
|
||||
/* Glass header */
|
||||
.glass {
|
||||
background: color-mix(in srgb, var(--bg) 72%, transparent);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* Animated reveal base (GSAP will animate). Fallback visible if no JS. */
|
||||
.reveal { opacity: 0; transform: translateY(30px); }
|
||||
.js-ready .reveal { will-change: opacity, transform; }
|
||||
|
||||
/* Splitting char stagger handled via GSAP, keep visible baseline */
|
||||
.split-heading .char { display: inline-block; }
|
||||
|
||||
/* Hero gradient overlays */
|
||||
.overlay-green { background: linear-gradient(135deg, rgba(8,30,18,0.92), rgba(10,10,10,0.7)); }
|
||||
.overlay-navy { background: linear-gradient(135deg, rgba(10,18,38,0.92), rgba(10,10,10,0.7)); }
|
||||
.overlay-dark { background: linear-gradient(135deg, rgba(10,10,10,0.9), rgba(10,10,10,0.55)); }
|
||||
|
||||
/* Price badge */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.7rem;
|
||||
border-radius: 0.5rem; font-weight: 800; font-size: 0.85rem;
|
||||
background: rgba(201,168,76,0.12); color: var(--gold); border: 1px solid var(--border);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px; font-size: 0.78rem; font-weight: 600;
|
||||
background: var(--bg-3); color: var(--text-dim); border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* LIVE pulsing dot */
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--success); position: relative; }
|
||||
.live-dot::after {
|
||||
content: ""; position: absolute; inset: 0; border-radius: 999px; background: var(--success);
|
||||
animation: ping 1.6s var(--ease) infinite;
|
||||
}
|
||||
@keyframes ping { 0% { transform: scale(1); opacity: 0.7; } 80%,100% { transform: scale(2.6); opacity: 0; } }
|
||||
|
||||
/* Price flash */
|
||||
.flash-up { animation: flashUp 0.8s var(--ease); }
|
||||
.flash-down { animation: flashDown 0.8s var(--ease); }
|
||||
@keyframes flashUp { 0% { background: rgba(70,201,139,0.28); } 100% { background: transparent; } }
|
||||
@keyframes flashDown { 0% { background: rgba(224,98,94,0.28); } 100% { background: transparent; } }
|
||||
|
||||
.text-success { color: var(--success); }
|
||||
.text-error { color: var(--error); }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.78); backdrop-filter: blur(8px); padding: 1.25rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%; padding: 0.85rem 1rem; border-radius: 0.7rem; background: var(--bg-3);
|
||||
border: 1px solid var(--border-soft); color: var(--text); font-size: 0.95rem; min-height: 44px;
|
||||
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
|
||||
}
|
||||
.input:focus { outline: none; border-color: var(--gold); box-shadow: 0 0 0 3px rgba(201,168,76,0.18); }
|
||||
|
||||
/* Masonry-ish reviews */
|
||||
.masonry { columns: 1; column-gap: 1.25rem; }
|
||||
@media (min-width: 640px) { .masonry { columns: 2; } }
|
||||
@media (min-width: 1024px) { .masonry { columns: 3; } }
|
||||
.masonry > * { break-inside: avoid; margin-bottom: 1.25rem; }
|
||||
|
||||
.divider { height: 1px; background: var(--border-soft); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
|
||||
.reveal { opacity: 1 !important; transform: none !important; }
|
||||
}
|
||||
|
||||
.star { color: var(--gold); letter-spacing: 0.05em; }
|
||||
|
||||
/* ===================== ENHANCED EFFECTS ===================== */
|
||||
|
||||
/* Scroll progress bar (top of viewport) */
|
||||
.scroll-progress {
|
||||
position: fixed; top: 0; left: 0; height: 2.5px; width: 0%;
|
||||
z-index: 60; transform-origin: left center;
|
||||
background: linear-gradient(90deg, var(--gold), var(--gold-soft), var(--gold));
|
||||
box-shadow: 0 0 12px rgba(201,168,76,0.6); will-change: width;
|
||||
}
|
||||
|
||||
/* 3D tilt for cards — JS sets --rx/--ry, CSS handles the smoothing */
|
||||
.card-hover {
|
||||
transform-style: preserve-3d;
|
||||
transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) translateY(0) translateZ(0);
|
||||
transition: transform 0.5s var(--ease), box-shadow 0.45s var(--ease), border-color 0.45s var(--ease);
|
||||
}
|
||||
.card-hover.is-tilting { transition: box-shadow 0.45s var(--ease), border-color 0.45s var(--ease); }
|
||||
.card-hover:hover {
|
||||
transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) translateY(-6px) translateZ(0);
|
||||
}
|
||||
|
||||
/* Glossy sweep on primary buttons */
|
||||
.btn-primary { position: relative; overflow: hidden; isolation: isolate; }
|
||||
.btn-primary::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
|
||||
background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.45) 50%, transparent 65%);
|
||||
transform: translateX(-130%); transition: transform 0.7s var(--ease);
|
||||
}
|
||||
.btn-primary:hover::after { transform: translateX(130%); }
|
||||
|
||||
/* Floating ambient gradient orbs for hero sections */
|
||||
.orbs { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: -1; }
|
||||
.orb {
|
||||
position: absolute; border-radius: 999px; filter: blur(60px); opacity: 0.5;
|
||||
will-change: transform; mix-blend-mode: screen;
|
||||
}
|
||||
html:not(.dark) .orb { mix-blend-mode: multiply; opacity: 0.35; }
|
||||
.orb-1 { width: 460px; height: 460px; top: -120px; left: -80px;
|
||||
background: radial-gradient(circle, rgba(201,168,76,0.55), transparent 70%);
|
||||
animation: orbFloat 18s ease-in-out infinite; }
|
||||
.orb-2 { width: 380px; height: 380px; bottom: -140px; right: -60px;
|
||||
background: radial-gradient(circle, rgba(47,158,143,0.4), transparent 70%);
|
||||
animation: orbFloat 22s ease-in-out infinite reverse; }
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(40px, -30px) scale(1.12); }
|
||||
}
|
||||
|
||||
/* Animated gold shimmer on the gold accent words */
|
||||
.shimmer-gold {
|
||||
background: linear-gradient(100deg, var(--gold) 0%, var(--gold-soft) 25%, #fff3cf 50%, var(--gold-soft) 75%, var(--gold) 100%);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent; color: transparent;
|
||||
animation: shimmerMove 6s linear infinite;
|
||||
}
|
||||
@keyframes shimmerMove { to { background-position: 200% center; } }
|
||||
|
||||
/* Image reveal with clip-path wipe (added by JS via .img-reveal) */
|
||||
.img-reveal { clip-path: inset(0 0 100% 0); transition: clip-path 1s var(--ease); }
|
||||
.img-reveal.is-in { clip-path: inset(0 0 0% 0); }
|
||||
|
||||
/* Nav underline gets a soft glow on active */
|
||||
.nav-link.active::after { box-shadow: 0 0 10px rgba(201,168,76,0.7); }
|
||||
|
||||
/* Live ticker price cells subtle scale pop handled in JS via flash classes already */
|
||||
|
||||
/* ---- Mobile tuning: keep it smooth & tasteful on phones ---- */
|
||||
@media (max-width: 767px) {
|
||||
/* Big blurs are expensive on mobile GPUs — lighten them up */
|
||||
.orb { filter: blur(42px); opacity: 0.4; }
|
||||
.orb-1 { width: 280px; height: 280px; top: -80px; left: -60px; }
|
||||
.orb-2 { width: 240px; height: 240px; bottom: -90px; right: -40px; }
|
||||
.glass { backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
|
||||
/* No hover on touch, so neutralise tilt vars for crisp rendering */
|
||||
.card-hover { transform: none; }
|
||||
.card-hover:hover { transform: translateY(-4px); }
|
||||
/* Card tap feedback for touch devices */
|
||||
.card-hover:active { transform: scale(0.985); transition: transform 0.15s var(--ease); }
|
||||
.btn:active { transform: scale(0.96); }
|
||||
.scroll-progress { height: 2px; }
|
||||
/* Make sure the diagonal image shine isn't stuck mid-frame on touch */
|
||||
.photo-slot.has-image::before { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb { animation: none; }
|
||||
.shimmer-gold { animation: none; }
|
||||
.img-reveal { clip-path: none !important; }
|
||||
.card-hover { transform: none !important; }
|
||||
.scroll-progress { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Scroll progress indicator -->
|
||||
<div class="scroll-progress" id="scrollProgress" aria-hidden="true"></div>
|
||||
|
||||
<!-- ================= HEADER / NAV ================= -->
|
||||
<header class="glass fixed top-0 inset-x-0 z-50">
|
||||
<nav class="wrap flex items-center justify-between h-[68px]" aria-label="Primary">
|
||||
<a href="#home" class="flex items-center gap-2.5 group" aria-label="Trilogy Hub home">
|
||||
<svg width="34" height="34" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<g fill="none" stroke="var(--gold)" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M16 5 L24 19 L8 19 Z" />
|
||||
<path d="M9 13 L17 27 L1 27 Z" opacity="0.55" />
|
||||
<path d="M23 13 L31 27 L15 27 Z" opacity="0.55" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="font-display font-extrabold text-lg tracking-tight">Trilogy<span class="text-gold">Hub</span></span>
|
||||
</a>
|
||||
|
||||
<div class="hidden md:flex items-center gap-7">
|
||||
<button class="nav-link" data-route="home">Home</button>
|
||||
<button class="nav-link" data-route="magic-mike">Magic Tree Co.</button>
|
||||
<button class="nav-link" data-route="nico">NovaTerra</button>
|
||||
<button class="nav-link" data-route="paris">PG Trading</button>
|
||||
<button class="nav-link" data-route="casino" data-members hidden>Casino</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="themeToggle" class="btn btn-ghost !p-2.5 !min-h-0 w-11 h-11" aria-label="Toggle light and dark mode" title="Toggle theme">
|
||||
<i data-lucide="moon" class="block dark:hidden w-5 h-5"></i>
|
||||
<i data-lucide="sun" class="hidden dark:block w-5 h-5"></i>
|
||||
</button>
|
||||
<button id="menuToggle" class="btn btn-ghost !p-2.5 !min-h-0 w-11 h-11 md:hidden" aria-label="Open menu" aria-expanded="false">
|
||||
<i data-lucide="menu" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div id="mobileMenu" class="md:hidden hidden border-t border-soft bg-ink2">
|
||||
<div class="wrap py-4 flex flex-col gap-1">
|
||||
<button class="nav-link text-left py-3" data-route="home">Home</button>
|
||||
<button class="nav-link text-left py-3" data-route="magic-mike">Magic Tree Co.</button>
|
||||
<button class="nav-link text-left py-3" data-route="nico">NovaTerra Immobilien</button>
|
||||
<button class="nav-link text-left py-3" data-route="paris">PG Trading Management</button>
|
||||
<button class="nav-link text-left py-3" data-route="casino" data-members hidden>🎰 Casino & Games</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="app" class="pt-[68px]">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ============================ HOME ============================ -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-home" class="page" aria-label="Home">
|
||||
<!-- Hero -->
|
||||
<div class="relative overflow-hidden">
|
||||
<div class="orbs" aria-hidden="true" data-parallax="0.08"><span class="orb orb-1"></span><span class="orb orb-2"></span></div>
|
||||
<div class="absolute inset-0 -z-10 opacity-[0.05]" style="background-image: radial-gradient(circle at 1px 1px, var(--gold) 1px, transparent 0); background-size: 28px 28px;"></div>
|
||||
<div class="wrap pt-20 pb-16 md:pt-28 md:pb-24 text-center">
|
||||
<p class="eyebrow reveal mb-5">Trees · Properties · Markets</p>
|
||||
<h1 class="split-heading font-display font-black leading-[0.95] tracking-tight text-[clamp(2.6rem,8vw,6rem)]" data-splitting>
|
||||
Three Hustlers.<br /><span class="text-gold shimmer-gold">One Vision.</span>
|
||||
</h1>
|
||||
<p class="reveal text-dim max-w-2xl mx-auto mt-7 text-lg md:text-xl">
|
||||
Trees. Properties. Markets. We don't just talk — we build.
|
||||
</p>
|
||||
<div class="reveal flex flex-wrap items-center justify-center gap-3 mt-9">
|
||||
<button class="btn btn-primary" data-route="magic-mike">Explore the Hub <i data-lucide="arrow-right" class="w-4 h-4"></i></button>
|
||||
<button class="btn btn-ghost" data-route="paris">Trading Dashboard</button>
|
||||
</div>
|
||||
|
||||
<!-- Three partner cards with photo slots -->
|
||||
<div class="grid sm:grid-cols-3 gap-5 mt-16 text-left">
|
||||
<article class="reveal card card-gold card-hover p-3" >
|
||||
<div class="photo-slot !min-h-[260px]" data-slot="Magic Mike" tabindex="0" role="button" aria-label="Upload photo of Magic Mike">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-7 h-7"></i><span class="slot-label">Magic Mike</span></div>
|
||||
<img class="preview" alt="Magic Mike" />
|
||||
<span class="slot-hover-name">Magic Mike</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-display font-bold text-lg">Magic Mike</h3>
|
||||
<p class="text-dim text-sm mt-1">Founder · Magic Tree Co.</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-3">
|
||||
<div class="photo-slot !min-h-[260px]" data-slot="Nico Gonzales" tabindex="0" role="button" aria-label="Upload photo of Nico Gonzales">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-7 h-7"></i><span class="slot-label">Nico Gonzales</span></div>
|
||||
<img class="preview" alt="Nico Gonzales" />
|
||||
<span class="slot-hover-name">Nico Gonzales</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-display font-bold text-lg">Nico Gonzales</h3>
|
||||
<p class="text-dim text-sm mt-1">Broker · NovaTerra Immobilien</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-3">
|
||||
<div class="photo-slot !min-h-[260px]" data-slot="Paris" tabindex="0" role="button" aria-label="Upload photo of Paris">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-7 h-7"></i><span class="slot-label">Paris</span></div>
|
||||
<img class="preview" alt="Paris" />
|
||||
<span class="slot-hover-name">Paris</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-display font-bold text-lg">Paris</h3>
|
||||
<p class="text-dim text-sm mt-1">Strategist · PG Trading Management</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Our Story -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28 grid md:grid-cols-2 gap-12 items-center">
|
||||
<div class="reveal">
|
||||
<p class="eyebrow mb-4">Our Story</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.9rem,4vw,3rem)] leading-tight mb-6">It started with a handshake and a vision.</h2>
|
||||
<p class="text-dim text-lg leading-relaxed mb-4">
|
||||
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.
|
||||
</p>
|
||||
<p class="text-dim text-lg leading-relaxed">
|
||||
PG Trading Management finances stakes in Nico's real estate portfolio. Magic Tree supplies rare ornamental
|
||||
trees to Nico's high-end property projects. <span class="text-gold font-semibold shimmer-gold">Three businesses. One ecosystem.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="photo-slot !min-h-[360px]" data-slot="Team Photo" tabindex="0" role="button" aria-label="Upload team photo">
|
||||
<div class="slot-default"><i data-lucide="users" class="slot-icon w-8 h-8"></i><span class="slot-label">Team Photo</span></div>
|
||||
<img class="preview" alt="Team photo" />
|
||||
<span class="slot-hover-name">Team Photo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Cards -->
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="text-center mb-14 reveal">
|
||||
<p class="eyebrow mb-4">The Trilogy</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.9rem,4vw,3rem)]">Three founders, three crafts.</h2>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<article class="reveal card card-gold card-hover p-7 flex flex-col">
|
||||
<i data-lucide="trees" class="w-9 h-9 text-gold mb-5"></i>
|
||||
<h3 class="font-display font-bold text-2xl">Magic Mike</h3>
|
||||
<p class="text-gold text-sm font-semibold mt-1 mb-3">Magic Tree Co.</p>
|
||||
<p class="text-dim leading-relaxed flex-1">Rare and ornamental trees for luxury gardens — from Japanese Maples to 300-year-old olive trees.</p>
|
||||
<button class="btn btn-ghost mt-6 self-start" data-route="magic-mike">Visit Page <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-7 flex flex-col">
|
||||
<i data-lucide="building-2" class="w-9 h-9 text-gold mb-5"></i>
|
||||
<h3 class="font-display font-bold text-2xl">Nico Gonzales</h3>
|
||||
<p class="text-gold text-sm font-semibold mt-1 mb-3">NovaTerra Immobilien</p>
|
||||
<p class="text-dim leading-relaxed flex-1">Premium real estate across Baden-Württemberg — buying, selling, and developing property into legacy.</p>
|
||||
<button class="btn btn-ghost mt-6 self-start" data-route="nico">Visit Page <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-7 flex flex-col">
|
||||
<i data-lucide="line-chart" class="w-9 h-9 text-gold mb-5"></i>
|
||||
<h3 class="font-display font-bold text-2xl">Paris</h3>
|
||||
<p class="text-gold text-sm font-semibold mt-1 mb-3">PG Trading Management</p>
|
||||
<p class="text-dim leading-relaxed flex-1">Real markets, no risk, full thrill. A paper-trading dashboard powered by live-simulated prices.</p>
|
||||
<button class="btn btn-ghost mt-6 self-start" data-route="paris">Visit Page <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ========================= MAGIC MIKE ========================= -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-magic-mike" class="page" aria-label="Magic Mike — Magic Tree Co.">
|
||||
<!-- Hero -->
|
||||
<div class="relative min-h-[72vh] flex items-center overflow-hidden">
|
||||
<div class="photo-slot absolute inset-0 !rounded-none !border-0 !min-h-0" data-slot="Hero Background" tabindex="0" role="button" aria-label="Upload hero background">
|
||||
<div class="slot-default"><i data-lucide="image" class="slot-icon w-8 h-8"></i><span class="slot-label">Hero Background</span></div>
|
||||
<img class="preview" alt="Magic Tree Co. hero" data-parallax-img="0.16" />
|
||||
</div>
|
||||
<div class="absolute inset-0 overlay-green pointer-events-none"></div>
|
||||
<div class="wrap relative py-24 text-center md:text-left">
|
||||
<p class="eyebrow mb-4">Magic Tree Co.</p>
|
||||
<h1 class="split-heading font-display font-black text-[clamp(2.4rem,7vw,5.2rem)] leading-[0.95] max-w-3xl" data-splitting>Every Garden Tells a Story</h1>
|
||||
<p class="reveal text-dim text-lg mt-6 max-w-xl">Premium ornamental trees, delivered with craftsmanship and a guarantee.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About -->
|
||||
<div class="wrap py-20 md:py-28 grid md:grid-cols-2 gap-12 items-center">
|
||||
<div class="photo-slot !min-h-[380px]" data-slot="Mike's Portrait" tabindex="0" role="button" aria-label="Upload Mike's portrait">
|
||||
<div class="slot-default"><i data-lucide="user" class="slot-icon w-8 h-8"></i><span class="slot-label">Mike's Portrait</span></div>
|
||||
<img class="preview" alt="Mike portrait" />
|
||||
<span class="slot-hover-name">Mike's Portrait</span>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<p class="eyebrow mb-4">About Mike</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)] mb-6 leading-tight">Where nature meets craftsmanship.</h2>
|
||||
<p class="text-dim text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">The Collection</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Featured Trees</h2>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6" id="mikeProducts"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reviews -->
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Word of Mouth</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Customer Reviews</h2>
|
||||
</div>
|
||||
<div class="masonry" id="mikeReviews"></div>
|
||||
</div>
|
||||
|
||||
<!-- Happy Customers Gallery -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Happy Customers</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Their Gardens, Transformed</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-5" id="mikeGallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ============================ NICO ============================ -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-nico" class="page" aria-label="Nico Gonzales — NovaTerra Immobilien">
|
||||
<!-- Hero -->
|
||||
<div class="relative min-h-[72vh] flex items-center overflow-hidden">
|
||||
<div class="photo-slot absolute inset-0 !rounded-none !border-0 !min-h-0" data-slot="Hero Background" tabindex="0" role="button" aria-label="Upload hero background">
|
||||
<div class="slot-default"><i data-lucide="image" class="slot-icon w-8 h-8"></i><span class="slot-label">Hero Background</span></div>
|
||||
<img class="preview" alt="NovaTerra hero" data-parallax-img="0.16" />
|
||||
</div>
|
||||
<div class="absolute inset-0 overlay-navy pointer-events-none"></div>
|
||||
<div class="wrap relative py-24 text-center md:text-left">
|
||||
<p class="eyebrow mb-4">NovaTerra Immobilien · New Ground. New Possibilities.</p>
|
||||
<h1 class="split-heading font-display font-black text-[clamp(2.4rem,7vw,5.2rem)] leading-[0.95] max-w-3xl" data-splitting>Your Property. Your Legacy.</h1>
|
||||
<p class="reveal text-dim text-lg mt-6 max-w-xl">Premium real estate, brokered with discretion and results.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About -->
|
||||
<div class="wrap py-20 md:py-28 grid md:grid-cols-2 gap-12 items-center">
|
||||
<div class="reveal">
|
||||
<p class="eyebrow mb-4">About Nico</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)] mb-6 leading-tight">Closing premium deals across the region.</h2>
|
||||
<p class="text-dim text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="photo-slot !min-h-[380px]" data-slot="Nico's Headshot" tabindex="0" role="button" aria-label="Upload Nico's headshot">
|
||||
<div class="slot-default"><i data-lucide="user" class="slot-icon w-8 h-8"></i><span class="slot-label">Nico's Headshot</span></div>
|
||||
<img class="preview" alt="Nico headshot" />
|
||||
<span class="slot-hover-name">Nico's Headshot</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listings -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Portfolio</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Featured Listings</h2>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-6" id="nicoListings"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Testimonials -->
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Trusted By</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Client Testimonials</h2>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-6" id="nicoTestimonials"></div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Moments</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Deals & Handshakes</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-5" id="nicoGallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- =========================== PARIS =========================== -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-paris" class="page" aria-label="Paris — PG Trading Management">
|
||||
<!-- Login gate -->
|
||||
<div id="parisLogin" class="wrap py-24 md:py-32">
|
||||
<div class="max-w-md mx-auto text-center mb-10 reveal">
|
||||
<i data-lucide="line-chart" class="w-12 h-12 text-gold mx-auto mb-5"></i>
|
||||
<p class="eyebrow mb-3">PG Trading Management</p>
|
||||
<h1 class="font-display font-black text-[clamp(2rem,6vw,3.4rem)] leading-tight">Your Portfolio. Your Game.</h1>
|
||||
<p class="text-dim mt-4 text-lg">Real markets. No risk. Full thrill.</p>
|
||||
</div>
|
||||
<form id="loginForm" class="card card-gold max-w-md mx-auto p-8" novalidate>
|
||||
<h2 class="font-display font-bold text-xl mb-1">Member Login</h2>
|
||||
<p class="text-faint text-sm mb-6">Demo access — user: <span class="text-gold">paris</span> / pass: <span class="text-gold">trading123</span></p>
|
||||
<label class="block text-sm font-semibold mb-2" for="username">Username</label>
|
||||
<input class="input mb-4" type="text" id="username" name="username" autocomplete="username" placeholder="paris" />
|
||||
<label class="block text-sm font-semibold mb-2" for="password">Password</label>
|
||||
<input class="input mb-2" type="password" id="password" name="password" autocomplete="current-password" placeholder="••••••••" />
|
||||
<p id="loginError" class="text-error text-sm h-5 mb-2" role="alert"></p>
|
||||
<button type="submit" class="btn btn-primary w-full">Log In <i data-lucide="lock-open" class="w-4 h-4"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="parisDashboard" class="hidden">
|
||||
<!-- Dashboard navbar -->
|
||||
<div class="bg-ink2 border-b border-soft">
|
||||
<div class="wrap flex flex-wrap items-center justify-between gap-4 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="line-chart" class="w-6 h-6 text-gold"></i>
|
||||
<span class="font-display font-bold">PG Trading <span class="text-gold">Management</span></span>
|
||||
<span class="chip ml-2"><span class="live-dot"></span> LIVE</span>
|
||||
<span class="chip text-gold">Play Money</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<p class="text-faint text-xs">Portfolio</p>
|
||||
<p class="font-display font-bold text-gold" data-counter="7334899.31" data-prefix="€" data-decimals="2">€7,334,899.31</p>
|
||||
</div>
|
||||
<span class="chip">Logged in as Paris</span>
|
||||
<button id="logoutBtn" class="btn btn-ghost !min-h-0 !py-2.5">Logout <i data-lucide="log-out" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap py-12">
|
||||
<!-- KPI cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Portfolio Value</p>
|
||||
<p class="font-display font-extrabold text-2xl" data-counter="7334899.31" data-prefix="€" data-decimals="2">€7,334,899.31</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Today's P&L</p>
|
||||
<p class="font-display font-extrabold text-2xl text-success"><span data-counter="48213.55" data-prefix="+€" data-decimals="2">+€48,213.55</span> <span class="text-sm">(+0.66%)</span></p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Open Positions</p>
|
||||
<p class="font-display font-extrabold text-2xl" data-counter="10" data-decimals="0">10</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Buying Power</p>
|
||||
<p class="font-display font-extrabold text-2xl" data-counter="57539.31" data-prefix="€" data-decimals="2">€57,539.31</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-8">
|
||||
<!-- Watchlist -->
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="font-display font-bold text-xl mb-4 flex items-center gap-2"><i data-lucide="list" class="w-5 h-5 text-gold"></i> Watchlist</h2>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm" id="watchlistTable">
|
||||
<thead>
|
||||
<tr class="text-faint text-left border-b border-soft">
|
||||
<th class="py-3 px-4 font-semibold">Ticker</th>
|
||||
<th class="py-3 px-4 font-semibold">Company</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">Shares</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">Avg Buy</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">Price</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">P&L</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">P&L%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="watchlistBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trade log -->
|
||||
<h2 class="font-display font-bold text-xl mt-10 mb-4 flex items-center gap-2"><i data-lucide="history" class="w-5 h-5 text-gold"></i> Trade Log</h2>
|
||||
<div class="card divide-y" id="tradeLog" style="--tw-divide-opacity:1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Pie chart -->
|
||||
<div>
|
||||
<h2 class="font-display font-bold text-xl mb-4 flex items-center gap-2"><i data-lucide="pie-chart" class="w-5 h-5 text-gold"></i> Allocation</h2>
|
||||
<div class="card p-6">
|
||||
<canvas id="allocationChart" height="260"></canvas>
|
||||
<div id="chartLegend" class="mt-5 space-y-2 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer -->
|
||||
<div class="bg-ink2 border-t border-soft">
|
||||
<div class="wrap py-4 text-center text-faint text-sm flex items-center justify-center gap-2">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-gold shrink-0"></i>
|
||||
<span>PG Trading Management is a simulation platform for entertainment purposes only. No real money is involved. Not financial advice.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ========================== CASINO =========================== -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-casino" class="page" aria-label="PlayBull Casino & Games">
|
||||
<!-- Login gate (shown when not authenticated) -->
|
||||
<div id="casinoGate" class="wrap py-24 md:py-32 hidden">
|
||||
<div class="max-w-md mx-auto text-center reveal">
|
||||
<i data-lucide="dices" class="w-12 h-12 text-gold mx-auto mb-5"></i>
|
||||
<p class="eyebrow mb-3">Members Only</p>
|
||||
<h1 class="font-display font-black text-[clamp(2rem,6vw,3.4rem)] leading-tight">Casino & Games</h1>
|
||||
<p class="text-dim mt-4 text-lg">Chicken Road, 10×10 Deck, Crash & mehr — mit deinem PlayBull-Konto.</p>
|
||||
<button class="btn btn-primary mt-8" id="casinoLoginBtn">Einloggen / Registrieren <i data-lucide="log-in" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Embedded full PlayBull arcade (shares the same login + balance) -->
|
||||
<div id="casinoFrameWrap" class="hidden">
|
||||
<div class="bg-ink2 border-b border-soft">
|
||||
<div class="wrap flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="dices" class="w-6 h-6 text-gold"></i>
|
||||
<span class="font-display font-bold">PlayBull <span class="text-gold">Arcade</span></span>
|
||||
<span class="chip"><span class="live-dot"></span> LIVE</span>
|
||||
<span class="chip text-gold">Play Money</span>
|
||||
</div>
|
||||
<a href="/play.html" target="_blank" rel="noopener" class="btn btn-ghost !min-h-0 !py-2.5">Vollbild öffnen <i data-lucide="external-link" class="w-4 h-4"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="casinoFrame" title="PlayBull Arcade" style="width:100%;height:calc(100vh - 68px - 64px);border:0;display:block;background:var(--bg)"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ================= FOOTER ================= -->
|
||||
<footer class="bg-ink2 border-t border-soft">
|
||||
<div class="wrap py-12 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<g fill="none" stroke="var(--gold)" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M16 5 L24 19 L8 19 Z" /><path d="M9 13 L17 27 L1 27 Z" opacity="0.55" /><path d="M23 13 L31 27 L15 27 Z" opacity="0.55" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="font-display font-extrabold">Trilogy<span class="text-gold">Hub</span></span>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-6" aria-label="Footer">
|
||||
<button class="nav-link" data-route="magic-mike">Magic Tree Co.</button>
|
||||
<button class="nav-link" data-route="nico">NovaTerra Immobilien</button>
|
||||
<button class="nav-link" data-route="paris">PG Trading Management</button>
|
||||
<button class="nav-link" data-route="casino" data-members hidden>Casino & Games</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="wrap pb-8 text-faint text-sm text-center md:text-left">
|
||||
Trilogy Hub © 2025 — Built by three, grown together.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- ================= SCRIPTS ================= -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="image-editor.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="playbull.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1225
public/js/app.js
Normal file
1225
public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
156
public/js/framed-image.js
Normal file
156
public/js/framed-image.js
Normal file
@@ -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 `<div class="${preset.cssClass} ${sizeClass(frame, size)} framed-image-host"
|
||||
data-framed="1" data-frame="${preset.id}" data-url="${escapeAttr(url)}" data-fx="${fx}" data-fy="${fy}" data-zoom="${z}">
|
||||
<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" draggable="false" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderUploadItem(item) {
|
||||
return `<div class="upload-item" data-upload-id="${item.id}">
|
||||
${renderFramedImage({ url: item.url, focus: item.focus, zoom: item.zoom, frame: item.frame, size: 'md' })}
|
||||
<div class="upload-item-actions">
|
||||
<button type="button" class="chip" data-upload-repos="${item.id}">↕️ Position</button>
|
||||
<button type="button" class="chip" data-upload-del="${item.id}">🗑️</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `<span class="${cls}" aria-hidden="true">${emoji}</span>`;
|
||||
}
|
||||
|
||||
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(/</g, '<');
|
||||
}
|
||||
198
public/js/image-editor.js
Normal file
198
public/js/image-editor.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
FRAMES,
|
||||
computeFramedLayout,
|
||||
focusFromLayout,
|
||||
applyFramedLayout,
|
||||
loadImage,
|
||||
} from './framed-image.js';
|
||||
|
||||
/**
|
||||
* Öffnet den Positions-Editor nach Dateiauswahl (oder für bestehendes Bild).
|
||||
* @returns {Promise<{focus:{x,y}, zoom:number}|null>}
|
||||
*/
|
||||
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 = `
|
||||
<div class="modal image-editor-modal">
|
||||
<div class="handle"></div>
|
||||
<div class="modal-head">
|
||||
<h2>${preset.label} positionieren</h2>
|
||||
<button type="button" class="modal-close" data-act="cancel">✕</button>
|
||||
</div>
|
||||
<p class="muted center image-editor-hint">So wird dein Bild überall angezeigt — ziehen & zoomen</p>
|
||||
<div class="image-editor-preview-wrap">
|
||||
<div class="image-editor-preview ${preset.cssClass} ${preset.previewClass || 'framed-image--avatar-xl'}" id="ieFrame">
|
||||
<img id="ieImg" src="${src}" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field image-editor-zoom-field">
|
||||
<label>Zoom <span id="ieZoomVal">${state.zoom.toFixed(1)}×</span></label>
|
||||
<input type="range" id="ieZoom" min="1" max="4" step="0.05" value="${state.zoom}" />
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn btn-ghost" data-act="reset">Zurücksetzen</button>
|
||||
<button type="button" class="btn btn-accent" data-act="save">Speichern</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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 });
|
||||
};
|
||||
});
|
||||
}
|
||||
72
public/js/upload-helper.js
Normal file
72
public/js/upload-helper.js
Normal file
@@ -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;
|
||||
}
|
||||
98
public/play.html
Normal file
98
public/play.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<style>html,body{background:#0a0a0a;color:#f4f1e9}</style>
|
||||
<title>PlayBull — Trilogy Hub Trading & Arcade</title>
|
||||
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@500,700,800,900&f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
|
||||
<!-- Animation libs -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Topbar (in iframe via ?embed=1 ausgeblendet) -->
|
||||
<header class="topbar" id="pbTopbar">
|
||||
<div class="brand" id="brandHome">
|
||||
<span class="brand-logo">🐂</span>
|
||||
<span class="brand-name">Play<span class="accent">Bull</span></span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="balance-chip" id="balanceChip" hidden>
|
||||
<span class="coin">🪙</span><span id="balanceValue">0</span>
|
||||
</div>
|
||||
<button class="btn-auth" id="authBtn">Login</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Globaler Live-Feed (Ticker) -->
|
||||
<div class="ticker" id="feedTicker"><div class="ticker-track" id="tickerTrack"></div></div>
|
||||
|
||||
<main id="app">
|
||||
<!-- ===== TRADING ===== -->
|
||||
<section class="view active" id="view-trading">
|
||||
<div class="view-head">
|
||||
<h1>Markt <span class="live-dot"></span><small>live</small></h1>
|
||||
<div class="seg" id="marketFilter">
|
||||
<button class="seg-btn active" data-f="all">Alle</button>
|
||||
<button class="seg-btn" data-f="stock">Aktien</button>
|
||||
<button class="seg-btn" data-f="crypto">Krypto</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list" id="assetList"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== GAMES ===== -->
|
||||
<section class="view" id="view-games">
|
||||
<div class="view-head"><h1>Casino 🎰</h1></div>
|
||||
<div class="games-grid" id="gamesGrid"></div>
|
||||
<div class="login-hint" id="gamesLoginHint" hidden>
|
||||
🔒 Zum Spielen bitte <button class="link" data-open-auth>einloggen</button>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PORTFOLIO ===== -->
|
||||
<section class="view" id="view-portfolio">
|
||||
<div class="view-head"><h1>Portfolio 💼</h1></div>
|
||||
<div id="portfolioContent"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SOCIAL / LEADERBOARD ===== -->
|
||||
<section class="view" id="view-social">
|
||||
<div class="view-head"><h1>Rangliste 🏆</h1></div>
|
||||
<div id="leaderboardContent"></div>
|
||||
<div class="view-head"><h2>Aktivität</h2></div>
|
||||
<div id="feedList" class="feed-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SETTINGS / ADMIN ===== -->
|
||||
<section class="view" id="view-settings">
|
||||
<div class="view-head"><h1>Einstellungen ⚙️</h1></div>
|
||||
<div id="settingsContent"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Bottom Nav -->
|
||||
<nav class="bottom-nav">
|
||||
<button class="nav-btn active" data-view="trading"><span>📈</span>Trading</button>
|
||||
<button class="nav-btn" data-view="games"><span>🎰</span>Casino</button>
|
||||
<button class="nav-btn" data-view="portfolio"><span>💼</span>Depot</button>
|
||||
<button class="nav-btn" data-view="social"><span>🏆</span>Social</button>
|
||||
<button class="nav-btn" data-view="settings"><span>⚙️</span>Mehr</button>
|
||||
</nav>
|
||||
|
||||
<!-- Modal Container -->
|
||||
<div class="modal-backdrop" id="modal" hidden>
|
||||
<div class="modal" id="modalBox"></div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast-wrap" id="toastWrap"></div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
460
public/playbull.js
Normal file
460
public/playbull.js
Normal file
@@ -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 = `<div class="card card-gold" style="width:100%;max-width:${maxw};padding:1.5rem;max-height:92vh;overflow-y:auto">${html}</div>`;
|
||||
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(`
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-display font-bold text-xl">${isLogin ? "Login" : "Registrieren"} 🐂</h2>
|
||||
<button class="btn btn-ghost !min-h-0 !p-2" data-close>✕</button>
|
||||
</div>
|
||||
<label class="block text-sm font-semibold mb-2">Username</label>
|
||||
<input class="input mb-3" id="pbUser" autocomplete="username" placeholder="dein_name" />
|
||||
<label class="block text-sm font-semibold mb-2">Passwort</label>
|
||||
<input class="input mb-2" id="pbPass" type="password" autocomplete="current-password" placeholder="••••" />
|
||||
<p class="text-error text-sm mb-2" id="pbAuthErr" style="min-height:18px"></p>
|
||||
<button class="btn btn-primary w-full" id="pbAuthSubmit">${isLogin ? "Einloggen" : "Account erstellen"}</button>
|
||||
<p class="text-center text-faint text-sm mt-4">
|
||||
${isLogin ? "Noch kein Account?" : "Schon registriert?"}
|
||||
<button class="text-gold font-semibold underline" id="pbAuthSwitch">${isLogin ? "Registrieren" : "Login"}</button>
|
||||
</p>`);
|
||||
$("#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 <span class="text-gold">PlayBull</span>-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 <i data-lucide="dices" class="w-4 h-4"></i>';
|
||||
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 = `
|
||||
<thead>
|
||||
<tr class="text-faint text-left border-b border-soft">
|
||||
<th class="py-3 px-3 font-semibold">Asset</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">Kurs</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">24h</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">Position</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="watchlistBody">
|
||||
${assets.map((a) => {
|
||||
const pos = posBySym[a.symbol];
|
||||
const cls = a.changePct >= 0 ? "text-success" : "text-error";
|
||||
return `<tr class="border-b border-soft" data-sym="${a.symbol}">
|
||||
<td class="py-3 px-3"><span class="font-bold">${a.emoji || ""} ${a.symbol}</span><div class="text-faint text-xs">${a.name}</div></td>
|
||||
<td class="py-3 px-3 text-right font-semibold" data-px>${px(a.price)}</td>
|
||||
<td class="py-3 px-3 text-right ${cls}" data-chg>${a.changePct >= 0 ? "+" : ""}${(a.changePct).toFixed(2)}%</td>
|
||||
<td class="py-3 px-3 text-right text-dim" data-pos>${pos ? pos.quantity + (pos.side === "short" ? " (S)" : "") : "—"}</td>
|
||||
<td class="py-3 px-3 text-right whitespace-nowrap">
|
||||
<button class="btn btn-primary !min-h-0 !py-1.5 !px-3 text-xs" data-buy>Kauf</button>
|
||||
<button class="btn btn-ghost !min-h-0 !py-1.5 !px-3 text-xs" data-sell>Verk.</button>
|
||||
<button class="btn btn-ghost !min-h-0 !py-1.5 !px-2 text-xs" data-pump title="Pump (alle)">🚀</button>
|
||||
<button class="btn btn-ghost !min-h-0 !py-1.5 !px-2 text-xs" data-dump title="Dump (alle)">📉</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
</tbody>`;
|
||||
$$("#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(`
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-display font-bold text-xl">${a.emoji || ""} ${sym} <span class="text-faint text-sm">${a.name}</span></h2>
|
||||
<button class="btn btn-ghost !min-h-0 !p-2" data-close>✕</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-faint text-sm">Kurs</span>
|
||||
<span class="font-display font-bold text-lg" id="tmPx">${px(a.price)}</span>
|
||||
</div>
|
||||
<div class="seg-tabs flex gap-2 mb-4">
|
||||
<button class="btn ${side === "buy" ? "btn-primary" : "btn-ghost"} flex-1" data-mode="buy">Kaufen (Long)</button>
|
||||
<button class="btn ${side === "sell" ? "btn-primary" : "btn-ghost"} flex-1" data-mode="sell">Verkauf / Short</button>
|
||||
</div>
|
||||
<label class="block text-sm font-semibold mb-2">Menge</label>
|
||||
<input class="input mb-3" id="tmQty" type="number" inputmode="decimal" value="1" min="0" step="0.1" />
|
||||
<div class="flex gap-2 mb-3">
|
||||
${[0.1, 1, 5, 10].map((v) => `<button class="chip" data-amt="${v}">${v}</button>`).join("")}
|
||||
<button class="chip" data-max>MAX</button>
|
||||
</div>
|
||||
<p class="text-faint text-center text-sm mb-3" id="tmEst"></p>
|
||||
<button class="btn btn-primary w-full" id="tmGo"></button>
|
||||
<p class="text-faint text-xs text-center mt-3">Guthaben: <span id="tmBal">${eur(PB.user.balance)}</span></p>`);
|
||||
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 `<div class="flex items-center gap-3 p-4 border-b border-soft">
|
||||
<span class="chip ${isBuy ? "text-success" : "text-error"}">${isBuy ? "BUY" : "SELL"}</span>
|
||||
<span class="text-dim text-sm">${t.qty} ${t.symbol} @ ${px(t.price)}</span>
|
||||
<span class="text-faint text-xs ml-auto">${d}</span>
|
||||
</div>`;
|
||||
}).join("") : '<div class="p-4 text-faint text-sm">Noch keine Trades.</div>';
|
||||
}
|
||||
|
||||
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 = '<p class="text-faint text-sm">Noch keine Positionen.</p>';
|
||||
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) => `
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2"><span style="width:10px;height:10px;border-radius:3px;background:${chartColors[i % chartColors.length]};display:inline-block"></span>${l}</span>
|
||||
<span class="text-faint">${((data[i] / total) * 100).toFixed(1)}%</span>
|
||||
</div>`).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();
|
||||
})();
|
||||
7
public/uploads/manifest.json
Normal file
7
public/uploads/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
BIN
public/uploads/page-home_Magic_Mike.jpg
Normal file
BIN
public/uploads/page-home_Magic_Mike.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
public/uploads/page-home_Nico_Gonzales.jpg
Normal file
BIN
public/uploads/page-home_Nico_Gonzales.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
public/uploads/page-home_Paris.jpg
Normal file
BIN
public/uploads/page-home_Paris.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
BIN
public/uploads/page-home_Team_Photo.jpg
Normal file
BIN
public/uploads/page-home_Team_Photo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
public/uploads/page-nico_Hero_Background.jpg
Normal file
BIN
public/uploads/page-nico_Hero_Background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
70
server/auth.js
Normal file
70
server/auth.js
Normal file
@@ -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 };
|
||||
40
server/config.js
Normal file
40
server/config.js
Normal file
@@ -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 });
|
||||
208
server/db.js
Normal file
208
server/db.js
Normal file
@@ -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);
|
||||
},
|
||||
};
|
||||
247
server/games.js
Normal file
247
server/games.js
Normal file
@@ -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 };
|
||||
}
|
||||
399
server/index.js
Normal file
399
server/index.js
Normal file
@@ -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)'}`);
|
||||
});
|
||||
});
|
||||
184
server/market.js
Normal file
184
server/market.js
Normal file
@@ -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<string, any>} */
|
||||
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 };
|
||||
15
server/realtime.js
Normal file
15
server/realtime.js
Normal file
@@ -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() });
|
||||
}
|
||||
98
server/trading.js
Normal file
98
server/trading.js
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user