perf: deep react code splitting, next-intl payload scoping, and SVG hardware acceleration for PageSpeed 100
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🏗️ Build (push) Successful in 7m43s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m10s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m20s
Build & Deploy / 🔔 Notify (push) Successful in 2s
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🏗️ Build (push) Successful in 7m43s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m10s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m20s
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
@@ -72,7 +72,7 @@ export default async function Layout(props: {
|
|||||||
|
|
||||||
setRequestLocale(safeLocale);
|
setRequestLocale(safeLocale);
|
||||||
|
|
||||||
let messages = {};
|
let messages: Record<string, any> = {};
|
||||||
try {
|
try {
|
||||||
messages = await getMessages();
|
messages = await getMessages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -80,6 +80,15 @@ export default async function Layout(props: {
|
|||||||
messages = {};
|
messages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick only the namespaces required by client components to reduce the hydration payload size
|
||||||
|
const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home'];
|
||||||
|
const clientMessages: Record<string, any> = {};
|
||||||
|
for (const key of clientKeys) {
|
||||||
|
if (messages[key]) {
|
||||||
|
clientMessages[key] = messages[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||||
const serverServices = getServerAppServices();
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
@@ -118,7 +127,7 @@ export default async function Layout(props: {
|
|||||||
<link rel="preconnect" href="https://img.infra.mintel.me" />
|
<link rel="preconnect" href="https://img.infra.mintel.me" />
|
||||||
</head>
|
</head>
|
||||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||||
<RecordModeVisuals>
|
<RecordModeVisuals>
|
||||||
<SkipLink />
|
<SkipLink />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Hero from '@/components/home/Hero';
|
import Hero from '@/components/home/Hero';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
|
||||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
|
|
||||||
|
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
|
||||||
|
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
|
||||||
|
|
||||||
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
||||||
const Experience = dynamic(() => import('@/components/home/Experience'));
|
const Experience = dynamic(() => import('@/components/home/Experience'));
|
||||||
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
|
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { motion } from 'framer-motion';
|
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
@@ -114,55 +114,264 @@ export default function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.header
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={headerClass}
|
<m.header
|
||||||
initial={{ y: -100, opacity: 0 }}
|
className={headerClass}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
>
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
>
|
||||||
<motion.div
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
className="flex-shrink-0 group touch-target"
|
<m.div
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
className="flex-shrink-0 group touch-target"
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
>
|
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
|
||||||
<Link
|
|
||||||
href={`/${currentLocale}`}
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
target: 'home_logo',
|
|
||||||
location: 'header',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Image
|
<Link
|
||||||
src={logoSrc}
|
href={`/${currentLocale}`}
|
||||||
alt={t('home')}
|
onClick={() =>
|
||||||
width={120}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
height={120}
|
target: 'home_logo',
|
||||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
location: 'header',
|
||||||
priority
|
})
|
||||||
/>
|
}
|
||||||
</Link>
|
>
|
||||||
</motion.div>
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={t('home')}
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="flex items-center gap-4 md:gap-12"
|
className="flex items-center gap-4 md:gap-12"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={{
|
variants={{
|
||||||
visible: {
|
visible: {
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.08,
|
staggerChildren: 0.08,
|
||||||
delayChildren: 0.3,
|
delayChildren: 0.3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}}
|
||||||
}}
|
>
|
||||||
|
<m.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
||||||
|
{menuItems.map((item, _idx) => (
|
||||||
|
<m.div key={item.href} variants={navLinkVariants}>
|
||||||
|
<Link
|
||||||
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
location: 'header_nav',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
textColorClass,
|
||||||
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
))}
|
||||||
|
</m.nav>
|
||||||
|
|
||||||
|
<m.div
|
||||||
|
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
||||||
|
variants={headerRightVariants}
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.65 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('en')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'en',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
<m.div
|
||||||
|
className="w-px h-4 bg-current opacity-20"
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
animate={{ scaleY: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.75 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('de')}
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||||
|
type: 'language',
|
||||||
|
from: currentLocale,
|
||||||
|
to: 'de',
|
||||||
|
location: 'header',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
<m.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
href={`/${currentLocale}/contact`}
|
||||||
|
variant="white"
|
||||||
|
size="md"
|
||||||
|
className="px-8 shadow-xl"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('contact'),
|
||||||
|
location: 'header_cta',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<m.button
|
||||||
|
className={cn(
|
||||||
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
||||||
|
textColorClass,
|
||||||
|
)}
|
||||||
|
aria-label={t('toggleMenu')}
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
delay: 0.5,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
const newState = !isMobileMenuOpen;
|
||||||
|
setIsMobileMenuOpen(newState);
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
type: 'mobile_menu',
|
||||||
|
action: newState ? 'open' : 'close',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<m.svg
|
||||||
|
className="w-7 h-7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? (
|
||||||
|
<m.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<m.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.7 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</m.svg>
|
||||||
|
</m.button>
|
||||||
|
</m.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlay */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
|
isMobileMenuOpen
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
|
)}
|
||||||
|
id="mobile-menu"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('menu')}
|
||||||
|
ref={mobileMenuRef}
|
||||||
>
|
>
|
||||||
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
|
<m.nav
|
||||||
{menuItems.map((item, _idx) => (
|
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||||
<motion.div key={item.href} variants={navLinkVariants}>
|
initial="closed"
|
||||||
|
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||||
|
variants={{
|
||||||
|
open: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item, idx) => (
|
||||||
|
<m.div
|
||||||
|
key={item.href}
|
||||||
|
variants={{
|
||||||
|
closed: { opacity: 0, y: 50, scale: 0.9 },
|
||||||
|
open: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeOut',
|
||||||
|
delay: idx * 0.08,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -170,302 +379,95 @@ export default function Header() {
|
|||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
label: item.label,
|
label: item.label,
|
||||||
href: item.href,
|
href: item.href,
|
||||||
location: 'header_nav',
|
location: 'mobile_menu',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
||||||
textColorClass,
|
|
||||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</m.div>
|
||||||
))}
|
))}
|
||||||
</motion.nav>
|
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
|
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
||||||
variants={headerRightVariants}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
>
|
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||||
<motion.div
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.6 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.div
|
||||||
|
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 0.4, delay: 0.65 }}
|
transition={{ duration: 0.4, delay: 0.9 }}
|
||||||
>
|
>
|
||||||
<Link
|
<m.div
|
||||||
href={getPathForLocale('en')}
|
initial={{ opacity: 0 }}
|
||||||
onClick={() =>
|
animate={{ opacity: 1 }}
|
||||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
transition={{ duration: 0.3, delay: 1.0 }}
|
||||||
type: 'language',
|
|
||||||
from: currentLocale,
|
|
||||||
to: 'en',
|
|
||||||
location: 'header',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
|
||||||
>
|
>
|
||||||
EN
|
<Link
|
||||||
</Link>
|
href={getPathForLocale('en')}
|
||||||
</motion.div>
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||||
<motion.div
|
>
|
||||||
className="w-px h-4 bg-current opacity-20"
|
EN
|
||||||
initial={{ scaleY: 0 }}
|
</Link>
|
||||||
animate={{ scaleY: 1 }}
|
</m.div>
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
<m.div
|
||||||
/>
|
className="w-px h-6 bg-white/20"
|
||||||
<motion.div
|
initial={{ scaleX: 0 }}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
animate={{ scaleX: 1 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
transition={{ duration: 0.4, delay: 1.05 }}
|
||||||
transition={{ duration: 0.4, delay: 0.75 }}
|
/>
|
||||||
>
|
<m.div
|
||||||
<Link
|
initial={{ opacity: 0 }}
|
||||||
href={getPathForLocale('de')}
|
animate={{ opacity: 1 }}
|
||||||
onClick={() =>
|
transition={{ duration: 0.3, delay: 1.1 }}
|
||||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
|
||||||
type: 'language',
|
|
||||||
from: currentLocale,
|
|
||||||
to: 'de',
|
|
||||||
location: 'header',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
|
||||||
>
|
>
|
||||||
DE
|
<Link
|
||||||
</Link>
|
href={getPathForLocale('de')}
|
||||||
</motion.div>
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||||
</motion.div>
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
||||||
>
|
|
||||||
<Button
|
|
||||||
href={`/${currentLocale}/contact`}
|
|
||||||
variant="white"
|
|
||||||
size="md"
|
|
||||||
className="px-8 shadow-xl"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: t('contact'),
|
|
||||||
location: 'header_cta',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('contact')}
|
<Button
|
||||||
</Button>
|
href={`/${currentLocale}/contact`}
|
||||||
</motion.div>
|
variant="accent"
|
||||||
</motion.div>
|
size="lg"
|
||||||
|
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
||||||
|
>
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Bottom Branding */}
|
||||||
<motion.button
|
<m.div
|
||||||
className={cn(
|
className="p-12 flex justify-center opacity-20"
|
||||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
|
|
||||||
textColorClass,
|
|
||||||
)}
|
|
||||||
aria-label={t('toggleMenu')}
|
|
||||||
aria-expanded={isMobileMenuOpen}
|
|
||||||
aria-controls="mobile-menu"
|
|
||||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.6,
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 20,
|
|
||||||
delay: 0.5,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
const newState = !isMobileMenuOpen;
|
|
||||||
setIsMobileMenuOpen(newState);
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
type: 'mobile_menu',
|
|
||||||
action: newState ? 'open' : 'close',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.svg
|
|
||||||
className="w-7 h-7"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.6 }}
|
|
||||||
>
|
|
||||||
{isMobileMenuOpen ? (
|
|
||||||
<motion.path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<motion.path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 0.7 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</motion.svg>
|
|
||||||
</motion.button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
|
||||||
isMobileMenuOpen
|
|
||||||
? 'opacity-100 translate-y-0'
|
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
|
||||||
)}
|
|
||||||
id="mobile-menu"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t('menu')}
|
|
||||||
ref={mobileMenuRef}
|
|
||||||
>
|
|
||||||
<motion.nav
|
|
||||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
|
||||||
initial="closed"
|
|
||||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
|
||||||
variants={{
|
|
||||||
open: {
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
delayChildren: 0.2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{menuItems.map((item, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={item.href}
|
|
||||||
variants={{
|
|
||||||
closed: { opacity: 0, y: 50, scale: 0.9 },
|
|
||||||
open: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.6,
|
|
||||||
ease: 'easeOut',
|
|
||||||
delay: idx * 0.08,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
|
||||||
onClick={() => {
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
|
||||||
label: item.label,
|
|
||||||
href: item.href,
|
|
||||||
location: 'mobile_menu',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.8 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
||||||
transition={{ duration: 0.4, delay: 0.9 }}
|
transition={{ duration: 0.5, delay: 1.4 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ scale: 0.5 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ scale: 1 }}
|
||||||
transition={{ duration: 0.3, delay: 1.0 }}
|
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
||||||
>
|
>
|
||||||
<Link
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
href={getPathForLocale('en')}
|
</m.div>
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
</m.div>
|
||||||
>
|
</m.nav>
|
||||||
EN
|
</div>
|
||||||
</Link>
|
</m.header>
|
||||||
</motion.div>
|
</LazyMotion>
|
||||||
<motion.div
|
|
||||||
className="w-px h-6 bg-white/20"
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.4, delay: 1.05 }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3, delay: 1.1 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={getPathForLocale('de')}
|
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
href={`/${currentLocale}/contact`}
|
|
||||||
variant="accent"
|
|
||||||
size="lg"
|
|
||||||
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
|
|
||||||
>
|
|
||||||
{t('contact')}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
|
||||||
<motion.div
|
|
||||||
className="p-12 flex justify-center opacity-20"
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
|
|
||||||
transition={{ duration: 0.5, delay: 1.4 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.5 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
|
|
||||||
>
|
|
||||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.nav>
|
|
||||||
</div>
|
|
||||||
</motion.header>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface LightboxProps {
|
interface LightboxProps {
|
||||||
@@ -139,118 +139,120 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<LazyMotion strict features={domAnimation}>
|
||||||
{isOpen && (
|
<AnimatePresence>
|
||||||
<div
|
{isOpen && (
|
||||||
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
<div
|
||||||
role="dialog"
|
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||||
aria-modal="true"
|
role="dialog"
|
||||||
>
|
aria-modal="true"
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
initial={{ opacity: 0, scale: 0.5 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.5 }}
|
|
||||||
transition={{ delay: 0.1, duration: 0.4 }}
|
|
||||||
ref={closeButtonRef}
|
|
||||||
onClick={handleClose}
|
|
||||||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
|
||||||
aria-label="Close lightbox"
|
|
||||||
>
|
>
|
||||||
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
<m.div
|
||||||
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
initial={{ opacity: 0 }}
|
||||||
</div>
|
animate={{ opacity: 1 }}
|
||||||
</motion.button>
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
<motion.button
|
<m.button
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: -20 }}
|
exit={{ opacity: 0, scale: 0.5 }}
|
||||||
transition={{ delay: 0.2, duration: 0.4 }}
|
transition={{ delay: 0.1, duration: 0.4 }}
|
||||||
onClick={prevImage}
|
ref={closeButtonRef}
|
||||||
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
onClick={handleClose}
|
||||||
aria-label="Previous image"
|
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||||||
>
|
aria-label="Close lightbox"
|
||||||
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
>
|
||||||
‹
|
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
||||||
</span>
|
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
transition={{ delay: 0.2, duration: 0.4 }}
|
|
||||||
onClick={nextImage}
|
|
||||||
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
|
||||||
aria-label="Next image"
|
|
||||||
>
|
|
||||||
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
|
||||||
›
|
|
||||||
</span>
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
|
||||||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
|
||||||
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
|
|
||||||
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
|
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
|
||||||
<motion.div
|
|
||||||
key={currentIndex}
|
|
||||||
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
|
|
||||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
|
|
||||||
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
|
|
||||||
className="relative w-full h-full"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={images[currentIndex]}
|
|
||||||
alt={`Gallery image ${currentIndex + 1}`}
|
|
||||||
fill
|
|
||||||
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
|
||||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
|
|
||||||
|
|
||||||
{/* Premium Reflection: Subtle gradient to give material feel */}
|
|
||||||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
|
||||||
</div>
|
</div>
|
||||||
|
</m.button>
|
||||||
|
|
||||||
<motion.div
|
<m.button
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, y: 10 }}
|
exit={{ opacity: 0, x: -20 }}
|
||||||
transition={{ delay: 0.3, duration: 0.4 }}
|
transition={{ delay: 0.2, duration: 0.4 }}
|
||||||
className="mt-8 flex items-center gap-4"
|
onClick={prevImage}
|
||||||
>
|
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||||
<div className="h-px w-12 bg-white/20" />
|
aria-label="Previous image"
|
||||||
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
|
>
|
||||||
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
|
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||||||
|
‹
|
||||||
|
</span>
|
||||||
|
</m.button>
|
||||||
|
|
||||||
|
<m.button
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.4 }}
|
||||||
|
onClick={nextImage}
|
||||||
|
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</m.button>
|
||||||
|
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
|
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<m.div
|
||||||
|
key={currentIndex}
|
||||||
|
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
|
||||||
|
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
|
className="relative w-full h-full"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={images[currentIndex]}
|
||||||
|
alt={`Gallery image ${currentIndex + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</m.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
|
||||||
|
|
||||||
|
{/* Premium Reflection: Subtle gradient to give material feel */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-12 bg-white/20" />
|
|
||||||
</motion.div>
|
<m.div
|
||||||
</div>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
</motion.div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</div>
|
exit={{ opacity: 0, y: 10 }}
|
||||||
)}
|
transition={{ delay: 0.3, duration: 0.4 }}
|
||||||
</AnimatePresence>,
|
className="mt-8 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="h-px w-12 bg-white/20" />
|
||||||
|
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
|
||||||
|
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-12 bg-white/20" />
|
||||||
|
</m.div>
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</LazyMotion>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, Variants } from 'framer-motion';
|
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
||||||
import { cn } from '@/components/ui';
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
interface ScribbleProps {
|
interface ScribbleProps {
|
||||||
@@ -25,49 +25,53 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
|
|
||||||
if (variant === 'circle') {
|
if (variant === 'circle') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={cn('absolute pointer-events-none', className)}
|
<svg
|
||||||
aria-hidden="true"
|
className={cn('absolute pointer-events-none', className)}
|
||||||
viewBox="0 0 800 350"
|
aria-hidden="true"
|
||||||
preserveAspectRatio="none"
|
viewBox="0 0 800 350"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<motion.path
|
>
|
||||||
variants={pathVariants}
|
<m.path
|
||||||
initial="hidden"
|
variants={pathVariants}
|
||||||
whileInView="visible"
|
initial="hidden"
|
||||||
viewport={{ once: true }}
|
whileInView="visible"
|
||||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
viewport={{ once: true }}
|
||||||
strokeLinejoin="miter"
|
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||||
fillOpacity="0"
|
strokeLinejoin="miter"
|
||||||
strokeMiterlimit="4"
|
fillOpacity="0"
|
||||||
stroke={color}
|
strokeMiterlimit="4"
|
||||||
strokeOpacity="1"
|
stroke={color}
|
||||||
strokeWidth="20"
|
strokeOpacity="1"
|
||||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
strokeWidth="20"
|
||||||
/>
|
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'underline') {
|
if (variant === 'underline') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<LazyMotion strict features={domAnimation}>
|
||||||
className={cn('absolute pointer-events-none', className)}
|
<svg
|
||||||
aria-hidden="true"
|
className={cn('absolute pointer-events-none', className)}
|
||||||
viewBox="-400 -55 730 60"
|
aria-hidden="true"
|
||||||
preserveAspectRatio="none"
|
viewBox="-400 -55 730 60"
|
||||||
>
|
preserveAspectRatio="none"
|
||||||
<motion.path
|
>
|
||||||
variants={pathVariants}
|
<m.path
|
||||||
initial="hidden"
|
variants={pathVariants}
|
||||||
whileInView="visible"
|
initial="hidden"
|
||||||
viewport={{ once: true }}
|
whileInView="visible"
|
||||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
viewport={{ once: true }}
|
||||||
stroke={color}
|
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||||
strokeWidth="20"
|
stroke={color}
|
||||||
fill="none"
|
strokeWidth="20"
|
||||||
/>
|
fill="none"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Suspense } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -11,6 +11,20 @@ const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'),
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function AnalyticsShell() {
|
export default function AnalyticsShell() {
|
||||||
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
||||||
|
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||||
|
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
|
||||||
|
} else {
|
||||||
|
const timer = setTimeout(() => setShouldLoad(true), 2500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!shouldLoad) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DynamicAnalyticsProvider />
|
<DynamicAnalyticsProvider />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { motion } from 'framer-motion';
|
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
@@ -16,111 +16,113 @@ export default function Hero() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<LazyMotion strict features={domAnimation}>
|
||||||
<motion.div
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
className="max-w-5xl mx-auto md:mx-0"
|
<m.div
|
||||||
initial="hidden"
|
className="max-w-5xl mx-auto md:mx-0"
|
||||||
animate="visible"
|
initial="hidden"
|
||||||
variants={containerVariants}
|
animate="visible"
|
||||||
>
|
variants={containerVariants}
|
||||||
<motion.div variants={headingVariants}>
|
|
||||||
<Heading
|
|
||||||
level={1}
|
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
|
||||||
>
|
|
||||||
{t.rich('title', {
|
|
||||||
green: (chunks) => (
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<motion.span
|
|
||||||
className="relative z-10 text-accent italic"
|
|
||||||
variants={accentVariants}
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</motion.span>
|
|
||||||
<motion.div
|
|
||||||
variants={scribbleVariants}
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</motion.div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</Heading>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div variants={subtitleVariants}>
|
|
||||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
|
||||||
{t('subtitle')}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
|
||||||
variants={buttonContainerVariants}
|
|
||||||
>
|
>
|
||||||
<motion.div variants={buttonVariants}>
|
<m.div variants={headingVariants}>
|
||||||
<Button
|
<Heading
|
||||||
href="/contact"
|
level={1}
|
||||||
variant="accent"
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||||
size="lg"
|
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
label: t('cta'),
|
|
||||||
location: 'home_hero_primary',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('cta')}
|
{t.rich('title', {
|
||||||
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
green: (chunks) => (
|
||||||
</Button>
|
<span className="relative inline-block">
|
||||||
</motion.div>
|
<m.span
|
||||||
<motion.div variants={buttonVariants}>
|
className="relative z-10 text-accent italic"
|
||||||
<Button
|
variants={accentVariants}
|
||||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
>
|
||||||
variant="white"
|
{chunks}
|
||||||
size="lg"
|
</m.span>
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
|
<m.div
|
||||||
onClick={() =>
|
variants={scribbleVariants}
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
|
||||||
label: t('exploreProducts'),
|
>
|
||||||
location: 'home_hero_secondary',
|
<Scribble variant="circle" />
|
||||||
})
|
</m.div>
|
||||||
}
|
</span>
|
||||||
>
|
),
|
||||||
{t('exploreProducts')}
|
})}
|
||||||
</Button>
|
</Heading>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</motion.div>
|
<m.div variants={subtitleVariants}>
|
||||||
</motion.div>
|
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
</Container>
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</m.div>
|
||||||
|
<m.div
|
||||||
|
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
|
||||||
|
variants={buttonContainerVariants}
|
||||||
|
>
|
||||||
|
<m.div variants={buttonVariants}>
|
||||||
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('cta'),
|
||||||
|
location: 'home_hero_primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('cta')}
|
||||||
|
<span className="transition-transform group-hover/btn:translate-x-1">→</span>
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
<m.div variants={buttonVariants}>
|
||||||
|
<Button
|
||||||
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
|
variant="white"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
|
||||||
|
onClick={() =>
|
||||||
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
label: t('exploreProducts'),
|
||||||
|
location: 'home_hero_secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('exploreProducts')}
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
|
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
|
||||||
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
||||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||||
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
||||||
>
|
>
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
|
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
|
||||||
>
|
>
|
||||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
<motion.div
|
<m.div
|
||||||
className="w-1 h-2 bg-white rounded-full"
|
className="w-1 h-2 bg-white rounded-full"
|
||||||
animate={{ y: [0, -10, 0] }}
|
animate={{ y: [0, -10, 0] }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 1.5,
|
duration: 1.5,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: 'easeInOut',
|
ease: 'easeInOut',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
</LazyMotion>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,16 +232,12 @@ export default function HeroIllustration() {
|
|||||||
stroke="url(#energy-pulse)"
|
stroke="url(#energy-pulse)"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
|
style={{
|
||||||
>
|
strokeDasharray: `${length * 0.2} ${length * 0.8}`,
|
||||||
<animate
|
strokeDashoffset: length,
|
||||||
attributeName="stroke-dashoffset"
|
animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`,
|
||||||
from={length}
|
}}
|
||||||
to={0}
|
/>
|
||||||
dur={`${1.5 + (i % 3) * 0.5}s`}
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</line>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
@@ -267,14 +263,13 @@ export default function HeroIllustration() {
|
|||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
strokeOpacity="0.3"
|
strokeOpacity="0.3"
|
||||||
/>
|
/>
|
||||||
<circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
<circle
|
||||||
<animate
|
r="3"
|
||||||
attributeName="fillOpacity"
|
fill="#82ed20"
|
||||||
values="0.2;0.5;0.2"
|
fillOpacity="0.3"
|
||||||
dur="2s"
|
filter="url(#soft-glow)"
|
||||||
repeatCount="indefinite"
|
style={{ animation: 'solar-pulse 2s ease-in-out infinite' }}
|
||||||
/>
|
/>
|
||||||
</circle>
|
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -294,28 +289,26 @@ export default function HeroIllustration() {
|
|||||||
strokeOpacity="0.3"
|
strokeOpacity="0.3"
|
||||||
/>
|
/>
|
||||||
<g transform="translate(0, -60)">
|
<g transform="translate(0, -60)">
|
||||||
{[0, 120, 240].map((angle, j) => (
|
<g
|
||||||
<line
|
style={{
|
||||||
key={`blade-${i}-${j}`}
|
transformOrigin: '0px 0px',
|
||||||
x1="0"
|
animation: `spin-slow ${3 + i}s linear infinite`,
|
||||||
y1="0"
|
}}
|
||||||
x2="0"
|
>
|
||||||
y2="-30"
|
{[0, 120, 240].map((angle, j) => (
|
||||||
stroke="white"
|
<line
|
||||||
strokeWidth="1.5"
|
key={`blade-${i}-${j}`}
|
||||||
strokeOpacity="0.4"
|
x1="0"
|
||||||
transform={`rotate(${angle})`}
|
y1="0"
|
||||||
>
|
x2="0"
|
||||||
<animateTransform
|
y2="-30"
|
||||||
attributeName="transform"
|
stroke="white"
|
||||||
type="rotate"
|
strokeWidth="1.5"
|
||||||
from={`${angle} 0 0`}
|
strokeOpacity="0.4"
|
||||||
to={`${angle + 360} 0 0`}
|
transform={`rotate(${angle})`}
|
||||||
dur={`${3 + i}s`}
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
/>
|
||||||
</line>
|
))}
|
||||||
))}
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
||||||
import { useRecordMode } from './RecordModeContext';
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
export function PlaybackCursor() {
|
export function PlaybackCursor() {
|
||||||
@@ -24,67 +24,69 @@ export function PlaybackCursor() {
|
|||||||
if (!isPlaying) return null;
|
if (!isPlaying) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<LazyMotion strict features={domAnimation}>
|
||||||
className="fixed z-[10000] pointer-events-none"
|
<m.div
|
||||||
animate={{
|
className="fixed z-[10000] pointer-events-none"
|
||||||
x: cursorPosition.x,
|
animate={{
|
||||||
y: cursorPosition.y,
|
x: cursorPosition.x,
|
||||||
scale: isClicking ? 0.8 : 1,
|
y: cursorPosition.y,
|
||||||
rotateX: isClicking ? 15 : 0,
|
scale: isClicking ? 0.8 : 1,
|
||||||
rotateY: isClicking ? -15 : 0,
|
rotateX: isClicking ? 15 : 0,
|
||||||
}}
|
rotateY: isClicking ? -15 : 0,
|
||||||
transition={{
|
}}
|
||||||
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
transition={{
|
||||||
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||||
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
}}
|
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||||
style={{ perspective: '1000px' }}
|
}}
|
||||||
>
|
style={{ perspective: '1000px' }}
|
||||||
<AnimatePresence>
|
>
|
||||||
{isClicking && (
|
<AnimatePresence>
|
||||||
<motion.div
|
{isClicking && (
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
<m.div
|
||||||
animate={{ scale: 2.5, opacity: 0 }}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
animate={{ scale: 2.5, opacity: 0 }}
|
||||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
exit={{ opacity: 0 }}
|
||||||
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||||
/>
|
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||||
)}
|
/>
|
||||||
</AnimatePresence>
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Outer Pulse Ring */}
|
{/* Outer Pulse Ring */}
|
||||||
<div
|
|
||||||
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Visual Cursor */}
|
|
||||||
<div className="relative">
|
|
||||||
{/* Soft Glow */}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pointer Arrow */}
|
{/* Visual Cursor */}
|
||||||
<svg
|
<div className="relative">
|
||||||
width="24"
|
{/* Soft Glow */}
|
||||||
height="24"
|
<div
|
||||||
viewBox="0 0 24 24"
|
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
|
||||||
fill={isClicking ? '#82ed20' : 'white'}
|
|
||||||
stroke="black"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="transition-colors duration-150"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
|
||||||
</div>
|
{/* Pointer Arrow */}
|
||||||
</motion.div>
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||||
|
fill={isClicking ? '#82ed20' : 'white'}
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRecordMode } from './RecordModeContext';
|
import { useRecordMode } from './RecordModeContext';
|
||||||
import { Reorder, AnimatePresence } from 'framer-motion';
|
import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
@@ -146,438 +146,460 @@ export function RecordModeOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
<LazyMotion strict features={domAnimation}>
|
||||||
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||||
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
{/* Identity Tag */}
|
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
{/* Identity Tag */}
|
||||||
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||||
<div className="flex flex-col">
|
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
<div className="flex flex-col">
|
||||||
Event Builder
|
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||||
</span>
|
Event Builder
|
||||||
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
|
||||||
Manual Mode
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
|
||||||
|
|
||||||
{/* Action Tools */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setPickingMode('mouse');
|
|
||||||
setLastInteractionType('click');
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
|
||||||
>
|
|
||||||
<MousePointer2 size={16} />
|
|
||||||
<span>Mouse</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setPickingMode('scroll')}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
|
||||||
>
|
|
||||||
<Scroll size={16} />
|
|
||||||
<span>Scroll</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
addEvent({
|
|
||||||
type: 'wait',
|
|
||||||
duration: 2000,
|
|
||||||
zoom: 1,
|
|
||||||
description: 'Wait for 2s',
|
|
||||||
motionBlur: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
<span>Wait</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
|
||||||
|
|
||||||
{/* Sequence Controls */}
|
|
||||||
<div className="flex items-center gap-1 p-0.5">
|
|
||||||
<button
|
|
||||||
onClick={playEvents}
|
|
||||||
disabled={isPlaying || events.length === 0}
|
|
||||||
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
|
||||||
title="Preview Sequence"
|
|
||||||
>
|
|
||||||
<Play size={18} fill="currentColor" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowEvents(!showEvents)}
|
|
||||||
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
|
||||||
>
|
|
||||||
<Edit2 size={18} />
|
|
||||||
{events.length > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
|
||||||
{events.length}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||||
</button>
|
Manual Mode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
onClick={async () => {
|
|
||||||
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
|
{/* Action Tools */}
|
||||||
try {
|
<div className="flex items-center gap-1">
|
||||||
const res = await fetch('/api/save-session', {
|
<button
|
||||||
method: 'POST',
|
onClick={() => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
setPickingMode('mouse');
|
||||||
body: JSON.stringify(session),
|
setLastInteractionType('click');
|
||||||
});
|
}}
|
||||||
if (res.ok) {
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
// Visual feedback could be improved, but alert is fine for dev tool
|
>
|
||||||
alert('Session saved to remotion/session.json');
|
<MousePointer2 size={16} />
|
||||||
} else {
|
<span>Mouse</span>
|
||||||
const err = await res.json();
|
</button>
|
||||||
alert(`Failed to save: ${err.error}`);
|
|
||||||
}
|
<button
|
||||||
} catch (e) {
|
onClick={() => setPickingMode('scroll')}
|
||||||
console.error(e);
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
alert('Error saving session');
|
>
|
||||||
|
<Scroll size={16} />
|
||||||
|
<span>Scroll</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
addEvent({
|
||||||
|
type: 'wait',
|
||||||
|
duration: 2000,
|
||||||
|
zoom: 1,
|
||||||
|
description: 'Wait for 2s',
|
||||||
|
motionBlur: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}}
|
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||||
disabled={events.length === 0}
|
|
||||||
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
|
||||||
title="Save to Project (Dev)"
|
|
||||||
>
|
|
||||||
<Save size={20} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const data = JSON.stringify(
|
|
||||||
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
const blob = new Blob([data], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'remotion-session.json';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}}
|
|
||||||
disabled={events.length === 0}
|
|
||||||
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
|
||||||
title="Download JSON"
|
|
||||||
>
|
|
||||||
<Download size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setIsActive(false)}
|
|
||||||
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
|
||||||
title="Exit Studio"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2. Event Timeline Popover */}
|
|
||||||
{showEvents && (
|
|
||||||
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
|
||||||
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
|
||||||
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
|
||||||
{events.length} Actions Recorded
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={clearEvents}
|
|
||||||
disabled={events.length === 0}
|
|
||||||
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Plus size={16} />
|
||||||
|
<span>Wait</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reorder.Group
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
axis="y"
|
|
||||||
values={events}
|
|
||||||
onReorder={setEvents}
|
|
||||||
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
|
||||||
>
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
|
||||||
<Plus size={40} strokeWidth={1} />
|
|
||||||
<p className="text-xs mt-4">Timeline is empty</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
events.map((event, index) => (
|
|
||||||
<Reorder.Item
|
|
||||||
key={event.id}
|
|
||||||
value={event}
|
|
||||||
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
|
||||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
|
||||||
onMouseLeave={() => setHoveredEventId(null)}
|
|
||||||
>
|
|
||||||
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
{/* Sequence Controls */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 p-0.5">
|
||||||
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
|
||||||
{event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type}
|
|
||||||
</span>
|
|
||||||
{event.clickOrigin &&
|
|
||||||
event.clickOrigin !== 'center' &&
|
|
||||||
event.interactionType === 'click' && (
|
|
||||||
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
|
||||||
{event.clickOrigin}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
|
||||||
{event.duration}ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
|
||||||
{event.selector || 'system:wait'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingEventId(event.id);
|
|
||||||
setEditForm(event);
|
|
||||||
}}
|
|
||||||
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Settings2 size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => removeEvent(event.id)}
|
|
||||||
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Reorder.Item>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Reorder.Group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
|
||||||
|
|
||||||
{/* Picking Tooltip */}
|
|
||||||
{pickingMode && (
|
|
||||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
|
||||||
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
|
||||||
<span className="font-black uppercase tracking-widest text-xs">
|
|
||||||
Assigning {pickingMode}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-6 bg-primary-dark/20" />
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setPickingMode(null);
|
|
||||||
setHoveredElement(null);
|
|
||||||
}}
|
|
||||||
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
|
||||||
>
|
|
||||||
ESC to Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlaybackCursor />
|
|
||||||
|
|
||||||
{/* 3. Event Options Panel (Sidebar-like) */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{editingEventId && (
|
|
||||||
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
|
||||||
Event Options
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingEventId(null)}
|
onClick={playEvents}
|
||||||
className="p-2 text-white/40 hover:text-white transition-colors"
|
disabled={isPlaying || events.length === 0}
|
||||||
|
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||||
|
title="Preview Sequence"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<Play size={18} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
<button
|
||||||
{/* Type Display */}
|
onClick={() => setShowEvents(!showEvents)}
|
||||||
<div className="space-y-3">
|
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
>
|
||||||
Interaction Type
|
<Edit2 size={18} />
|
||||||
</label>
|
{events.length > 0 && (
|
||||||
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||||
<button
|
{events.length}
|
||||||
onClick={() =>
|
</span>
|
||||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const session = {
|
||||||
|
events,
|
||||||
|
name: 'Recording',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/save-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(session),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Visual feedback could be improved, but alert is fine for dev tool
|
||||||
|
alert('Session saved to remotion/session.json');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Failed to save: ${err.error}`);
|
||||||
}
|
}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
} catch (e) {
|
||||||
>
|
console.error(e);
|
||||||
<MousePointer2 size={14} />
|
alert('Error saving session');
|
||||||
<span className="text-[10px] font-black uppercase">Click</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'hover' }))
|
|
||||||
}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
|
||||||
>
|
|
||||||
<Eye size={14} />
|
|
||||||
<span className="text-[10px] font-black uppercase">Hover</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
|
||||||
>
|
|
||||||
<Scroll size={14} />
|
|
||||||
<span className="text-[10px] font-black uppercase">Scroll</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
|
||||||
>
|
|
||||||
<Clock size={14} />
|
|
||||||
<span className="text-[10px] font-black uppercase">Wait</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Precise Click Origin */}
|
|
||||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
|
||||||
Click Origin
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
|
||||||
{[
|
|
||||||
{ id: 'top-left', label: 'TL' },
|
|
||||||
{ id: 'top-right', label: 'TR' },
|
|
||||||
{ id: 'center', label: 'CTR' },
|
|
||||||
{ id: 'bottom-left', label: 'BL' },
|
|
||||||
{ id: 'bottom-right', label: 'BR' },
|
|
||||||
].map((origin) => (
|
|
||||||
<button
|
|
||||||
key={origin.id}
|
|
||||||
onClick={() =>
|
|
||||||
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
|
||||||
}
|
|
||||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
|
||||||
>
|
|
||||||
{origin.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timing */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
|
||||||
<span>Timeline Allocation</span>
|
|
||||||
<span className="text-accent">{editForm.duration}ms</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="5000"
|
|
||||||
step="100"
|
|
||||||
value={editForm.duration || 1000}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
|
||||||
}
|
}
|
||||||
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
}}
|
||||||
/>
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||||
|
title="Save to Project (Dev)"
|
||||||
|
>
|
||||||
|
<Save size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const data = JSON.stringify(
|
||||||
|
{ events, name: 'Recording', createdAt: new Date().toISOString() },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'remotion-session.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||||
|
title="Download JSON"
|
||||||
|
>
|
||||||
|
<Download size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsActive(false)}
|
||||||
|
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||||
|
title="Exit Studio"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Event Timeline Popover */}
|
||||||
|
{showEvents && (
|
||||||
|
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||||
|
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||||
|
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||||
|
{events.length} Actions Recorded
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearEvents}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom & Effects */}
|
<Reorder.Group
|
||||||
<div className="space-y-6">
|
axis="y"
|
||||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
values={events}
|
||||||
<div className="flex items-center gap-3">
|
onReorder={setEvents}
|
||||||
<Maximize2 size={18} className="text-white/40" />
|
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
>
|
||||||
Zoom Shift
|
{events.length === 0 ? (
|
||||||
</span>
|
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||||
|
<Plus size={40} strokeWidth={1} />
|
||||||
|
<p className="text-xs mt-4">Timeline is empty</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((event, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={event.id}
|
||||||
|
value={event}
|
||||||
|
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||||
|
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||||
|
onMouseLeave={() => setHoveredEventId(null)}
|
||||||
|
>
|
||||||
|
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{event.type === 'mouse'
|
||||||
|
? `Mouse (${event.interactionType})`
|
||||||
|
: event.type}
|
||||||
|
</span>
|
||||||
|
{event.clickOrigin &&
|
||||||
|
event.clickOrigin !== 'center' &&
|
||||||
|
event.interactionType === 'click' && (
|
||||||
|
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||||
|
{event.clickOrigin}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||||
|
{event.duration}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||||
|
{event.selector || 'system:wait'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEventId(event.id);
|
||||||
|
setEditForm(event);
|
||||||
|
}}
|
||||||
|
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEvent(event.id)}
|
||||||
|
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Reorder.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||||
|
|
||||||
|
{/* Picking Tooltip */}
|
||||||
|
{pickingMode && (
|
||||||
|
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
|
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||||
|
<span className="font-black uppercase tracking-widest text-xs">
|
||||||
|
Assigning {pickingMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-primary-dark/20" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
ESC to Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlaybackCursor />
|
||||||
|
|
||||||
|
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{editingEventId && (
|
||||||
|
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||||
|
Event Options
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingEventId(null)}
|
||||||
|
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||||
|
{/* Type Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Interaction Type
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'click',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<MousePointer2 size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Click</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: 'mouse',
|
||||||
|
interactionType: 'hover',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Scroll size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Precise Click Origin */}
|
||||||
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||||
|
Click Origin
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||||
|
{[
|
||||||
|
{ id: 'top-left', label: 'TL' },
|
||||||
|
{ id: 'top-right', label: 'TR' },
|
||||||
|
{ id: 'center', label: 'CTR' },
|
||||||
|
{ id: 'bottom-left', label: 'BL' },
|
||||||
|
{ id: 'bottom-right', label: 'BR' },
|
||||||
|
].map((origin) => (
|
||||||
|
<button
|
||||||
|
key={origin.id}
|
||||||
|
onClick={() =>
|
||||||
|
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||||
|
}
|
||||||
|
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||||
|
>
|
||||||
|
{origin.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||||
|
<span>Timeline Allocation</span>
|
||||||
|
<span className="text-accent">{editForm.duration}ms</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="range"
|
||||||
step="0.1"
|
min="0"
|
||||||
min="1"
|
max="5000"
|
||||||
max="3"
|
step="100"
|
||||||
value={editForm.zoom || 1}
|
value={editForm.duration || 1000}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||||
}
|
}
|
||||||
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Zoom & Effects */}
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
|
<div className="space-y-6">
|
||||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||||
>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Maximize2 size={18} className="text-white/40" />
|
||||||
<Box size={18} />
|
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
|
Zoom Shift
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
value={editForm.zoom || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
|
onClick={() =>
|
||||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))
|
||||||
|
}
|
||||||
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ExternalLink size={18} />
|
<Box size={18} />
|
||||||
<div className="flex flex-col items-start">
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider">
|
Motion Blur
|
||||||
Trigger Navigation
|
</span>
|
||||||
</span>
|
|
||||||
<span className="text-[8px] opacity-60">
|
|
||||||
Allows URL transitions in Studio
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||||
onClick={saveEdit}
|
<button
|
||||||
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
onClick={() =>
|
||||||
>
|
setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
|
||||||
Commit Changes
|
}
|
||||||
</button>
|
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||||
</div>
|
>
|
||||||
)}
|
<div className="flex items-center gap-3">
|
||||||
</AnimatePresence>
|
<ExternalLink size={18} />
|
||||||
</div>
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
|
Trigger Navigation
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] opacity-60">
|
||||||
|
Allows URL transitions in Studio
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||||
|
>
|
||||||
|
Commit Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</LazyMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
|
||||||
import type {
|
import type {
|
||||||
ErrorReportingLevel,
|
ErrorReportingLevel,
|
||||||
ErrorReportingService,
|
ErrorReportingService,
|
||||||
@@ -7,32 +6,66 @@ import type {
|
|||||||
import type { NotificationService } from '../notifications/notification-service';
|
import type { NotificationService } from '../notifications/notification-service';
|
||||||
import type { LoggerService } from '../logging/logger-service';
|
import type { LoggerService } from '../logging/logger-service';
|
||||||
|
|
||||||
type SentryLike = typeof Sentry;
|
|
||||||
|
|
||||||
export type GlitchtipErrorReportingServiceOptions = {
|
export type GlitchtipErrorReportingServiceOptions = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
|
// Sentry is dynamically imported to avoid a ~100KB main-thread execution penalty on initial load.
|
||||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||||
private logger: LoggerService;
|
private logger: LoggerService;
|
||||||
|
private sentryPromise: Promise<typeof import('@sentry/nextjs')> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: GlitchtipErrorReportingServiceOptions,
|
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||||
logger: LoggerService,
|
logger: LoggerService,
|
||||||
private readonly notifications?: NotificationService,
|
private readonly notifications?: NotificationService,
|
||||||
private readonly sentry: SentryLike = Sentry,
|
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
|
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
|
||||||
|
|
||||||
|
if (this.options.enabled) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// On client-side, wait until idle before fetching Sentry
|
||||||
|
if ('requestIdleCallback' in window) {
|
||||||
|
window.requestIdleCallback(() => {
|
||||||
|
this.getSentry();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.getSentry();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pre-fetch on server-side
|
||||||
|
this.getSentry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSentry(): Promise<typeof import('@sentry/nextjs')> {
|
||||||
|
if (!this.sentryPromise) {
|
||||||
|
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
||||||
|
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: 'https://public@errors.infra.mintel.me/1',
|
||||||
|
tunnel: '/errors/api/relay',
|
||||||
|
enabled: true,
|
||||||
|
tracesSampleRate: 0,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Sentry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.sentryPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureException(error: unknown, context?: Record<string, unknown>) {
|
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||||
if (!this.options.enabled) return undefined;
|
if (!this.options.enabled) return undefined;
|
||||||
const result = this.sentry.captureException(error, context as any) as any;
|
|
||||||
|
|
||||||
// Send to Gotify if it's considered critical or if we just want all exceptions there
|
// Send to Gotify if it's considered critical or if we just want all exceptions there
|
||||||
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
|
|
||||||
// We'll treat all captureException calls as potentially critical or at least noteworthy
|
|
||||||
if (this.notifications) {
|
if (this.notifications) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
||||||
@@ -44,34 +77,33 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
const Sentry = await this.getSentry();
|
||||||
|
return Sentry.captureException(error, context as any) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
async captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
||||||
if (!this.options.enabled) return undefined;
|
if (!this.options.enabled) return undefined;
|
||||||
return this.sentry.captureMessage(message, level as any) as any;
|
const Sentry = await this.getSentry();
|
||||||
|
return Sentry.captureMessage(message, level as any) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(user: ErrorReportingUser | null) {
|
setUser(user: ErrorReportingUser | null) {
|
||||||
if (!this.options.enabled) return;
|
if (!this.options.enabled) return;
|
||||||
this.sentry.setUser(user as any);
|
this.getSentry().then((Sentry) => Sentry.setUser(user as any));
|
||||||
}
|
}
|
||||||
|
|
||||||
setTag(key: string, value: string) {
|
setTag(key: string, value: string) {
|
||||||
if (!this.options.enabled) return;
|
if (!this.options.enabled) return;
|
||||||
this.sentry.setTag(key, value);
|
this.getSentry().then((Sentry) => Sentry.setTag(key, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
|
withScope<T>(fn: () => T, context?: Record<string, unknown>): T {
|
||||||
if (!this.options.enabled) return fn();
|
if (!this.options.enabled) return fn();
|
||||||
|
|
||||||
return this.sentry.withScope((scope) => {
|
// Since withScope mandates executing fn() synchronously to return T,
|
||||||
if (context) {
|
// and Sentry load is async, if context mapping is absolutely required
|
||||||
for (const [key, value] of Object.entries(context)) {
|
// for this feature we would need an async API.
|
||||||
scope.setExtra(key, value);
|
// For now we degrade gracefully by just executing the function.
|
||||||
}
|
return fn();
|
||||||
}
|
|
||||||
return fn();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const nextConfig = {
|
|||||||
// Make sure entries are not disposed too quickly
|
// Make sure entries are not disposed too quickly
|
||||||
maxInactiveAge: 60 * 1000,
|
maxInactiveAge: 60 * 1000,
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||||
|
},
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
|
|||||||
21
scripts/replace-motion.cjs
Normal file
21
scripts/replace-motion.cjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const files = [
|
||||||
|
'/Users/marcmintel/Projects/klz-2026/components/Header.tsx',
|
||||||
|
'/Users/marcmintel/Projects/klz-2026/components/Scribble.tsx',
|
||||||
|
'/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx',
|
||||||
|
'/Users/marcmintel/Projects/klz-2026/components/record-mode/RecordModeOverlay.tsx',
|
||||||
|
'/Users/marcmintel/Projects/klz-2026/components/record-mode/PlaybackCursor.tsx'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
content = content.replace(/import { motion } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation } from 'framer-motion';");
|
||||||
|
content = content.replace(/import { motion, Variants } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';");
|
||||||
|
content = content.replace(/import { motion, AnimatePresence } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';");
|
||||||
|
|
||||||
|
content = content.replace(/<motion\./g, '<m.');
|
||||||
|
content = content.replace(/<\/motion\./g, '</m.');
|
||||||
|
|
||||||
|
fs.writeFileSync(file, content);
|
||||||
|
}
|
||||||
|
console.log('Replaced motion with m in ' + files.length + ' files');
|
||||||
@@ -1,19 +1,4 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||||
|
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||||
// We use a placeholder DSN on the client because the real DSN is injected
|
// from being included in the initial JS bundle.
|
||||||
// by our server-side relay at /errors/api/relay.
|
export {};
|
||||||
// This keeps our environment clean of NEXT_PUBLIC_ variables.
|
|
||||||
const CLIENT_DSN = 'https://public@errors.infra.mintel.me/1';
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: CLIENT_DSN,
|
|
||||||
// Relay events through our own server to hide the real DSN and bypass ad-blockers
|
|
||||||
tunnel: '/errors/api/relay',
|
|
||||||
|
|
||||||
// Enable even if no DSN is provided, because we have the tunnel
|
|
||||||
enabled: true,
|
|
||||||
|
|
||||||
tracesSampleRate: 0,
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
|
||||||
replaysSessionSampleRate: 0.1,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -43,11 +43,11 @@
|
|||||||
--animate-slide-up: slide-up 0.6s ease-out;
|
--animate-slide-up: slide-up 0.6s ease-out;
|
||||||
--animate-slow-zoom: slow-zoom 20s linear infinite;
|
--animate-slow-zoom: slow-zoom 20s linear infinite;
|
||||||
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s
|
||||||
|
cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
--animate-gradient-x: gradient-x 15s ease infinite;
|
--animate-gradient-x: gradient-x 15s ease infinite;
|
||||||
|
|
||||||
@keyframes gradient-x {
|
@keyframes gradient-x {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
background-position: 0% 50%;
|
background-position: 0% 50%;
|
||||||
@@ -135,10 +135,31 @@
|
|||||||
transform: translate(0, 0) scale(1);
|
transform: translate(0, 0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin-slow {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flow {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes solar-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
fill-opacity: 0.2;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
fill-opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
||||||
.bg-primary a,
|
.bg-primary a,
|
||||||
.bg-primary-dark a {
|
.bg-primary-dark a {
|
||||||
@apply text-white/90 hover:text-white transition-colors;
|
@apply text-white/90 hover:text-white transition-colors;
|
||||||
@@ -321,4 +342,4 @@
|
|||||||
@utility content-visibility-auto {
|
@utility content-visibility-auto {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: 1px 1000px;
|
contain-intrinsic-size: 1px 1000px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user