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:
797
public/app.js
Normal file
797
public/app.js
Normal file
@@ -0,0 +1,797 @@
|
||||
/* ============================================================
|
||||
Trilogy Hub — app.js
|
||||
Routing, animations, photo slots, theme, Paris dashboard
|
||||
============================================================ */
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const $ = (s, c = document) => c.querySelector(s);
|
||||
const $$ = (s, c = document) => Array.from(c.querySelectorAll(s));
|
||||
|
||||
/* -------------------- DATA -------------------- */
|
||||
const mikeProducts = [
|
||||
{ name: "Japanese Maple 'Emperor'", price: "€280", desc: "A stunning deep-red showstopper for any garden." },
|
||||
{ name: "Blue Atlas Cedar", price: "€450", desc: "Majestic evergreen with silver-blue needles. Rare and sought after." },
|
||||
{ name: "Olive Tree (300 years old)", price: "€1,200", desc: "History in your garden. Delivered with full root care." },
|
||||
{ name: "Himalayan Birch", price: "€190", desc: "Striking white bark, perfect for modern minimalist gardens." },
|
||||
];
|
||||
|
||||
const mikeReviews = [
|
||||
{ text: "Mike delivered a 200-year-old olive tree to our terrace. Unreal quality.", who: "Thomas K., Munich" },
|
||||
{ text: "The Japanese Maple is the centerpiece of our garden. Worth every cent.", who: "Anna L., Stuttgart" },
|
||||
{ text: "Professional, fast, and the tree arrived in perfect condition.", who: "Stefan B., Frankfurt" },
|
||||
{ text: "Magic Tree Co. transformed our backyard. Highly recommend.", who: "Julia R., Heidelberg" },
|
||||
{ text: "Best purchase we've ever made for our property.", who: "Markus & Sara W., Freiburg" },
|
||||
];
|
||||
|
||||
const nicoListings = [
|
||||
{ name: "Penthouse Stuttgart-Mitte", price: "€1.85M", size: "180m²", desc: "Floor-to-ceiling windows, rooftop terrace, panoramic city views." },
|
||||
{ name: "Villa Bebenhausen", price: "€2.3M", size: "320m²", desc: "Secluded forest estate with heated pool and private driveway." },
|
||||
{ name: "Modern Loft Tübingen", price: "€620,000", size: "110m²", desc: "Industrial chic in the heart of the old town. Fully renovated." },
|
||||
{ name: "Investment Apartment Block Esslingen", price: "€4.1M", size: "8 units", desc: "Fully rented, 5.8% yield. Backed by PG Trading Management." },
|
||||
];
|
||||
|
||||
const nicoTestimonials = [
|
||||
{ text: "Nico sold our Stuttgart apartment in 12 days above asking price. Unmatched.", who: "Familie Berger" },
|
||||
{ text: "NovaTerra found us our dream home in Tübingen. We never felt rushed.", who: "Dr. Katrin M." },
|
||||
{ text: "Professional, discreet, and results-driven. The best broker in the region.", who: "Investment Group H." },
|
||||
{ text: "Nico's market knowledge is extraordinary. He knows every street.", who: "Marc V., Stuttgart" },
|
||||
];
|
||||
|
||||
// Play-money portfolio. Holdings total ≈ €7,277,360 + buying power €57,539.31 = €7,334,899.31
|
||||
const positions = [
|
||||
{ ticker: "AAPL", company: "Apple Inc.", shares: 2000, avg: 165.0, price: 182.3 },
|
||||
{ ticker: "NVDA", company: "NVIDIA Corp.", shares: 1500, avg: 420.0, price: 875.4 },
|
||||
{ ticker: "TSLA", company: "Tesla Inc.", shares: 1000, avg: 240.0, price: 198.7 },
|
||||
{ ticker: "MSFT", company: "Microsoft", shares: 1200, avg: 310.0, price: 378.9 },
|
||||
{ ticker: "META", company: "Meta Platforms", shares: 900, avg: 285.0, price: 478.2 },
|
||||
{ ticker: "AMZN", company: "Amazon", shares: 1500, avg: 135.0, price: 184.6 },
|
||||
{ ticker: "GOOGL", company: "Alphabet Inc.", shares: 1000, avg: 140.0, price: 178.2 },
|
||||
{ ticker: "AMD", company: "Adv. Micro Devices", shares: 2000, avg: 110.0, price: 162.4 },
|
||||
{ ticker: "BTC-EUR", company: "Bitcoin", shares: 50, avg: 38000.0, price: 61200.0 },
|
||||
{ ticker: "ETH-EUR", company: "Ethereum", shares: 200, avg: 2200.0, price: 3380.0 },
|
||||
];
|
||||
|
||||
const tradeLog = [
|
||||
"BUY NVDA 200 shares @ €820.00 — June 18, 2026",
|
||||
"BUY ETH-EUR 50 @ €2,900.00 — June 10, 2026",
|
||||
"SELL TSLA 300 shares @ €225.00 — May 28, 2026",
|
||||
"BUY BTC-EUR 10 @ €54,000.00 — May 12, 2026",
|
||||
"BUY AAPL 500 shares @ €170.00 — April 30, 2026",
|
||||
];
|
||||
|
||||
const fmtEUR = (n, dec = 2) => "€" + n.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||
const fmtSigned = (n) => (n >= 0 ? "+" : "-") + "€" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const PORTFOLIO_TOTAL = 7334899.31;
|
||||
const BUYING_POWER = 57539.31;
|
||||
const TODAY_PNL = 48213.55;
|
||||
|
||||
/* -------------------- RENDER LISTS -------------------- */
|
||||
function slotMarkup(label, minH = "200px") {
|
||||
return `<div class="photo-slot" style="min-height:${minH}" data-slot="${label}" tabindex="0" role="button" aria-label="Upload ${label}">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-6 h-6"></i><span class="slot-label">${label}</span></div>
|
||||
<img class="preview" alt="${label}" /><span class="slot-hover-name">${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMike() {
|
||||
$("#mikeProducts").innerHTML = mikeProducts.map((p, i) => `
|
||||
<article class="reveal card card-gold card-hover overflow-hidden flex flex-col">
|
||||
${slotMarkup("Product Photo " + (i + 1), "190px")}
|
||||
<div class="p-5 flex flex-col flex-1">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 class="font-display font-bold text-base leading-tight">${p.name}</h3>
|
||||
<span class="badge shrink-0">${p.price}</span>
|
||||
</div>
|
||||
<p class="text-dim text-sm leading-relaxed flex-1">${p.desc}</p>
|
||||
<button class="btn btn-ghost mt-4 !min-h-0 !py-2.5 self-start">Request Info <i data-lucide="mail" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</article>`).join("");
|
||||
|
||||
$("#mikeReviews").innerHTML = mikeReviews.map((r) => `
|
||||
<figure class="reveal card card-hover p-6">
|
||||
<div class="star mb-3">★★★★★</div>
|
||||
<blockquote class="text-dim leading-relaxed">"${r.text}"</blockquote>
|
||||
<figcaption class="text-gold text-sm font-semibold mt-4">— ${r.who}</figcaption>
|
||||
</figure>`).join("");
|
||||
|
||||
$("#mikeGallery").innerHTML = Array.from({ length: 6 }, (_, i) =>
|
||||
`<div class="reveal">${slotMarkup("Customer Photo " + (i + 1), "200px")}</div>`).join("");
|
||||
restoreSlotImages($("#page-magic-mike"));
|
||||
}
|
||||
|
||||
function renderNico() {
|
||||
$("#nicoListings").innerHTML = nicoListings.map((p, i) => `
|
||||
<article class="reveal card card-gold card-hover overflow-hidden flex flex-col">
|
||||
${slotMarkup("Listing Photo " + (i + 1), "230px")}
|
||||
<div class="p-6 flex flex-col flex-1">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="badge">${p.price}</span><span class="chip">${p.size}</span>
|
||||
</div>
|
||||
<h3 class="font-display font-bold text-lg leading-tight mb-2">${p.name}</h3>
|
||||
<p class="text-dim text-sm leading-relaxed flex-1">${p.desc}</p>
|
||||
<button class="btn btn-ghost mt-5 !min-h-0 !py-2.5 self-start">Enquire Now <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</article>`).join("");
|
||||
|
||||
$("#nicoTestimonials").innerHTML = nicoTestimonials.map((r) => `
|
||||
<figure class="reveal card card-hover p-7">
|
||||
<i data-lucide="quote" class="w-7 h-7 text-gold mb-4"></i>
|
||||
<blockquote class="text-dim text-lg leading-relaxed">"${r.text}"</blockquote>
|
||||
<figcaption class="text-gold text-sm font-semibold mt-4">— ${r.who}</figcaption>
|
||||
</figure>`).join("");
|
||||
|
||||
const galleryLabels = ["Client Handshake", "Property Visit", "Signing Moment", "Property Visit 2", "Client Handshake 2", "Signing Moment 2"];
|
||||
$("#nicoGallery").innerHTML = galleryLabels.map((l) => `<div class="reveal">${slotMarkup(l, "200px")}</div>`).join("");
|
||||
restoreSlotImages($("#page-nico"));
|
||||
}
|
||||
|
||||
/* -------------------- PHOTO SLOTS -------------------- */
|
||||
const IMAGE_STORE_KEY = "trilogy-hub-uploads";
|
||||
|
||||
function getSlotId(slot) {
|
||||
if (!slot.dataset.slotId) {
|
||||
const page = slot.closest(".page")?.id || "global";
|
||||
const name = slot.dataset.slot || "upload";
|
||||
slot.dataset.slotId = `${page}::${name}`;
|
||||
}
|
||||
return slot.dataset.slotId;
|
||||
}
|
||||
|
||||
function readImageStore() {
|
||||
try { return JSON.parse(localStorage.getItem(IMAGE_STORE_KEY) || "{}"); }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
function writeImageStore(store) {
|
||||
try { localStorage.setItem(IMAGE_STORE_KEY, JSON.stringify(store)); return true; }
|
||||
catch (err) { console.warn("Image save to localStorage failed (quota?):", err); return false; }
|
||||
}
|
||||
|
||||
// Downscale + re-encode uploads so they actually fit in localStorage.
|
||||
function compressImage(file, maxDim = 1600, quality = 0.82) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file || !file.type || !file.type.startsWith("image/")) {
|
||||
reject(new Error("Not an image"));
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
try {
|
||||
let { width, height } = img;
|
||||
if (width > maxDim || height > maxDim) {
|
||||
const scale = maxDim / Math.max(width, height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width; canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
// PNGs with transparency keep PNG; everything else becomes JPEG for size.
|
||||
const isPng = file.type === "image/png";
|
||||
const out = canvas.toDataURL(isPng ? "image/png" : "image/jpeg", quality);
|
||||
resolve(out);
|
||||
} catch (err) { reject(err); }
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Image decode failed")); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// Server mode: when the page is served by server.js, images are written as
|
||||
// real files into ./uploads. Otherwise we fall back to localStorage.
|
||||
const server = { available: false };
|
||||
let remoteImages = {}; // slotId -> "/uploads/xxx.jpg"
|
||||
|
||||
async function loadServerImages() {
|
||||
try {
|
||||
const res = await fetch("/api/images", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("no server");
|
||||
remoteImages = await res.json();
|
||||
server.available = true;
|
||||
} catch {
|
||||
server.available = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageEntry(val) {
|
||||
if (!val) return null;
|
||||
if (typeof val === "string") return { url: val, focus: { x: 0.5, y: 0.5 }, zoom: 1 };
|
||||
return {
|
||||
url: val.url || val.dataUrl,
|
||||
focus: val.focus || { x: val.focusX ?? 0.5, y: val.focusY ?? 0.5 },
|
||||
zoom: val.zoom ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function applyImageToSlot(slot, entry) {
|
||||
const meta = normalizeImageEntry(entry);
|
||||
if (!meta?.url) return;
|
||||
const img = slot.querySelector("img.preview");
|
||||
if (!img) return;
|
||||
img.src = meta.url;
|
||||
slot.dataset.focusX = meta.focus.x;
|
||||
slot.dataset.focusY = meta.focus.y;
|
||||
slot.dataset.zoom = meta.zoom;
|
||||
slot.classList.add("has-image");
|
||||
|
||||
const paint = () => {
|
||||
const { computeLayout, applyLayout } = window.TrilogyImageEditor || {};
|
||||
if (!computeLayout || !applyLayout) return;
|
||||
const fw = slot.clientWidth;
|
||||
const fh = slot.clientHeight;
|
||||
if (fw < 2 || fh < 2) return;
|
||||
const layout = computeLayout(
|
||||
img.naturalWidth, img.naturalHeight, fw, fh,
|
||||
{ x: +slot.dataset.focusX, y: +slot.dataset.focusY },
|
||||
+slot.dataset.zoom,
|
||||
);
|
||||
applyLayout(img, layout);
|
||||
};
|
||||
if (img.complete && img.naturalWidth) paint();
|
||||
else img.addEventListener("load", paint, { once: true });
|
||||
}
|
||||
|
||||
function saveSlotImage(slot, dataUrl, meta) {
|
||||
const id = getSlotId(slot);
|
||||
const focus = meta?.focus || { x: 0.5, y: 0.5 };
|
||||
const zoom = meta?.zoom ?? 1;
|
||||
applyImageToSlot(slot, { url: dataUrl, focus, zoom });
|
||||
|
||||
const payload = { id, dataUrl, focusX: focus.x, focusY: focus.y, zoom };
|
||||
|
||||
if (server.available) {
|
||||
fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("upload failed"))))
|
||||
.then((data) => {
|
||||
if (data && data.url) {
|
||||
remoteImages[id] = { url: data.url, focus, zoom };
|
||||
applyImageToSlot(slot, remoteImages[id]);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Server upload failed, falling back to localStorage:", err);
|
||||
const store = readImageStore();
|
||||
store[id] = { url: dataUrl, focus, zoom };
|
||||
writeImageStore(store);
|
||||
});
|
||||
} else {
|
||||
const store = readImageStore();
|
||||
store[id] = { url: dataUrl, focus, zoom };
|
||||
writeImageStore(store);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSlotImages(root = document) {
|
||||
const store = readImageStore();
|
||||
$$(".photo-slot", root).forEach((slot) => {
|
||||
const id = getSlotId(slot);
|
||||
const entry = normalizeImageEntry(remoteImages[id] || store[id]);
|
||||
if (entry) applyImageToSlot(slot, entry);
|
||||
});
|
||||
}
|
||||
|
||||
let fileInput;
|
||||
function initPhotoSlots() {
|
||||
fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
let currentSlot = null;
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
const slot = currentSlot;
|
||||
currentSlot = null;
|
||||
fileInput.value = "";
|
||||
if (!file || !slot) return;
|
||||
|
||||
const existingFocus = {
|
||||
x: parseFloat(slot.dataset.focusX) || 0.5,
|
||||
y: parseFloat(slot.dataset.focusY) || 0.5,
|
||||
};
|
||||
const existingZoom = parseFloat(slot.dataset.zoom) || 1;
|
||||
|
||||
compressImage(file)
|
||||
.then((dataUrl) => {
|
||||
if (!window.TrilogyImageEditor) return { dataUrl, meta: { focus: existingFocus, zoom: existingZoom } };
|
||||
return window.TrilogyImageEditor.open({
|
||||
file,
|
||||
slot,
|
||||
focus: existingFocus,
|
||||
zoom: existingZoom,
|
||||
imageSrc: dataUrl,
|
||||
}).then((meta) => (meta ? { dataUrl, meta } : null));
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
saveSlotImage(slot, result.dataUrl, result.meta);
|
||||
})
|
||||
.catch(() => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const dataUrl = e.target.result;
|
||||
if (window.TrilogyImageEditor) {
|
||||
window.TrilogyImageEditor.open({ file, slot, imageSrc: dataUrl })
|
||||
.then((meta) => { if (meta) saveSlotImage(slot, dataUrl, meta); });
|
||||
} else {
|
||||
saveSlotImage(slot, dataUrl, { focus: { x: 0.5, y: 0.5 }, zoom: 1 });
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
|
||||
const openFor = (slot) => { currentSlot = slot; fileInput.click(); };
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const slot = e.target.closest(".photo-slot");
|
||||
if (!slot) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openFor(slot);
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && e.target.classList?.contains("photo-slot")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openFor(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
restoreSlotImages();
|
||||
}
|
||||
|
||||
/* -------------------- THEME -------------------- */
|
||||
function initTheme() {
|
||||
const root = document.documentElement;
|
||||
$("#themeToggle").addEventListener("click", () => {
|
||||
root.classList.toggle("dark");
|
||||
if (window.allocationChart && typeof window.allocationChart.update === "function") {
|
||||
window.allocationChart.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------- MOBILE MENU -------------------- */
|
||||
function initMobileMenu() {
|
||||
const btn = $("#menuToggle"), menu = $("#mobileMenu");
|
||||
btn.addEventListener("click", () => {
|
||||
const open = menu.classList.toggle("hidden") === false;
|
||||
btn.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------- ROUTING -------------------- */
|
||||
const routes = ["home", "magic-mike", "nico", "paris", "casino"];
|
||||
let currentRoute = null;
|
||||
|
||||
function setActiveNav(route) {
|
||||
$$(".nav-link[data-route]").forEach((b) => b.classList.toggle("active", b.dataset.route === route));
|
||||
}
|
||||
|
||||
function animateIn(pageEl) {
|
||||
if (prefersReduced) return;
|
||||
runSplitting(pageEl);
|
||||
setupReveals(pageEl);
|
||||
}
|
||||
|
||||
function showPage(route, instant = false) {
|
||||
if (!routes.includes(route)) route = "home";
|
||||
if (route === currentRoute) return;
|
||||
|
||||
const next = $("#page-" + route);
|
||||
const prev = currentRoute ? $("#page-" + currentRoute) : null;
|
||||
|
||||
const swap = () => {
|
||||
if (prev) prev.classList.remove("active");
|
||||
next.classList.add("active");
|
||||
window.scrollTo(0, 0);
|
||||
setActiveNav(route);
|
||||
currentRoute = route;
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
restoreSlotImages(next);
|
||||
animateIn(next);
|
||||
if (route === "paris") refreshParisIcons();
|
||||
if (window.ScrollTrigger) ScrollTrigger.refresh();
|
||||
};
|
||||
|
||||
if (instant || prefersReduced || !prev) {
|
||||
swap();
|
||||
if (next) gsap.set(next, { opacity: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
gsap.timeline()
|
||||
.to(prev, { opacity: 0, duration: 0.25, ease: "power2.out" })
|
||||
.add(() => { swap(); gsap.set(next, { opacity: 0 }); })
|
||||
.to(next, { opacity: 1, duration: 0.35, ease: "power2.out" });
|
||||
}
|
||||
|
||||
function routeFromHash() {
|
||||
const h = (location.hash || "#home").replace("#", "");
|
||||
return routes.includes(h) ? h : "home";
|
||||
}
|
||||
|
||||
function initRouting() {
|
||||
// delegate data-route buttons
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-route]");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
const r = btn.dataset.route;
|
||||
$("#mobileMenu").classList.add("hidden");
|
||||
if ("#" + r === location.hash) showPage(r);
|
||||
else location.hash = "#" + r;
|
||||
});
|
||||
window.addEventListener("hashchange", () => showPage(routeFromHash()));
|
||||
showPage(routeFromHash(), true);
|
||||
}
|
||||
|
||||
/* -------------------- SPLITTING + REVEALS -------------------- */
|
||||
function runSplitting(scope) {
|
||||
if (typeof Splitting !== "function") return;
|
||||
const targets = $$("[data-splitting]", scope).filter((el) => !el.dataset.split);
|
||||
targets.forEach((el) => (el.dataset.split = "1"));
|
||||
if (!targets.length) return;
|
||||
Splitting({ target: targets, by: "chars" });
|
||||
targets.forEach((el) => {
|
||||
const chars = $$(".char", el);
|
||||
gsap.set(chars, { opacity: 0, y: 24 });
|
||||
gsap.to(chars, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out", stagger: 0.04 });
|
||||
});
|
||||
}
|
||||
|
||||
function setupReveals(scope) {
|
||||
const els = $$(".reveal", scope).filter((el) => !el.dataset.revealed && !el.classList.contains("photo-slot"));
|
||||
els.forEach((el, i) => {
|
||||
el.dataset.revealed = "1";
|
||||
const isCard = el.classList.contains("card-hover");
|
||||
gsap.fromTo(el, { opacity: 0, y: 30 }, {
|
||||
opacity: 1, y: 0, duration: 0.8, ease: "power3.out",
|
||||
// Stagger siblings that share the same parent for a polished cascade
|
||||
delay: Math.min(0.18, (i % 4) * 0.06),
|
||||
scrollTrigger: { trigger: el, start: "top 88%", once: true },
|
||||
// Hand transform control back to CSS so card tilt/hover keeps working
|
||||
onComplete: () => { if (isCard) gsap.set(el, { clearProps: "transform" }); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------- COUNT-UP -------------------- */
|
||||
function formatCounter(el, value) {
|
||||
const dec = parseInt(el.dataset.decimals || "0", 10);
|
||||
const prefix = el.dataset.prefix || "";
|
||||
return prefix + value.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||
}
|
||||
|
||||
function countUp(el) {
|
||||
const target = parseFloat(el.dataset.counter);
|
||||
if (Number.isNaN(target)) return;
|
||||
|
||||
const setValue = (v) => { el.textContent = formatCounter(el, v); };
|
||||
|
||||
// Always show final value immediately as fallback
|
||||
setValue(target);
|
||||
|
||||
if (prefersReduced || typeof gsap === "undefined") return;
|
||||
|
||||
const obj = { v: 0 };
|
||||
setValue(0);
|
||||
gsap.to(obj, {
|
||||
v: target, duration: 1.4, ease: "power2.out",
|
||||
onUpdate: () => setValue(obj.v),
|
||||
onComplete: () => setValue(target),
|
||||
});
|
||||
}
|
||||
|
||||
function updateDashboardKPIs(scope) {
|
||||
$$("[data-counter]", scope || $("#parisDashboard")).forEach(countUp);
|
||||
}
|
||||
|
||||
/* -------------------- PARIS DASHBOARD -------------------- */
|
||||
let loggedIn = false;
|
||||
let priceTimer = null;
|
||||
|
||||
function refreshParisIcons() { if (window.lucide) window.lucide.createIcons(); }
|
||||
|
||||
function renderWatchlist() {
|
||||
const body = $("#watchlistBody");
|
||||
body.innerHTML = positions.map((p, i) => {
|
||||
const pl = (p.price - p.avg) * p.shares;
|
||||
const plPct = ((p.price - p.avg) / p.avg) * 100;
|
||||
const cls = pl >= 0 ? "text-success" : "text-error";
|
||||
return `<tr class="border-b border-soft" data-row="${i}">
|
||||
<td class="py-3 px-4 font-bold">${p.ticker}</td>
|
||||
<td class="py-3 px-4 text-dim">${p.company}</td>
|
||||
<td class="py-3 px-4 text-right">${p.shares}</td>
|
||||
<td class="py-3 px-4 text-right text-dim">${fmtEUR(p.avg)}</td>
|
||||
<td class="py-3 px-4 text-right font-semibold" data-price="${i}">${fmtEUR(p.price)}</td>
|
||||
<td class="py-3 px-4 text-right font-semibold ${cls}" data-pl="${i}">${fmtSigned(pl)}</td>
|
||||
<td class="py-3 px-4 text-right font-semibold ${cls}" data-plpct="${i}">${(plPct >= 0 ? "+" : "") + plPct.toFixed(1)}%</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderTradeLog() {
|
||||
$("#tradeLog").innerHTML = tradeLog.map((t) => {
|
||||
const isBuy = t.startsWith("BUY");
|
||||
return `<div class="flex items-center gap-3 p-4 border-b border-soft">
|
||||
<span class="chip ${isBuy ? "text-success" : "text-error"}">${isBuy ? "BUY" : "SELL"}</span>
|
||||
<span class="text-dim text-sm">${t}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const chartColors = ["#c9a84c", "#2f9e8f", "#5b7aa6", "#9a958a", "#7d8a4f"];
|
||||
function buildChart() {
|
||||
const ctx = $("#allocationChart");
|
||||
if (!ctx || typeof Chart === "undefined") return;
|
||||
const data = positions.map((p) => p.price * p.shares);
|
||||
const labels = positions.map((p) => p.ticker);
|
||||
const palette = labels.map((_, i) => chartColors[i % chartColors.length]);
|
||||
|
||||
if (window.allocationChart) window.allocationChart.destroy();
|
||||
window.allocationChart = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: { labels, datasets: [{ data, backgroundColor: palette, borderColor: "transparent", borderWidth: 2, hoverOffset: 8 }] },
|
||||
options: {
|
||||
cutout: "62%", responsive: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: (c) => ` ${c.label}: ${fmtEUR(c.parsed)}` } },
|
||||
},
|
||||
animation: { animateRotate: !prefersReduced, duration: prefersReduced ? 0 : 900 },
|
||||
},
|
||||
});
|
||||
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
$("#chartLegend").innerHTML = labels.map((l, i) => `
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2"><span style="width:10px;height:10px;border-radius:3px;background:${palette[i]};display:inline-block"></span>${l}</span>
|
||||
<span class="text-faint">${((data[i] / total) * 100).toFixed(1)}%</span>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function startLivePrices() {
|
||||
if (priceTimer) clearInterval(priceTimer);
|
||||
if (prefersReduced) return;
|
||||
priceTimer = setInterval(() => {
|
||||
const i = Math.floor(Math.random() * positions.length);
|
||||
const p = positions[i];
|
||||
const pct = (Math.random() * 0.4 + 0.1) / 100; // 0.1–0.5%
|
||||
const dir = Math.random() > 0.5 ? 1 : -1;
|
||||
p.price = p.price * (1 + dir * pct);
|
||||
|
||||
const priceCell = $(`[data-price="${i}"]`);
|
||||
const plCell = $(`[data-pl="${i}"]`);
|
||||
const plPctCell = $(`[data-plpct="${i}"]`);
|
||||
if (!priceCell) return;
|
||||
|
||||
const pl = (p.price - p.avg) * p.shares;
|
||||
const plPct = ((p.price - p.avg) / p.avg) * 100;
|
||||
priceCell.textContent = fmtEUR(p.price);
|
||||
plCell.textContent = fmtSigned(pl);
|
||||
plPctCell.textContent = (plPct >= 0 ? "+" : "") + plPct.toFixed(1) + "%";
|
||||
const cls = pl >= 0 ? "text-success" : "text-error";
|
||||
plCell.className = `py-3 px-4 text-right font-semibold ${cls}`;
|
||||
plPctCell.className = `py-3 px-4 text-right font-semibold ${cls}`;
|
||||
|
||||
const flash = dir > 0 ? "flash-up" : "flash-down";
|
||||
priceCell.classList.remove("flash-up", "flash-down");
|
||||
void priceCell.offsetWidth;
|
||||
priceCell.classList.add(flash);
|
||||
|
||||
if (window.allocationChart) {
|
||||
window.allocationChart.data.datasets[0].data[i] = p.price * p.shares;
|
||||
window.allocationChart.update("none");
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
function revealDashboard() {
|
||||
loggedIn = true;
|
||||
$("#parisLogin").classList.add("hidden");
|
||||
$("#parisDashboard").classList.remove("hidden");
|
||||
renderWatchlist();
|
||||
renderTradeLog();
|
||||
refreshParisIcons();
|
||||
|
||||
// KPIs first — must not depend on chart init
|
||||
updateDashboardKPIs();
|
||||
|
||||
// Chart after layout paint (canvas needs visible dimensions)
|
||||
requestAnimationFrame(() => {
|
||||
try { buildChart(); } catch (err) { console.warn("Chart init skipped:", err); }
|
||||
if (window.ScrollTrigger) ScrollTrigger.refresh();
|
||||
});
|
||||
|
||||
startLivePrices();
|
||||
}
|
||||
|
||||
function initParis() {
|
||||
$("#loginForm").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const u = $("#username").value.trim();
|
||||
const pw = $("#password").value;
|
||||
const err = $("#loginError");
|
||||
if (u === "paris" && pw === "trading123") {
|
||||
err.textContent = "";
|
||||
revealDashboard();
|
||||
} else {
|
||||
err.textContent = "Invalid credentials. Try paris / trading123.";
|
||||
}
|
||||
});
|
||||
$("#logoutBtn").addEventListener("click", () => {
|
||||
loggedIn = false;
|
||||
if (priceTimer) clearInterval(priceTimer);
|
||||
$("#parisDashboard").classList.add("hidden");
|
||||
$("#parisLogin").classList.remove("hidden");
|
||||
$("#username").value = ""; $("#password").value = "";
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------- LENIS SMOOTH SCROLL -------------------- */
|
||||
function initLenis() {
|
||||
if (typeof Lenis === "undefined" || prefersReduced) return;
|
||||
const lenis = new Lenis({ duration: 1.05, easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)) });
|
||||
function raf(time) { lenis.raf(time); requestAnimationFrame(raf); }
|
||||
requestAnimationFrame(raf);
|
||||
if (window.ScrollTrigger) lenis.on("scroll", ScrollTrigger.update);
|
||||
}
|
||||
|
||||
/* -------------------- MOTION micro-interactions -------------------- */
|
||||
function initMotion() {
|
||||
// Subtle press feedback on primary buttons via Motion if available; CSS handles hover.
|
||||
if (!window.Motion || prefersReduced) return;
|
||||
}
|
||||
|
||||
/* -------------------- SCROLL PROGRESS BAR -------------------- */
|
||||
function initScrollProgress() {
|
||||
const bar = $("#scrollProgress");
|
||||
if (!bar || prefersReduced) return;
|
||||
let ticking = false;
|
||||
const update = () => {
|
||||
const doc = document.documentElement;
|
||||
const max = doc.scrollHeight - window.innerHeight;
|
||||
const pct = max > 0 ? (window.scrollY / max) * 100 : 0;
|
||||
bar.style.width = Math.min(100, Math.max(0, pct)) + "%";
|
||||
ticking = false;
|
||||
};
|
||||
window.addEventListener("scroll", () => {
|
||||
if (!ticking) { requestAnimationFrame(update); ticking = true; }
|
||||
}, { passive: true });
|
||||
window.addEventListener("resize", update, { passive: true });
|
||||
update();
|
||||
}
|
||||
|
||||
/* -------------------- PARALLAX (orbs + hero images) -------------------- */
|
||||
function initParallax() {
|
||||
if (prefersReduced) return;
|
||||
const isMobile = () => window.matchMedia("(max-width: 767px)").matches;
|
||||
let ticking = false;
|
||||
const update = () => {
|
||||
const y = window.scrollY;
|
||||
const mobile = isMobile();
|
||||
$$("[data-parallax]").forEach((el) => {
|
||||
// Lighter parallax on phones to keep scrolling buttery.
|
||||
const speed = (parseFloat(el.dataset.parallax) || 0.1) * (mobile ? 0.5 : 1);
|
||||
el.style.transform = `translate3d(0, ${y * speed}px, 0)`;
|
||||
});
|
||||
// Hero background images: drift slightly slower than scroll, kept scaled so edges never show.
|
||||
$$("[data-parallax-img]").forEach((img) => {
|
||||
if (!img.closest(".has-image")) return;
|
||||
if (mobile) return;
|
||||
const slot = img.closest(".photo-slot");
|
||||
const rect = img.getBoundingClientRect();
|
||||
const offset = (rect.top + rect.height / 2 - window.innerHeight / 2);
|
||||
const speed = parseFloat(img.dataset.parallaxImg) || 0.15;
|
||||
const parallaxY = -offset * speed;
|
||||
if (slot && window.TrilogyImageEditor) {
|
||||
const fw = slot.clientWidth;
|
||||
const fh = slot.clientHeight;
|
||||
if (fw > 0 && fh > 0 && img.naturalWidth) {
|
||||
const layout = window.TrilogyImageEditor.computeLayout(
|
||||
img.naturalWidth, img.naturalHeight, fw, fh,
|
||||
{ x: +slot.dataset.focusX || 0.5, y: +slot.dataset.focusY || 0.5 },
|
||||
+slot.dataset.zoom || 1,
|
||||
);
|
||||
layout.top += parallaxY;
|
||||
window.TrilogyImageEditor.applyLayout(img, layout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
img.style.transform = `scale(1.14) translate3d(0, ${parallaxY.toFixed(1)}px, 0)`;
|
||||
});
|
||||
ticking = false;
|
||||
};
|
||||
const onScroll = () => { if (!ticking) { requestAnimationFrame(update); ticking = true; } };
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll, { passive: true });
|
||||
update();
|
||||
}
|
||||
|
||||
/* -------------------- 3D CARD TILT -------------------- */
|
||||
function initCardTilt() {
|
||||
if (prefersReduced || !window.matchMedia("(pointer: fine)").matches) return;
|
||||
const MAX = 6; // degrees — subtle
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
const card = e.target.closest(".card-hover");
|
||||
if (!card) return;
|
||||
const r = card.getBoundingClientRect();
|
||||
const px = (e.clientX - r.left) / r.width - 0.5;
|
||||
const py = (e.clientY - r.top) / r.height - 0.5;
|
||||
card.style.setProperty("--ry", (px * MAX).toFixed(2) + "deg");
|
||||
card.style.setProperty("--rx", (-py * MAX).toFixed(2) + "deg");
|
||||
});
|
||||
document.addEventListener("pointerout", (e) => {
|
||||
const card = e.target.closest(".card-hover");
|
||||
if (!card) return;
|
||||
card.style.setProperty("--rx", "0deg");
|
||||
card.style.setProperty("--ry", "0deg");
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------- MAGNETIC BUTTONS -------------------- */
|
||||
function initMagneticButtons() {
|
||||
if (prefersReduced || !window.matchMedia("(pointer: fine)").matches) return;
|
||||
const STRENGTH = 0.28, MAX = 7;
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
const btn = e.target.closest(".btn");
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
let dx = (e.clientX - (r.left + r.width / 2)) * STRENGTH;
|
||||
let dy = (e.clientY - (r.top + r.height / 2)) * STRENGTH;
|
||||
dx = Math.max(-MAX, Math.min(MAX, dx));
|
||||
dy = Math.max(-MAX, Math.min(MAX, dy));
|
||||
btn.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px) scale(1.03)`;
|
||||
});
|
||||
document.addEventListener("pointerout", (e) => {
|
||||
const btn = e.target.closest(".btn");
|
||||
if (!btn) return;
|
||||
btn.style.transform = "";
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------- INIT -------------------- */
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.body.classList.add("js-ready");
|
||||
if (window.gsap && window.ScrollTrigger) gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
renderMike();
|
||||
renderNico();
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
|
||||
initPhotoSlots();
|
||||
initTheme();
|
||||
initMobileMenu();
|
||||
initParis();
|
||||
initLenis();
|
||||
initMotion();
|
||||
initScrollProgress();
|
||||
initParallax();
|
||||
initCardTilt();
|
||||
initMagneticButtons();
|
||||
initRouting();
|
||||
|
||||
// Detect the upload server and restore any images already saved on disk.
|
||||
loadServerImages().then(() => {
|
||||
restoreSlotImages(document);
|
||||
if (window.ScrollTrigger) ScrollTrigger.refresh();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
restoreSlotImages(document);
|
||||
});
|
||||
});
|
||||
})();
|
||||
496
public/css/styles.css
Normal file
496
public/css/styles.css
Normal file
@@ -0,0 +1,496 @@
|
||||
/* ============================================================
|
||||
PlayBull — Trilogy Hub Edition
|
||||
Luxury gold-on-ink theme · mobile-first · animated arcade
|
||||
============================================================ */
|
||||
:root {
|
||||
/* TrilogyHub palette */
|
||||
--bg: #0a0a0a;
|
||||
--bg-2: #111111;
|
||||
--bg-3: #161616;
|
||||
--surface: #161616;
|
||||
--surface-2: #1e1e1e;
|
||||
--surface-3: #242424;
|
||||
--line: rgba(201, 168, 76, 0.18);
|
||||
--line-soft: rgba(255, 255, 255, 0.07);
|
||||
--text: #f4f1e9;
|
||||
--muted: #a8a39a;
|
||||
--faint: #6e6a62;
|
||||
|
||||
--gold: #c9a84c;
|
||||
--gold-soft: #d8bd6e;
|
||||
--gold-deep: #9a7c26;
|
||||
--green: #46c98b;
|
||||
--green-soft: rgba(70, 201, 139, 0.14);
|
||||
--red: #e0625e;
|
||||
--red-soft: rgba(224, 98, 94, 0.14);
|
||||
|
||||
/* legacy aliases kept so existing inline styles stay on-brand */
|
||||
--accent: #c9a84c;
|
||||
--accent-2: #d8bd6e;
|
||||
--purple: #c9a84c;
|
||||
|
||||
--radius: 16px;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||
--font-display: 'Cabinet Grotesk', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: 'Satoshi', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background:
|
||||
radial-gradient(1100px 560px at 50% -260px, rgba(201, 168, 76, 0.10), transparent 60%),
|
||||
radial-gradient(800px 500px at 100% 110%, rgba(201, 168, 76, 0.05), transparent 60%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100dvh;
|
||||
padding-bottom: calc(76px + var(--safe-b));
|
||||
overscroll-behavior-y: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* fine grain texture overlay for depth */
|
||||
body::before {
|
||||
content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||
opacity: 0.025; mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
#app, .topbar, .ticker, .bottom-nav, .modal-backdrop, .toast-wrap { position: relative; z-index: 1; }
|
||||
|
||||
h1 { font-size: clamp(1.45rem, 5vw, 1.75rem); margin: 0; font-family: var(--font-display); font-weight: 800; letter-spacing: -0.01em; }
|
||||
h2 { font-size: 1rem; margin: 18px 0 8px; color: var(--muted); font-weight: 700; letter-spacing: 0.01em; }
|
||||
h2 small { color: var(--faint); font-weight: 500; }
|
||||
small { font-weight: 400; }
|
||||
button { font-family: inherit; cursor: pointer; border: none; }
|
||||
.accent { color: var(--gold); }
|
||||
|
||||
body.pb-embed { padding-bottom: calc(76px + var(--safe-b)); }
|
||||
body.pb-embed main { padding-top: 10px; }
|
||||
|
||||
/* ---------- Topbar ---------- */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 40;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px clamp(14px, 4vw, 22px);
|
||||
padding-top: max(12px, env(safe-area-inset-top));
|
||||
background: linear-gradient(180deg, rgba(10, 10, 10, 0.92), rgba(10, 10, 10, 0.7));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-family: var(--font-display); font-weight: 800; font-size: 1.2rem; letter-spacing: -0.01em; }
|
||||
.brand-logo {
|
||||
font-size: 1.4rem; display: grid; place-items: center; width: 36px; height: 36px;
|
||||
border-radius: 10px; background: radial-gradient(circle at 30% 25%, rgba(201,168,76,.3), rgba(201,168,76,.06));
|
||||
border: 1px solid var(--line);
|
||||
filter: drop-shadow(0 0 10px rgba(201, 168, 76, 0.4));
|
||||
}
|
||||
.brand .accent, .brand-name .accent { color: var(--gold); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||||
.balance-chip {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: linear-gradient(135deg, rgba(201,168,76,.16), rgba(201,168,76,.04));
|
||||
border: 1px solid var(--line); padding: 7px 13px; border-radius: 999px;
|
||||
font-weight: 800; font-variant-numeric: tabular-nums; color: var(--gold-soft);
|
||||
}
|
||||
.balance-chip .coin { filter: drop-shadow(0 0 6px var(--gold)); }
|
||||
.btn-auth {
|
||||
background: var(--gold); color: #0a0a0a;
|
||||
padding: 9px 18px; border-radius: 999px; font-weight: 800; font-size: .9rem;
|
||||
box-shadow: 0 8px 24px -8px rgba(201, 168, 76, 0.6);
|
||||
transition: transform .3s var(--ease), box-shadow .3s var(--ease);
|
||||
}
|
||||
.btn-auth:active { transform: scale(.96); }
|
||||
|
||||
/* ---------- Ticker ---------- */
|
||||
.ticker { overflow: hidden; border-bottom: 1px solid var(--line-soft); background: rgba(17,17,17,.6); height: 32px; }
|
||||
.ticker-track { display: flex; gap: 30px; white-space: nowrap; align-items: center; height: 32px; padding-left: 100%; animation: scroll 40s linear infinite; }
|
||||
.ticker-track:hover { animation-play-state: paused; }
|
||||
.ticker-item { font-size: .76rem; color: var(--muted); letter-spacing: .01em; }
|
||||
.ticker-item b { color: var(--gold-soft); }
|
||||
@keyframes scroll { to { transform: translateX(-100%); } }
|
||||
|
||||
/* ---------- Views ---------- */
|
||||
main { padding: clamp(14px, 4vw, 22px); max-width: 760px; margin: 0 auto; }
|
||||
.view { display: none; }
|
||||
.view.active { display: block; animation: viewIn .4s var(--ease); }
|
||||
@keyframes viewIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.view-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.view-head h1 { display: flex; align-items: center; gap: 4px; }
|
||||
.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--green); margin: 0 4px 0 8px; box-shadow: 0 0 0 0 var(--green); animation: pulse 1.6s infinite; }
|
||||
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(70,201,139,.6);} 70%{ box-shadow: 0 0 0 9px rgba(70,201,139,0);} 100%{ box-shadow:0 0 0 0 rgba(70,201,139,0);} }
|
||||
|
||||
/* ---------- Segmented control ---------- */
|
||||
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--line-soft); border-radius: 999px; padding: 3px; }
|
||||
.seg-btn { background: transparent; color: var(--muted); padding: 7px 15px; border-radius: 999px; font-weight: 700; font-size: .82rem; transition: color .25s, background .25s; }
|
||||
.seg-btn.active { background: var(--gold); color: #0a0a0a; box-shadow: 0 4px 12px -4px rgba(201,168,76,.6); }
|
||||
|
||||
/* ---------- Asset list ---------- */
|
||||
.asset-list { display: flex; flex-direction: column; gap: 9px; }
|
||||
.asset-row {
|
||||
display: grid; grid-template-columns: 44px 1fr auto; gap: 13px; align-items: center;
|
||||
background: var(--surface); border: 1px solid var(--line-soft); border-radius: 14px; padding: 13px 15px;
|
||||
transition: transform .12s var(--ease), border-color .25s, box-shadow .25s; cursor: pointer;
|
||||
}
|
||||
.asset-row:hover { border-color: var(--line); box-shadow: 0 10px 30px -18px rgba(0,0,0,.8); }
|
||||
.asset-row:active { transform: scale(.985); }
|
||||
.asset-emoji { font-size: 1.45rem; width: 44px; height: 44px; display: grid; place-items: center; background: var(--surface-2); border-radius: 12px; border: 1px solid var(--line-soft); }
|
||||
.asset-info { min-width: 0; }
|
||||
.asset-info .sym { font-weight: 800; font-family: var(--font-display); letter-spacing: .01em; }
|
||||
.asset-info .name { color: var(--muted); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.asset-price { text-align: right; }
|
||||
.asset-price .px { font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.asset-price .chg { font-size: .76rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.up { color: var(--green); } .down { color: var(--red); }
|
||||
.flash-up { animation: fu .7s var(--ease); } .flash-down { animation: fd .7s var(--ease); }
|
||||
@keyframes fu { 0% { background: var(--green-soft); border-color: var(--green);} 100%{ background: var(--surface); border-color: var(--line-soft);} }
|
||||
@keyframes fd { 0% { background: var(--red-soft); border-color: var(--red);} 100%{ background: var(--surface); border-color: var(--line-soft);} }
|
||||
.badge-halt { font-size: .6rem; background: var(--red-soft); color: var(--red); padding: 2px 6px; border-radius: 6px; margin-left: 6px; vertical-align: middle; font-weight: 700; }
|
||||
|
||||
/* ---------- Games grid ---------- */
|
||||
.games-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 13px; }
|
||||
.game-card {
|
||||
position: relative; border-radius: 18px; padding: 16px; min-height: 150px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
background: linear-gradient(165deg, var(--surface-2), var(--surface));
|
||||
border: 1px solid var(--line-soft); overflow: hidden; cursor: pointer;
|
||||
transition: transform .25s var(--ease), border-color .3s, box-shadow .3s;
|
||||
}
|
||||
.game-card:hover { transform: translateY(-4px); border-color: var(--line); box-shadow: 0 22px 50px -26px rgba(0,0,0,.9); }
|
||||
.game-card:active { transform: scale(.97); }
|
||||
.game-card .g-emoji { font-size: 2.5rem; filter: drop-shadow(0 6px 14px rgba(0,0,0,.5)); transition: transform .4s var(--ease); }
|
||||
.game-card:hover .g-emoji { transform: translateY(-3px) rotate(-6deg) scale(1.08); }
|
||||
.game-card .g-name { font-weight: 800; font-size: 1.06rem; margin-top: 10px; font-family: var(--font-display); }
|
||||
.game-card .g-desc { color: var(--muted); font-size: .73rem; margin-top: 2px; line-height: 1.35; }
|
||||
.game-card .g-tag { position: absolute; top: 12px; right: 12px; font-size: .58rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--gold); border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px; background: rgba(201,168,76,.08); }
|
||||
.game-card::after { content: ""; position: absolute; inset: 0; background: radial-gradient(150px 100px at 100% 0%, var(--glow, rgba(201,168,76,.22)), transparent 70%); pointer-events: none; }
|
||||
.game-card::before { content: ""; position: absolute; inset: 0; background: linear-gradient(120deg, transparent 30%, rgba(255,255,255,.05) 50%, transparent 70%); transform: translateX(-120%); transition: transform .7s var(--ease); }
|
||||
.game-card:hover::before { transform: translateX(120%); }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn { padding: 14px 16px; border-radius: 13px; font-weight: 800; font-size: .95rem; width: 100%; color: var(--text); background: var(--surface-2); border: 1px solid var(--line-soft); transition: transform .12s var(--ease), box-shadow .3s, filter .2s; }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn-green { background: linear-gradient(135deg, #4fd99a, #2fa873); color: #052012; box-shadow: 0 8px 22px -8px rgba(70,201,139,.5); }
|
||||
.btn-red { background: linear-gradient(135deg, #ec716d, #c44743); color: #fff; box-shadow: 0 8px 22px -8px rgba(224,98,94,.5); }
|
||||
.btn-accent { background: var(--gold); color: #0a0a0a; box-shadow: 0 8px 22px -8px rgba(201,168,76,.6); }
|
||||
.btn-gold { background: linear-gradient(135deg, var(--gold-soft), var(--gold-deep)); color: #1a1305; box-shadow: 0 8px 22px -8px rgba(201,168,76,.6); }
|
||||
.btn-ghost { background: var(--surface); border-color: var(--line); color: var(--text); }
|
||||
.btn-ghost:active { filter: brightness(1.15); }
|
||||
.btn:disabled { opacity: .4; pointer-events: none; }
|
||||
.btn-row { display: flex; gap: 10px; }
|
||||
.link { background: none; color: var(--gold); text-decoration: underline; text-underline-offset: 2px; padding: 0; font-weight: 700; }
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
.field { margin-bottom: 13px; }
|
||||
.field label { display: block; font-size: .76rem; color: var(--muted); margin-bottom: 6px; font-weight: 600; letter-spacing: .01em; }
|
||||
input, select {
|
||||
width: 100%; padding: 13px 14px; border-radius: 12px; background: var(--bg-2);
|
||||
border: 1px solid var(--line-soft); color: var(--text); font-size: 1rem; font-family: inherit;
|
||||
font-variant-numeric: tabular-nums; transition: border-color .25s, box-shadow .25s;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--gold); box-shadow: 0 0 0 3px rgba(201,168,76,.15); }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { background: var(--surface-2); border: 1px solid var(--line-soft); color: var(--text); padding: 8px 13px; border-radius: 999px; font-weight: 700; font-size: .82rem; transition: all .2s var(--ease); }
|
||||
.chip:active { transform: scale(.94); }
|
||||
.chip.active { border-color: var(--gold); background: rgba(201,168,76,.18); color: var(--gold-soft); }
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.card { background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; }
|
||||
.stat-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--line-soft); gap: 10px; }
|
||||
.stat-row:last-child { border: none; }
|
||||
.stat-row .k { color: var(--muted); }
|
||||
.stat-row .v { font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.big-num { font-size: clamp(1.9rem, 8vw, 2.3rem); font-weight: 900; font-family: var(--font-display); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||
|
||||
/* ---------- Chart ---------- */
|
||||
.chart-wrap { background: var(--bg-2); border: 1px solid var(--line-soft); border-radius: 14px; padding: 8px; margin-bottom: 14px; }
|
||||
canvas { display: block; width: 100%; }
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
.modal-backdrop:not([hidden]) {
|
||||
position: fixed; inset: 0; background: rgba(5,5,5,.78); backdrop-filter: blur(8px); z-index: 100;
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
animation: backIn .25s ease;
|
||||
}
|
||||
@keyframes backIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.modal-backdrop[hidden] { display: none !important; }
|
||||
.modal {
|
||||
background: linear-gradient(180deg, var(--surface), var(--bg-2));
|
||||
border: 1px solid var(--line); border-top-color: var(--line);
|
||||
border-radius: 24px 24px 0 0; width: 100%; max-width: 580px; max-height: 93dvh; overflow-y: auto;
|
||||
padding: 20px; padding-bottom: calc(22px + var(--safe-b));
|
||||
box-shadow: 0 -20px 60px -20px rgba(0,0,0,.8);
|
||||
animation: slideUp .34s var(--ease);
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(100%); } to { transform: none; } }
|
||||
@media (min-width: 600px) { .modal-backdrop { align-items: center; } .modal { border-radius: 22px; } }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; gap: 10px; }
|
||||
.modal-head h2 { margin: 0; color: var(--text); font-size: 1.3rem; font-family: var(--font-display); font-weight: 800; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.modal-close { background: var(--surface-2); width: 34px; height: 34px; border-radius: 50%; color: var(--muted); font-size: 1.05rem; flex-shrink: 0; transition: background .2s, color .2s; }
|
||||
.modal-close:hover { background: var(--red-soft); color: var(--red); }
|
||||
.handle { width: 42px; height: 4px; background: var(--line); border-radius: 99px; margin: -8px auto 16px; }
|
||||
|
||||
/* ---------- Bottom nav ---------- */
|
||||
.bottom-nav {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 50;
|
||||
display: grid; grid-template-columns: repeat(5, 1fr);
|
||||
background: linear-gradient(180deg, rgba(13,13,13,.86), rgba(10,10,10,.97));
|
||||
backdrop-filter: blur(18px); border-top: 1px solid var(--line);
|
||||
padding-bottom: var(--safe-b);
|
||||
}
|
||||
.nav-btn { background: none; color: var(--faint); font-size: .64rem; font-weight: 700; padding: 10px 0 9px; display: flex; flex-direction: column; align-items: center; gap: 4px; letter-spacing: .02em; transition: color .2s; position: relative; }
|
||||
.nav-btn span { font-size: 1.3rem; filter: grayscale(.6) opacity(.7); transition: transform .25s var(--ease), filter .25s; }
|
||||
.nav-btn.active { color: var(--gold-soft); }
|
||||
.nav-btn.active span { filter: none; transform: translateY(-3px) scale(1.12); }
|
||||
.nav-btn.active::after { content: ""; position: absolute; top: 4px; width: 26px; height: 3px; border-radius: 99px; background: var(--gold); box-shadow: 0 0 10px var(--gold); }
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
.toast-wrap { position: fixed; top: 70px; left: 0; right: 0; z-index: 200; display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; padding: 0 12px; }
|
||||
.toast { background: var(--surface-2); border: 1px solid var(--line); padding: 12px 18px; border-radius: 13px; font-weight: 700; font-size: .9rem; box-shadow: 0 14px 40px rgba(0,0,0,.6); animation: toastIn .35s var(--ease); max-width: 92%; text-align: center; }
|
||||
.toast.win { border-color: var(--green); background: linear-gradient(135deg, #123322, var(--surface-2)); color: #d6f7e6; }
|
||||
.toast.lose { border-color: var(--red); background: linear-gradient(135deg, #331616, var(--surface-2)); color: #f7d6d6; }
|
||||
@keyframes toastIn { from { transform: translateY(-18px) scale(.95); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
/* ---------- Feed ---------- */
|
||||
.feed-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.feed-item { display: flex; gap: 9px; align-items: center; font-size: .85rem; padding: 10px 13px; background: var(--surface); border: 1px solid var(--line-soft); border-radius: 11px; }
|
||||
.feed-item .ago { color: var(--faint); margin-left: auto; font-size: .7rem; white-space: nowrap; }
|
||||
|
||||
/* ---------- Leaderboard ---------- */
|
||||
.lb-row { display: grid; grid-template-columns: 36px 1fr auto; gap: 11px; align-items: center; padding: 12px 14px; background: var(--surface); border: 1px solid var(--line-soft); border-radius: 13px; margin-bottom: 7px; }
|
||||
.lb-rank { font-weight: 900; color: var(--faint); font-family: var(--font-display); font-size: 1.05rem; text-align: center; }
|
||||
.lb-row.me { border-color: var(--gold); background: linear-gradient(135deg, rgba(201,168,76,.08), var(--surface)); }
|
||||
.lb-user { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.lb-user b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ============================================================
|
||||
GAME: CHICKEN ROAD — animated scene
|
||||
============================================================ */
|
||||
.chicken-stage {
|
||||
position: relative; border-radius: 18px; overflow: hidden;
|
||||
border: 1px solid var(--line); margin-bottom: 14px;
|
||||
background: linear-gradient(180deg, #0d1b12 0%, #0a0a0a 60%);
|
||||
box-shadow: inset 0 0 60px rgba(0,0,0,.6);
|
||||
}
|
||||
.chicken-track {
|
||||
display: flex; gap: 0; overflow-x: auto; padding: 0; scroll-behavior: smooth;
|
||||
scrollbar-width: none; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.chicken-track::-webkit-scrollbar { display: none; }
|
||||
.lane {
|
||||
min-width: 78px; height: 200px; position: relative; flex: 0 0 auto;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, #1c1c1c 0 22px, #181818 22px 44px);
|
||||
border-right: 2px dashed rgba(201,168,76,.22);
|
||||
}
|
||||
.lane:first-child { border-left: 2px solid rgba(201,168,76,.3); }
|
||||
.lane.curb { background: repeating-linear-gradient(0deg, #15301f 0 14px, #112818 14px 28px); min-width: 60px; }
|
||||
.lane.curb .mult-flag { color: var(--green); border-color: var(--green); background: var(--green-soft); }
|
||||
.lane .mult-flag {
|
||||
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
|
||||
font-size: .72rem; font-weight: 800; color: var(--gold-soft); font-family: var(--font-display);
|
||||
background: rgba(10,10,10,.7); border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px;
|
||||
white-space: nowrap; transition: all .3s var(--ease); z-index: 3;
|
||||
}
|
||||
.lane.passed { background: repeating-linear-gradient(0deg, #16291d 0 22px, #122318 22px 44px); }
|
||||
.lane.passed .mult-flag { background: var(--green-soft); border-color: var(--green); color: #8df0bd; }
|
||||
.lane.current { background: repeating-linear-gradient(0deg, #2a2410 0 22px, #221d0c 22px 44px); }
|
||||
.lane.current::after { content: ""; position: absolute; inset: 0; box-shadow: inset 0 0 0 2px var(--gold); border-radius: 2px; animation: laneGlow 1.2s ease-in-out infinite; }
|
||||
@keyframes laneGlow { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
|
||||
.lane.dead { background: repeating-linear-gradient(0deg, #2a1212 0 22px, #221010 22px 44px); }
|
||||
.lane .car {
|
||||
position: absolute; top: -60px; left: 50%; transform: translateX(-50%);
|
||||
font-size: 2rem; filter: drop-shadow(0 6px 8px rgba(0,0,0,.6));
|
||||
}
|
||||
.lane.dead .car { animation: carSlam .5s var(--ease) forwards; }
|
||||
@keyframes carSlam { 0% { top: -60px; } 60% { top: 78px; } 75% { top: 64px; } 100% { top: 72px; } }
|
||||
/* the chicken hero rides on top of the stage */
|
||||
.chicken-hero {
|
||||
position: absolute; bottom: 16px; left: 22px; font-size: 2.2rem; z-index: 5;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 8px 10px rgba(0,0,0,.6));
|
||||
will-change: transform, left;
|
||||
}
|
||||
.chicken-hero.hop { animation: hop .35s var(--ease); }
|
||||
@keyframes hop { 0% { transform: translateY(0) scaleY(1); } 30% { transform: translateY(-46px) scaleY(1.15) scaleX(.9); } 70% { transform: translateY(-46px) scaleY(1.1); } 100% { transform: translateY(0) scaleY(1); } }
|
||||
.chicken-hero.idle { animation: bob 1.4s ease-in-out infinite; }
|
||||
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
||||
.chicken-hud { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 10px 0; }
|
||||
.chicken-hud .mult-big { font-family: var(--font-display); font-weight: 900; color: var(--gold); font-size: 1.6rem; font-variant-numeric: tabular-nums; }
|
||||
.stage-shake { animation: shake .5s var(--ease); }
|
||||
@keyframes shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-8px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(4px); } }
|
||||
.boom-flash { position: absolute; inset: 0; background: radial-gradient(circle, rgba(224,98,94,.5), transparent 70%); opacity: 0; pointer-events: none; z-index: 6; }
|
||||
.boom-flash.go { animation: boomFlash .5s ease; }
|
||||
@keyframes boomFlash { 0% { opacity: 1; } 100% { opacity: 0; } }
|
||||
|
||||
/* ============================================================
|
||||
GAME: MINES 10x10 — 3D card flips
|
||||
============================================================ */
|
||||
.mines-grid { display: grid; grid-template-columns: repeat(10, 1fr); gap: 4px; margin: 12px 0; perspective: 700px; }
|
||||
.tile {
|
||||
aspect-ratio: 1; border-radius: 8px; position: relative; transform-style: preserve-3d;
|
||||
transition: transform .4s var(--ease); cursor: pointer;
|
||||
}
|
||||
.tile:active { transform: scale(.9); }
|
||||
.tile .face { position: absolute; inset: 0; border-radius: 8px; display: grid; place-items: center; font-size: clamp(.7rem, 2.6vw, 1rem); backface-visibility: hidden; }
|
||||
.tile .face.back {
|
||||
background: linear-gradient(160deg, var(--surface-3), var(--surface-2));
|
||||
border: 1px solid var(--line-soft); color: var(--faint);
|
||||
}
|
||||
.tile .face.back::before { content: ""; position: absolute; inset: 4px; border-radius: 5px; border: 1px solid rgba(201,168,76,.12); }
|
||||
.tile .face.front { transform: rotateY(180deg); }
|
||||
.tile.flipped { transform: rotateY(180deg); }
|
||||
.tile.safe .face.front { background: radial-gradient(circle at 50% 35%, rgba(70,201,139,.35), var(--green-soft)); border: 1px solid var(--green); animation: gemPop .45s var(--ease); }
|
||||
@keyframes gemPop { 0% { transform: rotateY(180deg) scale(.4); } 70% { transform: rotateY(180deg) scale(1.18); } 100% { transform: rotateY(180deg) scale(1); } }
|
||||
.tile.bomb .face.front { background: radial-gradient(circle at 50% 35%, rgba(224,98,94,.45), var(--red-soft)); border: 1px solid var(--red); }
|
||||
.tile.bomb.hit { animation: bombHit .5s var(--ease); z-index: 2; }
|
||||
@keyframes bombHit { 0% { transform: rotateY(180deg) scale(1); } 40% { transform: rotateY(180deg) scale(1.4); } 100% { transform: rotateY(180deg) scale(1); } }
|
||||
.tile.dim { opacity: .3; }
|
||||
|
||||
/* ============================================================
|
||||
GAME: CRASH — canvas rocket
|
||||
============================================================ */
|
||||
.crash-stage { position: relative; border-radius: 18px; overflow: hidden; border: 1px solid var(--line); margin-bottom: 14px; background: linear-gradient(180deg, #0c1020, #0a0a0a); }
|
||||
.crash-stage canvas { width: 100%; height: 240px; display: block; }
|
||||
.crash-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.crash-mult { font-size: clamp(2.6rem, 14vw, 3.6rem); font-weight: 900; font-family: var(--font-display); font-variant-numeric: tabular-nums; color: var(--text); text-shadow: 0 4px 30px rgba(0,0,0,.6); transition: color .2s, transform .15s; }
|
||||
.crash-mult.boom { color: var(--red); animation: crashShake .5s var(--ease); }
|
||||
@keyframes crashShake { 0%,100% { transform: translateX(0); } 25% { transform: translate(-6px,3px) rotate(-2deg); } 50% { transform: translate(6px,-2px) rotate(2deg); } 75% { transform: translate(-4px,2px); } }
|
||||
.crash-sub { color: var(--muted); font-weight: 700; font-size: .85rem; margin-top: 6px; }
|
||||
|
||||
/* legacy crash display fallback */
|
||||
.crash-display { text-align: center; padding: 28px 0; background: var(--bg-2); border-radius: 16px; border: 1px solid var(--line-soft); margin-bottom: 14px; }
|
||||
|
||||
/* ============================================================
|
||||
GAME: DICE — animated roll bar
|
||||
============================================================ */
|
||||
.dice-bar-wrap { position: relative; margin: 18px 0 30px; padding: 0 6px; }
|
||||
.dice-bar { position: relative; height: 14px; border-radius: 99px; overflow: hidden; background: var(--red); }
|
||||
.dice-bar .win-zone { position: absolute; top: 0; bottom: 0; background: linear-gradient(90deg, #4fd99a, #2fa873); transition: all .25s var(--ease); }
|
||||
.dice-marker {
|
||||
position: absolute; top: 50%; width: 34px; height: 34px; border-radius: 12px;
|
||||
background: var(--gold); color: #0a0a0a; display: grid; place-items: center;
|
||||
font-weight: 900; font-size: .72rem; font-family: var(--font-display);
|
||||
transform: translate(-50%, -50%); box-shadow: 0 6px 16px rgba(0,0,0,.5); z-index: 3;
|
||||
transition: left .08s linear;
|
||||
}
|
||||
.dice-marker.settle { transition: left .5s cubic-bezier(.34,1.56,.64,1); }
|
||||
.dice-scale { display: flex; justify-content: space-between; margin-top: 8px; color: var(--faint); font-size: .68rem; font-weight: 700; }
|
||||
.dice-result { font-family: var(--font-display); font-weight: 900; font-size: 2.4rem; text-align: center; font-variant-numeric: tabular-nums; margin: 6px 0; transition: color .3s; }
|
||||
|
||||
/* ============================================================
|
||||
GAME: COINFLIP — true 3D coin
|
||||
============================================================ */
|
||||
.coin-stage { display: grid; place-items: center; height: 200px; perspective: 900px; margin: 10px 0; }
|
||||
.coin { position: relative; width: 120px; height: 120px; transform-style: preserve-3d; transition: transform .1s; }
|
||||
.coin .side {
|
||||
position: absolute; inset: 0; border-radius: 50%; display: grid; place-items: center;
|
||||
font-size: 3rem; backface-visibility: hidden;
|
||||
background: radial-gradient(circle at 35% 30%, var(--gold-soft), var(--gold-deep));
|
||||
border: 4px solid #8a6e1f; box-shadow: inset 0 0 18px rgba(0,0,0,.4), 0 14px 30px -10px rgba(0,0,0,.7);
|
||||
color: #2a1d00;
|
||||
}
|
||||
.coin .heads { transform: translateZ(2px); }
|
||||
.coin .tails { transform: rotateY(180deg) translateZ(2px); }
|
||||
.coin.spin-heads { animation: spinHeads 2.4s cubic-bezier(.3,.1,.2,1) forwards; }
|
||||
.coin.spin-tails { animation: spinTails 2.4s cubic-bezier(.3,.1,.2,1) forwards; }
|
||||
@keyframes spinHeads { 0% { transform: rotateY(0) translateY(0); } 50% { transform: rotateY(1440deg) translateY(-50px); } 100% { transform: rotateY(2520deg) translateY(0); } }
|
||||
@keyframes spinTails { 0% { transform: rotateY(0) translateY(0); } 50% { transform: rotateY(1440deg) translateY(-50px); } 100% { transform: rotateY(2700deg) translateY(0); } }
|
||||
|
||||
/* ---------- misc ---------- */
|
||||
.login-hint { text-align: center; color: var(--muted); margin-top: 24px; padding: 22px; background: var(--surface); border: 1px solid var(--line-soft); border-radius: 14px; }
|
||||
.muted { color: var(--muted); }
|
||||
.center { text-align: center; }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.pill { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: .7rem; font-weight: 800; }
|
||||
.pill.long { background: var(--green-soft); color: var(--green); }
|
||||
.pill.short { background: var(--red-soft); color: var(--red); }
|
||||
.spacer { height: 10px; }
|
||||
.result-emoji { font-size: 3rem; text-align: center; margin: 8px 0; }
|
||||
.stat-tile { background: var(--surface); border: 1px solid var(--line-soft); border-radius: 14px; padding: 12px; text-align: center; }
|
||||
.stat-tile .lbl { color: var(--muted); font-size: .72rem; font-weight: 600; }
|
||||
.stat-tile .val { font-weight: 900; font-family: var(--font-display); font-size: 1.15rem; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---------- Framed images (Avatar etc.) ---------- */
|
||||
.framed-image { overflow: hidden; position: relative; background: var(--surface-2); flex-shrink: 0; }
|
||||
.framed-image--avatar { aspect-ratio: 1; border-radius: 50%; }
|
||||
.framed-image img { position: absolute; pointer-events: none; user-select: none; max-width: none; }
|
||||
.framed-image--avatar-sm { width: 36px; height: 36px; }
|
||||
.framed-image--avatar-md { width: 48px; height: 48px; }
|
||||
.framed-image--avatar-lg { width: 120px; height: 120px; }
|
||||
.framed-image--avatar-xl { width: min(72vw, 260px); height: min(72vw, 260px); }
|
||||
|
||||
.framed-image--gallery { aspect-ratio: 1; border-radius: 14px; }
|
||||
.framed-image--gallery-sm { width: 80px; height: 80px; }
|
||||
.framed-image--gallery-md { width: 100%; max-width: 140px; height: auto; aspect-ratio: 1; }
|
||||
.framed-image--gallery-lg { width: 140px; height: 140px; }
|
||||
.framed-image--gallery-xl { width: min(72vw, 280px); height: min(72vw, 280px); }
|
||||
|
||||
.framed-image--banner { aspect-ratio: 3 / 1; border-radius: 14px; }
|
||||
.framed-image--banner-sm { width: 120px; height: 40px; }
|
||||
.framed-image--banner-md { width: 100%; max-width: 200px; height: auto; aspect-ratio: 3; }
|
||||
.framed-image--banner-lg { width: 240px; height: 80px; }
|
||||
.framed-image--banner-xl { width: min(92vw, 360px); height: auto; aspect-ratio: 3; }
|
||||
|
||||
.framed-image--card { aspect-ratio: 2 / 3; border-radius: 12px; }
|
||||
.framed-image--card-sm { width: 48px; height: 72px; }
|
||||
.framed-image--card-md { width: 80px; height: 120px; }
|
||||
.framed-image--card-lg { width: 100px; height: 150px; }
|
||||
.framed-image--card-xl { width: min(50vw, 200px); height: auto; aspect-ratio: 2/3; }
|
||||
|
||||
.avatar-emoji { display: inline-grid; place-items: center; line-height: 1; }
|
||||
.avatar-emoji--sm { font-size: 1.35rem; width: 36px; height: 36px; }
|
||||
.avatar-emoji--md { font-size: 1.6rem; width: 48px; height: 48px; }
|
||||
.avatar-emoji--lg { font-size: 3.5rem; width: 120px; height: 120px; }
|
||||
|
||||
.profile-avatar-row { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; }
|
||||
.profile-avatar-actions { display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.profile-avatar-actions .btn { width: auto; font-size: .85rem; padding: 10px 14px; }
|
||||
|
||||
.btn-auth--avatar { padding: 0; width: 38px; height: 38px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--line); font-size: 1.2rem; box-shadow: none; }
|
||||
.btn-auth--avatar .framed-image--avatar-sm,
|
||||
.btn-auth--avatar .avatar-emoji--sm { width: 38px; height: 38px; }
|
||||
|
||||
.account-head { display: flex; align-items: center; gap: 12px; }
|
||||
.account-head h2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------- Image position editor ---------- */
|
||||
.image-editor-backdrop { z-index: 110; }
|
||||
.image-editor-modal { max-height: 94dvh; }
|
||||
.image-editor-hint { font-size: .82rem; margin: 0 0 14px; }
|
||||
.image-editor-preview-wrap { display: flex; justify-content: center; margin-bottom: 16px; }
|
||||
.image-editor-preview { touch-action: none; cursor: grab; border: 2px solid var(--line); box-shadow: 0 12px 40px rgba(0,0,0,.35); }
|
||||
.image-editor-preview:active { cursor: grabbing; }
|
||||
.image-editor-zoom-field input[type="range"] { padding: 0; height: 6px; -webkit-appearance: none; appearance: none; background: var(--line); border: none; border-radius: 99px; }
|
||||
.image-editor-zoom-field input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%; background: var(--gold); box-shadow: 0 2px 8px rgba(201,168,76,.5); }
|
||||
.image-editor-zoom-field label { display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
/* ---------- Uploads Galerie ---------- */
|
||||
.uploads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||
.upload-item { display: flex; flex-direction: column; gap: 8px; align-items: stretch; }
|
||||
.upload-item .framed-image--gallery-md,
|
||||
.upload-item .framed-image--banner-md,
|
||||
.upload-item .framed-image--card-md { width: 100%; max-width: none; }
|
||||
.upload-item-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.upload-item-actions .chip { font-size: .72rem; padding: 5px 8px; }
|
||||
.uploads-toolbar { display: flex; flex-direction: column; gap: 10px; }
|
||||
.image-upload-wrap { display: contents; }
|
||||
|
||||
/* range input global (dice etc.) */
|
||||
input[type="range"] { -webkit-appearance: none; appearance: none; height: 6px; padding: 0; background: var(--surface-3); border: none; border-radius: 99px; }
|
||||
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; border-radius: 50%; background: var(--gold); box-shadow: 0 2px 10px rgba(201,168,76,.5); cursor: pointer; }
|
||||
input[type="range"]::-moz-range-thumb { width: 24px; height: 24px; border: none; border-radius: 50%; background: var(--gold); box-shadow: 0 2px 10px rgba(201,168,76,.5); cursor: pointer; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
/* very small phones */
|
||||
@media (max-width: 360px) {
|
||||
.games-grid { gap: 10px; }
|
||||
.game-card { min-height: 138px; padding: 13px; }
|
||||
.lane { min-width: 70px; }
|
||||
}
|
||||
190
public/image-editor.js
Normal file
190
public/image-editor.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/* Trilogy Hub — Bild-Positions-Editor (gleicher Rahmen wie Slot-Anzeige) */
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
function computeLayout(imgW, imgH, frameW, frameH, focus, zoom) {
|
||||
const fx = focus?.x ?? 0.5;
|
||||
const fy = focus?.y ?? 0.5;
|
||||
const z = Math.max(1, zoom ?? 1);
|
||||
const cover = Math.max(frameW / imgW, frameH / imgH);
|
||||
const scale = cover * z;
|
||||
const w = imgW * scale;
|
||||
const h = imgH * scale;
|
||||
let left = frameW / 2 - fx * w;
|
||||
let top = frameH / 2 - fy * h;
|
||||
left = Math.min(0, Math.max(frameW - w, left));
|
||||
top = Math.min(0, Math.max(frameH - h, top));
|
||||
return { width: w, height: h, left, top };
|
||||
}
|
||||
|
||||
function focusFromLayout(imgW, imgH, frameW, frameH, layout, zoom) {
|
||||
const z = Math.max(1, zoom ?? 1);
|
||||
const cover = Math.max(frameW / imgW, frameH / imgH);
|
||||
const w = imgW * cover * z;
|
||||
const h = imgH * cover * z;
|
||||
return {
|
||||
x: Math.min(1, Math.max(0, (frameW / 2 - layout.left) / w)),
|
||||
y: Math.min(1, Math.max(0, (frameH / 2 - layout.top) / h)),
|
||||
};
|
||||
}
|
||||
|
||||
function applyLayout(img, layout) {
|
||||
img.style.width = layout.width + "px";
|
||||
img.style.height = layout.height + "px";
|
||||
img.style.left = layout.left + "px";
|
||||
img.style.top = layout.top + "px";
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Bild konnte nicht geladen werden"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function slotFrameSize(slot) {
|
||||
const cs = getComputedStyle(slot);
|
||||
let w = slot.offsetWidth;
|
||||
let h = slot.offsetHeight;
|
||||
if (w < 40) w = slot.parentElement?.offsetWidth || 320;
|
||||
if (h < 40) {
|
||||
const mh = cs.minHeight;
|
||||
h = mh && mh !== "0px" ? parseFloat(mh) : 220;
|
||||
}
|
||||
const isHero = slot.classList.contains("!rounded-none") || slot.closest(".min-h-\\[72vh\\]");
|
||||
const maxPreview = isHero ? 400 : 300;
|
||||
const ar = w / h;
|
||||
if (w > maxPreview) { w = maxPreview; h = w / ar; }
|
||||
return { width: Math.round(w), height: Math.round(h), radius: cs.borderRadius, label: slot.dataset.slot || "Bild" };
|
||||
}
|
||||
|
||||
async function open({ file, slot, focus, zoom, imageSrc }) {
|
||||
let src = imageSrc;
|
||||
let revoke = null;
|
||||
if (file) { src = URL.createObjectURL(file); revoke = src; }
|
||||
|
||||
const frame = slotFrameSize(slot);
|
||||
let img;
|
||||
try { img = await loadImage(src); }
|
||||
catch (e) { if (revoke) URL.revokeObjectURL(revoke); throw e; }
|
||||
|
||||
const state = {
|
||||
focus: { x: focus?.x ?? 0.5, y: focus?.y ?? 0.5 },
|
||||
zoom: Math.max(1, zoom ?? 1),
|
||||
dragging: false, lastX: 0, lastY: 0, pinchDist: 0, pinchZoom: 1,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "img-editor-backdrop";
|
||||
backdrop.innerHTML = `
|
||||
<div class="img-editor-modal card card-gold">
|
||||
<div class="img-editor-head">
|
||||
<h2 class="font-display font-bold text-xl">${frame.label} positionieren</h2>
|
||||
<button type="button" class="btn btn-ghost !min-h-0 !p-2.5 img-editor-close" aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
<p class="text-dim text-sm text-center mb-5">So wird das Bild im Rahmen angezeigt — ziehen & zoomen</p>
|
||||
<div class="img-editor-preview-outer">
|
||||
<div class="img-editor-preview" id="ieFrame" style="width:${frame.width}px;height:${frame.height}px;border-radius:${frame.radius}">
|
||||
<img id="ieImg" src="${src}" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-sm font-semibold mb-2 mt-5">Zoom <span id="ieZoomVal">${state.zoom.toFixed(1)}×</span></label>
|
||||
<input type="range" id="ieZoom" class="img-editor-range" min="1" max="4" step="0.05" value="${state.zoom}" />
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="button" class="btn btn-ghost flex-1" data-act="reset">Zurücksetzen</button>
|
||||
<button type="button" class="btn btn-primary flex-1" data-act="save">Speichern</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const frameEl = backdrop.querySelector("#ieFrame");
|
||||
const imgEl = backdrop.querySelector("#ieImg");
|
||||
const zoomInput = backdrop.querySelector("#ieZoom");
|
||||
const zoomVal = backdrop.querySelector("#ieZoomVal");
|
||||
let layout = { left: 0, top: 0, width: 0, height: 0 };
|
||||
|
||||
const fw = () => frameEl.clientWidth;
|
||||
const fh = () => frameEl.clientHeight;
|
||||
|
||||
function syncFromFocus() {
|
||||
layout = computeLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), state.focus, state.zoom);
|
||||
applyLayout(imgEl, layout);
|
||||
}
|
||||
function syncFocusFromLayout() {
|
||||
state.focus = focusFromLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), layout, state.zoom);
|
||||
}
|
||||
function finish(result) {
|
||||
document.body.style.overflow = "";
|
||||
backdrop.remove();
|
||||
if (revoke) URL.revokeObjectURL(revoke);
|
||||
resolve(result);
|
||||
}
|
||||
function setZoom(z) {
|
||||
const old = { ...layout };
|
||||
state.zoom = Math.min(4, Math.max(1, z));
|
||||
layout = computeLayout(img.naturalWidth, img.naturalHeight, fw(), fh(), state.focus, state.zoom);
|
||||
const cx = fw() / 2, cy = fh() / 2;
|
||||
const rx = (cx - old.left) / old.width;
|
||||
const ry = (cy - old.top) / old.height;
|
||||
layout.left = cx - rx * layout.width;
|
||||
layout.top = cy - ry * layout.height;
|
||||
layout.left = Math.min(0, Math.max(fw() - layout.width, layout.left));
|
||||
layout.top = Math.min(0, Math.max(fh() - layout.height, layout.top));
|
||||
syncFocusFromLayout();
|
||||
applyLayout(imgEl, layout);
|
||||
zoomInput.value = state.zoom;
|
||||
zoomVal.textContent = state.zoom.toFixed(1) + "×";
|
||||
}
|
||||
|
||||
imgEl.addEventListener("load", syncFromFocus, { once: true });
|
||||
if (imgEl.complete) syncFromFocus();
|
||||
|
||||
frameEl.addEventListener("pointerdown", (e) => {
|
||||
if (e.target.closest("button, input")) return;
|
||||
state.dragging = true;
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
frameEl.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
frameEl.addEventListener("pointermove", (e) => {
|
||||
if (!state.dragging) return;
|
||||
layout.left = Math.min(0, Math.max(fw() - layout.width, layout.left + e.clientX - state.lastX));
|
||||
layout.top = Math.min(0, Math.max(fh() - layout.height, layout.top + e.clientY - state.lastY));
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
applyLayout(imgEl, layout);
|
||||
syncFocusFromLayout();
|
||||
});
|
||||
frameEl.addEventListener("pointerup", () => { state.dragging = false; });
|
||||
frameEl.addEventListener("pointercancel", () => { state.dragging = false; });
|
||||
frameEl.addEventListener("wheel", (e) => { e.preventDefault(); setZoom(state.zoom + (e.deltaY > 0 ? -0.08 : 0.08)); }, { passive: false });
|
||||
frameEl.addEventListener("touchstart", (e) => {
|
||||
if (e.touches.length === 2) {
|
||||
state.pinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
||||
state.pinchZoom = state.zoom;
|
||||
}
|
||||
}, { passive: true });
|
||||
frameEl.addEventListener("touchmove", (e) => {
|
||||
if (e.touches.length === 2 && state.pinchDist > 0) {
|
||||
e.preventDefault();
|
||||
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
||||
setZoom(state.pinchZoom * (dist / state.pinchDist));
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
zoomInput.addEventListener("input", () => setZoom(+zoomInput.value));
|
||||
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(null); });
|
||||
backdrop.querySelector(".img-editor-close").onclick = () => finish(null);
|
||||
backdrop.querySelector('[data-act="reset"]').onclick = () => { state.focus = { x: 0.5, y: 0.5 }; state.zoom = 1; setZoom(1); };
|
||||
backdrop.querySelector('[data-act="save"]').onclick = () => { syncFocusFromLayout(); finish({ focus: { ...state.focus }, zoom: state.zoom }); };
|
||||
});
|
||||
}
|
||||
|
||||
window.TrilogyImageEditor = { open, computeLayout, applyLayout };
|
||||
})();
|
||||
880
public/index.html
Normal file
880
public/index.html
Normal file
@@ -0,0 +1,880 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Trilogy Hub — Three Hustlers. One Vision.</title>
|
||||
<meta name="description" content="Trilogy Hub — the personal brand home of three business partners: Magic Tree Co., NovaTerra Immobilien, and PG Trading Management." />
|
||||
|
||||
<!-- Favicon (custom SVG: three interlocking triangles) -->
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230a0a0a'/%3E%3Cg fill='none' stroke='%23c9a84c' stroke-width='1.6' stroke-linejoin='round'%3E%3Cpath d='M16 6 L23 18 L9 18 Z'/%3E%3Cpath d='M10 14 L17 26 L3 26 Z' opacity='0.55'/%3E%3Cpath d='M22 14 L29 26 L15 26 Z' opacity='0.55'/%3E%3C/g%3E%3C/svg%3E" />
|
||||
|
||||
<!-- Fonts: Cabinet Grotesk (display) + Satoshi (body) via Fontshare -->
|
||||
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,700,800,900&f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Tailwind CSS v4 (CDN browser build) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
|
||||
<!-- GSAP + ScrollTrigger -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
||||
|
||||
<!-- Splitting.js -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/splitting/dist/splitting.css" />
|
||||
<script src="https://unpkg.com/splitting/dist/splitting.min.js"></script>
|
||||
|
||||
<!-- Lenis smooth scroll -->
|
||||
<script src="https://unpkg.com/lenis@1.1.13/dist/lenis.min.js"></script>
|
||||
|
||||
<!-- Motion.js (Framer Motion vanilla) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
|
||||
|
||||
<!-- Lucide icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
--color-ink-900: #0a0a0a;
|
||||
--color-ink-800: #111111;
|
||||
--color-ink-700: #1a1a1a;
|
||||
--color-ink-600: #242424;
|
||||
--color-gold: #c9a84c;
|
||||
--color-gold-soft: #d8bd6e;
|
||||
--color-cream: #f4f1e9;
|
||||
--color-success: #46c98b;
|
||||
--color-error: #e0625e;
|
||||
--font-display: "Cabinet Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: "Satoshi", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-2: #111111;
|
||||
--bg-3: #1a1a1a;
|
||||
--surface: #161616;
|
||||
--border: rgba(201, 168, 76, 0.18);
|
||||
--border-soft: rgba(255, 255, 255, 0.08);
|
||||
--text: #f4f1e9;
|
||||
--text-dim: #a8a39a;
|
||||
--text-faint: #6e6a62;
|
||||
--gold: #c9a84c;
|
||||
--gold-soft: #d8bd6e;
|
||||
--success: #46c98b;
|
||||
--error: #e0625e;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
html:not(.dark) {
|
||||
--bg: #f6f4ee;
|
||||
--bg-2: #efece3;
|
||||
--bg-3: #e7e3d8;
|
||||
--surface: #ffffff;
|
||||
--border: rgba(154, 124, 38, 0.28);
|
||||
--border-soft: rgba(10, 10, 10, 0.1);
|
||||
--text: #1a1712;
|
||||
--text-dim: #4f4a40;
|
||||
--text-faint: #7d7668;
|
||||
--gold: #9a7c26;
|
||||
--gold-soft: #b89537;
|
||||
}
|
||||
|
||||
* { -webkit-font-smoothing: antialiased; }
|
||||
|
||||
html { scroll-behavior: auto; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
overflow-x: hidden;
|
||||
transition: background-color 0.5s var(--ease), color 0.5s var(--ease);
|
||||
}
|
||||
|
||||
.font-display { font-family: var(--font-display); }
|
||||
|
||||
::selection { background: var(--gold); color: #0a0a0a; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 8px; border: 2px solid var(--bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--gold); }
|
||||
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-faint { color: var(--text-faint); }
|
||||
.text-gold { color: var(--gold); }
|
||||
.bg-surface { background-color: var(--surface); }
|
||||
.bg-ink2 { background-color: var(--bg-2); }
|
||||
.bg-ink3 { background-color: var(--bg-3); }
|
||||
.border-soft { border-color: var(--border-soft); }
|
||||
.border-gold { border-color: var(--border); }
|
||||
|
||||
/* Section container */
|
||||
.wrap { width: 100%; max-width: 1200px; margin-inline: auto; padding-inline: clamp(1.25rem, 4vw, 3rem); }
|
||||
|
||||
/* Pages */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
/* Eyebrow label */
|
||||
.eyebrow {
|
||||
font-size: 0.75rem; letter-spacing: 0.22em; text-transform: uppercase;
|
||||
color: var(--gold); font-weight: 700;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
min-height: 44px; padding: 0.75rem 1.4rem; border-radius: 0.7rem;
|
||||
font-weight: 700; font-size: 0.95rem; cursor: pointer;
|
||||
transition: transform 0.4s var(--ease), box-shadow 0.4s var(--ease), background-color 0.3s var(--ease), color 0.3s var(--ease);
|
||||
border: 1px solid transparent; text-decoration: none; line-height: 1;
|
||||
}
|
||||
.btn-primary { background: var(--gold); color: #0a0a0a; }
|
||||
.btn-primary:hover { transform: scale(1.03); box-shadow: 0 10px 34px -8px rgba(201,168,76,0.55); }
|
||||
.btn-ghost { background: transparent; color: var(--text); border-color: var(--border); }
|
||||
.btn-ghost:hover { transform: scale(1.03); border-color: var(--gold); box-shadow: 0 10px 30px -12px rgba(201,168,76,0.4); }
|
||||
.btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border-soft);
|
||||
border-radius: 1.1rem; transition: transform 0.45s var(--ease), box-shadow 0.45s var(--ease), border-color 0.45s var(--ease);
|
||||
}
|
||||
.card-gold { border-color: var(--border); }
|
||||
.card-hover:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 24px 60px -24px rgba(0,0,0,0.7), 0 0 0 1px var(--border);
|
||||
}
|
||||
html:not(.dark) .card-hover:hover { box-shadow: 0 24px 50px -24px rgba(0,0,0,0.25), 0 0 0 1px var(--border); }
|
||||
|
||||
/* Photo upload slot */
|
||||
.photo-slot {
|
||||
position: relative; display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column; gap: 0.6rem; cursor: pointer; overflow: hidden;
|
||||
background: var(--bg-3); border: 1.5px dashed var(--gold);
|
||||
border-radius: 1rem; color: var(--text-dim); text-align: center; padding: 1.5rem;
|
||||
transition: border-color 0.4s var(--ease), background-color 0.4s var(--ease);
|
||||
min-height: 180px;
|
||||
}
|
||||
.photo-slot:hover { border-color: var(--gold-soft); background: var(--bg-2); }
|
||||
.photo-slot:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
|
||||
.photo-slot .slot-icon { color: var(--gold); transition: transform 0.4s var(--ease); }
|
||||
.photo-slot:hover .slot-icon { transform: translateY(-3px) scale(1.08); }
|
||||
.photo-slot .slot-label { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.02em; }
|
||||
.photo-slot img.preview {
|
||||
position: absolute; max-width: none; pointer-events: none;
|
||||
opacity: 0; transition: opacity 0.6s var(--ease), filter 0.6s var(--ease);
|
||||
border-radius: inherit; z-index: 2;
|
||||
}
|
||||
.photo-slot.has-image img.preview { opacity: 1; }
|
||||
/* Subtle brightness lift on hover for uploaded images */
|
||||
.photo-slot.has-image:hover img.preview { filter: saturate(1.08) brightness(1.04); }
|
||||
/* Diagonal glossy shine that sweeps across images on hover */
|
||||
.photo-slot.has-image::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
|
||||
background: linear-gradient(115deg, transparent 30%, rgba(255,255,255,0.18) 48%, transparent 60%);
|
||||
transform: translateX(-130%); transition: transform 0.85s var(--ease); border-radius: inherit;
|
||||
}
|
||||
.photo-slot.has-image:hover::before { transform: translateX(130%); }
|
||||
/* Gentle gold ring glow when hovering an image */
|
||||
.photo-slot.has-image:hover { box-shadow: 0 22px 55px -28px rgba(0,0,0,0.85), 0 0 0 1px var(--border); }
|
||||
.photo-slot.has-image {
|
||||
border-style: solid;
|
||||
border-color: var(--border);
|
||||
padding: 0;
|
||||
}
|
||||
.photo-slot .slot-default { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; transition: opacity 0.4s var(--ease); z-index: 1; position: relative; }
|
||||
.photo-slot .slot-hover-name {
|
||||
position: absolute; inset: auto 0 0 0; padding: 0.6rem 0.9rem; z-index: 2;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.82), transparent);
|
||||
color: #fff; font-size: 0.78rem; font-weight: 600; opacity: 0;
|
||||
transform: translateY(8px); transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
|
||||
text-align: left;
|
||||
}
|
||||
.photo-slot.has-image:hover .slot-hover-name { opacity: 1; transform: translateY(0); }
|
||||
|
||||
/* Image position editor */
|
||||
.img-editor-backdrop {
|
||||
position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.82); backdrop-filter: blur(10px); padding: 1.25rem;
|
||||
}
|
||||
.img-editor-modal { width: 100%; max-width: 420px; padding: 1.5rem; max-height: 92vh; overflow-y: auto; }
|
||||
.img-editor-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||
.img-editor-preview-outer { display: flex; justify-content: center; }
|
||||
.img-editor-preview {
|
||||
position: relative; overflow: hidden; background: var(--bg-3);
|
||||
border: 2px solid var(--border); touch-action: none; cursor: grab;
|
||||
}
|
||||
.img-editor-preview:active { cursor: grabbing; }
|
||||
.img-editor-preview img { position: absolute; max-width: none; pointer-events: none; user-select: none; }
|
||||
.img-editor-range { width: 100%; accent-color: var(--gold); height: 6px; cursor: pointer; }
|
||||
|
||||
/* Nav */
|
||||
.nav-link {
|
||||
position: relative; font-weight: 500; color: var(--text-dim); font-size: 0.95rem;
|
||||
padding: 0.4rem 0; transition: color 0.3s var(--ease); cursor: pointer; background: none; border: none;
|
||||
}
|
||||
.nav-link:hover, .nav-link.active { color: var(--text); }
|
||||
.nav-link::after {
|
||||
content: ""; position: absolute; left: 0; bottom: -2px; height: 2px; width: 0;
|
||||
background: var(--gold); transition: width 0.4s var(--ease);
|
||||
}
|
||||
.nav-link:hover::after, .nav-link.active::after { width: 100%; }
|
||||
|
||||
/* Glass header */
|
||||
.glass {
|
||||
background: color-mix(in srgb, var(--bg) 72%, transparent);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* Animated reveal base (GSAP will animate). Fallback visible if no JS. */
|
||||
.reveal { opacity: 0; transform: translateY(30px); }
|
||||
.js-ready .reveal { will-change: opacity, transform; }
|
||||
|
||||
/* Splitting char stagger handled via GSAP, keep visible baseline */
|
||||
.split-heading .char { display: inline-block; }
|
||||
|
||||
/* Hero gradient overlays */
|
||||
.overlay-green { background: linear-gradient(135deg, rgba(8,30,18,0.92), rgba(10,10,10,0.7)); }
|
||||
.overlay-navy { background: linear-gradient(135deg, rgba(10,18,38,0.92), rgba(10,10,10,0.7)); }
|
||||
.overlay-dark { background: linear-gradient(135deg, rgba(10,10,10,0.9), rgba(10,10,10,0.55)); }
|
||||
|
||||
/* Price badge */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.7rem;
|
||||
border-radius: 0.5rem; font-weight: 800; font-size: 0.85rem;
|
||||
background: rgba(201,168,76,0.12); color: var(--gold); border: 1px solid var(--border);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px; font-size: 0.78rem; font-weight: 600;
|
||||
background: var(--bg-3); color: var(--text-dim); border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* LIVE pulsing dot */
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--success); position: relative; }
|
||||
.live-dot::after {
|
||||
content: ""; position: absolute; inset: 0; border-radius: 999px; background: var(--success);
|
||||
animation: ping 1.6s var(--ease) infinite;
|
||||
}
|
||||
@keyframes ping { 0% { transform: scale(1); opacity: 0.7; } 80%,100% { transform: scale(2.6); opacity: 0; } }
|
||||
|
||||
/* Price flash */
|
||||
.flash-up { animation: flashUp 0.8s var(--ease); }
|
||||
.flash-down { animation: flashDown 0.8s var(--ease); }
|
||||
@keyframes flashUp { 0% { background: rgba(70,201,139,0.28); } 100% { background: transparent; } }
|
||||
@keyframes flashDown { 0% { background: rgba(224,98,94,0.28); } 100% { background: transparent; } }
|
||||
|
||||
.text-success { color: var(--success); }
|
||||
.text-error { color: var(--error); }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.78); backdrop-filter: blur(8px); padding: 1.25rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%; padding: 0.85rem 1rem; border-radius: 0.7rem; background: var(--bg-3);
|
||||
border: 1px solid var(--border-soft); color: var(--text); font-size: 0.95rem; min-height: 44px;
|
||||
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
|
||||
}
|
||||
.input:focus { outline: none; border-color: var(--gold); box-shadow: 0 0 0 3px rgba(201,168,76,0.18); }
|
||||
|
||||
/* Masonry-ish reviews */
|
||||
.masonry { columns: 1; column-gap: 1.25rem; }
|
||||
@media (min-width: 640px) { .masonry { columns: 2; } }
|
||||
@media (min-width: 1024px) { .masonry { columns: 3; } }
|
||||
.masonry > * { break-inside: avoid; margin-bottom: 1.25rem; }
|
||||
|
||||
.divider { height: 1px; background: var(--border-soft); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
|
||||
.reveal { opacity: 1 !important; transform: none !important; }
|
||||
}
|
||||
|
||||
.star { color: var(--gold); letter-spacing: 0.05em; }
|
||||
|
||||
/* ===================== ENHANCED EFFECTS ===================== */
|
||||
|
||||
/* Scroll progress bar (top of viewport) */
|
||||
.scroll-progress {
|
||||
position: fixed; top: 0; left: 0; height: 2.5px; width: 0%;
|
||||
z-index: 60; transform-origin: left center;
|
||||
background: linear-gradient(90deg, var(--gold), var(--gold-soft), var(--gold));
|
||||
box-shadow: 0 0 12px rgba(201,168,76,0.6); will-change: width;
|
||||
}
|
||||
|
||||
/* 3D tilt for cards — JS sets --rx/--ry, CSS handles the smoothing */
|
||||
.card-hover {
|
||||
transform-style: preserve-3d;
|
||||
transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) translateY(0) translateZ(0);
|
||||
transition: transform 0.5s var(--ease), box-shadow 0.45s var(--ease), border-color 0.45s var(--ease);
|
||||
}
|
||||
.card-hover.is-tilting { transition: box-shadow 0.45s var(--ease), border-color 0.45s var(--ease); }
|
||||
.card-hover:hover {
|
||||
transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) translateY(-6px) translateZ(0);
|
||||
}
|
||||
|
||||
/* Glossy sweep on primary buttons */
|
||||
.btn-primary { position: relative; overflow: hidden; isolation: isolate; }
|
||||
.btn-primary::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
|
||||
background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.45) 50%, transparent 65%);
|
||||
transform: translateX(-130%); transition: transform 0.7s var(--ease);
|
||||
}
|
||||
.btn-primary:hover::after { transform: translateX(130%); }
|
||||
|
||||
/* Floating ambient gradient orbs for hero sections */
|
||||
.orbs { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: -1; }
|
||||
.orb {
|
||||
position: absolute; border-radius: 999px; filter: blur(60px); opacity: 0.5;
|
||||
will-change: transform; mix-blend-mode: screen;
|
||||
}
|
||||
html:not(.dark) .orb { mix-blend-mode: multiply; opacity: 0.35; }
|
||||
.orb-1 { width: 460px; height: 460px; top: -120px; left: -80px;
|
||||
background: radial-gradient(circle, rgba(201,168,76,0.55), transparent 70%);
|
||||
animation: orbFloat 18s ease-in-out infinite; }
|
||||
.orb-2 { width: 380px; height: 380px; bottom: -140px; right: -60px;
|
||||
background: radial-gradient(circle, rgba(47,158,143,0.4), transparent 70%);
|
||||
animation: orbFloat 22s ease-in-out infinite reverse; }
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(40px, -30px) scale(1.12); }
|
||||
}
|
||||
|
||||
/* Animated gold shimmer on the gold accent words */
|
||||
.shimmer-gold {
|
||||
background: linear-gradient(100deg, var(--gold) 0%, var(--gold-soft) 25%, #fff3cf 50%, var(--gold-soft) 75%, var(--gold) 100%);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent; color: transparent;
|
||||
animation: shimmerMove 6s linear infinite;
|
||||
}
|
||||
@keyframes shimmerMove { to { background-position: 200% center; } }
|
||||
|
||||
/* Image reveal with clip-path wipe (added by JS via .img-reveal) */
|
||||
.img-reveal { clip-path: inset(0 0 100% 0); transition: clip-path 1s var(--ease); }
|
||||
.img-reveal.is-in { clip-path: inset(0 0 0% 0); }
|
||||
|
||||
/* Nav underline gets a soft glow on active */
|
||||
.nav-link.active::after { box-shadow: 0 0 10px rgba(201,168,76,0.7); }
|
||||
|
||||
/* Live ticker price cells subtle scale pop handled in JS via flash classes already */
|
||||
|
||||
/* ---- Mobile tuning: keep it smooth & tasteful on phones ---- */
|
||||
@media (max-width: 767px) {
|
||||
/* Big blurs are expensive on mobile GPUs — lighten them up */
|
||||
.orb { filter: blur(42px); opacity: 0.4; }
|
||||
.orb-1 { width: 280px; height: 280px; top: -80px; left: -60px; }
|
||||
.orb-2 { width: 240px; height: 240px; bottom: -90px; right: -40px; }
|
||||
.glass { backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
|
||||
/* No hover on touch, so neutralise tilt vars for crisp rendering */
|
||||
.card-hover { transform: none; }
|
||||
.card-hover:hover { transform: translateY(-4px); }
|
||||
/* Card tap feedback for touch devices */
|
||||
.card-hover:active { transform: scale(0.985); transition: transform 0.15s var(--ease); }
|
||||
.btn:active { transform: scale(0.96); }
|
||||
.scroll-progress { height: 2px; }
|
||||
/* Make sure the diagonal image shine isn't stuck mid-frame on touch */
|
||||
.photo-slot.has-image::before { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb { animation: none; }
|
||||
.shimmer-gold { animation: none; }
|
||||
.img-reveal { clip-path: none !important; }
|
||||
.card-hover { transform: none !important; }
|
||||
.scroll-progress { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Scroll progress indicator -->
|
||||
<div class="scroll-progress" id="scrollProgress" aria-hidden="true"></div>
|
||||
|
||||
<!-- ================= HEADER / NAV ================= -->
|
||||
<header class="glass fixed top-0 inset-x-0 z-50">
|
||||
<nav class="wrap flex items-center justify-between h-[68px]" aria-label="Primary">
|
||||
<a href="#home" class="flex items-center gap-2.5 group" aria-label="Trilogy Hub home">
|
||||
<svg width="34" height="34" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<g fill="none" stroke="var(--gold)" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M16 5 L24 19 L8 19 Z" />
|
||||
<path d="M9 13 L17 27 L1 27 Z" opacity="0.55" />
|
||||
<path d="M23 13 L31 27 L15 27 Z" opacity="0.55" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="font-display font-extrabold text-lg tracking-tight">Trilogy<span class="text-gold">Hub</span></span>
|
||||
</a>
|
||||
|
||||
<div class="hidden md:flex items-center gap-7">
|
||||
<button class="nav-link" data-route="home">Home</button>
|
||||
<button class="nav-link" data-route="magic-mike">Magic Tree Co.</button>
|
||||
<button class="nav-link" data-route="nico">NovaTerra</button>
|
||||
<button class="nav-link" data-route="paris">PG Trading</button>
|
||||
<button class="nav-link" data-route="casino" data-members hidden>Casino</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="themeToggle" class="btn btn-ghost !p-2.5 !min-h-0 w-11 h-11" aria-label="Toggle light and dark mode" title="Toggle theme">
|
||||
<i data-lucide="moon" class="block dark:hidden w-5 h-5"></i>
|
||||
<i data-lucide="sun" class="hidden dark:block w-5 h-5"></i>
|
||||
</button>
|
||||
<button id="menuToggle" class="btn btn-ghost !p-2.5 !min-h-0 w-11 h-11 md:hidden" aria-label="Open menu" aria-expanded="false">
|
||||
<i data-lucide="menu" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div id="mobileMenu" class="md:hidden hidden border-t border-soft bg-ink2">
|
||||
<div class="wrap py-4 flex flex-col gap-1">
|
||||
<button class="nav-link text-left py-3" data-route="home">Home</button>
|
||||
<button class="nav-link text-left py-3" data-route="magic-mike">Magic Tree Co.</button>
|
||||
<button class="nav-link text-left py-3" data-route="nico">NovaTerra Immobilien</button>
|
||||
<button class="nav-link text-left py-3" data-route="paris">PG Trading Management</button>
|
||||
<button class="nav-link text-left py-3" data-route="casino" data-members hidden>🎰 Casino & Games</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="app" class="pt-[68px]">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ============================ HOME ============================ -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-home" class="page" aria-label="Home">
|
||||
<!-- Hero -->
|
||||
<div class="relative overflow-hidden">
|
||||
<div class="orbs" aria-hidden="true" data-parallax="0.08"><span class="orb orb-1"></span><span class="orb orb-2"></span></div>
|
||||
<div class="absolute inset-0 -z-10 opacity-[0.05]" style="background-image: radial-gradient(circle at 1px 1px, var(--gold) 1px, transparent 0); background-size: 28px 28px;"></div>
|
||||
<div class="wrap pt-20 pb-16 md:pt-28 md:pb-24 text-center">
|
||||
<p class="eyebrow reveal mb-5">Trees · Properties · Markets</p>
|
||||
<h1 class="split-heading font-display font-black leading-[0.95] tracking-tight text-[clamp(2.6rem,8vw,6rem)]" data-splitting>
|
||||
Three Hustlers.<br /><span class="text-gold shimmer-gold">One Vision.</span>
|
||||
</h1>
|
||||
<p class="reveal text-dim max-w-2xl mx-auto mt-7 text-lg md:text-xl">
|
||||
Trees. Properties. Markets. We don't just talk — we build.
|
||||
</p>
|
||||
<div class="reveal flex flex-wrap items-center justify-center gap-3 mt-9">
|
||||
<button class="btn btn-primary" data-route="magic-mike">Explore the Hub <i data-lucide="arrow-right" class="w-4 h-4"></i></button>
|
||||
<button class="btn btn-ghost" data-route="paris">Trading Dashboard</button>
|
||||
</div>
|
||||
|
||||
<!-- Three partner cards with photo slots -->
|
||||
<div class="grid sm:grid-cols-3 gap-5 mt-16 text-left">
|
||||
<article class="reveal card card-gold card-hover p-3" >
|
||||
<div class="photo-slot !min-h-[260px]" data-slot="Magic Mike" tabindex="0" role="button" aria-label="Upload photo of Magic Mike">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-7 h-7"></i><span class="slot-label">Magic Mike</span></div>
|
||||
<img class="preview" alt="Magic Mike" />
|
||||
<span class="slot-hover-name">Magic Mike</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-display font-bold text-lg">Magic Mike</h3>
|
||||
<p class="text-dim text-sm mt-1">Founder · Magic Tree Co.</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-3">
|
||||
<div class="photo-slot !min-h-[260px]" data-slot="Nico Gonzales" tabindex="0" role="button" aria-label="Upload photo of Nico Gonzales">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-7 h-7"></i><span class="slot-label">Nico Gonzales</span></div>
|
||||
<img class="preview" alt="Nico Gonzales" />
|
||||
<span class="slot-hover-name">Nico Gonzales</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-display font-bold text-lg">Nico Gonzales</h3>
|
||||
<p class="text-dim text-sm mt-1">Broker · NovaTerra Immobilien</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-3">
|
||||
<div class="photo-slot !min-h-[260px]" data-slot="Paris" tabindex="0" role="button" aria-label="Upload photo of Paris">
|
||||
<div class="slot-default"><i data-lucide="image-plus" class="slot-icon w-7 h-7"></i><span class="slot-label">Paris</span></div>
|
||||
<img class="preview" alt="Paris" />
|
||||
<span class="slot-hover-name">Paris</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-display font-bold text-lg">Paris</h3>
|
||||
<p class="text-dim text-sm mt-1">Strategist · PG Trading Management</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Our Story -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28 grid md:grid-cols-2 gap-12 items-center">
|
||||
<div class="reveal">
|
||||
<p class="eyebrow mb-4">Our Story</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.9rem,4vw,3rem)] leading-tight mb-6">It started with a handshake and a vision.</h2>
|
||||
<p class="text-dim text-lg leading-relaxed mb-4">
|
||||
Mike plants the seeds — literally. Nico turns land into legacy. Paris reads the market like a map.
|
||||
Together, we invest in each other's ventures and grow as one unit.
|
||||
</p>
|
||||
<p class="text-dim text-lg leading-relaxed">
|
||||
PG Trading Management finances stakes in Nico's real estate portfolio. Magic Tree supplies rare ornamental
|
||||
trees to Nico's high-end property projects. <span class="text-gold font-semibold shimmer-gold">Three businesses. One ecosystem.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="photo-slot !min-h-[360px]" data-slot="Team Photo" tabindex="0" role="button" aria-label="Upload team photo">
|
||||
<div class="slot-default"><i data-lucide="users" class="slot-icon w-8 h-8"></i><span class="slot-label">Team Photo</span></div>
|
||||
<img class="preview" alt="Team photo" />
|
||||
<span class="slot-hover-name">Team Photo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Cards -->
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="text-center mb-14 reveal">
|
||||
<p class="eyebrow mb-4">The Trilogy</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.9rem,4vw,3rem)]">Three founders, three crafts.</h2>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<article class="reveal card card-gold card-hover p-7 flex flex-col">
|
||||
<i data-lucide="trees" class="w-9 h-9 text-gold mb-5"></i>
|
||||
<h3 class="font-display font-bold text-2xl">Magic Mike</h3>
|
||||
<p class="text-gold text-sm font-semibold mt-1 mb-3">Magic Tree Co.</p>
|
||||
<p class="text-dim leading-relaxed flex-1">Rare and ornamental trees for luxury gardens — from Japanese Maples to 300-year-old olive trees.</p>
|
||||
<button class="btn btn-ghost mt-6 self-start" data-route="magic-mike">Visit Page <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-7 flex flex-col">
|
||||
<i data-lucide="building-2" class="w-9 h-9 text-gold mb-5"></i>
|
||||
<h3 class="font-display font-bold text-2xl">Nico Gonzales</h3>
|
||||
<p class="text-gold text-sm font-semibold mt-1 mb-3">NovaTerra Immobilien</p>
|
||||
<p class="text-dim leading-relaxed flex-1">Premium real estate across Baden-Württemberg — buying, selling, and developing property into legacy.</p>
|
||||
<button class="btn btn-ghost mt-6 self-start" data-route="nico">Visit Page <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</article>
|
||||
<article class="reveal card card-gold card-hover p-7 flex flex-col">
|
||||
<i data-lucide="line-chart" class="w-9 h-9 text-gold mb-5"></i>
|
||||
<h3 class="font-display font-bold text-2xl">Paris</h3>
|
||||
<p class="text-gold text-sm font-semibold mt-1 mb-3">PG Trading Management</p>
|
||||
<p class="text-dim leading-relaxed flex-1">Real markets, no risk, full thrill. A paper-trading dashboard powered by live-simulated prices.</p>
|
||||
<button class="btn btn-ghost mt-6 self-start" data-route="paris">Visit Page <i data-lucide="arrow-up-right" class="w-4 h-4"></i></button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ========================= MAGIC MIKE ========================= -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-magic-mike" class="page" aria-label="Magic Mike — Magic Tree Co.">
|
||||
<!-- Hero -->
|
||||
<div class="relative min-h-[72vh] flex items-center overflow-hidden">
|
||||
<div class="photo-slot absolute inset-0 !rounded-none !border-0 !min-h-0" data-slot="Hero Background" tabindex="0" role="button" aria-label="Upload hero background">
|
||||
<div class="slot-default"><i data-lucide="image" class="slot-icon w-8 h-8"></i><span class="slot-label">Hero Background</span></div>
|
||||
<img class="preview" alt="Magic Tree Co. hero" data-parallax-img="0.16" />
|
||||
</div>
|
||||
<div class="absolute inset-0 overlay-green pointer-events-none"></div>
|
||||
<div class="wrap relative py-24 text-center md:text-left">
|
||||
<p class="eyebrow mb-4">Magic Tree Co.</p>
|
||||
<h1 class="split-heading font-display font-black text-[clamp(2.4rem,7vw,5.2rem)] leading-[0.95] max-w-3xl" data-splitting>Every Garden Tells a Story</h1>
|
||||
<p class="reveal text-dim text-lg mt-6 max-w-xl">Premium ornamental trees, delivered with craftsmanship and a guarantee.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About -->
|
||||
<div class="wrap py-20 md:py-28 grid md:grid-cols-2 gap-12 items-center">
|
||||
<div class="photo-slot !min-h-[380px]" data-slot="Mike's Portrait" tabindex="0" role="button" aria-label="Upload Mike's portrait">
|
||||
<div class="slot-default"><i data-lucide="user" class="slot-icon w-8 h-8"></i><span class="slot-label">Mike's Portrait</span></div>
|
||||
<img class="preview" alt="Mike portrait" />
|
||||
<span class="slot-hover-name">Mike's Portrait</span>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<p class="eyebrow mb-4">About Mike</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)] mb-6 leading-tight">Where nature meets craftsmanship.</h2>
|
||||
<p class="text-dim text-lg leading-relaxed">
|
||||
Mike has been cultivating rare and ornamental trees for over a decade. From Japanese Maples to Giant
|
||||
Sequoias, every tree he sells carries a story and a guarantee. Magic Tree Co. is more than a nursery —
|
||||
it's where nature meets craftsmanship.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">The Collection</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Featured Trees</h2>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6" id="mikeProducts"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reviews -->
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Word of Mouth</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Customer Reviews</h2>
|
||||
</div>
|
||||
<div class="masonry" id="mikeReviews"></div>
|
||||
</div>
|
||||
|
||||
<!-- Happy Customers Gallery -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Happy Customers</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Their Gardens, Transformed</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-5" id="mikeGallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ============================ NICO ============================ -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-nico" class="page" aria-label="Nico Gonzales — NovaTerra Immobilien">
|
||||
<!-- Hero -->
|
||||
<div class="relative min-h-[72vh] flex items-center overflow-hidden">
|
||||
<div class="photo-slot absolute inset-0 !rounded-none !border-0 !min-h-0" data-slot="Hero Background" tabindex="0" role="button" aria-label="Upload hero background">
|
||||
<div class="slot-default"><i data-lucide="image" class="slot-icon w-8 h-8"></i><span class="slot-label">Hero Background</span></div>
|
||||
<img class="preview" alt="NovaTerra hero" data-parallax-img="0.16" />
|
||||
</div>
|
||||
<div class="absolute inset-0 overlay-navy pointer-events-none"></div>
|
||||
<div class="wrap relative py-24 text-center md:text-left">
|
||||
<p class="eyebrow mb-4">NovaTerra Immobilien · New Ground. New Possibilities.</p>
|
||||
<h1 class="split-heading font-display font-black text-[clamp(2.4rem,7vw,5.2rem)] leading-[0.95] max-w-3xl" data-splitting>Your Property. Your Legacy.</h1>
|
||||
<p class="reveal text-dim text-lg mt-6 max-w-xl">Premium real estate, brokered with discretion and results.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About -->
|
||||
<div class="wrap py-20 md:py-28 grid md:grid-cols-2 gap-12 items-center">
|
||||
<div class="reveal">
|
||||
<p class="eyebrow mb-4">About Nico</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)] mb-6 leading-tight">Closing premium deals across the region.</h2>
|
||||
<p class="text-dim text-lg leading-relaxed">
|
||||
Nico Gonzales has been closing premium real estate deals across Baden-Württemberg for years. With a network
|
||||
spanning developers, investors, and private buyers, NovaTerra Immobilien is your partner for buying, selling,
|
||||
and developing property — backed by the investment muscle of PG Trading Management.
|
||||
</p>
|
||||
</div>
|
||||
<div class="photo-slot !min-h-[380px]" data-slot="Nico's Headshot" tabindex="0" role="button" aria-label="Upload Nico's headshot">
|
||||
<div class="slot-default"><i data-lucide="user" class="slot-icon w-8 h-8"></i><span class="slot-label">Nico's Headshot</span></div>
|
||||
<img class="preview" alt="Nico headshot" />
|
||||
<span class="slot-hover-name">Nico's Headshot</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listings -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Portfolio</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Featured Listings</h2>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-6" id="nicoListings"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Testimonials -->
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Trusted By</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Client Testimonials</h2>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-6" id="nicoTestimonials"></div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="bg-ink2 border-y border-soft">
|
||||
<div class="wrap py-20 md:py-28">
|
||||
<div class="mb-12 reveal">
|
||||
<p class="eyebrow mb-4">Moments</p>
|
||||
<h2 class="font-display font-extrabold text-[clamp(1.8rem,4vw,2.8rem)]">Deals & Handshakes</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-5" id="nicoGallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- =========================== PARIS =========================== -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-paris" class="page" aria-label="Paris — PG Trading Management">
|
||||
<!-- Login gate -->
|
||||
<div id="parisLogin" class="wrap py-24 md:py-32">
|
||||
<div class="max-w-md mx-auto text-center mb-10 reveal">
|
||||
<i data-lucide="line-chart" class="w-12 h-12 text-gold mx-auto mb-5"></i>
|
||||
<p class="eyebrow mb-3">PG Trading Management</p>
|
||||
<h1 class="font-display font-black text-[clamp(2rem,6vw,3.4rem)] leading-tight">Your Portfolio. Your Game.</h1>
|
||||
<p class="text-dim mt-4 text-lg">Real markets. No risk. Full thrill.</p>
|
||||
</div>
|
||||
<form id="loginForm" class="card card-gold max-w-md mx-auto p-8" novalidate>
|
||||
<h2 class="font-display font-bold text-xl mb-1">Member Login</h2>
|
||||
<p class="text-faint text-sm mb-6">Demo access — user: <span class="text-gold">paris</span> / pass: <span class="text-gold">trading123</span></p>
|
||||
<label class="block text-sm font-semibold mb-2" for="username">Username</label>
|
||||
<input class="input mb-4" type="text" id="username" name="username" autocomplete="username" placeholder="paris" />
|
||||
<label class="block text-sm font-semibold mb-2" for="password">Password</label>
|
||||
<input class="input mb-2" type="password" id="password" name="password" autocomplete="current-password" placeholder="••••••••" />
|
||||
<p id="loginError" class="text-error text-sm h-5 mb-2" role="alert"></p>
|
||||
<button type="submit" class="btn btn-primary w-full">Log In <i data-lucide="lock-open" class="w-4 h-4"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="parisDashboard" class="hidden">
|
||||
<!-- Dashboard navbar -->
|
||||
<div class="bg-ink2 border-b border-soft">
|
||||
<div class="wrap flex flex-wrap items-center justify-between gap-4 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="line-chart" class="w-6 h-6 text-gold"></i>
|
||||
<span class="font-display font-bold">PG Trading <span class="text-gold">Management</span></span>
|
||||
<span class="chip ml-2"><span class="live-dot"></span> LIVE</span>
|
||||
<span class="chip text-gold">Play Money</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<p class="text-faint text-xs">Portfolio</p>
|
||||
<p class="font-display font-bold text-gold" data-counter="7334899.31" data-prefix="€" data-decimals="2">€7,334,899.31</p>
|
||||
</div>
|
||||
<span class="chip">Logged in as Paris</span>
|
||||
<button id="logoutBtn" class="btn btn-ghost !min-h-0 !py-2.5">Logout <i data-lucide="log-out" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap py-12">
|
||||
<!-- KPI cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Portfolio Value</p>
|
||||
<p class="font-display font-extrabold text-2xl" data-counter="7334899.31" data-prefix="€" data-decimals="2">€7,334,899.31</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Today's P&L</p>
|
||||
<p class="font-display font-extrabold text-2xl text-success"><span data-counter="48213.55" data-prefix="+€" data-decimals="2">+€48,213.55</span> <span class="text-sm">(+0.66%)</span></p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Open Positions</p>
|
||||
<p class="font-display font-extrabold text-2xl" data-counter="10" data-decimals="0">10</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-faint text-xs uppercase tracking-wider mb-2">Buying Power</p>
|
||||
<p class="font-display font-extrabold text-2xl" data-counter="57539.31" data-prefix="€" data-decimals="2">€57,539.31</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-8">
|
||||
<!-- Watchlist -->
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="font-display font-bold text-xl mb-4 flex items-center gap-2"><i data-lucide="list" class="w-5 h-5 text-gold"></i> Watchlist</h2>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm" id="watchlistTable">
|
||||
<thead>
|
||||
<tr class="text-faint text-left border-b border-soft">
|
||||
<th class="py-3 px-4 font-semibold">Ticker</th>
|
||||
<th class="py-3 px-4 font-semibold">Company</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">Shares</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">Avg Buy</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">Price</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">P&L</th>
|
||||
<th class="py-3 px-4 font-semibold text-right">P&L%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="watchlistBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trade log -->
|
||||
<h2 class="font-display font-bold text-xl mt-10 mb-4 flex items-center gap-2"><i data-lucide="history" class="w-5 h-5 text-gold"></i> Trade Log</h2>
|
||||
<div class="card divide-y" id="tradeLog" style="--tw-divide-opacity:1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Pie chart -->
|
||||
<div>
|
||||
<h2 class="font-display font-bold text-xl mb-4 flex items-center gap-2"><i data-lucide="pie-chart" class="w-5 h-5 text-gold"></i> Allocation</h2>
|
||||
<div class="card p-6">
|
||||
<canvas id="allocationChart" height="260"></canvas>
|
||||
<div id="chartLegend" class="mt-5 space-y-2 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer -->
|
||||
<div class="bg-ink2 border-t border-soft">
|
||||
<div class="wrap py-4 text-center text-faint text-sm flex items-center justify-center gap-2">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-gold shrink-0"></i>
|
||||
<span>PG Trading Management is a simulation platform for entertainment purposes only. No real money is involved. Not financial advice.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ========================== CASINO =========================== -->
|
||||
<!-- ============================================================= -->
|
||||
<section id="page-casino" class="page" aria-label="PlayBull Casino & Games">
|
||||
<!-- Login gate (shown when not authenticated) -->
|
||||
<div id="casinoGate" class="wrap py-24 md:py-32 hidden">
|
||||
<div class="max-w-md mx-auto text-center reveal">
|
||||
<i data-lucide="dices" class="w-12 h-12 text-gold mx-auto mb-5"></i>
|
||||
<p class="eyebrow mb-3">Members Only</p>
|
||||
<h1 class="font-display font-black text-[clamp(2rem,6vw,3.4rem)] leading-tight">Casino & Games</h1>
|
||||
<p class="text-dim mt-4 text-lg">Chicken Road, 10×10 Deck, Crash & mehr — mit deinem PlayBull-Konto.</p>
|
||||
<button class="btn btn-primary mt-8" id="casinoLoginBtn">Einloggen / Registrieren <i data-lucide="log-in" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Embedded full PlayBull arcade (shares the same login + balance) -->
|
||||
<div id="casinoFrameWrap" class="hidden">
|
||||
<div class="bg-ink2 border-b border-soft">
|
||||
<div class="wrap flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="dices" class="w-6 h-6 text-gold"></i>
|
||||
<span class="font-display font-bold">PlayBull <span class="text-gold">Arcade</span></span>
|
||||
<span class="chip"><span class="live-dot"></span> LIVE</span>
|
||||
<span class="chip text-gold">Play Money</span>
|
||||
</div>
|
||||
<a href="/play.html" target="_blank" rel="noopener" class="btn btn-ghost !min-h-0 !py-2.5">Vollbild öffnen <i data-lucide="external-link" class="w-4 h-4"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="casinoFrame" title="PlayBull Arcade" style="width:100%;height:calc(100vh - 68px - 64px);border:0;display:block;background:var(--bg)"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ================= FOOTER ================= -->
|
||||
<footer class="bg-ink2 border-t border-soft">
|
||||
<div class="wrap py-12 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<g fill="none" stroke="var(--gold)" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M16 5 L24 19 L8 19 Z" /><path d="M9 13 L17 27 L1 27 Z" opacity="0.55" /><path d="M23 13 L31 27 L15 27 Z" opacity="0.55" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="font-display font-extrabold">Trilogy<span class="text-gold">Hub</span></span>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-6" aria-label="Footer">
|
||||
<button class="nav-link" data-route="magic-mike">Magic Tree Co.</button>
|
||||
<button class="nav-link" data-route="nico">NovaTerra Immobilien</button>
|
||||
<button class="nav-link" data-route="paris">PG Trading Management</button>
|
||||
<button class="nav-link" data-route="casino" data-members hidden>Casino & Games</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="wrap pb-8 text-faint text-sm text-center md:text-left">
|
||||
Trilogy Hub © 2025 — Built by three, grown together.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- ================= SCRIPTS ================= -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="image-editor.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="playbull.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1225
public/js/app.js
Normal file
1225
public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
156
public/js/framed-image.js
Normal file
156
public/js/framed-image.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Gemeinsame Transform-Logik für Editor-Vorschau und finale Anzeige
|
||||
|
||||
export const FRAMES = {
|
||||
avatar: {
|
||||
id: 'avatar',
|
||||
aspectRatio: 1,
|
||||
label: 'Profilbild',
|
||||
cssClass: 'framed-image framed-image--avatar',
|
||||
previewClass: 'framed-image--avatar-xl',
|
||||
sizes: { sm: 'framed-image--avatar-sm', md: 'framed-image--avatar-md', lg: 'framed-image--avatar-lg' },
|
||||
},
|
||||
gallery: {
|
||||
id: 'gallery',
|
||||
aspectRatio: 1,
|
||||
label: 'Quadrat',
|
||||
cssClass: 'framed-image framed-image--gallery',
|
||||
previewClass: 'framed-image--gallery-xl',
|
||||
sizes: { sm: 'framed-image--gallery-sm', md: 'framed-image--gallery-md', lg: 'framed-image--gallery-lg' },
|
||||
},
|
||||
banner: {
|
||||
id: 'banner',
|
||||
aspectRatio: 3,
|
||||
label: 'Banner',
|
||||
cssClass: 'framed-image framed-image--banner',
|
||||
previewClass: 'framed-image--banner-xl',
|
||||
sizes: { sm: 'framed-image--banner-sm', md: 'framed-image--banner-md', lg: 'framed-image--banner-lg' },
|
||||
},
|
||||
card: {
|
||||
id: 'card',
|
||||
aspectRatio: 2 / 3,
|
||||
label: 'Karte',
|
||||
cssClass: 'framed-image framed-image--card',
|
||||
previewClass: 'framed-image--card-xl',
|
||||
sizes: { sm: 'framed-image--card-sm', md: 'framed-image--card-md', lg: 'framed-image--card-lg' },
|
||||
},
|
||||
};
|
||||
|
||||
export function computeFramedLayout(imgW, imgH, frameW, frameH, focus, zoom) {
|
||||
const fx = focus?.x ?? 0.5;
|
||||
const fy = focus?.y ?? 0.5;
|
||||
const z = Math.max(1, zoom ?? 1);
|
||||
const cover = Math.max(frameW / imgW, frameH / imgH);
|
||||
const scale = cover * z;
|
||||
const w = imgW * scale;
|
||||
const h = imgH * scale;
|
||||
let left = frameW / 2 - fx * w;
|
||||
let top = frameH / 2 - fy * h;
|
||||
left = Math.min(0, Math.max(frameW - w, left));
|
||||
top = Math.min(0, Math.max(frameH - h, top));
|
||||
return { width: w, height: h, left, top, scale };
|
||||
}
|
||||
|
||||
export function focusFromLayout(imgW, imgH, frameW, frameH, layout, zoom) {
|
||||
const z = Math.max(1, zoom ?? 1);
|
||||
const cover = Math.max(frameW / imgW, frameH / imgH);
|
||||
const scale = cover * z;
|
||||
const w = imgW * scale;
|
||||
const h = imgH * scale;
|
||||
return {
|
||||
x: Math.min(1, Math.max(0, (frameW / 2 - layout.left) / w)),
|
||||
y: Math.min(1, Math.max(0, (frameH / 2 - layout.top) / h)),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyFramedLayout(imgEl, layout) {
|
||||
imgEl.style.width = `${layout.width}px`;
|
||||
imgEl.style.height = `${layout.height}px`;
|
||||
imgEl.style.left = `${layout.left}px`;
|
||||
imgEl.style.top = `${layout.top}px`;
|
||||
}
|
||||
|
||||
export function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Bild konnte nicht geladen werden'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function sizeClass(frame, size) {
|
||||
const preset = FRAMES[frame] || FRAMES.gallery;
|
||||
return preset.sizes[size] || preset.sizes.sm;
|
||||
}
|
||||
|
||||
export function isImageAvatar(avatar) {
|
||||
return avatar && typeof avatar === 'object' && avatar.type === 'image' && avatar.url;
|
||||
}
|
||||
|
||||
export function avatarEmoji(avatar) {
|
||||
if (!avatar) return '🐂';
|
||||
if (typeof avatar === 'string') return avatar;
|
||||
if (isImageAvatar(avatar)) return avatar.emoji || '🐂';
|
||||
return avatar.value || '🐂';
|
||||
}
|
||||
|
||||
export function renderFramedImage({ url, focus, zoom, frame = 'gallery', size = 'sm', alt = '' }) {
|
||||
const preset = FRAMES[frame] || FRAMES.gallery;
|
||||
const fx = focus?.x ?? 0.5;
|
||||
const fy = focus?.y ?? 0.5;
|
||||
const z = zoom ?? 1;
|
||||
return `<div class="${preset.cssClass} ${sizeClass(frame, size)} framed-image-host"
|
||||
data-framed="1" data-frame="${preset.id}" data-url="${escapeAttr(url)}" data-fx="${fx}" data-fy="${fy}" data-zoom="${z}">
|
||||
<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" draggable="false" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderUploadItem(item) {
|
||||
return `<div class="upload-item" data-upload-id="${item.id}">
|
||||
${renderFramedImage({ url: item.url, focus: item.focus, zoom: item.zoom, frame: item.frame, size: 'md' })}
|
||||
<div class="upload-item-actions">
|
||||
<button type="button" class="chip" data-upload-repos="${item.id}">↕️ Position</button>
|
||||
<button type="button" class="chip" data-upload-del="${item.id}">🗑️</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderAvatar(avatar, { size = 'sm', alt = '' } = {}) {
|
||||
if (isImageAvatar(avatar)) {
|
||||
return renderFramedImage({
|
||||
url: avatar.url,
|
||||
focus: avatar.focus,
|
||||
zoom: avatar.zoom,
|
||||
frame: 'avatar',
|
||||
size,
|
||||
alt,
|
||||
});
|
||||
}
|
||||
const emoji = avatarEmoji(avatar);
|
||||
const cls = size === 'lg' ? 'avatar-emoji avatar-emoji--lg' : size === 'md' ? 'avatar-emoji avatar-emoji--md' : 'avatar-emoji avatar-emoji--sm';
|
||||
return `<span class="${cls}" aria-hidden="true">${emoji}</span>`;
|
||||
}
|
||||
|
||||
export function hydrateFramedImages(root = document) {
|
||||
root.querySelectorAll('.framed-image-host[data-framed]').forEach((host) => {
|
||||
const img = host.querySelector('img');
|
||||
if (!img) return;
|
||||
const apply = () => {
|
||||
const layout = computeFramedLayout(
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
host.clientWidth,
|
||||
host.clientHeight,
|
||||
{ x: +host.dataset.fx, y: +host.dataset.fy },
|
||||
+host.dataset.zoom,
|
||||
);
|
||||
applyFramedLayout(img, layout);
|
||||
};
|
||||
if (img.complete && img.naturalWidth) apply();
|
||||
else img.addEventListener('load', apply, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
198
public/js/image-editor.js
Normal file
198
public/js/image-editor.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
FRAMES,
|
||||
computeFramedLayout,
|
||||
focusFromLayout,
|
||||
applyFramedLayout,
|
||||
loadImage,
|
||||
} from './framed-image.js';
|
||||
|
||||
/**
|
||||
* Öffnet den Positions-Editor nach Dateiauswahl (oder für bestehendes Bild).
|
||||
* @returns {Promise<{focus:{x,y}, zoom:number}|null>}
|
||||
*/
|
||||
export async function openImagePositionEditor({ file, url, focus, zoom, frame = 'avatar' } = {}) {
|
||||
const preset = FRAMES[frame] || FRAMES.avatar;
|
||||
let src = url;
|
||||
if (file) src = URL.createObjectURL(file);
|
||||
|
||||
let img;
|
||||
try {
|
||||
img = await loadImage(src);
|
||||
} catch (e) {
|
||||
if (file) URL.revokeObjectURL(src);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const state = {
|
||||
focus: { x: focus?.x ?? 0.5, y: focus?.y ?? 0.5 },
|
||||
zoom: Math.max(1, zoom ?? 1),
|
||||
img,
|
||||
dragging: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
pinchDist: 0,
|
||||
pinchZoom: 1,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop image-editor-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
<div class="modal image-editor-modal">
|
||||
<div class="handle"></div>
|
||||
<div class="modal-head">
|
||||
<h2>${preset.label} positionieren</h2>
|
||||
<button type="button" class="modal-close" data-act="cancel">✕</button>
|
||||
</div>
|
||||
<p class="muted center image-editor-hint">So wird dein Bild überall angezeigt — ziehen & zoomen</p>
|
||||
<div class="image-editor-preview-wrap">
|
||||
<div class="image-editor-preview ${preset.cssClass} ${preset.previewClass || 'framed-image--avatar-xl'}" id="ieFrame">
|
||||
<img id="ieImg" src="${src}" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field image-editor-zoom-field">
|
||||
<label>Zoom <span id="ieZoomVal">${state.zoom.toFixed(1)}×</span></label>
|
||||
<input type="range" id="ieZoom" min="1" max="4" step="0.05" value="${state.zoom}" />
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn btn-ghost" data-act="reset">Zurücksetzen</button>
|
||||
<button type="button" class="btn btn-accent" data-act="save">Speichern</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const frameEl = backdrop.querySelector('#ieFrame');
|
||||
const imgEl = backdrop.querySelector('#ieImg');
|
||||
const zoomInput = backdrop.querySelector('#ieZoom');
|
||||
const zoomVal = backdrop.querySelector('#ieZoomVal');
|
||||
|
||||
let layout = { left: 0, top: 0, width: 0, height: 0, scale: 1 };
|
||||
|
||||
function syncFromFocus() {
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom);
|
||||
applyFramedLayout(imgEl, layout);
|
||||
}
|
||||
|
||||
function syncFocusFromLayout() {
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
state.focus = focusFromLayout(img.naturalWidth, img.naturalHeight, fw, fh, layout, state.zoom);
|
||||
}
|
||||
|
||||
function finish(result) {
|
||||
document.body.style.overflow = '';
|
||||
backdrop.remove();
|
||||
if (file) URL.revokeObjectURL(src);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
if (e.target.closest('button, input')) return;
|
||||
state.dragging = true;
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
frameEl.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!state.dragging) return;
|
||||
const dx = e.clientX - state.lastX;
|
||||
const dy = e.clientY - state.lastY;
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
layout.left = Math.min(0, Math.max(fw - layout.width, layout.left + dx));
|
||||
layout.top = Math.min(0, Math.max(fh - layout.height, layout.top + dy));
|
||||
applyFramedLayout(imgEl, layout);
|
||||
syncFocusFromLayout();
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
state.dragging = false;
|
||||
try { frameEl.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.08 : 0.08;
|
||||
setZoom(state.zoom + delta);
|
||||
}
|
||||
|
||||
function setZoom(z) {
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
const oldZoom = state.zoom;
|
||||
state.zoom = Math.min(4, Math.max(1, z));
|
||||
const oldLayout = { ...layout };
|
||||
layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom);
|
||||
// Fokuspunkt in der Mitte halten beim Zoomen
|
||||
const cx = fw / 2;
|
||||
const cy = fh / 2;
|
||||
const relX = (cx - oldLayout.left) / oldLayout.width;
|
||||
const relY = (cy - oldLayout.top) / oldLayout.height;
|
||||
layout.left = cx - relX * layout.width;
|
||||
layout.top = cy - relY * layout.height;
|
||||
layout.left = Math.min(0, Math.max(fw - layout.width, layout.left));
|
||||
layout.top = Math.min(0, Math.max(fh - layout.height, layout.top));
|
||||
syncFocusFromLayout();
|
||||
applyFramedLayout(imgEl, layout);
|
||||
zoomInput.value = state.zoom;
|
||||
zoomVal.textContent = `${state.zoom.toFixed(1)}×`;
|
||||
}
|
||||
|
||||
imgEl.addEventListener('load', syncFromFocus, { once: true });
|
||||
if (imgEl.complete) syncFromFocus();
|
||||
else requestAnimationFrame(syncFromFocus);
|
||||
|
||||
frameEl.addEventListener('pointerdown', onPointerDown);
|
||||
frameEl.addEventListener('pointermove', onPointerMove);
|
||||
frameEl.addEventListener('pointerup', onPointerUp);
|
||||
frameEl.addEventListener('pointercancel', onPointerUp);
|
||||
frameEl.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
// Pinch-Zoom auf Touch
|
||||
frameEl.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 2) {
|
||||
state.pinchDist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY,
|
||||
);
|
||||
state.pinchZoom = state.zoom;
|
||||
}
|
||||
}, { passive: true });
|
||||
frameEl.addEventListener('touchmove', (e) => {
|
||||
if (e.touches.length === 2 && state.pinchDist > 0) {
|
||||
e.preventDefault();
|
||||
const dist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY,
|
||||
);
|
||||
setZoom(state.pinchZoom * (dist / state.pinchDist));
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
zoomInput.addEventListener('input', () => setZoom(+zoomInput.value));
|
||||
|
||||
backdrop.addEventListener('click', (e) => {
|
||||
if (e.target === backdrop) finish(null);
|
||||
});
|
||||
backdrop.querySelector('[data-act="cancel"]').onclick = () => finish(null);
|
||||
backdrop.querySelector('[data-act="reset"]').onclick = () => {
|
||||
state.focus = { x: 0.5, y: 0.5 };
|
||||
state.zoom = 1;
|
||||
zoomInput.value = 1;
|
||||
zoomVal.textContent = '1.0×';
|
||||
syncFromFocus();
|
||||
};
|
||||
backdrop.querySelector('[data-act="save"]').onclick = () => {
|
||||
syncFocusFromLayout();
|
||||
finish({ focus: { ...state.focus }, zoom: state.zoom });
|
||||
};
|
||||
});
|
||||
}
|
||||
72
public/js/upload-helper.js
Normal file
72
public/js/upload-helper.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { openImagePositionEditor } from './image-editor.js';
|
||||
|
||||
/**
|
||||
* Öffnet Editor und lädt Bild hoch. Für Avatar, Galerie und alle anderen Uploads.
|
||||
*/
|
||||
export async function uploadImageWithEditor(file, { frame, uploadFn }) {
|
||||
if (!file) return null;
|
||||
const result = await openImagePositionEditor({ file, frame });
|
||||
if (!result) return null;
|
||||
return uploadFn(file, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor für bestehendes Bild, dann Position speichern.
|
||||
*/
|
||||
export async function repositionImageWithEditor(item, { frame, saveFn }) {
|
||||
const result = await openImagePositionEditor({
|
||||
url: item.url,
|
||||
focus: item.focus,
|
||||
zoom: item.zoom,
|
||||
frame: frame || item.frame || 'gallery',
|
||||
});
|
||||
if (!result) return null;
|
||||
return saveFn(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindet einen File-Input mit Editor + Upload-Callback.
|
||||
*/
|
||||
export function wireImageFileInput(input, { frame, onPick }) {
|
||||
input.addEventListener('change', async () => {
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
await onPick(file, frame);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt versteckten File-Input + Button — Editor öffnet sich immer vor Upload.
|
||||
*/
|
||||
export function createImageUploadButton({ label, frame, onPick, className = 'btn btn-accent' }) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'image-upload-wrap';
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = className;
|
||||
btn.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.hidden = true;
|
||||
btn.onclick = () => input.click();
|
||||
wireImageFileInput(input, {
|
||||
frame,
|
||||
onPick: async (file) => onPick(file, frame),
|
||||
});
|
||||
wrap.append(btn, input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function appendPositionFields(fd, result, frame) {
|
||||
fd.append('focusX', result.focus.x);
|
||||
fd.append('focusY', result.focus.y);
|
||||
fd.append('zoom', result.zoom);
|
||||
if (frame) fd.append('frame', frame);
|
||||
return fd;
|
||||
}
|
||||
98
public/play.html
Normal file
98
public/play.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<style>html,body{background:#0a0a0a;color:#f4f1e9}</style>
|
||||
<title>PlayBull — Trilogy Hub Trading & Arcade</title>
|
||||
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@500,700,800,900&f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
|
||||
<!-- Animation libs -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Topbar (in iframe via ?embed=1 ausgeblendet) -->
|
||||
<header class="topbar" id="pbTopbar">
|
||||
<div class="brand" id="brandHome">
|
||||
<span class="brand-logo">🐂</span>
|
||||
<span class="brand-name">Play<span class="accent">Bull</span></span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="balance-chip" id="balanceChip" hidden>
|
||||
<span class="coin">🪙</span><span id="balanceValue">0</span>
|
||||
</div>
|
||||
<button class="btn-auth" id="authBtn">Login</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Globaler Live-Feed (Ticker) -->
|
||||
<div class="ticker" id="feedTicker"><div class="ticker-track" id="tickerTrack"></div></div>
|
||||
|
||||
<main id="app">
|
||||
<!-- ===== TRADING ===== -->
|
||||
<section class="view active" id="view-trading">
|
||||
<div class="view-head">
|
||||
<h1>Markt <span class="live-dot"></span><small>live</small></h1>
|
||||
<div class="seg" id="marketFilter">
|
||||
<button class="seg-btn active" data-f="all">Alle</button>
|
||||
<button class="seg-btn" data-f="stock">Aktien</button>
|
||||
<button class="seg-btn" data-f="crypto">Krypto</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list" id="assetList"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== GAMES ===== -->
|
||||
<section class="view" id="view-games">
|
||||
<div class="view-head"><h1>Casino 🎰</h1></div>
|
||||
<div class="games-grid" id="gamesGrid"></div>
|
||||
<div class="login-hint" id="gamesLoginHint" hidden>
|
||||
🔒 Zum Spielen bitte <button class="link" data-open-auth>einloggen</button>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PORTFOLIO ===== -->
|
||||
<section class="view" id="view-portfolio">
|
||||
<div class="view-head"><h1>Portfolio 💼</h1></div>
|
||||
<div id="portfolioContent"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SOCIAL / LEADERBOARD ===== -->
|
||||
<section class="view" id="view-social">
|
||||
<div class="view-head"><h1>Rangliste 🏆</h1></div>
|
||||
<div id="leaderboardContent"></div>
|
||||
<div class="view-head"><h2>Aktivität</h2></div>
|
||||
<div id="feedList" class="feed-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SETTINGS / ADMIN ===== -->
|
||||
<section class="view" id="view-settings">
|
||||
<div class="view-head"><h1>Einstellungen ⚙️</h1></div>
|
||||
<div id="settingsContent"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Bottom Nav -->
|
||||
<nav class="bottom-nav">
|
||||
<button class="nav-btn active" data-view="trading"><span>📈</span>Trading</button>
|
||||
<button class="nav-btn" data-view="games"><span>🎰</span>Casino</button>
|
||||
<button class="nav-btn" data-view="portfolio"><span>💼</span>Depot</button>
|
||||
<button class="nav-btn" data-view="social"><span>🏆</span>Social</button>
|
||||
<button class="nav-btn" data-view="settings"><span>⚙️</span>Mehr</button>
|
||||
</nav>
|
||||
|
||||
<!-- Modal Container -->
|
||||
<div class="modal-backdrop" id="modal" hidden>
|
||||
<div class="modal" id="modalBox"></div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast-wrap" id="toastWrap"></div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
460
public/playbull.js
Normal file
460
public/playbull.js
Normal file
@@ -0,0 +1,460 @@
|
||||
/* ============================================================
|
||||
PlayBull Integration — verbindet Trilogy Hub mit dem
|
||||
PlayBull-Backend (echtes Login, Live-Trading, Casino).
|
||||
Additiv: laeuft NACH app.js, ohne dessen Logik zu entfernen.
|
||||
============================================================ */
|
||||
(() => {
|
||||
"use strict";
|
||||
const $ = (s, c = document) => c.querySelector(s);
|
||||
const $$ = (s, c = document) => Array.from(c.querySelectorAll(s));
|
||||
|
||||
const PB = {
|
||||
user: null,
|
||||
assets: new Map(),
|
||||
portfolio: null,
|
||||
socket: null,
|
||||
chart: null,
|
||||
parisLive: false,
|
||||
casinoLoaded: false,
|
||||
};
|
||||
window.PlayBull = PB;
|
||||
|
||||
/* -------------------- API -------------------- */
|
||||
async function api(path, body, method = "POST") {
|
||||
const opts = { method, headers: { "Content-Type": "application/json" }, credentials: "same-origin" };
|
||||
if (body && method !== "GET") opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, method === "GET" ? { credentials: "same-origin" } : opts);
|
||||
const data = await res.json().catch(() => ({ ok: false, error: "Netzwerkfehler" }));
|
||||
return data;
|
||||
}
|
||||
const get = (p) => api(p, null, "GET");
|
||||
|
||||
/* -------------------- Format -------------------- */
|
||||
const eur = (n, d = 2) => "€" + (Number(n) || 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const px = (n) => (Number(n) < 1 ? (Number(n)).toLocaleString("de-DE", { maximumFractionDigits: 4 }) : eur(n));
|
||||
const signed = (n) => (n >= 0 ? "+" : "-") + "€" + Math.abs(Number(n) || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
/* -------------------- Toast -------------------- */
|
||||
function toast(msg, type = "") {
|
||||
let wrap = $("#pbToasts");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.id = "pbToasts";
|
||||
wrap.style.cssText = "position:fixed;top:80px;left:0;right:0;z-index:200;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none";
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
const border = type === "win" ? "var(--success)" : type === "lose" ? "var(--error)" : "var(--border)";
|
||||
el.style.cssText = `pointer-events:auto;background:var(--surface);border:1px solid ${border};color:var(--text);padding:11px 18px;border-radius:12px;font-weight:600;font-size:.9rem;box-shadow:0 10px 30px rgba(0,0,0,.45);max-width:90%`;
|
||||
el.innerHTML = msg;
|
||||
wrap.appendChild(el);
|
||||
setTimeout(() => { el.style.transition = "opacity .3s"; el.style.opacity = "0"; }, 2600);
|
||||
setTimeout(() => el.remove(), 3000);
|
||||
}
|
||||
|
||||
/* -------------------- Modal -------------------- */
|
||||
function openModal(html, maxw = "440px") {
|
||||
closeModal();
|
||||
const back = document.createElement("div");
|
||||
back.className = "modal-backdrop";
|
||||
back.id = "pbModal";
|
||||
back.innerHTML = `<div class="card card-gold" style="width:100%;max-width:${maxw};padding:1.5rem;max-height:92vh;overflow-y:auto">${html}</div>`;
|
||||
document.body.appendChild(back);
|
||||
back.addEventListener("click", (e) => { if (e.target === back) closeModal(); });
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
return back;
|
||||
}
|
||||
function closeModal() { const m = $("#pbModal"); if (m) m.remove(); }
|
||||
PB.closeModal = closeModal;
|
||||
|
||||
/* -------------------- Auth -------------------- */
|
||||
function openAuthModal(mode = "login") {
|
||||
const isLogin = mode === "login";
|
||||
openModal(`
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-display font-bold text-xl">${isLogin ? "Login" : "Registrieren"} 🐂</h2>
|
||||
<button class="btn btn-ghost !min-h-0 !p-2" data-close>✕</button>
|
||||
</div>
|
||||
<label class="block text-sm font-semibold mb-2">Username</label>
|
||||
<input class="input mb-3" id="pbUser" autocomplete="username" placeholder="dein_name" />
|
||||
<label class="block text-sm font-semibold mb-2">Passwort</label>
|
||||
<input class="input mb-2" id="pbPass" type="password" autocomplete="current-password" placeholder="••••" />
|
||||
<p class="text-error text-sm mb-2" id="pbAuthErr" style="min-height:18px"></p>
|
||||
<button class="btn btn-primary w-full" id="pbAuthSubmit">${isLogin ? "Einloggen" : "Account erstellen"}</button>
|
||||
<p class="text-center text-faint text-sm mt-4">
|
||||
${isLogin ? "Noch kein Account?" : "Schon registriert?"}
|
||||
<button class="text-gold font-semibold underline" id="pbAuthSwitch">${isLogin ? "Registrieren" : "Login"}</button>
|
||||
</p>`);
|
||||
$("#pbModal [data-close]").onclick = closeModal;
|
||||
$("#pbAuthSwitch").onclick = () => openAuthModal(isLogin ? "register" : "login");
|
||||
const submit = async () => {
|
||||
const username = $("#pbUser").value.trim();
|
||||
const password = $("#pbPass").value;
|
||||
const r = await api(isLogin ? "/api/login" : "/api/register", { username, password });
|
||||
if (!r.ok) { $("#pbAuthErr").textContent = r.error || "Fehler"; return; }
|
||||
PB.user = r.user;
|
||||
closeModal();
|
||||
toast(`Willkommen, ${r.user.username}! 🎉`, "win");
|
||||
onAuthChange();
|
||||
};
|
||||
$("#pbAuthSubmit").onclick = submit;
|
||||
$("#pbPass").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||||
setTimeout(() => $("#pbUser")?.focus(), 50);
|
||||
}
|
||||
PB.openAuthModal = openAuthModal;
|
||||
|
||||
async function logout() {
|
||||
await api("/api/logout", {});
|
||||
PB.user = null;
|
||||
PB.parisLive = false;
|
||||
onAuthChange();
|
||||
toast("Ausgeloggt");
|
||||
}
|
||||
|
||||
function onAuthChange() {
|
||||
// Members-only Navigation ein/ausblenden
|
||||
$$("[data-members]").forEach((el) => { el.hidden = !PB.user; });
|
||||
// aktuelle Seite aktualisieren
|
||||
const route = (location.hash || "#home").replace("#", "");
|
||||
if (route === "paris") setupParis();
|
||||
if (route === "casino") setupCasino();
|
||||
// Falls eingeloggt: Portfolio laden
|
||||
if (PB.user) loadPortfolio();
|
||||
}
|
||||
|
||||
/* -------------------- Portfolio / Markt -------------------- */
|
||||
async function loadMarket() {
|
||||
const r = await get("/api/market");
|
||||
if (r.ok) r.assets.forEach((a) => PB.assets.set(a.symbol, a));
|
||||
}
|
||||
async function loadPortfolio() {
|
||||
if (!PB.user) return;
|
||||
const r = await get("/api/portfolio");
|
||||
if (r.ok) { PB.portfolio = r.portfolio; if (PB.user) PB.user.balance = r.portfolio.balance; }
|
||||
}
|
||||
|
||||
/* ==================== PARIS — LIVE TRADING ==================== */
|
||||
function setupParis() {
|
||||
// Demo-Handler von app.js abklemmen: Login-Form + Logout klonen
|
||||
const form = $("#loginForm");
|
||||
if (form && !form.dataset.pbWired) {
|
||||
const clone = form.cloneNode(true);
|
||||
form.replaceWith(clone);
|
||||
clone.dataset.pbWired = "1";
|
||||
clone.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const u = clone.querySelector("#username").value.trim();
|
||||
const pw = clone.querySelector("#password").value;
|
||||
const err = clone.querySelector("#loginError");
|
||||
// 1) Login versuchen, 2) sonst automatisch registrieren
|
||||
let r = await api("/api/login", { username: u, password: pw });
|
||||
if (!r.ok) {
|
||||
const reg = await api("/api/register", { username: u, password: pw });
|
||||
if (reg.ok) r = reg;
|
||||
}
|
||||
if (!r.ok) { err.textContent = r.error || "Login fehlgeschlagen"; return; }
|
||||
err.textContent = "";
|
||||
PB.user = r.user;
|
||||
onAuthChange();
|
||||
revealDashboard();
|
||||
});
|
||||
// Hinweis-Text anpassen
|
||||
const hint = clone.querySelector("p.text-faint");
|
||||
if (hint) hint.innerHTML = 'Dein <span class="text-gold">PlayBull</span>-Konto — neu? Einfach Name + Passwort eingeben, der Account wird automatisch erstellt.';
|
||||
}
|
||||
const logoutBtn = $("#logoutBtn");
|
||||
if (logoutBtn && !logoutBtn.dataset.pbWired) {
|
||||
const lc = logoutBtn.cloneNode(true);
|
||||
logoutBtn.replaceWith(lc);
|
||||
lc.dataset.pbWired = "1";
|
||||
lc.addEventListener("click", () => { logout(); $("#parisDashboard").classList.add("hidden"); $("#parisLogin").classList.remove("hidden"); });
|
||||
}
|
||||
if (PB.user) revealDashboard();
|
||||
}
|
||||
|
||||
async function revealDashboard() {
|
||||
if (!PB.user) return;
|
||||
$("#parisLogin")?.classList.add("hidden");
|
||||
$("#parisDashboard")?.classList.remove("hidden");
|
||||
await loadPortfolio();
|
||||
injectDashboardExtras();
|
||||
renderMarketTable();
|
||||
renderTradeLog();
|
||||
renderHoldings();
|
||||
updateKpis();
|
||||
requestAnimationFrame(() => { try { buildAllocationChart(); } catch {} });
|
||||
PB.parisLive = true;
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
// Logged-in-as Anzeige + Casino-CTA in die Navbar setzen
|
||||
function injectDashboardExtras() {
|
||||
const chip = $$("#parisDashboard .chip").find((c) => /Logged in as|Eingeloggt/.test(c.textContent));
|
||||
if (chip) chip.textContent = `Eingeloggt: ${PB.user.username}`;
|
||||
const bar = $("#parisDashboard .wrap.flex");
|
||||
if (bar && !$("#pbCasinoCta")) {
|
||||
const a = document.createElement("button");
|
||||
a.id = "pbCasinoCta";
|
||||
a.className = "btn btn-ghost !min-h-0 !py-2.5";
|
||||
a.innerHTML = 'Casino & Games <i data-lucide="dices" class="w-4 h-4"></i>';
|
||||
a.onclick = () => { location.hash = "#casino"; };
|
||||
const logout = $("#parisDashboard #logoutBtn");
|
||||
logout?.parentElement?.insertBefore(a, logout);
|
||||
}
|
||||
}
|
||||
|
||||
function updateKpis() {
|
||||
const p = PB.portfolio; if (!p) return;
|
||||
const pnl = (p.holdings || []).reduce((s, h) => s + h.pnl, 0);
|
||||
// Reihenfolge im KPI-Grid: Portfolio Value, Today's P&L, Open Positions, Buying Power
|
||||
const grid = $("#parisDashboard .grid.grid-cols-2");
|
||||
if (grid) {
|
||||
const vals = $$("[data-counter]", grid);
|
||||
if (vals[0]) vals[0].textContent = eur(p.netWorth);
|
||||
if (vals[1]) { vals[1].textContent = signed(pnl); vals[1].style.color = pnl >= 0 ? "var(--success)" : "var(--error)"; }
|
||||
if (vals[2]) vals[2].textContent = String((p.holdings || []).length);
|
||||
if (vals[3]) vals[3].textContent = eur(p.balance);
|
||||
// statische "(+0.66%)" Klammer hinter P&L entfernen, falls vorhanden
|
||||
const pnlExtra = vals[1]?.parentElement?.querySelector("span.text-sm");
|
||||
if (pnlExtra) pnlExtra.textContent = "";
|
||||
}
|
||||
// Portfolio-Wert oben in der Navbar
|
||||
const navPv = $("#parisDashboard .text-right [data-counter]");
|
||||
if (navPv) navPv.textContent = eur(p.netWorth);
|
||||
}
|
||||
|
||||
function renderMarketTable() {
|
||||
const table = $("#watchlistTable");
|
||||
if (!table) return;
|
||||
const assets = [...PB.assets.values()];
|
||||
const posBySym = {};
|
||||
(PB.portfolio?.holdings || []).forEach((h) => (posBySym[h.symbol] = h));
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr class="text-faint text-left border-b border-soft">
|
||||
<th class="py-3 px-3 font-semibold">Asset</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">Kurs</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">24h</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">Position</th>
|
||||
<th class="py-3 px-3 font-semibold text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="watchlistBody">
|
||||
${assets.map((a) => {
|
||||
const pos = posBySym[a.symbol];
|
||||
const cls = a.changePct >= 0 ? "text-success" : "text-error";
|
||||
return `<tr class="border-b border-soft" data-sym="${a.symbol}">
|
||||
<td class="py-3 px-3"><span class="font-bold">${a.emoji || ""} ${a.symbol}</span><div class="text-faint text-xs">${a.name}</div></td>
|
||||
<td class="py-3 px-3 text-right font-semibold" data-px>${px(a.price)}</td>
|
||||
<td class="py-3 px-3 text-right ${cls}" data-chg>${a.changePct >= 0 ? "+" : ""}${(a.changePct).toFixed(2)}%</td>
|
||||
<td class="py-3 px-3 text-right text-dim" data-pos>${pos ? pos.quantity + (pos.side === "short" ? " (S)" : "") : "—"}</td>
|
||||
<td class="py-3 px-3 text-right whitespace-nowrap">
|
||||
<button class="btn btn-primary !min-h-0 !py-1.5 !px-3 text-xs" data-buy>Kauf</button>
|
||||
<button class="btn btn-ghost !min-h-0 !py-1.5 !px-3 text-xs" data-sell>Verk.</button>
|
||||
<button class="btn btn-ghost !min-h-0 !py-1.5 !px-2 text-xs" data-pump title="Pump (alle)">🚀</button>
|
||||
<button class="btn btn-ghost !min-h-0 !py-1.5 !px-2 text-xs" data-dump title="Dump (alle)">📉</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
</tbody>`;
|
||||
$$("#watchlistTable tr[data-sym]").forEach((tr) => {
|
||||
const sym = tr.dataset.sym;
|
||||
tr.querySelector("[data-buy]").onclick = () => openTradeModal(sym, "buy");
|
||||
tr.querySelector("[data-sell]").onclick = () => openTradeModal(sym, "sell");
|
||||
tr.querySelector("[data-pump]").onclick = () => manipulate(sym, "pump");
|
||||
tr.querySelector("[data-dump]").onclick = () => manipulate(sym, "dump");
|
||||
});
|
||||
}
|
||||
|
||||
async function manipulate(sym, action) {
|
||||
const r = await api("/api/manipulate", { symbol: sym, action, value: action === "pump" || action === "dump" ? 3 : 0 });
|
||||
if (!r.ok) return toast(r.error, "lose");
|
||||
toast(`${sym}: ${action === "pump" ? "🚀 gepumpt" : "📉 gedumpt"} (für alle)`);
|
||||
}
|
||||
|
||||
function openTradeModal(sym, side) {
|
||||
const a = PB.assets.get(sym);
|
||||
openModal(`
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-display font-bold text-xl">${a.emoji || ""} ${sym} <span class="text-faint text-sm">${a.name}</span></h2>
|
||||
<button class="btn btn-ghost !min-h-0 !p-2" data-close>✕</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-faint text-sm">Kurs</span>
|
||||
<span class="font-display font-bold text-lg" id="tmPx">${px(a.price)}</span>
|
||||
</div>
|
||||
<div class="seg-tabs flex gap-2 mb-4">
|
||||
<button class="btn ${side === "buy" ? "btn-primary" : "btn-ghost"} flex-1" data-mode="buy">Kaufen (Long)</button>
|
||||
<button class="btn ${side === "sell" ? "btn-primary" : "btn-ghost"} flex-1" data-mode="sell">Verkauf / Short</button>
|
||||
</div>
|
||||
<label class="block text-sm font-semibold mb-2">Menge</label>
|
||||
<input class="input mb-3" id="tmQty" type="number" inputmode="decimal" value="1" min="0" step="0.1" />
|
||||
<div class="flex gap-2 mb-3">
|
||||
${[0.1, 1, 5, 10].map((v) => `<button class="chip" data-amt="${v}">${v}</button>`).join("")}
|
||||
<button class="chip" data-max>MAX</button>
|
||||
</div>
|
||||
<p class="text-faint text-center text-sm mb-3" id="tmEst"></p>
|
||||
<button class="btn btn-primary w-full" id="tmGo"></button>
|
||||
<p class="text-faint text-xs text-center mt-3">Guthaben: <span id="tmBal">${eur(PB.user.balance)}</span></p>`);
|
||||
let mode = side;
|
||||
const qty = $("#tmQty");
|
||||
const refresh = () => {
|
||||
const cur = PB.assets.get(sym);
|
||||
$("#tmPx").textContent = px(cur.price);
|
||||
const cost = (parseFloat(qty.value) || 0) * cur.price;
|
||||
$("#tmEst").textContent = `≈ ${eur(cost)}`;
|
||||
$("#tmGo").textContent = mode === "buy" ? "Jetzt kaufen" : "Jetzt verkaufen";
|
||||
$("#tmGo").className = "btn w-full " + (mode === "buy" ? "btn-primary" : "btn-ghost");
|
||||
};
|
||||
$("#pbModal [data-close]").onclick = closeModal;
|
||||
$$("#pbModal [data-mode]").forEach((b) => b.onclick = () => {
|
||||
mode = b.dataset.mode;
|
||||
$$("#pbModal [data-mode]").forEach((x) => x.className = "btn flex-1 " + (x.dataset.mode === mode ? "btn-primary" : "btn-ghost"));
|
||||
refresh();
|
||||
});
|
||||
$$("#pbModal [data-amt]").forEach((c) => c.onclick = () => { qty.value = c.dataset.amt; refresh(); });
|
||||
$("#pbModal [data-max]").onclick = () => { qty.value = (PB.user.balance / PB.assets.get(sym).price).toFixed(4); refresh(); };
|
||||
qty.addEventListener("input", refresh);
|
||||
$("#tmGo").onclick = async () => {
|
||||
const r = await api("/api/trade", { symbol: sym, side: mode, qty: parseFloat(qty.value) });
|
||||
if (!r.ok) return toast(r.error, "lose");
|
||||
const tradedQty = parseFloat(qty.value);
|
||||
PB.user.balance = r.balance;
|
||||
closeModal();
|
||||
toast(`${mode === "buy" ? "Gekauft" : "Verkauft"}: ${tradedQty} ${sym} @ ${px(r.price)}`, "win");
|
||||
await loadPortfolio();
|
||||
updateKpis(); renderMarketTable(); renderTradeLog(); buildAllocationChart();
|
||||
};
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function renderTradeLog() {
|
||||
const log = $("#tradeLog");
|
||||
if (!log) return;
|
||||
const r = await get("/api/trades");
|
||||
const trades = r.ok ? r.trades : [];
|
||||
log.innerHTML = trades.length ? trades.slice(0, 12).map((t) => {
|
||||
const isBuy = t.side === "buy";
|
||||
const d = new Date(t.ts).toLocaleString("de-DE", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
return `<div class="flex items-center gap-3 p-4 border-b border-soft">
|
||||
<span class="chip ${isBuy ? "text-success" : "text-error"}">${isBuy ? "BUY" : "SELL"}</span>
|
||||
<span class="text-dim text-sm">${t.qty} ${t.symbol} @ ${px(t.price)}</span>
|
||||
<span class="text-faint text-xs ml-auto">${d}</span>
|
||||
</div>`;
|
||||
}).join("") : '<div class="p-4 text-faint text-sm">Noch keine Trades.</div>';
|
||||
}
|
||||
|
||||
function renderHoldings() { updateKpis(); }
|
||||
|
||||
const chartColors = ["#c9a84c", "#2f9e8f", "#5b7aa6", "#9a958a", "#7d8a4f", "#b8763a", "#6a8caf", "#a85f7a"];
|
||||
function buildAllocationChart() {
|
||||
const ctx = $("#allocationChart");
|
||||
if (!ctx || typeof Chart === "undefined") return;
|
||||
const holds = (PB.portfolio?.holdings || []).filter((h) => Math.abs(h.value) > 0.01);
|
||||
const data = holds.map((h) => Math.abs(h.value));
|
||||
const labels = holds.map((h) => h.symbol);
|
||||
if (PB.chart) PB.chart.destroy();
|
||||
if (!data.length) {
|
||||
const c2 = ctx.getContext("2d"); c2.clearRect(0, 0, ctx.width, ctx.height);
|
||||
$("#chartLegend").innerHTML = '<p class="text-faint text-sm">Noch keine Positionen.</p>';
|
||||
return;
|
||||
}
|
||||
PB.chart = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: { labels, datasets: [{ data, backgroundColor: labels.map((_, i) => chartColors[i % chartColors.length]), borderColor: "transparent", borderWidth: 2, hoverOffset: 8 }] },
|
||||
options: { cutout: "62%", responsive: true, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => ` ${c.label}: ${eur(c.parsed)}` } } } },
|
||||
});
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
$("#chartLegend").innerHTML = labels.map((l, i) => `
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2"><span style="width:10px;height:10px;border-radius:3px;background:${chartColors[i % chartColors.length]};display:inline-block"></span>${l}</span>
|
||||
<span class="text-faint">${((data[i] / total) * 100).toFixed(1)}%</span>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
/* ==================== CASINO (eingebettete Arcade) ==================== */
|
||||
function setupCasino() {
|
||||
const gate = $("#casinoGate"), wrap = $("#casinoFrameWrap");
|
||||
if (!gate || !wrap) return;
|
||||
if (PB.user) {
|
||||
gate.classList.add("hidden");
|
||||
wrap.classList.remove("hidden");
|
||||
const frame = $("#casinoFrame");
|
||||
if (frame) {
|
||||
const url = "/play.html?embed=1";
|
||||
if (!frame.src.includes("embed=1")) frame.src = url;
|
||||
PB.casinoLoaded = true;
|
||||
}
|
||||
} else {
|
||||
gate.classList.remove("hidden");
|
||||
wrap.classList.add("hidden");
|
||||
}
|
||||
const btn = $("#casinoLoginBtn");
|
||||
if (btn && !btn.dataset.pbWired) { btn.dataset.pbWired = "1"; btn.onclick = () => openAuthModal(); }
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
/* -------------------- Live (Socket.IO) -------------------- */
|
||||
function connectSocket() {
|
||||
if (typeof io === "undefined") return;
|
||||
const socket = io();
|
||||
PB.socket = socket;
|
||||
socket.on("hello", ({ assets }) => { assets && assets.forEach((a) => PB.assets.set(a.symbol, a)); });
|
||||
socket.on("prices", ({ data }) => {
|
||||
data.forEach((d) => {
|
||||
const a = PB.assets.get(d.s);
|
||||
if (!a) return;
|
||||
const prev = a.price;
|
||||
a.price = d.p; a.change = d.c; a.changePct = d.cp; a.halted = !!d.h;
|
||||
if ((location.hash || "#home").replace("#", "") === "paris" && PB.parisLive) {
|
||||
const row = $(`#watchlistTable tr[data-sym="${d.s}"]`);
|
||||
if (row) {
|
||||
const pxc = row.querySelector("[data-px]"), chc = row.querySelector("[data-chg]");
|
||||
if (pxc) { pxc.textContent = px(d.p); pxc.classList.remove("flash-up", "flash-down"); void pxc.offsetWidth; pxc.classList.add(d.p >= prev ? "flash-up" : "flash-down"); }
|
||||
if (chc) { chc.textContent = (d.cp >= 0 ? "+" : "") + d.cp.toFixed(2) + "%"; chc.className = "py-3 px-3 text-right " + (d.cp >= 0 ? "text-success" : "text-error"); }
|
||||
}
|
||||
}
|
||||
});
|
||||
// Portfolio-Wert live nachrechnen
|
||||
if (PB.portfolio && PB.parisLive) recomputeLive();
|
||||
});
|
||||
socket.on("balance_changed", ({ userId, balance }) => {
|
||||
if (PB.user && userId === PB.user.id) { PB.user.balance = balance; if (PB.portfolio) PB.portfolio.balance = balance; updateKpis(); }
|
||||
});
|
||||
}
|
||||
function recomputeLive() {
|
||||
const p = PB.portfolio; if (!p) return;
|
||||
let hv = 0;
|
||||
(p.holdings || []).forEach((h) => {
|
||||
const a = PB.assets.get(h.symbol);
|
||||
if (a) { h.price = a.price; h.value = h.quantity * a.price; h.pnl = h.value - h.quantity * h.avgPrice; }
|
||||
hv += h.value;
|
||||
});
|
||||
p.holdingsValue = hv; p.netWorth = p.balance + hv;
|
||||
updateKpis();
|
||||
if (PB.chart) { PB.chart.data.datasets[0].data = (p.holdings || []).filter(h => Math.abs(h.value) > 0.01).map(h => Math.abs(h.value)); PB.chart.update("none"); }
|
||||
}
|
||||
|
||||
/* -------------------- Route Hooks -------------------- */
|
||||
function onRoute() {
|
||||
const route = (location.hash || "#home").replace("#", "");
|
||||
if (route === "paris") setupParis();
|
||||
if (route === "casino") setupCasino();
|
||||
}
|
||||
PB.onRoute = onRoute;
|
||||
|
||||
/* -------------------- Init -------------------- */
|
||||
async function init() {
|
||||
await loadMarket();
|
||||
const me = await get("/api/me");
|
||||
if (me.ok && me.user) { PB.user = me.user; await loadPortfolio(); }
|
||||
$$("[data-members]").forEach((el) => { el.hidden = !PB.user; });
|
||||
connectSocket();
|
||||
window.addEventListener("hashchange", onRoute);
|
||||
// Erststart-Route behandeln (z.B. direkt auf #paris)
|
||||
onRoute();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||||
else init();
|
||||
})();
|
||||
7
public/uploads/manifest.json
Normal file
7
public/uploads/manifest.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"page-home::Magic Mike": "/uploads/page-home_Magic_Mike.jpg",
|
||||
"page-home::Nico Gonzales": "/uploads/page-home_Nico_Gonzales.jpg",
|
||||
"page-home::Paris": "/uploads/page-home_Paris.jpg",
|
||||
"page-home::Team Photo": "/uploads/page-home_Team_Photo.jpg",
|
||||
"page-nico::Hero Background": "/uploads/page-nico_Hero_Background.jpg"
|
||||
}
|
||||
BIN
public/uploads/page-home_Magic_Mike.jpg
Normal file
BIN
public/uploads/page-home_Magic_Mike.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
public/uploads/page-home_Nico_Gonzales.jpg
Normal file
BIN
public/uploads/page-home_Nico_Gonzales.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
public/uploads/page-home_Paris.jpg
Normal file
BIN
public/uploads/page-home_Paris.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
BIN
public/uploads/page-home_Team_Photo.jpg
Normal file
BIN
public/uploads/page-home_Team_Photo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
public/uploads/page-nico_Hero_Background.jpg
Normal file
BIN
public/uploads/page-nico_Hero_Background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
Reference in New Issue
Block a user