Files
playbull/public/app.js
Mike 94b6cc3be9 Initial PlayBull release with Trilogy Hub integration.
Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:31:36 +02:00

798 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================
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.10.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);
});
});
})();