feat: optimize event capturing and playback accuracy
This commit is contained in:
5
.env
5
.env
@@ -22,8 +22,8 @@ DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
|||||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||||
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
DIRECTUS_DB_NAME=directus
|
DIRECTUS_DB_NAME=directus
|
||||||
DIRECTUS_DB_USER=directus
|
DIRECTUS_DB_USER=klz_db_user
|
||||||
DIRECTUS_DB_PASSWORD=directus
|
DIRECTUS_DB_PASSWORD=klz_db_pass
|
||||||
# Local Development
|
# Local Development
|
||||||
PROJECT_NAME=klz-cables
|
PROJECT_NAME=klz-cables
|
||||||
GATEKEEPER_BYPASS_ENABLED=true
|
GATEKEEPER_BYPASS_ENABLED=true
|
||||||
@@ -33,3 +33,4 @@ GATEKEEPER_PASSWORD=klz2026
|
|||||||
COOKIE_DOMAIN=localhost
|
COOKIE_DOMAIN=localhost
|
||||||
INFRA_DIRECTUS_URL=http://localhost:8059
|
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||||
|
GATEKEEPER_ORIGIN=http://klz.localhost
|
||||||
11
Dockerfile
11
Dockerfile
@@ -34,10 +34,15 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build application
|
# Stage 2: Development (Hot-Reloading)
|
||||||
RUN pnpm build
|
FROM builder AS development
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Build application
|
||||||
|
# RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 3: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import Header from '@/components/Header';
|
|||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
import { FeedbackOverlay } from '@mintel/next-feedback';
|
|
||||||
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||||
import { RecordModeOverlay } from '@/components/record-mode/RecordModeOverlay';
|
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
||||||
|
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
@@ -98,20 +98,49 @@ export default async function LocaleLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||||
|
<head>
|
||||||
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
/* Effectively Invisible Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(130, 237, 32, 0.4);
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
*:hover {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(130, 237, 32, 0.2) transparent;
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
</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={messages} locale={safeLocale}>
|
||||||
<RecordModeProvider>
|
<RecordModeProvider>
|
||||||
<JsonLd />
|
<RecordModeVisuals>
|
||||||
<Header />
|
<JsonLd />
|
||||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
<Header />
|
||||||
<Footer />
|
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</RecordModeVisuals>
|
||||||
|
|
||||||
<CMSConnectivityNotice />
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AnalyticsProvider />
|
<AnalyticsProvider />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
<ToolCoordinator />
|
||||||
<RecordModeOverlay />
|
|
||||||
</RecordModeProvider>
|
</RecordModeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
<Link href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`} className="hover:text-accent transition-colors">
|
||||||
{t('title')}
|
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-3 opacity-30">/</span>
|
<span className="mx-3 opacity-30">/</span>
|
||||||
<span className="text-white/90">{categoryTitle}</span>
|
<span className="text-white/90">{categoryTitle}</span>
|
||||||
@@ -361,8 +361,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
<Link href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`} className="hover:text-accent transition-colors">
|
||||||
{t('title')}
|
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-4 opacity-20">/</span>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
|
|||||||
@@ -17,23 +17,23 @@ interface ProductsPageProps {
|
|||||||
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||||
const title = t('meta.title') || t('title');
|
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
|
||||||
const description = t('meta.description') || t('subtitle');
|
const description = t('meta.description') || t('subtitle');
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${locale}/products`,
|
canonical: `/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de/products',
|
de: `/de/${await mapFileSlugToTranslated('products', 'de')}`,
|
||||||
en: '/en/products',
|
en: `/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
'x-default': '/en/products',
|
'x-default': `/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/products`,
|
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -54,34 +54,36 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||||
|
|
||||||
|
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
title: t('categories.lowVoltage.title'),
|
title: t('categories.lowVoltage.title'),
|
||||||
desc: t('categories.lowVoltage.description'),
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: `/${locale}/products/${lowVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.mediumVoltage.title'),
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: t('categories.mediumVoltage.description'),
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: `/${locale}/products/${mediumVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.highVoltage.title'),
|
title: t('categories.highVoltage.title'),
|
||||||
desc: t('categories.highVoltage.description'),
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: `/${locale}/products/${highVoltageSlug}`,
|
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.solar.title'),
|
title: t('categories.solar.title'),
|
||||||
desc: t('categories.solar.description'),
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2024/11/solar-category.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: `/${locale}/products/${solarSlug}`,
|
href: `/${locale}/${productsSlug}/${solarSlug}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
32
app/api/save-session/route.ts
Normal file
32
app/api/save-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Only allow in development
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Ensure we are in the project root by using process.cwd()
|
||||||
|
// Path: <project-root>/remotion/session.json
|
||||||
|
const remotionDir = path.join(process.cwd(), 'remotion');
|
||||||
|
const filePath = path.join(remotionDir, 'session.json');
|
||||||
|
|
||||||
|
// Create remotion directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(remotionDir)) {
|
||||||
|
fs.mkdirSync(remotionDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the JSON file
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, path: filePath });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save session:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ export default function Footer() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/products`}
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||||
>
|
>
|
||||||
{navT('products')}
|
{navT('products')}
|
||||||
|
|||||||
@@ -46,10 +46,22 @@ export default function Header() {
|
|||||||
return segments.join('/');
|
return segments.join('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [productsSlug, setProductsSlug] = useState('products');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// We can't use mapFileSlugToTranslated directly in client components easily without an API or similar
|
||||||
|
// For now, let's just check the locale
|
||||||
|
if (currentLocale === 'de') {
|
||||||
|
setProductsSlug('produkte');
|
||||||
|
} else {
|
||||||
|
setProductsSlug('products');
|
||||||
|
}
|
||||||
|
}, [currentLocale]);
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ label: t('home'), href: '/' },
|
{ label: t('home'), href: '/' },
|
||||||
{ label: t('team'), href: '/team' },
|
{ label: t('team'), href: '/team' },
|
||||||
{ label: t('products'), href: '/products' },
|
{ label: t('products'), href: `/${productsSlug}` },
|
||||||
{ label: t('blog'), href: '/blog' },
|
{ label: t('blog'), href: '/blog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ interface RelatedProductsProps {
|
|||||||
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
const t = await getTranslations('Products');
|
const t = await getTranslations('Products');
|
||||||
|
|
||||||
// Filter products: same category, not current product
|
// Filter products: same category, not current product
|
||||||
const related = allProducts
|
const related = allProducts
|
||||||
.filter(p =>
|
.filter(p =>
|
||||||
p.slug !== currentSlug &&
|
p.slug !== currentSlug &&
|
||||||
p.frontmatter.categories.some(cat => categories.includes(cat))
|
p.frontmatter.categories.some(cat => categories.includes(cat))
|
||||||
)
|
)
|
||||||
.slice(0, 3); // Limit to 3 for better spacing
|
.slice(0, 3); // Limit to 3 for better spacing
|
||||||
@@ -42,17 +42,19 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
|||||||
const catSlug = categorySlugs.find(slug => {
|
const catSlug = categorySlugs.find(slug => {
|
||||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
const title = t(`categories.${key}.title`);
|
const title = t(`categories.${key}.title`);
|
||||||
return product.frontmatter.categories.some(cat =>
|
return product.frontmatter.categories.some(cat =>
|
||||||
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
|
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
|
||||||
);
|
);
|
||||||
}) || 'low-voltage-cables';
|
}) || 'low-voltage-cables';
|
||||||
|
|
||||||
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
|
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||||
|
const translatedCategorySlug = await mapFileSlugToTranslated(catSlug, locale);
|
||||||
|
const productsBase = await mapFileSlugToTranslated('products', locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={product.slug}
|
key={product.slug}
|
||||||
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
|
href={`/${locale}/${productsBase}/${translatedCategorySlug}/${translatedProductSlug}`}
|
||||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||||
>
|
>
|
||||||
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
|
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
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 { motion } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const t = useTranslations('Home.hero');
|
const t = useTranslations('Home.hero');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
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">
|
||||||
@@ -66,7 +67,7 @@ export default function Hero() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div variants={buttonVariants}>
|
<motion.div variants={buttonVariants}>
|
||||||
<Button
|
<Button
|
||||||
href="/products"
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
variant="white"
|
variant="white"
|
||||||
size="lg"
|
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"
|
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"
|
||||||
|
|||||||
@@ -8,34 +8,36 @@ export default function ProductCategories() {
|
|||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const productsBase = locale === 'de' ? 'produkte' : 'products';
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
title: t('categories.lowVoltage.title'),
|
title: t('categories.lowVoltage.title'),
|
||||||
desc: t('categories.lowVoltage.description'),
|
desc: t('categories.lowVoltage.description'),
|
||||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||||
href: `/${locale}/products/low-voltage-cables`,
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'niederspannungskabel' : 'low-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.mediumVoltage.title'),
|
title: t('categories.mediumVoltage.title'),
|
||||||
desc: t('categories.mediumVoltage.description'),
|
desc: t('categories.mediumVoltage.description'),
|
||||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||||
href: `/${locale}/products/medium-voltage-cables`,
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'mittelspannungskabel' : 'medium-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.highVoltage.title'),
|
title: t('categories.highVoltage.title'),
|
||||||
desc: t('categories.highVoltage.description'),
|
desc: t('categories.highVoltage.description'),
|
||||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||||
href: `/${locale}/products/high-voltage-cables`,
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'hochspannungskabel' : 'high-voltage-cables'}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('categories.solar.title'),
|
title: t('categories.solar.title'),
|
||||||
desc: t('categories.solar.description'),
|
desc: t('categories.solar.description'),
|
||||||
img: '/uploads/2024/11/solar-category.webp',
|
img: '/uploads/2024/11/solar-category.webp',
|
||||||
icon: '/uploads/2024/11/Solar.svg',
|
icon: '/uploads/2024/11/Solar.svg',
|
||||||
href: `/${locale}/products/solar-cables`,
|
href: `/${locale}/${productsBase}/${locale === 'de' ? 'solarkabel' : 'solar-cables'}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
108
components/record-mode/PickingHelper.tsx
Normal file
108
components/record-mode/PickingHelper.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { finder } from '@medv/finder';
|
||||||
|
|
||||||
|
export function PickingHelper() {
|
||||||
|
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
||||||
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data.type === 'START_PICKING') {
|
||||||
|
setPickingMode(e.data.mode);
|
||||||
|
} else if (e.data.type === 'STOP_PICKING') {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pickingMode) return;
|
||||||
|
|
||||||
|
const handleMouseOver = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.record-mode-ignore') || target.closest('.feedback-ui-ignore')) return;
|
||||||
|
setHoveredElement(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (hoveredElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const selector = finder(hoveredElement, {
|
||||||
|
root: document.body,
|
||||||
|
seedMinLength: 3,
|
||||||
|
optimizedMinLength: 2,
|
||||||
|
className: (name) =>
|
||||||
|
!name.startsWith('record-mode-') &&
|
||||||
|
!name.startsWith('feedback-') &&
|
||||||
|
!name.includes('[') &&
|
||||||
|
!name.includes('/') &&
|
||||||
|
!name.match(/^[a-z]-[0-9]/) &&
|
||||||
|
!name.match(/[0-9]{4,}/), // Avoid dynamic IDs in classnames
|
||||||
|
idName: (name) => !name.startsWith('__next') && !name.includes(':') && !name.match(/[0-9]{5,}/),
|
||||||
|
});
|
||||||
|
const rect = hoveredElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'ELEMENT_SELECTED',
|
||||||
|
selector,
|
||||||
|
rect: {
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
},
|
||||||
|
tagName: hoveredElement.tagName.toLowerCase()
|
||||||
|
}, '*');
|
||||||
|
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
window.parent.postMessage({ type: 'PICKING_CANCELLED' }, '*');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mouseover', handleMouseOver);
|
||||||
|
window.addEventListener('click', handleClick, true);
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mouseover', handleMouseOver);
|
||||||
|
window.removeEventListener('click', handleClick, true);
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [pickingMode, hoveredElement]);
|
||||||
|
|
||||||
|
if (!pickingMode || !hoveredElement) return null;
|
||||||
|
|
||||||
|
const rect = hoveredElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/5 transition-all z-[9999] shadow-[0_0_40px_rgba(130,237,32,0.4)] rounded-sm backdrop-blur-[2px]"
|
||||||
|
style={{
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 bg-[#82ed20] text-black text-[10px] font-black px-1.5 py-1 transform -translate-y-full uppercase tracking-tighter shadow-xl">
|
||||||
|
{hoveredElement.tagName.toLowerCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/record-mode/PlaybackCursor.tsx
Normal file
55
components/record-mode/PlaybackCursor.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
|
export function PlaybackCursor() {
|
||||||
|
const { isPlaying, cursorPosition } = useRecordMode();
|
||||||
|
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Track scroll so cursor stays locked to the correct element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll(); // Init
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
if (!isPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="fixed z-[10000] pointer-events-none"
|
||||||
|
animate={{
|
||||||
|
x: cursorPosition.x,
|
||||||
|
y: cursorPosition.y,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
damping: 30,
|
||||||
|
stiffness: 250,
|
||||||
|
mass: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Outer Pulse Ring */}
|
||||||
|
<div className="absolute -inset-3 rounded-full bg-[#82ed20]/10 animate-ping" />
|
||||||
|
|
||||||
|
{/* Visual Cursor */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Soft Glow */}
|
||||||
|
<div className="absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md" />
|
||||||
|
|
||||||
|
{/* Pointer Arrow */}
|
||||||
|
<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)]">
|
||||||
|
<path d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z" fill="white" stroke="black" strokeWidth="1.5" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,6 @@ import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
|||||||
interface RecordModeContextType {
|
interface RecordModeContextType {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
setIsActive: (active: boolean) => void;
|
setIsActive: (active: boolean) => void;
|
||||||
isRecording: boolean;
|
|
||||||
startRecording: () => void;
|
|
||||||
stopRecording: () => void;
|
|
||||||
events: RecordEvent[];
|
events: RecordEvent[];
|
||||||
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||||
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||||
@@ -17,149 +14,209 @@ interface RecordModeContextType {
|
|||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
playEvents: () => void;
|
playEvents: () => void;
|
||||||
stopPlayback: () => void;
|
stopPlayback: () => void;
|
||||||
|
cursorPosition: { x: number; y: number };
|
||||||
|
zoomLevel: number;
|
||||||
|
isBlurry: boolean;
|
||||||
currentSession: RecordingSession | null;
|
currentSession: RecordingSession | null;
|
||||||
saveSession: (name: string) => void;
|
saveSession: (name: string) => void;
|
||||||
|
isFeedbackActive: boolean;
|
||||||
|
setIsFeedbackActive: (active: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||||
|
|
||||||
export function useRecordMode() {
|
export function useRecordMode(): RecordModeContextType {
|
||||||
const context = useContext(RecordModeContext);
|
const context = useContext(RecordModeContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useRecordMode must be used within a RecordModeProvider');
|
// Return a fail-safe fallback for SSR/Static generation where provider might be missing
|
||||||
|
return {
|
||||||
|
isActive: false,
|
||||||
|
setIsActive: () => { },
|
||||||
|
events: [],
|
||||||
|
addEvent: () => { },
|
||||||
|
updateEvent: () => { },
|
||||||
|
removeEvent: () => { },
|
||||||
|
clearEvents: () => { },
|
||||||
|
isPlaying: false,
|
||||||
|
playEvents: () => { },
|
||||||
|
stopPlayback: () => { },
|
||||||
|
cursorPosition: { x: 0, y: 0 },
|
||||||
|
zoomLevel: 1,
|
||||||
|
isBlurry: false,
|
||||||
|
currentSession: null,
|
||||||
|
isFeedbackActive: false,
|
||||||
|
setIsFeedbackActive: () => { },
|
||||||
|
saveSession: () => { },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
|
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [isActive, setIsActive] = useState(false);
|
const [isActive, setIsActiveState] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
|
||||||
const [events, setEvents] = useState<RecordEvent[]>([]);
|
const [events, setEvents] = useState<RecordEvent[]>([]);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentSession, setCurrentSession] = useState<RecordingSession | null>(null);
|
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||||
const startTimeRef = useRef<number>(0);
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
const [isBlurry, setIsBlurry] = useState(false);
|
||||||
|
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
|
||||||
|
|
||||||
const startRecording = () => {
|
// Synchronous mutually exclusive setters
|
||||||
setIsRecording(true);
|
const setIsActive = (active: boolean) => {
|
||||||
setEvents([]);
|
setIsActiveState(active);
|
||||||
startTimeRef.current = Date.now();
|
if (active) setIsFeedbackActiveState(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopRecording = () => {
|
const setIsFeedbackActive = (active: boolean) => {
|
||||||
setIsRecording(false);
|
setIsFeedbackActiveState(active);
|
||||||
|
if (active) setIsActiveState(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addEvent = (eventData: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
const playbackRequestRef = useRef<number>(0);
|
||||||
const timestamp = Date.now() - startTimeRef.current;
|
const isPlayingRef = useRef(false);
|
||||||
|
|
||||||
|
// Load draft from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedEvents = localStorage.getItem('klz-record-events');
|
||||||
|
const savedActive = localStorage.getItem('klz-record-active');
|
||||||
|
if (savedEvents) setEvents(JSON.parse(savedEvents));
|
||||||
|
if (savedActive) setIsActive(JSON.parse(savedActive));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync events to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('klz-record-events', JSON.stringify(events));
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
// Sync active state to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
|
||||||
|
if (isActive && isFeedbackActive) {
|
||||||
|
setIsFeedbackActive(false);
|
||||||
|
}
|
||||||
|
}, [isActive, isFeedbackActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFeedbackActive && isActive) {
|
||||||
|
setIsActive(false);
|
||||||
|
}
|
||||||
|
}, [isFeedbackActive, isActive]);
|
||||||
|
|
||||||
|
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||||
const newEvent: RecordEvent = {
|
const newEvent: RecordEvent = {
|
||||||
id: crypto.randomUUID(),
|
...event,
|
||||||
timestamp,
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
...eventData,
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
setEvents((prev) => [...prev, newEvent]);
|
setEvents((prev) => [...prev, newEvent]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEvent = (id: string, updates: Partial<RecordEvent>) => {
|
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
|
||||||
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...updates } : e)));
|
setEvents((prev) =>
|
||||||
|
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event))
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEvent = (id: string) => {
|
const removeEvent = (id: string) => {
|
||||||
setEvents((prev) => prev.filter((e) => e.id !== id));
|
setEvents((prev) => prev.filter((event) => event.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearEvents = () => {
|
const clearEvents = () => {
|
||||||
setEvents([]);
|
if (confirm('Clear all recorded events?')) {
|
||||||
|
setEvents([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSession: RecordingSession | null = events.length > 0 ? {
|
||||||
|
id: 'draft',
|
||||||
|
name: 'Draft Session',
|
||||||
|
events,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const saveSession = (name: string) => {
|
||||||
|
// In a real app, this would be an API call
|
||||||
|
console.log('Saving session:', name, events);
|
||||||
};
|
};
|
||||||
|
|
||||||
const playEvents = async () => {
|
const playEvents = async () => {
|
||||||
if (events.length === 0) return;
|
if (events.length === 0 || isPlayingRef.current) return;
|
||||||
setIsPlaying(true);
|
|
||||||
|
|
||||||
// Simple playback logic mostly for preview
|
setIsPlaying(true);
|
||||||
const startPlayTime = Date.now();
|
isPlayingRef.current = true;
|
||||||
|
|
||||||
// Sort events by timestamp just in case
|
// Sort events by timestamp just in case
|
||||||
const sortedEvents = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||||
|
|
||||||
for (const event of sortedEvents) {
|
for (const event of sortedEvents) {
|
||||||
if (!isPlaying) break; // Check if stopped
|
if (!isPlayingRef.current) break;
|
||||||
|
|
||||||
const targetTime = startPlayTime + event.timestamp;
|
// 1. Move Cursor
|
||||||
const now = Date.now();
|
if (event.rect) {
|
||||||
const delay = targetTime - now;
|
setCursorPosition({
|
||||||
|
x: event.rect.x + event.rect.width / 2,
|
||||||
if (delay > 0) {
|
y: event.rect.y + event.rect.height / 2,
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute event visual feedback
|
// 2. Handle Action
|
||||||
if (document) {
|
if (event.selector) {
|
||||||
if (event.selector) {
|
const el = document.querySelector(event.selector) as HTMLElement;
|
||||||
const el = document.querySelector(event.selector);
|
if (el) {
|
||||||
if (el) {
|
if (event.type === 'scroll') {
|
||||||
// Highlight or scroll to element
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
if (event.type === 'scroll') {
|
} else if (event.type === 'click') {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
// Precise industrial click sequence: mousedown -> mouseup -> click
|
||||||
} else if (event.type === 'click') {
|
const eventCoords = {
|
||||||
// Visualize click
|
clientX: event.rect ? event.rect.x + event.rect.width / 2 : 0,
|
||||||
const rect = el.getBoundingClientRect();
|
clientY: event.rect ? event.rect.y + event.rect.height / 2 : 0
|
||||||
const clickMarker = document.createElement('div');
|
};
|
||||||
clickMarker.style.position = 'fixed';
|
|
||||||
clickMarker.style.left = `${rect.left + rect.width / 2}px`;
|
const dispatchMouse = (type: string) => {
|
||||||
clickMarker.style.top = `${rect.top + rect.height / 2}px`;
|
el.dispatchEvent(new MouseEvent(type, {
|
||||||
clickMarker.style.width = '20px';
|
view: window,
|
||||||
clickMarker.style.height = '20px';
|
bubbles: true,
|
||||||
clickMarker.style.borderRadius = '50%';
|
cancelable: true,
|
||||||
clickMarker.style.backgroundColor = 'rgba(255, 0, 0, 0.5)';
|
...eventCoords
|
||||||
clickMarker.style.transform = 'translate(-50%, -50%)';
|
}));
|
||||||
clickMarker.style.zIndex = '99999';
|
};
|
||||||
document.body.appendChild(clickMarker);
|
|
||||||
setTimeout(() => clickMarker.remove(), 500);
|
dispatchMouse('mousedown');
|
||||||
}
|
dispatchMouse('mouseup');
|
||||||
|
dispatchMouse('click');
|
||||||
|
|
||||||
|
// Fallback for native elements
|
||||||
|
el.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Zoom/Blur
|
||||||
|
if (event.zoom) setZoomLevel(event.zoom);
|
||||||
|
if (event.motionBlur) setIsBlurry(true);
|
||||||
|
|
||||||
|
// 4. Wait
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
|
||||||
|
setIsBlurry(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
isPlayingRef.current = false;
|
||||||
|
setZoomLevel(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopPlayback = () => {
|
const stopPlayback = () => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
isPlayingRef.current = false;
|
||||||
|
setZoomLevel(1);
|
||||||
|
setIsBlurry(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSession = (name: string) => {
|
|
||||||
const session: RecordingSession = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name,
|
|
||||||
events,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setCurrentSession(session);
|
|
||||||
// Ideally save to local storage or API
|
|
||||||
localStorage.setItem('klz-record-session', JSON.stringify(session));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load session on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem('klz-record-session');
|
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
setCurrentSession(JSON.parse(saved));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load session', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecordModeContext.Provider
|
<RecordModeContext.Provider
|
||||||
value={{
|
value={{
|
||||||
isActive,
|
isActive,
|
||||||
setIsActive,
|
setIsActive,
|
||||||
isRecording,
|
|
||||||
startRecording,
|
|
||||||
stopRecording,
|
|
||||||
events,
|
events,
|
||||||
addEvent,
|
addEvent,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
@@ -168,8 +225,13 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
playEvents,
|
playEvents,
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
|
cursorPosition,
|
||||||
|
zoomLevel,
|
||||||
|
isBlurry,
|
||||||
currentSession,
|
currentSession,
|
||||||
saveSession,
|
saveSession,
|
||||||
|
isFeedbackActive,
|
||||||
|
setIsFeedbackActive,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
|
Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { RecordEvent } from '@/types/record-mode';
|
import { RecordEvent } from '@/types/record-mode';
|
||||||
|
import { PlaybackCursor } from './PlaybackCursor';
|
||||||
|
|
||||||
export function RecordModeOverlay() {
|
export function RecordModeOverlay() {
|
||||||
const {
|
const {
|
||||||
isActive,
|
isActive,
|
||||||
setIsActive,
|
setIsActive,
|
||||||
isRecording,
|
|
||||||
startRecording,
|
|
||||||
stopRecording,
|
|
||||||
events,
|
events,
|
||||||
addEvent,
|
addEvent,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
@@ -41,26 +40,18 @@ export function RecordModeOverlay() {
|
|||||||
|
|
||||||
// Edit form state
|
// Edit form state
|
||||||
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) return;
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleMouseOver = (e: MouseEvent) => {
|
useEffect(() => {
|
||||||
if (pickingMode) {
|
if (!mounted || !isActive) return;
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('.record-mode-ui')) return;
|
|
||||||
setHoveredElement(target);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleMessage = (e: MessageEvent) => {
|
||||||
if (pickingMode && hoveredElement) {
|
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||||
e.stopPropagation();
|
const { selector, rect, tagName } = e.data;
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const selector = finder(hoveredElement);
|
|
||||||
|
|
||||||
if (pickingMode === 'click') {
|
if (pickingMode === 'click') {
|
||||||
addEvent({
|
addEvent({
|
||||||
@@ -68,285 +59,285 @@ export function RecordModeOverlay() {
|
|||||||
selector,
|
selector,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
description: `Click on ${hoveredElement.tagName.toLowerCase()}`,
|
description: `Click on ${tagName}`,
|
||||||
motionBlur: false,
|
motionBlur: false,
|
||||||
|
rect,
|
||||||
});
|
});
|
||||||
} else if (pickingMode === 'scroll') {
|
} else if (pickingMode === 'scroll') {
|
||||||
addEvent({
|
addEvent({
|
||||||
type: 'scroll',
|
type: 'scroll',
|
||||||
selector,
|
selector,
|
||||||
duration: 1000,
|
duration: 1500,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
description: `Scroll to ${hoveredElement.tagName.toLowerCase()}`,
|
description: `Scroll to ${tagName}`,
|
||||||
motionBlur: false,
|
motionBlur: false,
|
||||||
|
rect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setPickingMode(null);
|
setPickingMode(null);
|
||||||
setHoveredElement(null);
|
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||||
|
setPickingMode(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
if (pickingMode) {
|
if (pickingMode) {
|
||||||
window.addEventListener('mouseover', handleMouseOver);
|
// Find the iframe and signal start picking
|
||||||
window.addEventListener('click', handleClick, true);
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Signal stop picking
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mouseover', handleMouseOver);
|
window.removeEventListener('message', handleMessage);
|
||||||
window.removeEventListener('click', handleClick, true);
|
|
||||||
};
|
};
|
||||||
}, [isActive, pickingMode, hoveredElement, addEvent]);
|
}, [isActive, pickingMode, addEvent]);
|
||||||
|
|
||||||
const startEditing = (event: RecordEvent) => {
|
const [showEvents, setShowEvents] = useState(false);
|
||||||
setEditingEventId(event.id);
|
|
||||||
setEditForm({ ...event });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEdit = () => {
|
if (!mounted) return null;
|
||||||
if (editingEventId && editForm) {
|
|
||||||
updateEvent(editingEventId, editForm);
|
|
||||||
setEditingEventId(null);
|
|
||||||
setEditForm({});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setEditingEventId(null);
|
|
||||||
setEditForm({});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
|
// Failsafe: Never render host toggle in embedded mode
|
||||||
|
if (typeof window !== 'undefined' && (window.self !== window.top || window.name === 'record-mode-iframe' || window.location.search.includes('embedded=true'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsActive(true)}
|
onClick={() => setIsActive(true)}
|
||||||
className="fixed bottom-4 right-4 z-[9999] bg-red-600 text-white p-3 rounded-full shadow-lg hover:scale-110 transition-transform record-mode-ui"
|
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||||
>
|
>
|
||||||
<div className="w-4 h-4 rounded-full bg-white" />
|
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||||
{/* Hover Highlighter */}
|
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||||
{pickingMode && hoveredElement && (
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||||
<div
|
<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">
|
||||||
className="fixed pointer-events-none border-2 border-red-500 bg-red-500/20 transition-all z-[9998]"
|
|
||||||
style={{
|
|
||||||
top: hoveredElement.getBoundingClientRect().top,
|
|
||||||
left: hoveredElement.getBoundingClientRect().left,
|
|
||||||
width: hoveredElement.getBoundingClientRect().width,
|
|
||||||
height: hoveredElement.getBoundingClientRect().height,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Control Panel */}
|
{/* Identity Tag */}
|
||||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md text-white p-4 rounded-xl shadow-2xl pointer-events-auto record-mode-ui border border-white/10 w-[600px] max-h-[80vh] flex flex-col">
|
<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 items-center justify-between mb-4">
|
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
<h3 className="font-bold flex items-center gap-2">
|
<div className="flex flex-col">
|
||||||
<div
|
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">Event Builder</span>
|
||||||
className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-500'}`}
|
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">Manual Mode</span>
|
||||||
/>
|
|
||||||
Record Mode
|
|
||||||
</h3>
|
|
||||||
<button onClick={() => setIsActive(false)} className="text-white/50 hover:text-white">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-4 overflow-x-auto pb-2">
|
|
||||||
{!isRecording ? (
|
|
||||||
<button
|
|
||||||
onClick={startRecording}
|
|
||||||
className="flex items-center gap-2 bg-red-600 px-4 py-2 rounded-lg hover:bg-red-700 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<div className="w-3 h-3 rounded-full bg-white" />
|
|
||||||
Start Rec
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={stopRecording}
|
|
||||||
className="flex items-center gap-2 bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-600 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Square size={16} fill="currentColor" />
|
|
||||||
Stop Rec
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="w-px h-8 bg-white/20 mx-2" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled={!isRecording}
|
|
||||||
onClick={() => setPickingMode('click')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'click' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
|
||||||
>
|
|
||||||
<MousePointer2 size={16} />
|
|
||||||
Add Click
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled={!isRecording}
|
|
||||||
onClick={() => setPickingMode('scroll')}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'scroll' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
|
||||||
>
|
|
||||||
<Scroll size={16} />
|
|
||||||
Add Scroll
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-px h-8 bg-white/20 mx-2" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={playEvents}
|
|
||||||
disabled={isRecording || events.length === 0}
|
|
||||||
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Play size={20} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => saveSession('Session 1')}
|
|
||||||
disabled={events.length === 0}
|
|
||||||
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Save size={20} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={clearEvents}
|
|
||||||
disabled={events.length === 0}
|
|
||||||
className="p-2 hover:bg-red-500/20 text-red-400 rounded-lg disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Edit Form */}
|
|
||||||
{editingEventId && (
|
|
||||||
<div className="bg-blue-900/40 p-3 rounded-lg mb-4 border border-blue-500/30">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="font-bold text-sm text-blue-300">Edit Event</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={saveEdit}
|
|
||||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded"
|
|
||||||
>
|
|
||||||
<Check size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelEdit}
|
|
||||||
className="p-1 hover:bg-red-500/20 text-red-400 rounded"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div>
|
|
||||||
<label className="block text-white/50 mb-1">Type</label>
|
|
||||||
<select
|
|
||||||
value={editForm.type}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, type: e.target.value as any })}
|
|
||||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
||||||
>
|
|
||||||
<option value="click">Click</option>
|
|
||||||
<option value="scroll">Scroll</option>
|
|
||||||
<option value="wait">Wait</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-white/50 mb-1">Duration (ms)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editForm.duration}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, duration: parseInt(e.target.value) })}
|
|
||||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-white/50 mb-1">Zoom (x)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={editForm.zoom}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, zoom: parseFloat(e.target.value) })}
|
|
||||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-white/50 mb-1">Motion Blur</label>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditForm({ ...editForm, motionBlur: !editForm.motionBlur })}
|
|
||||||
className={`w-full p-1 rounded text-center border ${editForm.motionBlur ? 'bg-blue-500/20 border-blue-500 text-blue-300' : 'bg-black/40 border-white/10 text-white/50'}`}
|
|
||||||
>
|
|
||||||
{editForm.motionBlur ? 'Enabled' : 'Disabled'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-white/50 mb-1">Description</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editForm.description || ''}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
|
||||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Event Timeline */}
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
<div className="bg-white/5 rounded-lg p-2 flex-1 overflow-y-auto space-y-2 min-h-[200px]">
|
|
||||||
{events.length === 0 && (
|
{/* Action Tools */}
|
||||||
<div className="text-center text-white/30 text-sm py-4">No events recorded yet.</div>
|
<div className="flex items-center gap-1">
|
||||||
)}
|
<button
|
||||||
{events.map((event, index) => (
|
onClick={() => setPickingMode('click')}
|
||||||
<div
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'click' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||||
key={event.id}
|
|
||||||
className={`flex items-center gap-3 bg-white/5 p-2 rounded text-sm group cursor-pointer hover:bg-white/10 border border-transparent ${editingEventId === event.id ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
|
||||||
onClick={() => startEditing(event)}
|
|
||||||
>
|
>
|
||||||
<span className="text-white/30 w-6 text-center">{index + 1}</span>
|
<MousePointer2 size={16} />
|
||||||
{event.type === 'click' && <MousePointer2 size={14} className="text-blue-400" />}
|
<span>Click</span>
|
||||||
{event.type === 'scroll' && <Scroll size={14} className="text-green-400" />}
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 truncate">
|
<button
|
||||||
<span className="font-mono text-white/50 text-xs mr-2">{event.selector}</span>
|
onClick={() => setPickingMode('scroll')}
|
||||||
{event.motionBlur && (
|
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'}`}
|
||||||
<span className="text-[10px] bg-purple-500/20 text-purple-300 px-1 rounded ml-1">
|
>
|
||||||
Blur
|
<Scroll size={16} />
|
||||||
</span>
|
<span>Scroll</span>
|
||||||
)}
|
</button>
|
||||||
{event.zoom && event.zoom !== 1 && (
|
|
||||||
<span className="text-[10px] bg-yellow-500/20 text-yellow-300 px-1 rounded ml-1">
|
|
||||||
x{event.zoom}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-xs text-white/40">{(event.timestamp / 1000).toFixed(1)}s</span>
|
<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>
|
||||||
|
|
||||||
<button
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
{/* Sequence Controls */}
|
||||||
removeEvent(event.id);
|
<div className="flex items-center gap-1 p-0.5">
|
||||||
}}
|
<button
|
||||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-white/10 rounded text-red-400"
|
onClick={playEvents}
|
||||||
>
|
disabled={isPlaying || events.length === 0}
|
||||||
<Trash2 size={12} />
|
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||||
</button>
|
title="Preview Sequence"
|
||||||
</div>
|
>
|
||||||
))}
|
<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>
|
||||||
|
)}
|
||||||
|
</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}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Error saving session');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Picking Instructions */}
|
{/* 2. Event Timeline Popover */}
|
||||||
{pickingMode && (
|
{showEvents && (
|
||||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 bg-blue-600 text-white px-6 py-2 rounded-full shadow-xl z-[10000] animate-bounce">
|
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||||
Select element to {pickingMode}
|
<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 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) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="group flex items-center gap-4 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 rounded-full bg-white/5 border border-white/5 flex items-center justify-center text-[9px] font-bold text-white/30">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-xs font-bold uppercase tracking-widest">{event.type}</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-[10px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||||
|
{event.selector || 'system:wait'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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 />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
189
components/record-mode/RecordModeVisuals.tsx
Normal file
189
components/record-mode/RecordModeVisuals.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
|
||||||
|
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
const [isEmbedded, setIsEmbedded] = React.useState(false);
|
||||||
|
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
// Explicit non-magical detection
|
||||||
|
const embedded = window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
|
||||||
|
setIsEmbedded(embedded);
|
||||||
|
|
||||||
|
if (!embedded) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('embedded', 'true');
|
||||||
|
setIframeUrl(url.toString());
|
||||||
|
}
|
||||||
|
}, [isEmbedded]);
|
||||||
|
|
||||||
|
// Hydration Guard: Match server on first render
|
||||||
|
if (!mounted) return <>{children}</>;
|
||||||
|
|
||||||
|
// Recursion Guard: If we are already in an embedded iframe,
|
||||||
|
// strictly return just the children to prevent Inception.
|
||||||
|
if (isEmbedded) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
|
||||||
|
#nextjs-portal,
|
||||||
|
#nextjs-portal-root,
|
||||||
|
[data-nextjs-toast-wrapper],
|
||||||
|
.nextjs-static-indicator,
|
||||||
|
[data-nextjs-indicator],
|
||||||
|
[class*="nextjs-"],
|
||||||
|
[id*="nextjs-"],
|
||||||
|
nextjs-portal,
|
||||||
|
#feedback-overlay,
|
||||||
|
.feedback-ui-root,
|
||||||
|
.feedback-ui-ignore,
|
||||||
|
[class*="z-[9999]"],
|
||||||
|
[class*="z-[10000]"],
|
||||||
|
[style*="z-index: 9999"],
|
||||||
|
[style*="z-index: 10000"],
|
||||||
|
.fixed.bottom-6.left-6,
|
||||||
|
.fixed.bottom-6.left-1\/2,
|
||||||
|
.feedback-ui-overlay,
|
||||||
|
[id^="feedback-"],
|
||||||
|
[class^="feedback-"] {
|
||||||
|
display: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
z-index: -10000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
border-radius: 3rem;
|
||||||
|
background: #050505 !important;
|
||||||
|
color: white !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global Style for Body Lock */}
|
||||||
|
{isActive && (
|
||||||
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
html, body {
|
||||||
|
overflow: hidden !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
position: fixed !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
}
|
||||||
|
/* Kill Next.js Dev tools on host while Studio is active */
|
||||||
|
#nextjs-portal,
|
||||||
|
[data-nextjs-toast-wrapper],
|
||||||
|
.nextjs-static-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}>
|
||||||
|
{/* Studio Background - Only visible when active */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
|
||||||
|
<div className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
|
||||||
|
style={{ background: 'radial-gradient(circle, #10b981 0%, transparent 70%)', filter: 'blur(160px)', animation: 'mesh-float-1 18s ease-in-out infinite' }} />
|
||||||
|
<div className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
|
||||||
|
style={{ background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)', filter: 'blur(150px)', animation: 'mesh-float-2 22s ease-in-out infinite' }} />
|
||||||
|
<div className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
|
||||||
|
style={{ background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)', filter: 'blur(130px)', animation: 'mesh-float-3 14s ease-in-out infinite' }} />
|
||||||
|
<div className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
|
||||||
|
style={{ background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)', filter: 'blur(140px)', animation: 'mesh-float-4 20s ease-in-out infinite' }} />
|
||||||
|
<div className="absolute inset-0 opacity-[0.12] mix-blend-overlay" style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`, backgroundSize: '128px 128px' }} />
|
||||||
|
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
|
||||||
|
style={{
|
||||||
|
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
|
||||||
|
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
|
||||||
|
filter: isBlurry ? 'blur(4px)' : 'none',
|
||||||
|
willChange: 'transform, filter',
|
||||||
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={isActive ? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate' : 'w-full h-full'}
|
||||||
|
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
|
||||||
|
<div className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
|
||||||
|
style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))', animation: 'pulse-ring 4s ease-in-out infinite' }} />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={isActive ? "w-full h-full rounded-[3rem] overflow-hidden relative" : "w-full h-full relative"}
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
|
||||||
|
transform: isActive ? 'translateZ(0)' : 'none'
|
||||||
|
}}>
|
||||||
|
{isActive && iframeUrl ? (
|
||||||
|
<iframe
|
||||||
|
src={iframeUrl}
|
||||||
|
name="record-mode-iframe"
|
||||||
|
className="w-full h-full border-0 block"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#050505',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={isActive ? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700' : 'transition-all duration-700'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
|
||||||
|
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
|
||||||
|
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
|
||||||
|
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
|
||||||
|
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
|
||||||
|
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
|
||||||
|
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||||
|
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/record-mode/ToolCoordinator.tsx
Normal file
63
components/record-mode/ToolCoordinator.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||||
|
import { RecordModeOverlay } from './RecordModeOverlay';
|
||||||
|
import { PickingHelper } from './PickingHelper';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
|
export function ToolCoordinator({ isEmbedded: isEmbeddedProp }: { isEmbedded?: boolean }) {
|
||||||
|
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive } = useRecordMode();
|
||||||
|
const [isEmbedded, setIsEmbedded] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const embedded =
|
||||||
|
isEmbeddedProp ||
|
||||||
|
window.location.search.includes('embedded=true') ||
|
||||||
|
window.name === 'record-mode-iframe' ||
|
||||||
|
(window.self !== window.top);
|
||||||
|
setIsEmbedded(embedded);
|
||||||
|
}, [isEmbeddedProp]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
// ABSOLUTE Priority 1: Inside Iframe - ONLY Rendering PickingHelper
|
||||||
|
if (isEmbedded) {
|
||||||
|
return <PickingHelper />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ABSOLUTE Priority 2: Record Mode Studio Active - NO OTHER TOOLS ALLOWED
|
||||||
|
if (isActive) {
|
||||||
|
return <RecordModeOverlay />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Feedback Tool Active - NO OTHER TOOLS ALLOWED
|
||||||
|
if (isFeedbackActive) {
|
||||||
|
return (
|
||||||
|
<FeedbackOverlay
|
||||||
|
isActive={isFeedbackActive}
|
||||||
|
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline: Both toggle buttons (inactive state)
|
||||||
|
// Only render if neither is active to prevent any overlapping residues
|
||||||
|
// IMPORTANT: FeedbackOverlay must be rendered with isActive={false} to provide the toggle button,
|
||||||
|
// but only if Record Mode is not active.
|
||||||
|
return (
|
||||||
|
<div className="feedback-ui-ignore">
|
||||||
|
{config.feedbackEnabled && (
|
||||||
|
<FeedbackOverlay
|
||||||
|
isActive={false}
|
||||||
|
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<RecordModeOverlay />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
directus/schema/minimal_schema.yaml
Normal file
83
directus/schema/minimal_schema.yaml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
version: 1
|
||||||
|
directus: 11.14.1
|
||||||
|
vendor: postgres
|
||||||
|
collections:
|
||||||
|
- collection: contact_submissions
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
collapse: open
|
||||||
|
collection: contact_submissions
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{name}} | {{email}}'
|
||||||
|
hidden: false
|
||||||
|
icon: contact_mail
|
||||||
|
singleton: false
|
||||||
|
schema:
|
||||||
|
name: contact_submissions
|
||||||
|
fields:
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: name
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: email
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
interface: textarea
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: message
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: text
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
relations: []
|
||||||
@@ -6,51 +6,474 @@ collections:
|
|||||||
meta:
|
meta:
|
||||||
accountability: all
|
accountability: all
|
||||||
archive_app_filter: true
|
archive_app_filter: true
|
||||||
archive_field: null
|
|
||||||
archive_value: null
|
|
||||||
collapse: open
|
collapse: open
|
||||||
collection: contact_submissions
|
collection: contact_submissions
|
||||||
color: '#002b49'
|
color: '#002b49'
|
||||||
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
display_template: '{{name}} | {{email}}'
|
||||||
group: null
|
|
||||||
hidden: false
|
hidden: false
|
||||||
icon: contact_mail
|
icon: contact_mail
|
||||||
item_duplication_fields: null
|
|
||||||
note: null
|
|
||||||
preview_url: null
|
|
||||||
singleton: false
|
singleton: false
|
||||||
sort: null
|
|
||||||
sort_field: null
|
|
||||||
translations: null
|
|
||||||
unarchive_value: null
|
|
||||||
versioning: false
|
|
||||||
schema:
|
schema:
|
||||||
name: contact_submissions
|
name: contact_submissions
|
||||||
- collection: product_requests
|
- collection: product_requests
|
||||||
meta:
|
meta:
|
||||||
accountability: all
|
accountability: all
|
||||||
archive_app_filter: true
|
archive_app_filter: true
|
||||||
archive_field: null
|
|
||||||
archive_value: null
|
|
||||||
collapse: open
|
collapse: open
|
||||||
collection: product_requests
|
collection: product_requests
|
||||||
color: '#002b49'
|
color: '#002b49'
|
||||||
display_template: null
|
display_template: '{{product_name}} | {{email}}'
|
||||||
group: null
|
|
||||||
hidden: false
|
hidden: false
|
||||||
icon: inventory
|
icon: inventory
|
||||||
item_duplication_fields: null
|
|
||||||
note: null
|
|
||||||
preview_url: null
|
|
||||||
singleton: false
|
singleton: false
|
||||||
sort: null
|
|
||||||
sort_field: null
|
|
||||||
translations: null
|
|
||||||
unarchive_value: null
|
|
||||||
versioning: false
|
|
||||||
schema:
|
schema:
|
||||||
name: product_requests
|
name: product_requests
|
||||||
fields: []
|
- collection: products
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
collection: products
|
||||||
|
icon: inventory_2
|
||||||
|
singleton: false
|
||||||
|
schema:
|
||||||
|
name: products
|
||||||
|
- collection: products_translations
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
collection: products_translations
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: products_translations
|
||||||
|
- collection: visual_feedback
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||||
|
hidden: false
|
||||||
|
icon: feedback
|
||||||
|
singleton: false
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
accountability: all
|
||||||
|
archive_app_filter: true
|
||||||
|
collapse: open
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
color: '#002b49'
|
||||||
|
display_template: '{{user_name}}: {{text}}'
|
||||||
|
hidden: false
|
||||||
|
icon: comment
|
||||||
|
singleton: false
|
||||||
|
versioning: false
|
||||||
|
schema:
|
||||||
|
name: visual_feedback_comments
|
||||||
|
fields:
|
||||||
|
# contact_submissions
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: name
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: name
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: email
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: email
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: character varying
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: message
|
||||||
|
interface: textarea
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: message
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: text
|
||||||
|
- collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: contact_submissions
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: contact_submissions
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
|
||||||
|
# product_requests
|
||||||
|
- collection: product_requests
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: product_requests
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: product_requests
|
||||||
|
field: product_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: product_name
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: product_name
|
||||||
|
table: product_requests
|
||||||
|
data_type: character varying
|
||||||
|
- collection: product_requests
|
||||||
|
field: email
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: email
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: email
|
||||||
|
table: product_requests
|
||||||
|
data_type: character varying
|
||||||
|
- collection: product_requests
|
||||||
|
field: message
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: message
|
||||||
|
interface: textarea
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: message
|
||||||
|
table: product_requests
|
||||||
|
data_type: text
|
||||||
|
- collection: product_requests
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: product_requests
|
||||||
|
field: date_created
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: product_requests
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
|
||||||
|
# products
|
||||||
|
- collection: products
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: products
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: products
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
|
||||||
|
# products_translations
|
||||||
|
- collection: products_translations
|
||||||
|
field: id
|
||||||
|
type: integer
|
||||||
|
meta:
|
||||||
|
collection: products_translations
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: products_translations
|
||||||
|
data_type: integer
|
||||||
|
is_primary_key: true
|
||||||
|
has_auto_increment: true
|
||||||
|
|
||||||
|
# visual_feedback (from current snapshot)
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: uuid
|
||||||
|
is_nullable: false
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: status
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
display: labels
|
||||||
|
interface: select-dropdown
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: status
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
default_value: open
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: type
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
display: labels
|
||||||
|
interface: select-dropdown
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: type
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
interface: input-multiline
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: text
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: url
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: url
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_info_group
|
||||||
|
interface: group-detail
|
||||||
|
sort: 6
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_name
|
||||||
|
group: user_info_group
|
||||||
|
interface: input
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: user_identity
|
||||||
|
group: user_info_group
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: user_identity
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
type: alias
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: technical_details_group
|
||||||
|
interface: group-detail
|
||||||
|
sort: 7
|
||||||
|
special:
|
||||||
|
- alias
|
||||||
|
- no-data
|
||||||
|
- group
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: selector
|
||||||
|
group: technical_details_group
|
||||||
|
interface: input
|
||||||
|
readonly: true
|
||||||
|
sort: 1
|
||||||
|
schema:
|
||||||
|
name: selector
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: character varying
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: x
|
||||||
|
group: technical_details_group
|
||||||
|
interface: input
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: x
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
type: float
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
field: 'y'
|
||||||
|
group: technical_details_group
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: 'y'
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: real
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 8
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
is_nullable: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: id
|
||||||
|
hidden: true
|
||||||
|
schema:
|
||||||
|
name: id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
is_primary_key: true
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
type: uuid
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
interface: select-relational
|
||||||
|
sort: 2
|
||||||
|
schema:
|
||||||
|
name: feedback_id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: uuid
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
type: string
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: user_name
|
||||||
|
interface: input
|
||||||
|
sort: 3
|
||||||
|
schema:
|
||||||
|
name: user_name
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: character varying
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
type: text
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: text
|
||||||
|
interface: input-multiline
|
||||||
|
sort: 4
|
||||||
|
schema:
|
||||||
|
name: text
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: text
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: date_created
|
||||||
|
type: timestamp
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
interface: datetime
|
||||||
|
readonly: true
|
||||||
|
sort: 5
|
||||||
|
schema:
|
||||||
|
name: date_created
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: timestamp with time zone
|
||||||
|
default_value: CURRENT_TIMESTAMP
|
||||||
|
|
||||||
systemFields:
|
systemFields:
|
||||||
- collection: directus_activity
|
- collection: directus_activity
|
||||||
field: timestamp
|
field: timestamp
|
||||||
@@ -64,4 +487,18 @@ systemFields:
|
|||||||
field: parent
|
field: parent
|
||||||
schema:
|
schema:
|
||||||
is_indexed: true
|
is_indexed: true
|
||||||
relations: []
|
|
||||||
|
relations:
|
||||||
|
- collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
related_collection: visual_feedback
|
||||||
|
schema:
|
||||||
|
column: feedback_id
|
||||||
|
foreign_key_column: id
|
||||||
|
foreign_key_table: visual_feedback
|
||||||
|
table: visual_feedback_comments
|
||||||
|
meta:
|
||||||
|
many_collection: visual_feedback_comments
|
||||||
|
many_field: feedback_id
|
||||||
|
one_collection: visual_feedback
|
||||||
|
one_field: null
|
||||||
|
|||||||
43
docker-compose.override.yml
Normal file
43
docker-compose.override.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
services:
|
||||||
|
klz-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: development
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/.next
|
||||||
|
environment:
|
||||||
|
- WATCHPACK_POLLING=true # Useful for Docker volume mounting issues on some systems
|
||||||
|
restart: "no"
|
||||||
|
container_name: klz-app-dev
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# Clear any production middlewares/headers redirect
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares="
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||||
|
# Configure main router for local HTTP without auth
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=web"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares="
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
directus-cms:
|
||||||
|
container_name: klz-cms-dev
|
||||||
|
restart: "no"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz.localhost}`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.service=${PROJECT_NAME:-klz-cables}-cms"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-cms.loadbalancer.server.port=8055"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
klz-db:
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
gatekeeper:
|
||||||
|
restart: "no"
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
klz-app:
|
klz-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
|
||||||
|
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- infra
|
- infra
|
||||||
@@ -10,22 +16,22 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
||||||
# HTTPS router (Standard)
|
# HTTPS router (Standard)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||||
|
|
||||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.rule=(${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}) && (PathPrefix(\"/health\", \"/sitemap.xml\", \"/robots.txt\", \"/manifest.webmanifest\", \"/api/og\") || PathRegexp(\".*opengraph-image.*\") || PathRegexp(\".*sitemap.*\"))"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`, `/sitemap.xml`, `/robots.txt`, `/manifest.webmanifest`, `/api/og`) || PathRegexp(`.*opengraph-image.*`) || PathRegexp(`.*sitemap.*`))"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.service=${PROJECT_NAME:-klz-cables}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.service=${PROJECT_NAME:-klz-cables}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.priority=2000"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.priority=2000"
|
||||||
@@ -47,20 +53,20 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
|
|
||||||
# Middleware Definitions
|
# Rate Limit Middleware
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/" ]
|
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
|
||||||
interval: 10s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 45s
|
||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
profiles: [ "gatekeeper" ]
|
profiles: [ "gatekeeper" ]
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
aliases:
|
aliases:
|
||||||
@@ -78,22 +84,18 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(\"${TRAEFIK_HOST:-testing.klz-cables.com}\") && PathPrefix(\"/gatekeeper\"))"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
directus:
|
directus-cms:
|
||||||
image: directus/directus:11
|
image: registry.infra.mintel.me/mintel/directus:latest
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
networks:
|
command: [ "node", "cli.js", "start" ]
|
||||||
default:
|
|
||||||
infra:
|
|
||||||
aliases:
|
|
||||||
- ${PROJECT_NAME:-klz-cables}-directus
|
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
@@ -102,37 +104,35 @@ services:
|
|||||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
DB_CLIENT: 'pg'
|
DB_CLIENT: 'pg'
|
||||||
DB_HOST: 'directus-db'
|
DB_HOST: 'klz-db'
|
||||||
DB_PORT: '5432'
|
DB_PORT: '5432'
|
||||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||||
WEBSOCKETS_ENABLED: 'true'
|
WEBSOCKETS_ENABLED: 'true'
|
||||||
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
|
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
|
||||||
# Error Tracking
|
HOST: '0.0.0.0'
|
||||||
SENTRY_DSN: ${SENTRY_DSN}
|
networks:
|
||||||
SENTRY_ENVIRONMENT: ${TARGET:-development}
|
- infra
|
||||||
LOGGER_LEVEL: ${LOG_LEVEL:-info}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./directus/uploads:/directus/uploads
|
- ./directus/uploads:/directus/uploads
|
||||||
- ./directus/extensions:/directus/extensions
|
- ./directus/extensions:/directus/extensions
|
||||||
- ./directus/schema:/directus/schema
|
- ./directus/schema:/directus/schema
|
||||||
- ./directus/migrations:/directus/migrations
|
- ./directus/migrations:/directus/migrations
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(\"${DIRECTUS_HOST}\")"
|
- "traefik.http.routers.klz-production-cms.rule=Host(`cms.klz.localhost`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
- "traefik.http.routers.klz-production-cms.entrypoints=web"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
- "traefik.http.routers.klz-production-cms.priority=5000"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
- "traefik.http.routers.klz-production-cms.tls=false"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
|
- "traefik.http.routers.klz-production-cms.service=klz-production-cms-svc"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
|
- "traefik.http.services.klz-production-cms-svc.loadbalancer.server.port=8055"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
klz-db:
|
||||||
directus-db:
|
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
@@ -141,6 +141,8 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||||
volumes:
|
volumes:
|
||||||
- directus-db-data:/var/lib/postgresql/data
|
- directus-db-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function createConfig() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
logging: {
|
logging: {
|
||||||
level: env.LOG_LEVEL,
|
level: env.LOG_LEVEL || 'info',
|
||||||
},
|
},
|
||||||
|
|
||||||
mail: {
|
mail: {
|
||||||
|
|||||||
@@ -207,6 +207,7 @@
|
|||||||
"description": "Entdecken Sie unser umfassendes Sortiment an zertifizierten Kabeln: von Niederspannung über Mittel- und Hochspannung bis hin zu spezialisierten Solarkabeln."
|
"description": "Entdecken Sie unser umfassendes Sortiment an zertifizierten Kabeln: von Niederspannung über Mittel- und Hochspannung bis hin zu spezialisierten Solarkabeln."
|
||||||
},
|
},
|
||||||
"title": "Unsere <green>Produkte</green>",
|
"title": "Unsere <green>Produkte</green>",
|
||||||
|
"breadcrumb": "Produkte",
|
||||||
"subtitle": "Entdecken Sie unser umfassendes Sortiment an hochwertigen Kabeln für jede Anwendung.",
|
"subtitle": "Entdecken Sie unser umfassendes Sortiment an hochwertigen Kabeln für jede Anwendung.",
|
||||||
"heroSubtitle": "Produktportfolio",
|
"heroSubtitle": "Produktportfolio",
|
||||||
"categoryLabel": "Kategorie",
|
"categoryLabel": "Kategorie",
|
||||||
@@ -393,4 +394,4 @@
|
|||||||
"cta": "Zurück zur Sicherheit"
|
"cta": "Zurück zur Sicherheit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,6 +207,7 @@
|
|||||||
"description": "Explore our comprehensive range of certified cables: from low voltage to medium and high voltage, as well as specialized solar cables."
|
"description": "Explore our comprehensive range of certified cables: from low voltage to medium and high voltage, as well as specialized solar cables."
|
||||||
},
|
},
|
||||||
"title": "Our <green>Products</green>",
|
"title": "Our <green>Products</green>",
|
||||||
|
"breadcrumb": "Products",
|
||||||
"subtitle": "Explore our comprehensive range of high-quality cables designed for every application.",
|
"subtitle": "Explore our comprehensive range of high-quality cables designed for every application.",
|
||||||
"heroSubtitle": "Product Portfolio",
|
"heroSubtitle": "Product Portfolio",
|
||||||
"categoryLabel": "Category",
|
"categoryLabel": "Category",
|
||||||
@@ -393,4 +394,4 @@
|
|||||||
"cta": "Back to Safety"
|
"cta": "Back to Safety"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
32
package.json
32
package.json
@@ -5,9 +5,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^21.0.0",
|
"@directus/sdk": "^21.0.0",
|
||||||
"@medv/finder": "^4.0.2",
|
"@medv/finder": "^4.0.2",
|
||||||
"@mintel/mail": "1.7.12",
|
"@mintel/mail": "1.8.3",
|
||||||
"@mintel/next-config": "1.7.12",
|
"@mintel/next-config": "1.8.3",
|
||||||
"@mintel/next-feedback": "1.7.12",
|
"@mintel/next-feedback": "1.8.10",
|
||||||
"@mintel/next-utils": "^1.7.15",
|
"@mintel/next-utils": "^1.7.15",
|
||||||
"@react-email/components": "^1.0.7",
|
"@react-email/components": "^1.0.7",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
"import-in-the-middle": "^1.11.0",
|
"import-in-the-middle": "^1.11.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.562.0",
|
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-i18next": "^15.4.3",
|
"next-i18next": "^15.4.3",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
@@ -46,8 +45,12 @@
|
|||||||
"@commitlint/cli": "^20.4.0",
|
"@commitlint/cli": "^20.4.0",
|
||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "1.7.12",
|
"@mintel/eslint-config": "1.8.3",
|
||||||
"@mintel/tsconfig": "1.7.12",
|
"@mintel/tsconfig": "1.8.3",
|
||||||
|
"@remotion/cli": "^4.0.421",
|
||||||
|
"@remotion/google-fonts": "^4.0.421",
|
||||||
|
"@remotion/player": "^4.0.421",
|
||||||
|
"@remotion/renderer": "^4.0.421",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
@@ -64,8 +67,10 @@
|
|||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"remotion": "^4.0.421",
|
||||||
"sass": "^1.97.1",
|
"sass": "^1.97.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@@ -73,7 +78,8 @@
|
|||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db gatekeeper",
|
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app directus-cms klz-db gatekeeper",
|
||||||
|
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d directus-cms klz-db gatekeeper",
|
||||||
"dev:local": "next dev",
|
"dev:local": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -102,6 +108,8 @@
|
|||||||
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
||||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||||
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||||
|
"remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
|
||||||
|
"remotion:preview": "remotion preview remotion/index.ts",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
@@ -110,5 +118,13 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"next": "16.1.6"
|
"next": "16.1.6"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@remotion/cli": "^4.0.421",
|
||||||
|
"@remotion/google-fonts": "^4.0.421",
|
||||||
|
"@remotion/player": "^4.0.421",
|
||||||
|
"@remotion/renderer": "^4.0.421",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"remotion": "^4.0.421"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1021
pnpm-lock.yaml
generated
1021
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
32
remotion/Root.tsx
Normal file
32
remotion/Root.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Composition } from 'remotion';
|
||||||
|
import { WebsiteVideo } from './WebsiteVideo';
|
||||||
|
import sessionData from './session.json';
|
||||||
|
import { RecordingSession } from '../types/record-mode';
|
||||||
|
|
||||||
|
const FPS = 60;
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
// Calculate duration based on last event + padding
|
||||||
|
const durationMs = (sessionData as unknown as RecordingSession).events.reduce((max, e) => {
|
||||||
|
return Math.max(max, e.timestamp + (e.duration || 1000));
|
||||||
|
}, 0);
|
||||||
|
const durationInFrames = Math.ceil((durationMs + 2000) / 1000 * FPS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Composition
|
||||||
|
id="WebsiteVideo"
|
||||||
|
component={WebsiteVideo}
|
||||||
|
durationInFrames={durationInFrames}
|
||||||
|
fps={FPS}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
defaultProps={{
|
||||||
|
session: sessionData as unknown as RecordingSession,
|
||||||
|
siteUrl: 'http://localhost:3000'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
107
remotion/WebsiteVideo.tsx
Normal file
107
remotion/WebsiteVideo.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { AbsoluteFill, useVideoConfig, useCurrentFrame, interpolate, spring, Easing } from 'remotion';
|
||||||
|
import { RecordingSession, RecordEvent } from '../types/record-mode';
|
||||||
|
|
||||||
|
export const WebsiteVideo: React.FC<{
|
||||||
|
session: RecordingSession | null;
|
||||||
|
siteUrl: string;
|
||||||
|
}> = ({ session, siteUrl }) => {
|
||||||
|
const { fps, width, height, durationInFrames } = useVideoConfig();
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
if (!session || !session.events.length) {
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: 'black', color: 'white', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
No session data found.
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEvents = useMemo(() => {
|
||||||
|
return [...session.events].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const elapsedTimeMs = (frame / fps) * 1000;
|
||||||
|
|
||||||
|
// --- Interpolation Logic ---
|
||||||
|
|
||||||
|
// 1. Find the current window (between which two events are we?)
|
||||||
|
const nextEventIndex = sortedEvents.findIndex(e => e.timestamp > elapsedTimeMs);
|
||||||
|
let currentEventIndex;
|
||||||
|
|
||||||
|
if (nextEventIndex === -1) {
|
||||||
|
// We are past the last event, stay at the end
|
||||||
|
currentEventIndex = sortedEvents.length - 1;
|
||||||
|
} else {
|
||||||
|
currentEventIndex = Math.max(0, nextEventIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEvent = sortedEvents[currentEventIndex];
|
||||||
|
// If there is no next event, we just stay at current (next=current)
|
||||||
|
const nextEvent = (nextEventIndex !== -1) ? sortedEvents[nextEventIndex] : currentEvent;
|
||||||
|
|
||||||
|
// 2. Calculate Progress between events
|
||||||
|
const gap = nextEvent.timestamp - currentEvent.timestamp;
|
||||||
|
const progress = gap > 0 ? (elapsedTimeMs - currentEvent.timestamp) / gap : 1;
|
||||||
|
const easedProgress = Easing.cubic(Math.min(Math.max(progress, 0), 1));
|
||||||
|
|
||||||
|
// 3. Calculate Cursor Position from Rects
|
||||||
|
const getCenter = (event: RecordEvent) => {
|
||||||
|
if (event.rect) {
|
||||||
|
return {
|
||||||
|
x: event.rect.x + event.rect.width / 2,
|
||||||
|
y: event.rect.y + event.rect.height / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { x: width / 2, y: height / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const p1 = getCenter(currentEvent);
|
||||||
|
const p2 = getCenter(nextEvent);
|
||||||
|
|
||||||
|
const cursorX = interpolate(easedProgress, [0, 1], [p1.x, p2.x]);
|
||||||
|
const cursorY = interpolate(easedProgress, [0, 1], [p1.y, p2.y]);
|
||||||
|
|
||||||
|
// 4. Zoom & Blur
|
||||||
|
const zoom = interpolate(easedProgress, [0, 1], [currentEvent.zoom || 1, nextEvent.zoom || 1]);
|
||||||
|
const isBlurry = currentEvent.motionBlur || nextEvent.motionBlur;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: '#000' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
transformOrigin: `${cursorX}px ${cursorY}px`,
|
||||||
|
filter: isBlurry ? 'blur(8px)' : 'none',
|
||||||
|
transition: 'filter 0.1s ease-out'
|
||||||
|
}}>
|
||||||
|
<iframe
|
||||||
|
src={siteUrl}
|
||||||
|
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||||
|
title="Website"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Cursor */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: cursorX,
|
||||||
|
top: cursorY,
|
||||||
|
width: 34, height: 34,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '3px solid black',
|
||||||
|
boxShadow: '0 4px 15px rgba(0,0,0,0.4)',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 100
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 12, height: 12, backgroundColor: '#3b82f6', borderRadius: '50%' }} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
remotion/index.ts
Normal file
4
remotion/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from 'remotion';
|
||||||
|
import { RemotionRoot } from './Root';
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
35
remotion/session.json
Normal file
35
remotion/session.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"id": "sample-session",
|
||||||
|
"name": "Sample Recording",
|
||||||
|
"createdAt": "2024-03-20T10:00:00.000Z",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"type": "click",
|
||||||
|
"timestamp": 1000,
|
||||||
|
"duration": 1000,
|
||||||
|
"zoom": 1,
|
||||||
|
"selector": "body",
|
||||||
|
"rect": {
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 50,
|
||||||
|
"height": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"type": "scroll",
|
||||||
|
"timestamp": 2500,
|
||||||
|
"duration": 1500,
|
||||||
|
"zoom": 1,
|
||||||
|
"selector": "footer",
|
||||||
|
"rect": {
|
||||||
|
"x": 500,
|
||||||
|
"y": 800,
|
||||||
|
"width": 100,
|
||||||
|
"height": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ if [ -z "$ENV" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed 's/-nextjs$//')
|
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed 's/-nextjs$//' | sed 's/-cables$//')
|
||||||
|
|
||||||
case $ENV in
|
case $ENV in
|
||||||
local)
|
local)
|
||||||
@@ -26,8 +26,8 @@ case $ENV in
|
|||||||
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
|
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
|
||||||
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
|
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
|
||||||
production)
|
production)
|
||||||
PROJECT_NAME="${PRJ_ID}-prod"
|
PROJECT_NAME="${PRJ_ID}-production"
|
||||||
OLD_PROJECT_NAME="${PRJ_ID}com" # Fallback for legacy naming
|
OLD_PROJECT_NAME="${PRJ_ID}-prod" # Fallback for previous convention
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ REMOTE_HOST="root@alpha.mintel.me"
|
|||||||
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
|
|
||||||
# DB Details (matching docker-compose defaults)
|
# DB Details (matching docker-compose defaults)
|
||||||
DB_USER="directus"
|
DB_USER="klz_db_user"
|
||||||
DB_NAME="directus"
|
DB_NAME="directus"
|
||||||
|
|
||||||
ACTION=$1
|
ACTION=$1
|
||||||
@@ -49,9 +49,9 @@ esac
|
|||||||
# Detect local container
|
# Detect local container
|
||||||
echo "🔍 Detecting local database..."
|
echo "🔍 Detecting local database..."
|
||||||
# Use a more robust way to find the container if multiple projects exist locally
|
# Use a more robust way to find the container if multiple projects exist locally
|
||||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-directus-db)
|
||||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||||
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
|
echo "❌ Local klz-directus-db container not found. Is it running? (npm run dev)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,19 @@
|
|||||||
--font-heading: 'Inter', system-ui, sans-serif;
|
--font-heading: 'Inter', system-ui, sans-serif;
|
||||||
--font-body: 'Inter', system-ui, sans-serif;
|
--font-body: 'Inter', system-ui, sans-serif;
|
||||||
|
|
||||||
--color-primary: #001a4d; /* Deep Blue */
|
--color-primary: #001a4d;
|
||||||
|
/* Deep Blue */
|
||||||
--color-primary-dark: #000d26;
|
--color-primary-dark: #000d26;
|
||||||
--color-primary-light: #e6ebf5;
|
--color-primary-light: #e6ebf5;
|
||||||
|
|
||||||
--color-saturated: #011dff; /* Saturated Blue Accent */
|
--color-saturated: #011dff;
|
||||||
|
/* Saturated Blue Accent */
|
||||||
|
|
||||||
--color-secondary: #003d82;
|
--color-secondary: #003d82;
|
||||||
--color-secondary-light: #0056b3;
|
--color-secondary-light: #0056b3;
|
||||||
|
|
||||||
--color-accent: #82ed20; /* Sustainability Green */
|
--color-accent: #82ed20;
|
||||||
|
/* Sustainability Green */
|
||||||
--color-accent-dark: #6bc41a;
|
--color-accent-dark: #6bc41a;
|
||||||
--color-accent-light: #f0f9e6;
|
--color-accent-light: #f0f9e6;
|
||||||
|
|
||||||
@@ -40,76 +43,107 @@
|
|||||||
--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
|
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
background-position: 100% 50%;
|
background-position: 100% 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slow-zoom {
|
@keyframes slow-zoom {
|
||||||
from {
|
from {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes reveal {
|
@keyframes reveal {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
filter: blur(8px);
|
filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slight-fade-in-from-bottom {
|
@keyframes slight-fade-in-from-bottom {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
filter: blur(4px);
|
filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
33% {
|
||||||
|
transform: translate(2%, 4%) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
66% {
|
||||||
|
transform: translate(-3%, 2%) scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply text-base md:text-lg antialiased;
|
@apply text-base md:text-lg antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -132,18 +166,23 @@
|
|||||||
h1 {
|
h1 {
|
||||||
@apply text-3xl md:text-5xl lg:text-6xl leading-[1.1];
|
@apply text-3xl md:text-5xl lg:text-6xl leading-[1.1];
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@apply text-2xl md:text-4xl lg:text-5xl leading-[1.2];
|
@apply text-2xl md:text-4xl lg:text-5xl leading-[1.2];
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@apply text-xl md:text-2xl lg:text-3xl leading-[1.3];
|
@apply text-xl md:text-2xl lg:text-3xl leading-[1.3];
|
||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
@apply text-lg md:text-xl lg:text-2xl leading-[1.4];
|
@apply text-lg md:text-xl lg:text-2xl leading-[1.4];
|
||||||
}
|
}
|
||||||
|
|
||||||
h5 {
|
h5 {
|
||||||
@apply text-base md:text-lg leading-[1.5];
|
@apply text-base md:text-lg leading-[1.5];
|
||||||
}
|
}
|
||||||
|
|
||||||
h6 {
|
h6 {
|
||||||
@apply text-sm md:text-base leading-[1.6];
|
@apply text-sm md:text-base leading-[1.6];
|
||||||
}
|
}
|
||||||
@@ -202,18 +241,23 @@
|
|||||||
.glass-panel {
|
.glass-panel {
|
||||||
@apply bg-white/80 backdrop-blur-md border border-white/20 shadow-lg;
|
@apply bg-white/80 backdrop-blur-md border border-white/20 shadow-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-overlay-gradient {
|
.image-overlay-gradient {
|
||||||
@apply absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent;
|
@apply absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.premium-card {
|
.premium-card {
|
||||||
@apply bg-white rounded-3xl shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1;
|
@apply bg-white rounded-3xl shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-narrative-container {
|
.sticky-narrative-container {
|
||||||
@apply grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20;
|
@apply grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-narrative-sidebar {
|
.sticky-narrative-sidebar {
|
||||||
@apply lg:col-span-4 lg:sticky lg:top-32 h-fit;
|
@apply lg:col-span-4 lg:sticky lg:top-32 h-fit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-narrative-content {
|
.sticky-narrative-content {
|
||||||
@apply lg:col-span-8;
|
@apply lg:col-span-8;
|
||||||
}
|
}
|
||||||
@@ -221,7 +265,8 @@
|
|||||||
|
|
||||||
/* Custom Utilities */
|
/* Custom Utilities */
|
||||||
@utility touch-target {
|
@utility touch-target {
|
||||||
min-height: 48px; /* Increased for better touch-first feel */
|
min-height: 48px;
|
||||||
|
/* Increased for better touch-first feel */
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -276,4 +321,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;
|
||||||
}
|
}
|
||||||
0
traefik_dump.json
Normal file
0
traefik_dump.json
Normal file
@@ -3,10 +3,18 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"@/*": [
|
||||||
"lib/*": ["./lib/*"],
|
"./*"
|
||||||
"components/*": ["./components/*"],
|
],
|
||||||
"data/*": ["./data/*"]
|
"lib/*": [
|
||||||
|
"./lib/*"
|
||||||
|
],
|
||||||
|
"components/*": [
|
||||||
|
"./components/*"
|
||||||
|
],
|
||||||
|
"data/*": [
|
||||||
|
"./data/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -17,5 +25,11 @@
|
|||||||
"tests/**/*.test.ts",
|
"tests/**/*.test.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "scripts", "reference", "data"]
|
"exclude": [
|
||||||
}
|
"node_modules",
|
||||||
|
"scripts",
|
||||||
|
"reference",
|
||||||
|
"data",
|
||||||
|
"remotion"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export interface RecordEvent {
|
|||||||
zoom?: number; // Zoom level during event
|
zoom?: number; // Zoom level during event
|
||||||
description?: string; // Optional label
|
description?: string; // Optional label
|
||||||
motionBlur?: boolean; // Enable motion blur effect
|
motionBlur?: boolean; // Enable motion blur effect
|
||||||
|
rect?: { x: number; y: number; width: number; height: number }; // Element position for rendering
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordingSession {
|
export interface RecordingSession {
|
||||||
|
|||||||
Reference in New Issue
Block a user