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:
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user