/* ============================================================
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 `
${label}
${label}
`;
}
function renderMike() {
$("#mikeProducts").innerHTML = mikeProducts.map((p, i) => `
${slotMarkup("Product Photo " + (i + 1), "190px")}
${p.name}
${p.price}
${p.desc}
`).join("");
$("#mikeReviews").innerHTML = mikeReviews.map((r) => `
★★★★★
"${r.text}"
— ${r.who}
`).join("");
$("#mikeGallery").innerHTML = Array.from({ length: 6 }, (_, i) =>
`${slotMarkup("Customer Photo " + (i + 1), "200px")}
`).join("");
restoreSlotImages($("#page-magic-mike"));
}
function renderNico() {
$("#nicoListings").innerHTML = nicoListings.map((p, i) => `
${slotMarkup("Listing Photo " + (i + 1), "230px")}
${p.price}${p.size}
${p.name}
${p.desc}
`).join("");
$("#nicoTestimonials").innerHTML = nicoTestimonials.map((r) => `
"${r.text}"
— ${r.who}
`).join("");
const galleryLabels = ["Client Handshake", "Property Visit", "Signing Moment", "Property Visit 2", "Client Handshake 2", "Signing Moment 2"];
$("#nicoGallery").innerHTML = galleryLabels.map((l) => `${slotMarkup(l, "200px")}
`).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 `
| ${p.ticker} |
${p.company} |
${p.shares} |
${fmtEUR(p.avg)} |
${fmtEUR(p.price)} |
${fmtSigned(pl)} |
${(plPct >= 0 ? "+" : "") + plPct.toFixed(1)}% |
`;
}).join("");
}
function renderTradeLog() {
$("#tradeLog").innerHTML = tradeLog.map((t) => {
const isBuy = t.startsWith("BUY");
return `
${isBuy ? "BUY" : "SELL"}
${t}
`;
}).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) => `
${l}
${((data[i] / total) * 100).toFixed(1)}%
`).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);
});
});
})();