Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack. Co-authored-by: Cursor <cursoragent@cursor.com>
798 lines
32 KiB
JavaScript
798 lines
32 KiB
JavaScript
/* ============================================================
|
||
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);
|
||
});
|
||
});
|
||
})();
|