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