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:
2026-07-02 08:31:36 +02:00
commit 94b6cc3be9
60 changed files with 9206 additions and 0 deletions

460
public/playbull.js Normal file
View 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();
})();