/* ============================================================ 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}${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); }); }); })();