Files
playbull/public/js/app.js
Mike 94b6cc3be9 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>
2026-07-02 08:31:36 +02:00

1226 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ===================== PlayBull Frontend =====================
import { renderAvatar, renderUploadItem, hydrateFramedImages, isImageAvatar, avatarEmoji } from './framed-image.js';
import { uploadImageWithEditor, repositionImageWithEditor, appendPositionFields } from './upload-helper.js';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const state = {
user: null,
assets: new Map(), // symbol -> asset obj
liveHistory: new Map(), // symbol -> [{t,p}]
filter: 'all',
selected: null,
socket: null,
};
// ---------- API ----------
async function api(path, body, method = 'POST') {
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin' };
if (body && method !== 'GET') opts.body = JSON.stringify(body);
const res = await fetch(path, method === 'GET' ? { credentials: 'same-origin' } : opts);
const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' }));
if (!res.ok && !data.error) data.error = 'Fehler ' + res.status;
return data;
}
const get = (p) => api(p, null, 'GET');
async function apiForm(path, formData, method = 'POST') {
const res = await fetch(path, { method, body: formData, credentials: 'same-origin' });
const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' }));
if (!res.ok && !data.error) data.error = 'Fehler ' + res.status;
return data;
}
async function apiDelete(path) {
const res = await fetch(path, { method: 'DELETE', credentials: 'same-origin' });
const data = await res.json().catch(() => ({ ok: false, error: 'Netzwerkfehler' }));
if (!res.ok && !data.error) data.error = 'Fehler ' + res.status;
return data;
}
// ---------- Formatters ----------
const nf = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const nf4 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
function fmt(n) { return nf.format(Number(n) || 0); }
function coins(n) { return `🪙 ${fmt(n)}`; }
function px(n) { const v = Number(n); return v < 1 ? nf4.format(v) : nf.format(v); }
function timeAgo(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
}
// ---------- Toast ----------
function toast(msg, type = '') {
const el = document.createElement('div');
el.className = 'toast ' + type;
el.innerHTML = msg;
$('#toastWrap').appendChild(el);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 2600);
setTimeout(() => el.remove(), 3000);
}
// ---------- Celebration FX ----------
const GOLD_CONFETTI = ['#c9a84c', '#d8bd6e', '#f4f1e9', '#46c98b', '#8a6e1f'];
function burstConfetti(opts = {}) {
if (typeof window.confetti !== 'function') return;
window.confetti({ particleCount: 110, spread: 75, startVelocity: 42, ticks: 180, origin: { y: 0.55 }, colors: GOLD_CONFETTI, disableForReducedMotion: true, ...opts });
}
function bigWinFX(mult = 1) {
burstConfetti({ particleCount: Math.min(60 + mult * 18, 220), spread: 100 });
if (mult >= 3) setTimeout(() => burstConfetti({ angle: 60, origin: { x: 0, y: 0.6 } }), 120);
if (mult >= 3) setTimeout(() => burstConfetti({ angle: 120, origin: { x: 1, y: 0.6 } }), 220);
}
function gsapOK() { return typeof window.gsap !== 'undefined'; }
// ---------- Modal ----------
function openModal(html) {
$('#modalBox').innerHTML = `<div class="handle"></div>${html}`;
$('#modal').hidden = false;
document.body.style.overflow = 'hidden';
}
function closeModal() {
$('#modal').hidden = true;
document.body.style.overflow = '';
state.selected = null;
}
$('#modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
// ---------- Balance ----------
function setBalance(b) {
if (!state.user) return;
state.user.balance = b;
const bal = $('#balanceValue');
if (bal) bal.textContent = fmt(b);
}
function updateAuthUI() {
const btn = $('#authBtn');
const chip = $('#balanceChip');
const bal = $('#balanceValue');
if (btn) {
if (state.user) {
if (chip) chip.hidden = false;
if (bal) bal.textContent = fmt(state.user.balance);
btn.className = 'btn-auth btn-auth--avatar';
btn.innerHTML = renderAvatar(state.user.avatar, { size: 'sm' });
hydrateFramedImages(btn);
btn.onclick = openAccountMenu;
} else {
if (chip) chip.hidden = true;
btn.className = 'btn-auth';
btn.textContent = 'Login';
btn.onclick = openAuthModal;
}
}
const hint = $('#gamesLoginHint');
if (hint) hint.hidden = !!state.user;
}
// ===================== AUTH =====================
function openAuthModal(mode = 'login') {
const isLogin = mode === 'login';
openModal(`
<div class="modal-head"><h2>${isLogin ? 'Login' : 'Registrieren'} 🐂</h2><button class="modal-close" onclick="window._closeModal()">✕</button></div>
<div class="field"><label>Username</label><input id="auUser" autocomplete="username" placeholder="dein_name" /></div>
<div class="field"><label>Passwort</label><input id="auPass" type="password" autocomplete="current-password" placeholder="••••" /></div>
<div id="auErr" class="muted" style="color:var(--red);font-size:.85rem;min-height:18px"></div>
<button class="btn btn-accent" id="auSubmit">${isLogin ? 'Einloggen' : 'Account erstellen'}</button>
<p class="center muted" style="margin-top:14px;font-size:.85rem">
${isLogin ? 'Noch kein Account?' : 'Schon registriert?'}
<button class="link" id="auSwitch">${isLogin ? 'Registrieren' : 'Login'}</button>
</p>
`);
$('#auSwitch').onclick = () => openAuthModal(isLogin ? 'register' : 'login');
$('#auSubmit').onclick = async () => {
const username = $('#auUser').value.trim();
const password = $('#auPass').value;
const r = await api(isLogin ? '/api/login' : '/api/register', { username, password });
if (!r.ok) { $('#auErr').textContent = r.error; return; }
state.user = r.user;
updateAuthUI();
closeModal();
toast(`Willkommen, ${r.user.username}! 🎉`, 'win');
renderActiveView();
};
$('#auPass').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#auSubmit').click(); });
}
window._closeModal = closeModal;
function openAccountMenu() {
const u = state.user;
openModal(`
<div class="modal-head account-head">
<h2>${renderAvatar(u.avatar, { size: 'md' })} ${u.username}</h2>
<button class="modal-close" onclick="window._closeModal()">✕</button>
</div>
<div class="card"><div class="stat-row"><span class="k">Guthaben</span><span class="v">${coins(u.balance)}</span></div></div>
<button class="btn btn-ghost" id="goSettings">⚙️ Einstellungen & Geld</button>
<div class="spacer"></div>
<button class="btn btn-red" id="logoutBtn">Logout</button>
`);
hydrateFramedImages($('#modalBox'));
$('#goSettings').onclick = () => { closeModal(); switchView('settings'); };
$('#logoutBtn').onclick = async () => {
await api('/api/logout', {});
state.user = null; updateAuthUI(); closeModal(); renderActiveView();
toast('Ausgeloggt');
};
}
// ===================== NAVIGATION =====================
function switchView(name) {
$$('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + name));
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === name));
window.scrollTo(0, 0);
renderActiveView();
}
$$('.nav-btn').forEach(b => b.onclick = () => switchView(b.dataset.view));
$('#brandHome')?.addEventListener('click', () => switchView('trading'));
$$('[data-open-auth]').forEach(b => b.onclick = () => openAuthModal());
function activeView() {
const v = $$('.view').find(v => v.classList.contains('active'));
return v ? v.id.replace('view-', '') : 'trading';
}
function renderActiveView() {
const v = activeView();
if (v === 'trading') renderAssets();
else if (v === 'games') renderGames();
else if (v === 'portfolio') renderPortfolio();
else if (v === 'social') renderSocial();
else if (v === 'settings') renderSettings();
}
// ===================== TRADING =====================
function renderAssets() {
const list = $('#assetList');
const arr = [...state.assets.values()].filter(a => state.filter === 'all' || a.type === state.filter);
list.innerHTML = arr.map(a => assetRowHTML(a)).join('');
$$('.asset-row', list).forEach(row => row.onclick = () => openAssetDetail(row.dataset.sym));
}
function assetRowHTML(a) {
const cls = a.change >= 0 ? 'up' : 'down';
const sign = a.change >= 0 ? '+' : '';
return `
<div class="asset-row" data-sym="${a.symbol}" id="row-${a.symbol}">
<div class="asset-emoji">${a.emoji}</div>
<div class="asset-info">
<div class="sym">${a.symbol}${a.halted ? '<span class="badge-halt">HALT</span>' : ''}</div>
<div class="name">${a.name}</div>
</div>
<div class="asset-price">
<div class="px" data-px>${px(a.price)}</div>
<div class="chg ${cls}" data-chg>${sign}${fmt(a.changePct)}%</div>
</div>
</div>`;
}
$$('#marketFilter .seg-btn').forEach(b => b.onclick = () => {
$$('#marketFilter .seg-btn').forEach(x => x.classList.remove('active'));
b.classList.add('active');
state.filter = b.dataset.f;
renderAssets();
});
async function openAssetDetail(sym) {
state.selected = sym;
const a = state.assets.get(sym);
openModal(`
<div class="modal-head">
<h2>${a.emoji} ${a.symbol} <small class="muted">${a.name}</small></h2>
<button class="modal-close" onclick="window._closeModal()">✕</button>
</div>
<div class="big-num" id="dPrice">${px(a.price)}</div>
<div class="${a.change>=0?'up':'down'}" id="dChg" style="font-weight:700;margin-bottom:12px">${a.change>=0?'+':''}${fmt(a.change)} (${fmt(a.changePct)}%)</div>
<div class="chart-wrap"><canvas id="chart" height="180"></canvas></div>
${state.user ? tradePanelHTML() : '<div class="login-hint">🔒 Zum Handeln <button class="link" onclick="window._closeModal();window._openAuth()">einloggen</button></div>'}
<h2>🎛️ Markt manipulieren <small>(wirkt für ALLE)</small></h2>
${state.user ? manipPanelHTML() : '<div class="muted center" style="padding:8px">Login nötig</div>'}
`);
drawChartFor(sym);
if (state.user) wireTradePanel(sym), wireManipPanel(sym);
}
window._openAuth = () => openAuthModal();
function tradePanelHTML() {
return `
<div class="field"><label>Menge</label><input id="tQty" type="number" inputmode="decimal" value="1" min="0" step="0.1" /></div>
<div class="chips" style="margin-bottom:12px">
<button class="chip" data-amt="0.1">0.1</button>
<button class="chip" data-amt="1">1</button>
<button class="chip" data-amt="5">5</button>
<button class="chip" data-amt="10">10</button>
<button class="chip" data-max>MAX</button>
</div>
<div id="tEst" class="muted center" style="margin-bottom:10px;font-size:.85rem"></div>
<div class="btn-row">
<button class="btn btn-green" id="btnBuy">Kaufen (Long)</button>
<button class="btn btn-red" id="btnSell">Verkaufen / Short</button>
</div>`;
}
function wireTradePanel(sym) {
const qty = $('#tQty');
const updEst = () => {
const a = state.assets.get(sym);
const cost = (parseFloat(qty.value) || 0) * a.price;
$('#tEst').textContent = `${coins(cost)}`;
};
qty.addEventListener('input', updEst); updEst();
$$('.chip[data-amt]').forEach(c => c.onclick = () => { qty.value = c.dataset.amt; updEst(); });
$('.chip[data-max]')?.addEventListener('click', () => {
const a = state.assets.get(sym);
qty.value = (state.user.balance / a.price).toFixed(4);
updEst();
});
$('#btnBuy').onclick = () => doTrade(sym, 'buy', parseFloat(qty.value));
$('#btnSell').onclick = () => doTrade(sym, 'sell', parseFloat(qty.value));
}
async function doTrade(sym, side, qty) {
if (!(qty > 0)) return toast('Menge ungültig', 'lose');
const r = await api('/api/trade', { symbol: sym, side, qty });
if (!r.ok) return toast(r.error, 'lose');
setBalance(r.balance);
toast(`${side === 'buy' ? 'Gekauft' : 'Verkauft'}: ${fmt(qty)} ${sym} @ ${px(r.price)}`, 'win');
}
function manipPanelHTML() {
return `
<div class="btn-row" style="margin-bottom:8px">
<button class="btn btn-green" data-mp="pump">🚀 Pump</button>
<button class="btn btn-red" data-mp="dump">📉 Dump</button>
</div>
<div class="field"><label>Kurs hart setzen</label>
<div style="display:flex;gap:8px">
<input id="mSet" type="number" inputmode="decimal" placeholder="z.B. 999" />
<button class="btn btn-gold" style="width:auto;padding:0 18px" data-mp="set">Set</button>
</div>
</div>
<div class="btn-row">
<button class="btn btn-ghost" data-mp="halt">⏸️ Halt</button>
<button class="btn btn-ghost" data-mp="resume">▶️ Resume</button>
<button class="btn btn-ghost" data-mp="reset">♻️ Reset</button>
</div>`;
}
function wireManipPanel(sym) {
$$('[data-mp]').forEach(b => b.onclick = async () => {
const action = b.dataset.mp;
const value = action === 'set' ? parseFloat($('#mSet').value) : (action === 'pump' || action === 'dump' ? 3 : 0);
const r = await api('/api/manipulate', { symbol: sym, action, value });
if (!r.ok) return toast(r.error, 'lose');
toast(`${sym}: ${action}`);
});
}
// ---------- Chart ----------
async function drawChartFor(sym) {
let hist = state.liveHistory.get(sym);
if (!hist || hist.length < 5) {
const r = await get(`/api/market/${sym}/history`);
if (r.ok) { hist = r.history; state.liveHistory.set(sym, hist.slice()); }
}
drawChart();
}
function drawChart() {
const c = $('#chart'); if (!c) return;
const sym = state.selected; if (!sym) return;
const hist = state.liveHistory.get(sym) || [];
const dpr = window.devicePixelRatio || 1;
const w = c.clientWidth, h = 180;
c.width = w * dpr; c.height = h * dpr;
const ctx = c.getContext('2d'); ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (hist.length < 2) return;
const ys = hist.map(p => p.p);
let min = Math.min(...ys), max = Math.max(...ys);
if (min === max) { min *= 0.999; max *= 1.001; }
const pad = 10;
const X = i => pad + (i / (hist.length - 1)) * (w - 2 * pad);
const Y = v => h - pad - ((v - min) / (max - min)) * (h - 2 * pad);
const up = ys[ys.length - 1] >= ys[0];
const col = up ? '#1fd17a' : '#ff4d61';
// Fill
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, up ? 'rgba(31,209,122,.25)' : 'rgba(255,77,97,.25)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath(); ctx.moveTo(X(0), Y(ys[0]));
hist.forEach((p, i) => ctx.lineTo(X(i), Y(p.p)));
ctx.lineTo(X(hist.length - 1), h - pad); ctx.lineTo(X(0), h - pad); ctx.closePath();
ctx.fillStyle = grad; ctx.fill();
// Line
ctx.beginPath(); ctx.moveTo(X(0), Y(ys[0]));
hist.forEach((p, i) => ctx.lineTo(X(i), Y(p.p)));
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke();
// Last dot
const lx = X(hist.length - 1), ly = Y(ys[ys.length - 1]);
ctx.beginPath(); ctx.arc(lx, ly, 3.5, 0, 7); ctx.fillStyle = col; ctx.fill();
}
// ===================== GAMES =====================
const GAMES = [
{ id: 'chicken', emoji: '🐔', name: 'Chicken Road', desc: 'Überquere die Spuren, cashe vor dem Crash', tag: 'Skill', glow: 'rgba(201,168,76,.28)' },
{ id: 'mines', emoji: '🃏', name: '10×10 Deck', desc: '100 Karten, finde die Diamanten', tag: 'Cards', glow: 'rgba(70,201,139,.24)' },
{ id: 'crash', emoji: '🚀', name: 'Crash', desc: 'Raus bevor die Rakete explodiert', tag: 'Live', glow: 'rgba(224,98,94,.26)' },
{ id: 'mine_dice', emoji: '🎲', name: 'Dice', desc: 'Über/Unter — wähle dein Risiko', tag: 'Classic', glow: 'rgba(201,168,76,.24)' },
{ id: 'coin', emoji: '🪙', name: 'Coinflip', desc: 'Kopf oder Zahl, 2×', tag: '50/50', glow: 'rgba(216,189,110,.26)' },
];
function renderGames() {
$('#gamesGrid').innerHTML = GAMES.map((g, i) => `
<div class="game-card" data-game="${g.id}" style="--glow:${g.glow}">
<span class="g-tag">${g.tag}</span>
<div class="g-emoji">${g.emoji}</div>
<div>
<div class="g-name">${g.name}</div>
<div class="g-desc">${g.desc}</div>
</div>
</div>`).join('');
const cards = $$('.game-card');
cards.forEach(c => c.onclick = () => {
if (!state.user) return openAuthModal();
openGame(c.dataset.game);
});
if (gsapOK()) gsap.from(cards, { opacity: 0, y: 18, scale: .96, duration: .5, ease: 'power3.out', stagger: .07 });
$('#gamesLoginHint').hidden = !!state.user;
}
function openGame(id) {
if (id === 'chicken') gameChicken();
else if (id === 'mines') gameMines();
else if (id === 'crash') gameCrash();
else if (id === 'mine_dice') gameDice();
else if (id === 'coin') gameCoin();
}
function betHeader(title, emoji) {
return `<div class="modal-head"><h2>${emoji} ${title}</h2><button class="modal-close" onclick="window._closeModal()">✕</button></div>
<div class="muted" id="gBal" style="margin-bottom:10px">Guthaben: ${coins(state.user.balance)}</div>`;
}
function refreshGBal() { const el = $('#gBal'); if (el) el.textContent = `Guthaben: ${coins(state.user.balance)}`; }
// ---------- Chicken Road ----------
function gameChicken() {
openModal(`${betHeader('Chicken Road', '🐔')}
<div id="chSetup">
<div class="field"><label>Einsatz</label><input id="chBet" type="number" value="10" min="1" /></div>
<div class="field"><label>Schwierigkeit</label>
<div class="chips" id="chDiff">
<button class="chip active" data-d="easy">Easy</button>
<button class="chip" data-d="medium">Medium</button>
<button class="chip" data-d="hard">Hard</button>
<button class="chip" data-d="insane">Insane 🔥</button>
</div>
</div>
<button class="btn btn-gold" id="chStart">Start 🐔</button>
</div>
<div id="chPlay" hidden>
<div class="chicken-stage" id="chStage">
<div class="chicken-track" id="chTrack"></div>
<div class="chicken-hero idle" id="chHero">🐔</div>
<div class="boom-flash" id="chBoom"></div>
</div>
<div class="chicken-hud"><span class="muted">Multiplikator</span> <span class="mult-big" id="chMult">1.00×</span></div>
<div class="btn-row">
<button class="btn btn-accent" id="chWalk">Schritt 🐾</button>
<button class="btn btn-green" id="chCash">Cashout 💰</button>
</div>
</div>`);
let diff = 'easy';
$$('#chDiff .chip').forEach(c => c.onclick = () => { $$('#chDiff .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); diff = c.dataset.d; });
let round = null, busy = false;
function buildStage(rd) {
const cells = ['<div class="lane curb" id="cell-0"><div class="mult-flag">START</div></div>'];
rd.mults.forEach((m, i) => cells.push(
`<div class="lane" id="cell-${i + 1}"><div class="mult-flag">${m.toFixed(2)}×</div><div class="car">🚗</div></div>`));
$('#chTrack').innerHTML = cells.join('');
}
function markLanes(rd) {
for (let i = 0; i <= rd.lanes; i++) {
const c = $(`#cell-${i}`); if (!c) continue;
c.classList.toggle('passed', i > 0 && i <= rd.lane);
c.classList.toggle('current', i === rd.lane + 1);
}
}
function scrollTo(lane) {
const cell = $(`#cell-${lane}`); if (!cell) return;
$('#chTrack').scrollTo({ left: Math.max(0, cell.offsetLeft - 24), behavior: 'smooth' });
}
function hop() {
const hero = $('#chHero'); if (!hero) return;
hero.classList.remove('idle', 'hop'); void hero.offsetWidth; hero.classList.add('hop');
setTimeout(() => hero && hero.classList.add('idle'), 360);
}
function setMult(v) {
const el = $('#chMult'); if (!el) return;
el.textContent = v.toFixed(2) + '×';
if (gsapOK()) gsap.fromTo(el, { scale: 1.4 }, { scale: 1, duration: .4, ease: 'back.out(3)' });
}
$('#chStart').onclick = async () => {
if (busy) return; busy = true;
const bet = parseFloat($('#chBet').value);
const r = await api('/api/games/chicken/start', { bet, difficulty: diff });
busy = false;
if (!r.ok) return toast(r.error, 'lose');
setBalance(r.balance); refreshGBal();
round = { id: r.roundId, mults: r.multipliers, lane: 0, lanes: r.lanes, bet };
$('#chSetup').hidden = true; $('#chPlay').hidden = false;
buildStage(round); markLanes(round); scrollTo(0);
$('#chHero').textContent = '🐔'; $('#chHero').className = 'chicken-hero idle';
$('#chMult').textContent = '1.00×';
};
$('#chWalk').onclick = async () => {
if (!round || busy) return; busy = true;
const r = await api('/api/games/chicken/step', { roundId: round.id });
if (!r.ok) { busy = false; return toast(r.error, 'lose'); }
setBalance(r.balance ?? state.user.balance); refreshGBal();
if (r.dead) {
hop();
const cell = $(`#cell-${r.lane}`);
if (cell) cell.classList.add('dead');
scrollTo(r.lane);
setTimeout(() => {
const stage = $('#chStage'), hero = $('#chHero'), boom = $('#chBoom');
if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); }
if (boom) { boom.classList.remove('go'); void boom.offsetWidth; boom.classList.add('go'); }
if (hero) { hero.classList.remove('idle'); hero.textContent = '💥'; }
toast('💥 Erwischt! Verloren.', 'lose');
}, 280);
endChicken();
} else {
round.lane = r.lane;
hop(); markLanes(round); scrollTo(round.lane);
setMult(r.multiplier);
if (r.maxed) { bigWinFX(r.multiplier); toast(`🏁 Ende erreicht! +${coins(r.payout)}`, 'win'); endChicken(); }
else busy = false;
}
};
$('#chCash').onclick = async () => {
if (!round || busy) return; busy = true;
const r = await api('/api/games/chicken/cashout', { roundId: round.id });
if (!r.ok) { busy = false; return toast(r.error, 'lose'); }
setBalance(r.balance); refreshGBal();
bigWinFX(r.multiplier);
toast(`💰 Cashout ${r.multiplier.toFixed(2)}× → +${coins(r.payout)}`, 'win');
endChicken();
};
function endChicken() {
round = null;
setTimeout(() => { busy = false; if ($('#chPlay')) { $('#chSetup').hidden = false; $('#chPlay').hidden = true; } }, 1600);
}
}
// ---------- Mines 10x10 ----------
function gameMines() {
openModal(`${betHeader('10×10 Karten-Deck', '🃏')}
<div id="mSetup">
<div class="field"><label>Einsatz</label><input id="mBet" type="number" value="10" min="1" /></div>
<div class="field"><label>Bomben 💣</label>
<div class="chips" id="mBombs">
<button class="chip" data-b="5">5</button>
<button class="chip active" data-b="10">10</button>
<button class="chip" data-b="20">20</button>
<button class="chip" data-b="35">35</button>
</div>
</div>
<button class="btn btn-accent" id="mStart">Deck mischen 🃏</button>
</div>
<div id="mPlay" hidden>
<div class="grid2" style="margin-bottom:10px">
<div class="stat-tile"><div class="lbl">Multiplikator</div><div class="val" id="mMult" style="color:var(--gold)">1.00×</div></div>
<div class="stat-tile"><div class="lbl">Auszahlung</div><div class="val" id="mPay" style="color:var(--green)">0</div></div>
</div>
<div class="mines-grid" id="mGrid"></div>
<button class="btn btn-green" id="mCash">Cashout 💰</button>
</div>`);
let bombs = 10;
$$('#mBombs .chip').forEach(c => c.onclick = () => { $$('#mBombs .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); bombs = +c.dataset.b; });
let round = null, busy = false;
const tileHTML = (i) => `<div class="tile" data-i="${i}"><div class="face back"></div><div class="face front"></div></div>`;
function flip(t, kind, sym) {
t.classList.add('flipped', kind);
const f = t.querySelector('.front'); if (f) f.textContent = sym;
}
function setStats(mult) {
const mEl = $('#mMult'), pEl = $('#mPay');
if (mEl) mEl.textContent = mult.toFixed(2) + '×';
if (pEl) pEl.textContent = fmt(round.bet * mult);
if (gsapOK() && mEl) gsap.fromTo([mEl, pEl], { scale: 1.25 }, { scale: 1, duration: .35, ease: 'back.out(2.5)' });
}
$('#mStart').onclick = async () => {
if (busy) return; busy = true;
const bet = parseFloat($('#mBet').value);
const r = await api('/api/games/mines/start', { bet, bombs });
busy = false;
if (!r.ok) return toast(r.error, 'lose');
setBalance(r.balance); refreshGBal();
round = { id: r.roundId, bet, picks: 0, mult: 1, over: false };
$('#mSetup').hidden = true; $('#mPlay').hidden = false;
$('#mMult').textContent = '1.00×'; $('#mPay').textContent = '0';
$('#mGrid').innerHTML = Array.from({ length: 100 }, (_, i) => tileHTML(i)).join('');
$$('#mGrid .tile').forEach(t => t.onclick = () => revealTile(+t.dataset.i, t));
if (gsapOK()) gsap.from('#mGrid .tile', { opacity: 0, scale: .5, duration: .35, ease: 'power2.out', stagger: { each: .004, from: 'center', grid: [10, 10] } });
};
async function revealTile(i, el) {
if (!round || round.over || el.classList.contains('flipped') || busy) return;
busy = true;
const r = await api('/api/games/mines/reveal', { roundId: round.id, index: i });
if (!r.ok) { busy = false; return toast(r.error, 'lose'); }
if (r.bomb) {
round.over = true;
flip(el, 'bomb', '💣'); el.classList.add('hit');
const stage = $('#mGrid');
if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); }
r.mines.forEach((m, idx) => {
if (m === i) return;
const t = $(`#mGrid .tile[data-i="${m}"]`);
if (t) setTimeout(() => flip(t, 'bomb', '💣'), 60 + idx * 18);
});
$$('#mGrid .tile').forEach(t => { if (!t.classList.contains('flipped')) t.classList.add('dim'); });
setBalance(r.balance); refreshGBal();
toast('💥 Bombe! Verloren.', 'lose');
endMines();
} else {
flip(el, 'safe', '💎');
round.picks = r.picks; round.mult = r.multiplier;
setStats(r.multiplier);
if (r.cleared) { setBalance(r.balance); refreshGBal(); bigWinFX(r.multiplier); toast(`🏆 Deck geknackt! +${coins(r.payout)}`, 'win'); endMines(); }
else busy = false;
}
}
$('#mCash').onclick = async () => {
if (!round || round.over || busy) return; busy = true;
const r = await api('/api/games/mines/cashout', { roundId: round.id });
if (!r.ok) { busy = false; return toast(r.error, 'lose'); }
round.over = true;
setBalance(r.balance); refreshGBal();
r.mines.forEach((m, idx) => { const t = $(`#mGrid .tile[data-i="${m}"]`); if (t && !t.classList.contains('flipped')) setTimeout(() => flip(t, 'bomb', '💣'), idx * 14); });
bigWinFX(r.multiplier);
toast(`💰 +${coins(r.payout)} (${r.multiplier.toFixed(2)}×)`, 'win');
endMines();
};
function endMines() { if (round) round.over = true; setTimeout(() => { busy = false; if ($('#mPlay')) { $('#mSetup').hidden = false; $('#mPlay').hidden = true; } }, 1800); }
}
// ---------- Crash ----------
function gameCrash() {
openModal(`${betHeader('Crash', '🚀')}
<div id="crSetup">
<div class="field"><label>Einsatz</label><input id="crBet" type="number" value="10" min="1" /></div>
<button class="btn btn-gold" id="crStart">Start 🚀</button>
</div>
<div id="crPlay" hidden>
<div class="crash-stage" id="crStage">
<canvas id="crCanvas"></canvas>
<div class="crash-overlay">
<div class="crash-mult" id="crMult">1.00×</div>
<div class="crash-sub" id="crSub">🚀 steigt…</div>
</div>
</div>
<button class="btn btn-green" id="crCash">Cashout 💰</button>
</div>`);
let round = null, raf = null, poll = null, particles = [];
const stars = Array.from({ length: 46 }, () => ({ x: Math.random(), y: Math.random(), r: Math.random() * 1.3 + 0.3, tw: Math.random() * 6 }));
function cv() {
const c = $('#crCanvas'); if (!c) return null;
const dpr = window.devicePixelRatio || 1;
const w = c.clientWidth || 320, h = 240;
if (c.width !== Math.round(w * dpr)) { c.width = w * dpr; c.height = h * dpr; }
const ctx = c.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, w, h };
}
function rocketPos(elapsed, m, w, h) {
const curMax = Math.max(1.4, m * 1.12), pad = 14;
const x = pad + Math.min(1, elapsed / Math.max(elapsed, 1)) * (w - 2 * pad);
const y = h - pad - ((m - 1) / (curMax - 1)) * (h - 2 * pad);
return { x, y, curMax, pad };
}
function draw(elapsed, m, color) {
const ctxv = cv(); if (!ctxv) return; const { ctx, w, h } = ctxv;
ctx.clearRect(0, 0, w, h);
// stars
stars.forEach(s => { ctx.globalAlpha = .35 + .45 * Math.abs(Math.sin(elapsed / 700 + s.tw)); ctx.fillStyle = '#d8bd6e'; ctx.beginPath(); ctx.arc(s.x * w, s.y * h, s.r, 0, 7); ctx.fill(); });
ctx.globalAlpha = 1;
const { x: rx, y: ry, curMax, pad } = rocketPos(elapsed, m, w, h);
const N = 64;
// fill under curve
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, color === 'red' ? 'rgba(224,98,94,.30)' : 'rgba(201,168,76,.30)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath(); ctx.moveTo(pad, h - pad);
for (let i = 0; i <= N; i++) { const t = elapsed * i / N; const mm = Math.exp(round.k * t); const x = pad + (i / N) * (rx - pad); const y = h - pad - ((mm - 1) / (curMax - 1)) * (h - 2 * pad); ctx.lineTo(x, y); }
ctx.lineTo(rx, h - pad); ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
// curve line
ctx.beginPath();
for (let i = 0; i <= N; i++) { const t = elapsed * i / N; const mm = Math.exp(round.k * t); const x = pad + (i / N) * (rx - pad); const y = h - pad - ((mm - 1) / (curMax - 1)) * (h - 2 * pad); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
ctx.strokeStyle = color === 'red' ? '#e0625e' : '#d8bd6e'; ctx.lineWidth = 3; ctx.lineJoin = 'round';
ctx.shadowColor = color === 'red' ? 'rgba(224,98,94,.7)' : 'rgba(201,168,76,.6)'; ctx.shadowBlur = 12; ctx.stroke(); ctx.shadowBlur = 0;
if (color !== 'red') {
// flame
const fl = 8 + Math.random() * 8;
ctx.fillStyle = 'rgba(255,180,60,.8)'; ctx.beginPath(); ctx.moveTo(rx - 6, ry + 6); ctx.lineTo(rx - 16 - fl, ry + 12); ctx.lineTo(rx - 6, ry + 14); ctx.closePath(); ctx.fill();
// rocket
ctx.font = '22px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.save(); ctx.translate(rx, ry); ctx.rotate(-Math.PI / 5); ctx.fillText('🚀', 0, 0); ctx.restore();
}
// particles (explosion)
particles.forEach(p => {
p.x += p.vx; p.y += p.vy; p.vy += 0.12; p.life -= 0.022; p.vx *= 0.99;
ctx.globalAlpha = Math.max(0, p.life); ctx.fillStyle = p.c; ctx.beginPath(); ctx.arc(p.x, p.y, p.s, 0, 7); ctx.fill();
});
particles = particles.filter(p => p.life > 0);
ctx.globalAlpha = 1;
}
function explode(x, y) {
const cols = ['#e0625e', '#ffb43c', '#d8bd6e', '#f4f1e9'];
for (let i = 0; i < 46; i++) { const a = Math.random() * Math.PI * 2, sp = 1 + Math.random() * 5; particles.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1, s: 1.5 + Math.random() * 3, life: 1, c: cols[i % cols.length] }); }
}
$('#crStart').onclick = async () => {
const bet = parseFloat($('#crBet').value);
const r = await api('/api/games/crash/start', { bet });
if (!r.ok) return toast(r.error, 'lose');
setBalance(r.balance); refreshGBal();
round = { id: r.roundId, k: r.k, start: Date.now(), over: false, bet };
particles = [];
$('#crSetup').hidden = true; $('#crPlay').hidden = false;
$('#crMult').classList.remove('boom'); $('#crMult').style.color = ''; $('#crSub').textContent = '🚀 steigt…';
animate();
poll = setInterval(checkStatus, 280);
};
function animate() {
if (!round || round.over) return;
const elapsed = Date.now() - round.start;
const m = Math.exp(round.k * elapsed);
$('#crMult').textContent = m.toFixed(2) + '×';
$('#crMult').style.color = m >= 2 ? 'var(--gold-soft)' : '';
draw(elapsed, m, 'gold');
raf = requestAnimationFrame(animate);
}
async function checkStatus() {
if (!round || round.over) return;
const r = await api('/api/games/crash/status', { roundId: round.id });
if (r.ok && r.crashed) boom(r.multiplier);
}
function boom(at) {
if (!round) return;
round.over = true; cancelAnimationFrame(raf); clearInterval(poll);
const elapsed = Date.now() - round.start;
const ctxv = cv();
if (ctxv) { const p = rocketPos(elapsed, at, ctxv.w, ctxv.h); explode(p.x, p.y); }
$('#crMult').textContent = at.toFixed(2) + '×'; $('#crMult').classList.add('boom');
$('#crSub').textContent = '💥 CRASHED!';
const stage = $('#crStage'); if (stage) { stage.classList.remove('stage-shake'); void stage.offsetWidth; stage.classList.add('stage-shake'); }
boomLoop(elapsed, at);
toast('💥 Crashed! Verloren.', 'lose');
endCrash();
}
function boomLoop(elapsed, at) {
if (!particles.length) return;
draw(elapsed, at, 'red');
requestAnimationFrame(() => boomLoop(elapsed, at));
}
$('#crCash').onclick = async () => {
if (!round || round.over) return;
const r = await api('/api/games/crash/cashout', { roundId: round.id });
if (!r.ok) return toast(r.error, 'lose');
round.over = true; cancelAnimationFrame(raf); clearInterval(poll);
setBalance(r.balance); refreshGBal();
if (r.crashed) {
const elapsed = Date.now() - round.start; const ctxv = cv();
if (ctxv) { const p = rocketPos(elapsed, r.crashPoint || 1, ctxv.w, ctxv.h); explode(p.x, p.y); boomLoop(elapsed, r.crashPoint || 1); }
$('#crMult').classList.add('boom'); $('#crSub').textContent = '💥 zu spät!'; toast('💥 Zu spät!', 'lose');
} else {
$('#crMult').textContent = r.multiplier.toFixed(2) + '×'; $('#crMult').style.color = 'var(--green)'; $('#crSub').textContent = '💰 Cashout!';
bigWinFX(r.multiplier); toast(`💰 ${r.multiplier.toFixed(2)}× → +${coins(r.payout)}`, 'win');
}
endCrash();
};
function endCrash() { setTimeout(() => { if ($('#crPlay')) { $('#crSetup').hidden = false; $('#crPlay').hidden = true; } round = null; }, 2200); }
}
// ---------- Dice ----------
function gameDice() {
openModal(`${betHeader('Dice', '🎲')}
<div class="dice-bar-wrap">
<div class="dice-bar"><div class="win-zone" id="dZone"></div><div class="dice-marker settle" id="dMark">50</div></div>
<div class="dice-scale"><span>0</span><span>25</span><span>50</span><span>75</span><span>100</span></div>
</div>
<div class="dice-result muted" id="dRes">Bereit?</div>
<div class="field"><label>Einsatz</label><input id="dBet" type="number" value="10" min="1" /></div>
<div class="field"><label>Richtung</label>
<div class="chips" id="dDir"><button class="chip active" data-d="under">Unter ⬇️</button><button class="chip" data-d="over">Über ⬆️</button></div>
</div>
<div class="field"><label>Ziel: <b id="dTval">50</b></label><input id="dTarget" type="range" min="2" max="98" value="50" /></div>
<div class="grid2" style="margin-bottom:14px">
<div class="stat-tile"><div class="lbl">Gewinnchance</div><div class="val" id="dChance">50%</div></div>
<div class="stat-tile"><div class="lbl">Multiplikator</div><div class="val" id="dMult" style="color:var(--gold)">1.98×</div></div>
</div>
<button class="btn btn-green" id="dRoll">Würfeln 🎲</button>`);
let dir = 'under', rolling = false;
const upd = () => {
const t = +$('#dTarget').value; $('#dTval').textContent = t;
const chance = dir === 'under' ? t : 100 - t;
$('#dChance').textContent = chance + '%';
$('#dMult').textContent = ((100 / chance) * 0.99).toFixed(2) + '×';
const zone = $('#dZone');
if (dir === 'under') { zone.style.left = '0%'; zone.style.right = 'auto'; zone.style.width = t + '%'; }
else { zone.style.left = t + '%'; zone.style.right = '0'; zone.style.width = (100 - t) + '%'; }
};
function moveMarker(v, settle) {
const m = $('#dMark'); if (!m) return;
m.classList.toggle('settle', !!settle);
m.style.left = v + '%'; m.textContent = (Math.round(v * 10) / 10).toFixed(1);
}
$('#dTarget').addEventListener('input', upd);
$$('#dDir .chip').forEach(c => c.onclick = () => { $$('#dDir .chip').forEach(x => x.classList.remove('active')); c.classList.add('active'); dir = c.dataset.d; upd(); });
upd();
$('#dRoll').onclick = async () => {
if (rolling) return; rolling = true;
const btn = $('#dRoll'); btn.disabled = true;
$('#dRes').textContent = '🎲'; $('#dRes').className = 'dice-result muted';
const r = await api('/api/games/dice', { bet: parseFloat($('#dBet').value), target: +$('#dTarget').value, direction: dir });
if (!r.ok) { rolling = false; btn.disabled = false; return toast(r.error, 'lose'); }
setBalance(r.balance); refreshGBal();
// spin the marker, then settle on the real roll
let ticks = 0; const spin = setInterval(() => { moveMarker(2 + Math.random() * 96, false); ticks++; }, 55);
setTimeout(() => {
clearInterval(spin);
moveMarker(r.roll, true);
const res = $('#dRes'); res.textContent = r.roll.toFixed(2);
res.className = 'dice-result ' + (r.win ? 'up' : 'down');
if (r.win) { bigWinFX(r.multiplier); toast(`🎉 Gewonnen! +${coins(r.payout)}`, 'win'); }
else toast('😢 Verloren', 'lose');
rolling = false; btn.disabled = false;
}, 760);
};
}
// ---------- Coinflip ----------
function gameCoin() {
openModal(`${betHeader('Coinflip', '🪙')}
<div class="coin-stage"><div class="coin" id="cCoin"><div class="side heads">👑</div><div class="side tails">🔢</div></div></div>
<div class="field"><label>Einsatz</label><input id="cBet" type="number" value="10" min="1" /></div>
<div class="btn-row">
<button class="btn btn-gold" id="cHeads">Kopf 👑</button>
<button class="btn btn-ghost" id="cTails">Zahl 🔢</button>
</div>`);
let busy = false;
async function flip(choice) {
if (busy) return; busy = true;
$('#cHeads').disabled = $('#cTails').disabled = true;
const coin = $('#cCoin');
coin.classList.remove('spin-heads', 'spin-tails'); coin.style.transform = 'none'; void coin.offsetWidth;
const r = await api('/api/games/coinflip', { bet: parseFloat($('#cBet').value), choice });
if (!r.ok) { busy = false; $('#cHeads').disabled = $('#cTails').disabled = false; return toast(r.error, 'lose'); }
setBalance(r.balance); refreshGBal();
coin.style.transform = '';
coin.classList.add(r.flip === 'heads' ? 'spin-heads' : 'spin-tails');
setTimeout(() => {
if (r.win) { bigWinFX(2); toast(`🎉 ${r.flip === 'heads' ? 'Kopf' : 'Zahl'}! +${coins(r.payout)}`, 'win'); }
else toast(`😢 ${r.flip === 'heads' ? 'Kopf' : 'Zahl'} — verloren`, 'lose');
busy = false; $('#cHeads').disabled = $('#cTails').disabled = false;
}, 2400);
}
$('#cHeads').onclick = () => flip('heads');
$('#cTails').onclick = () => flip('tails');
}
// ===================== PORTFOLIO =====================
async function renderPortfolio() {
const el = $('#portfolioContent');
if (!state.user) { el.innerHTML = '<div class="login-hint">🔒 <button class="link" data-open-auth onclick="window._openAuth()">Einloggen</button> um dein Depot zu sehen</div>'; return; }
const r = await get('/api/portfolio');
if (!r.ok) { el.innerHTML = `<div class="muted">${r.error}</div>`; return; }
const p = r.portfolio;
const pnlTotal = p.holdings.reduce((s, h) => s + h.pnl, 0);
el.innerHTML = `
<div class="card">
<div class="muted" style="font-size:.8rem">Gesamtwert (Net Worth)</div>
<div class="big-num">${coins(p.netWorth)}</div>
<div class="stat-row"><span class="k">💵 Cash</span><span class="v">${coins(p.balance)}</span></div>
<div class="stat-row"><span class="k">📊 Positionen</span><span class="v">${coins(p.holdingsValue)}</span></div>
<div class="stat-row"><span class="k">P&L offen</span><span class="v ${pnlTotal>=0?'up':'down'}">${pnlTotal>=0?'+':''}${fmt(pnlTotal)}</span></div>
</div>
<h2>Positionen</h2>
${p.holdings.length ? p.holdings.map(h => `
<div class="asset-row" style="grid-template-columns:40px 1fr auto" data-sym="${h.symbol}">
<div class="asset-emoji">${h.emoji}</div>
<div class="asset-info">
<div class="sym">${h.symbol} <span class="pill ${h.side}">${h.side.toUpperCase()}</span></div>
<div class="name">${fmt(h.quantity)} @ ${px(h.avgPrice)}</div>
</div>
<div class="asset-price">
<div class="px">${coins(h.value)}</div>
<div class="chg ${h.pnl>=0?'up':'down'}">${h.pnl>=0?'+':''}${fmt(h.pnl)} (${fmt(h.pnlPct)}%)</div>
</div>
</div>`).join('') : '<div class="muted center" style="padding:20px">Noch keine Positionen. Geh ins Trading! 📈</div>'}
<h2>Letzte Trades</h2>
<div id="tradeHist" class="feed-list"></div>`;
$$('#portfolioContent .asset-row').forEach(row => row.onclick = () => { switchView('trading'); openAssetDetail(row.dataset.sym); });
const tr = await get('/api/trades');
if (tr.ok) $('#tradeHist').innerHTML = tr.trades.slice(0, 15).map(t => `
<div class="feed-item"><span>${t.side === 'buy' ? '🟢' : '🔴'}</span><span>${t.side === 'buy' ? 'Kauf' : 'Verkauf'} ${fmt(t.qty)} ${t.symbol} @ ${px(t.price)}</span><span class="ago">${timeAgo(t.ts)}</span></div>`).join('') || '<div class="muted">—</div>';
}
// ===================== SOCIAL =====================
async function renderSocial() {
const r = await get('/api/leaderboard');
const lb = $('#leaderboardContent');
if (r.ok) {
lb.innerHTML = r.leaderboard.map((u, i) => `
<div class="lb-row ${state.user && u.username === state.user.username ? 'me' : ''}">
<div class="lb-rank">${['🥇','🥈','🥉'][i] || '#' + (i + 1)}</div>
<div class="lb-user">${renderAvatar(u.avatar, { size: 'sm' })}<b>${u.username}</b></div>
<div class="v">${coins(u.netWorth)}</div>
</div>`).join('');
hydrateFramedImages(lb);
}
const f = await get('/api/feed');
if (f.ok) renderFeedList(f.feed);
}
function renderFeedList(feed) {
const el = $('#feedList'); if (!el) return;
const icon = { win: '🏆', trade: '💱', manip: '🎛️', admin: '💰', join: '🎉' };
el.innerHTML = feed.map(f => `<div class="feed-item"><span>${icon[f.kind] || '•'}</span><span>${f.text}</span><span class="ago">${timeAgo(f.ts)}</span></div>`).join('');
}
// ===================== SETTINGS / ADMIN =====================
async function handleAvatarFile(file) {
if (!file) return;
try {
await uploadImageWithEditor(file, {
frame: 'avatar',
uploadFn: async (f, result) => {
const fd = new FormData();
fd.append('file', f);
appendPositionFields(fd, result);
const r = await apiForm('/api/settings/avatar/image', fd);
if (!r.ok) { toast(r.error, 'lose'); return null; }
state.user.avatar = r.avatar;
updateAuthUI();
renderSettings();
toast('Profilbild gespeichert ✅', 'win');
return r;
},
});
} catch (e) {
toast(e.message || 'Upload fehlgeschlagen', 'lose');
}
}
async function adjustAvatarPosition() {
const av = state.user?.avatar;
if (!isImageAvatar(av)) return;
try {
await repositionImageWithEditor(av, {
frame: 'avatar',
saveFn: async (result) => {
const r = await api('/api/settings/avatar/image/position', {
focusX: result.focus.x,
focusY: result.focus.y,
zoom: result.zoom,
}, 'PATCH');
if (!r.ok) { toast(r.error, 'lose'); return null; }
state.user.avatar = r.avatar;
updateAuthUI();
renderSettings();
toast('Position gespeichert ✅', 'win');
return r;
},
});
} catch (e) {
toast(e.message || 'Fehler', 'lose');
}
}
async function removeAvatarImage() {
const r = await apiDelete('/api/settings/avatar/image');
if (!r.ok) return toast(r.error, 'lose');
state.user.avatar = r.avatar;
updateAuthUI();
renderSettings();
toast('Profilbild entfernt');
}
let selectedUploadFrame = 'gallery';
async function handleGalleryUpload(file, frame) {
if (!file) return;
try {
await uploadImageWithEditor(file, {
frame,
uploadFn: async (f, result) => {
const fd = new FormData();
fd.append('file', f);
appendPositionFields(fd, result, frame);
const r = await apiForm('/api/uploads', fd);
if (!r.ok) { toast(r.error, 'lose'); return null; }
toast('Bild hochgeladen ✅', 'win');
renderSettings();
return r;
},
});
} catch (e) {
toast(e.message || 'Upload fehlgeschlagen', 'lose');
}
}
async function repositionGalleryItem(item) {
try {
await repositionImageWithEditor(item, {
frame: item.frame,
saveFn: async (result) => {
const r = await api(`/api/uploads/${item.id}/position`, {
focusX: result.focus.x,
focusY: result.focus.y,
zoom: result.zoom,
}, 'PATCH');
if (!r.ok) { toast(r.error, 'lose'); return null; }
toast('Position gespeichert ✅', 'win');
renderSettings();
return r;
},
});
} catch (e) {
toast(e.message || 'Fehler', 'lose');
}
}
async function deleteGalleryItem(id) {
const r = await apiDelete(`/api/uploads/${id}`);
if (!r.ok) return toast(r.error, 'lose');
toast('Bild gelöscht');
renderSettings();
}
async function renderSettings() {
const el = $('#settingsContent');
if (!state.user) { el.innerHTML = '<div class="login-hint">🔒 <button class="link" onclick="window._openAuth()">Einloggen</button></div>'; return; }
const av = state.user.avatar;
const hasImage = isImageAvatar(av);
const currentEmoji = avatarEmoji(av);
const uploadsRes = await get('/api/uploads');
const uploads = uploadsRes.ok ? uploadsRes.uploads : [];
el.innerHTML = `
<div class="card">
<h2 style="margin-top:0">👤 Profil</h2>
<div class="profile-avatar-row">
${renderAvatar(av, { size: 'lg' })}
<div class="profile-avatar-actions">
<button type="button" class="btn btn-accent" id="avUploadBtn">📷 Profilbild hochladen</button>
<input type="file" id="avFile" accept="image/*" hidden />
${hasImage ? '<button type="button" class="btn btn-ghost" id="avReposition">↕️ Position anpassen</button>' : ''}
${hasImage ? '<button type="button" class="btn btn-ghost" id="avRemoveImg">🗑️ Bild entfernen</button>' : ''}
</div>
</div>
<div class="field"><label>Avatar (Emoji)</label>
<div class="chips" id="avChips">${['🐂','🚀','💎','🔥','🤑','👑','🦍','🐳','🃏','🎰'].map(e => `<button class="chip ${!hasImage && currentEmoji === e ? 'active' : ''}" data-av="${e}">${e}</button>`).join('')}</div>
</div>
</div>
<div class="card">
<h2 style="margin-top:0">🖼️ Uploads</h2>
<p class="muted" style="font-size:.82rem;margin-top:0">Bilder hochladen — Editor zeigt den Rahmen wie auf der Seite.</p>
<div class="uploads-toolbar">
<div class="field" style="margin-bottom:0">
<label>Rahmen-Typ</label>
<div class="chips" id="uploadFrameChips">
<button type="button" class="chip ${selectedUploadFrame === 'gallery' ? 'active' : ''}" data-uframe="gallery">⬜ Quadrat</button>
<button type="button" class="chip ${selectedUploadFrame === 'banner' ? 'active' : ''}" data-uframe="banner">▬ Banner</button>
<button type="button" class="chip ${selectedUploadFrame === 'card' ? 'active' : ''}" data-uframe="card">🃏 Karte</button>
</div>
</div>
<button type="button" class="btn btn-accent" id="galleryUploadBtn">📷 Bild hochladen</button>
<input type="file" id="galleryFile" accept="image/*" hidden />
</div>
<div class="uploads-grid" id="uploadsGrid">
${uploads.length ? uploads.map(renderUploadItem).join('') : '<p class="muted center" style="grid-column:1/-1;padding:16px">Noch keine Uploads</p>'}
</div>
</div>
<div class="card">
<h2 style="margin-top:0">💰 Geld bearbeiten</h2>
<p class="muted" style="font-size:.82rem;margin-top:0">Spaßmodus: jeder darf bei sich & anderen Geld ändern.</p>
<div class="field"><label>User</label><select id="adUser"></select></div>
<div class="field"><label>Betrag</label><input id="adAmount" type="number" value="1000" /></div>
<div class="btn-row">
<button class="btn btn-green" id="adAdd"> Hinzufügen</button>
<button class="btn btn-accent" id="adSet">✏️ Setzen</button>
</div>
<div class="spacer"></div>
<div class="chips">
<button class="chip" data-quick="1000">+1k</button>
<button class="chip" data-quick="10000">+10k</button>
<button class="chip" data-quick="100000">+100k</button>
<button class="chip" data-quick="1000000">+1M</button>
</div>
</div>
<div class="card">
<h2 style="margin-top:0">🎛️ Markt-Kontrolle</h2>
<p class="muted" style="font-size:.82rem;margin-top:0">Pumpe/dumpe Kurse für ALLE Spieler gleichzeitig.</p>
<div class="field"><label>Asset</label><select id="mkSym">${[...state.assets.values()].map(a => `<option value="${a.symbol}">${a.emoji} ${a.symbol}</option>`).join('')}</select></div>
<div class="btn-row">
<button class="btn btn-green" data-mc="pump">🚀 Pump</button>
<button class="btn btn-red" data-mc="dump">📉 Dump</button>
<button class="btn btn-ghost" data-mc="reset">♻️</button>
</div>
</div>
<div class="card">
<h2 style="margin-top:0">👥 Alle Spieler</h2>
<div id="userList"></div>
</div>`;
hydrateFramedImages(el);
const fileInput = $('#avFile');
$('#avUploadBtn').onclick = () => fileInput.click();
fileInput.onchange = () => {
const f = fileInput.files?.[0];
fileInput.value = '';
handleAvatarFile(f);
};
$('#avReposition')?.addEventListener('click', adjustAvatarPosition);
$('#avRemoveImg')?.addEventListener('click', removeAvatarImage);
$$('#uploadFrameChips .chip').forEach(c => c.onclick = () => {
selectedUploadFrame = c.dataset.uframe;
$$('#uploadFrameChips .chip').forEach(x => x.classList.toggle('active', x.dataset.uframe === selectedUploadFrame));
});
const galleryInput = $('#galleryFile');
$('#galleryUploadBtn').onclick = () => galleryInput.click();
galleryInput.onchange = () => {
const f = galleryInput.files?.[0];
galleryInput.value = '';
handleGalleryUpload(f, selectedUploadFrame);
};
const uploadMap = new Map(uploads.map(u => [u.id, u]));
$$('[data-upload-repos]').forEach(btn => btn.onclick = () => {
const item = uploadMap.get(+btn.dataset.uploadRepos);
if (item) repositionGalleryItem(item);
});
$$('[data-upload-del]').forEach(btn => btn.onclick = () => deleteGalleryItem(+btn.dataset.uploadDel));
$$('#avChips .chip').forEach(c => c.onclick = async () => {
const r = await api('/api/settings/avatar', { avatar: c.dataset.av });
if (r.ok) { state.user.avatar = r.avatar; updateAuthUI(); renderSettings(); }
});
const users = (await get('/api/users')).users || [];
const sel = $('#adUser');
sel.innerHTML = users.map(u => `<option value="${u.id}" ${u.id === state.user.id ? 'selected' : ''}>${avatarEmoji(u.avatar)} ${u.username} (${fmt(u.balance)})</option>`).join('');
$('#userList').innerHTML = users.map(u => `<div class="stat-row"><span class="k" style="display:flex;align-items:center;gap:8px">${renderAvatar(u.avatar, { size: 'sm' })} ${u.username}</span><span class="v">${coins(u.balance)}</span></div>`).join('');
hydrateFramedImages($('#userList'));
const doBalance = async (mode, amount) => {
const r = await api('/api/admin/balance', { userId: +sel.value, mode, amount });
if (!r.ok) return toast(r.error, 'lose');
toast('Guthaben aktualisiert ✅', 'win');
if (+sel.value === state.user.id) setBalance(r.balance);
renderSettings();
};
$('#adAdd').onclick = () => doBalance('add', parseFloat($('#adAmount').value));
$('#adSet').onclick = () => doBalance('set', parseFloat($('#adAmount').value));
$$('[data-quick]').forEach(c => c.onclick = () => doBalance('add', +c.dataset.quick));
$$('[data-mc]').forEach(b => b.onclick = async () => {
const action = b.dataset.mc;
const r = await api('/api/manipulate', { symbol: $('#mkSym').value, action, value: action === 'reset' ? 0 : 4 });
if (!r.ok) return toast(r.error, 'lose');
toast(`${$('#mkSym').value}: ${action}`);
});
}
// ===================== LIVE (Socket.IO) =====================
function connectSocket() {
const socket = io();
state.socket = socket;
socket.on('hello', ({ assets, feed }) => {
assets.forEach(a => state.assets.set(a.symbol, a));
if (activeView() === 'trading') renderAssets();
if (feed) buildTicker(feed);
});
socket.on('prices', ({ data }) => {
data.forEach(d => {
const a = state.assets.get(d.s);
if (!a) return;
const prev = a.price;
a.price = d.p; a.change = d.c; a.changePct = d.cp; a.halted = !!d.h;
// live history
let h = state.liveHistory.get(d.s); if (!h) { h = []; state.liveHistory.set(d.s, h); }
h.push({ t: Date.now(), p: d.p }); if (h.length > 120) h.shift();
updateAssetRow(d.s, prev);
if (state.selected === d.s) updateDetail(a);
});
if (state.selected) drawChart();
});
socket.on('feed', (f) => {
addTickerItem(f);
if (activeView() === 'social') get('/api/feed').then(r => r.ok && renderFeedList(r.feed));
});
socket.on('balance_changed', ({ userId, balance }) => {
if (state.user && userId === state.user.id) setBalance(balance);
});
socket.on('manip', ({ symbol, action, by }) => { /* feed handles UI */ });
}
function updateAssetRow(sym, prevPrice) {
const row = $(`#row-${sym}`); if (!row) return;
const a = state.assets.get(sym);
const pxEl = row.querySelector('[data-px]'); const chgEl = row.querySelector('[data-chg]');
if (pxEl) pxEl.textContent = px(a.price);
if (chgEl) { chgEl.textContent = (a.change >= 0 ? '+' : '') + fmt(a.changePct) + '%'; chgEl.className = 'chg ' + (a.change >= 0 ? 'up' : 'down'); }
if (a.price !== prevPrice) {
row.classList.remove('flash-up', 'flash-down');
void row.offsetWidth;
row.classList.add(a.price > prevPrice ? 'flash-up' : 'flash-down');
}
}
function updateDetail(a) {
const p = $('#dPrice'); if (p) p.textContent = px(a.price);
const c = $('#dChg'); if (c) { c.textContent = `${a.change>=0?'+':''}${fmt(a.change)} (${fmt(a.changePct)}%)`; c.className = a.change >= 0 ? 'up' : 'down'; }
}
// ---------- Ticker ----------
function buildTicker(feed) {
const t = $('#tickerTrack');
if (!t) return;
const items = (feed && feed.length) ? feed.map(f => f.text) : ['Willkommen bei PlayBull 🐂', 'Echte Kurse · Live · Spielgeld'];
t.innerHTML = items.map(x => `<span class="ticker-item">${x}</span>`).join('') + items.map(x => `<span class="ticker-item">${x}</span>`).join('');
}
function addTickerItem(f) {
const t = $('#tickerTrack'); if (!t) return;
const s = document.createElement('span'); s.className = 'ticker-item'; s.innerHTML = f.text;
t.prepend(s);
while (t.children.length > 60) t.lastChild.remove();
}
// ===================== INIT =====================
async function init() {
const embed = new URLSearchParams(location.search).has('embed');
if (embed) {
document.body.classList.add('pb-embed');
$('#pbTopbar')?.remove();
$('#feedTicker')?.remove();
}
const me = await get('/api/me');
if (me.ok && me.user) state.user = me.user;
updateAuthUI();
const m = await get('/api/market');
if (m.ok) m.assets.forEach(a => state.assets.set(a.symbol, a));
connectSocket();
if (embed) switchView('games');
else renderActiveView();
setInterval(() => { // Ticker-Zeiten & Feed leichtgewichtig aktualisieren
if (activeView() === 'social') { /* live via socket */ }
}, 1000);
}
const authBtn = $('#authBtn');
if (authBtn) authBtn.onclick = openAuthModal;
init();