fix(og): enable automatic OG image discovery and refine Traefik whitelist
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
- Removed manual 'images' metadata overrides. - This allows Next.js to use built-in automatic discovery. - Ensures metadata uses the dynamic metadataBase from the environment. - Refined Traefik public router regex for sub-routes. - Restored and verified imports in modified page.tsx files.
This commit is contained in:
@@ -5,7 +5,6 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -50,7 +49,6 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
url: `${SITE_URL}/${locale}/${slug}`,
|
url: `${SITE_URL}/${locale}/${slug}`,
|
||||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import PowerCTA from '@/components/blog/PowerCTA';
|
|||||||
import TableOfContents from '@/components/blog/TableOfContents';
|
import TableOfContents from '@/components/blog/TableOfContents';
|
||||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
interface BlogPostProps {
|
interface BlogPostProps {
|
||||||
@@ -45,7 +44,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import Image from 'next/image';
|
|||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
|
import { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface BlogIndexProps {
|
interface BlogIndexProps {
|
||||||
@@ -31,7 +31,6 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
|||||||
title: `${t('title')} | KLZ Cables`,
|
title: `${t('title')} | KLZ Cables`,
|
||||||
description: t('description'),
|
description: t('description'),
|
||||||
url: `${SITE_URL}/${locale}/blog`,
|
url: `${SITE_URL}/${locale}/blog`,
|
||||||
images: getOGImageMetadata('blog', t('title'), locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/contact`,
|
url: `${SITE_URL}/${locale}/contact`,
|
||||||
siteName: 'KLZ Cables',
|
siteName: 'KLZ Cables',
|
||||||
images: getOGImageMetadata('contact', title, locale),
|
|
||||||
locale: `${locale.toUpperCase()}_DE`,
|
locale: `${locale.toUpperCase()}_DE`,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
@@ -43,7 +42,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
|||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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 { FeedbackOverlay } from '@mintel/next-feedback';
|
||||||
|
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||||
|
import { RecordModeOverlay } from '@/components/record-mode/RecordModeOverlay';
|
||||||
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,16 +100,19 @@ export default async function LocaleLayout({
|
|||||||
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
|
||||||
<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}>
|
||||||
<JsonLd />
|
<RecordModeProvider>
|
||||||
<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>
|
||||||
<CMSConnectivityNotice />
|
<Footer />
|
||||||
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AnalyticsProvider />
|
<AnalyticsProvider />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||||
|
<RecordModeOverlay />
|
||||||
|
</RecordModeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Metadata } from 'next';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
@@ -35,7 +34,6 @@ export async function generateMetadata({ params }: ProductsPageProps): Promise<M
|
|||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/products`,
|
url: `${SITE_URL}/${locale}/products`,
|
||||||
images: getOGImageMetadata('products', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Metadata } from 'next';
|
|||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
@@ -34,7 +33,6 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
|
|||||||
title: `${title} | KLZ Cables`,
|
title: `${title} | KLZ Cables`,
|
||||||
description,
|
description,
|
||||||
url: `${SITE_URL}/${locale}/team`,
|
url: `${SITE_URL}/${locale}/team`,
|
||||||
images: getOGImageMetadata('team', title, locale),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
|
|||||||
178
components/record-mode/RecordModeContext.tsx
Normal file
178
components/record-mode/RecordModeContext.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||||
|
import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
||||||
|
|
||||||
|
interface RecordModeContextType {
|
||||||
|
isActive: boolean;
|
||||||
|
setIsActive: (active: boolean) => void;
|
||||||
|
isRecording: boolean;
|
||||||
|
startRecording: () => void;
|
||||||
|
stopRecording: () => void;
|
||||||
|
events: RecordEvent[];
|
||||||
|
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||||
|
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||||
|
removeEvent: (id: string) => void;
|
||||||
|
clearEvents: () => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
playEvents: () => void;
|
||||||
|
stopPlayback: () => void;
|
||||||
|
currentSession: RecordingSession | null;
|
||||||
|
saveSession: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||||
|
|
||||||
|
export function useRecordMode() {
|
||||||
|
const context = useContext(RecordModeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRecordMode must be used within a RecordModeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [events, setEvents] = useState<RecordEvent[]>([]);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentSession, setCurrentSession] = useState<RecordingSession | null>(null);
|
||||||
|
const startTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const startRecording = () => {
|
||||||
|
setIsRecording(true);
|
||||||
|
setEvents([]);
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
setIsRecording(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEvent = (eventData: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||||
|
const timestamp = Date.now() - startTimeRef.current;
|
||||||
|
const newEvent: RecordEvent = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp,
|
||||||
|
...eventData,
|
||||||
|
};
|
||||||
|
setEvents((prev) => [...prev, newEvent]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEvent = (id: string, updates: Partial<RecordEvent>) => {
|
||||||
|
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...updates } : e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEvent = (id: string) => {
|
||||||
|
setEvents((prev) => prev.filter((e) => e.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearEvents = () => {
|
||||||
|
setEvents([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playEvents = async () => {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
// Simple playback logic mostly for preview
|
||||||
|
const startPlayTime = Date.now();
|
||||||
|
|
||||||
|
// Sort events by timestamp just in case
|
||||||
|
const sortedEvents = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
for (const event of sortedEvents) {
|
||||||
|
if (!isPlaying) break; // Check if stopped
|
||||||
|
|
||||||
|
const targetTime = startPlayTime + event.timestamp;
|
||||||
|
const now = Date.now();
|
||||||
|
const delay = targetTime - now;
|
||||||
|
|
||||||
|
if (delay > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute event visual feedback
|
||||||
|
if (document) {
|
||||||
|
if (event.selector) {
|
||||||
|
const el = document.querySelector(event.selector);
|
||||||
|
if (el) {
|
||||||
|
// Highlight or scroll to element
|
||||||
|
if (event.type === 'scroll') {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else if (event.type === 'click') {
|
||||||
|
// Visualize click
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const clickMarker = document.createElement('div');
|
||||||
|
clickMarker.style.position = 'fixed';
|
||||||
|
clickMarker.style.left = `${rect.left + rect.width / 2}px`;
|
||||||
|
clickMarker.style.top = `${rect.top + rect.height / 2}px`;
|
||||||
|
clickMarker.style.width = '20px';
|
||||||
|
clickMarker.style.height = '20px';
|
||||||
|
clickMarker.style.borderRadius = '50%';
|
||||||
|
clickMarker.style.backgroundColor = 'rgba(255, 0, 0, 0.5)';
|
||||||
|
clickMarker.style.transform = 'translate(-50%, -50%)';
|
||||||
|
clickMarker.style.zIndex = '99999';
|
||||||
|
document.body.appendChild(clickMarker);
|
||||||
|
setTimeout(() => clickMarker.remove(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPlayback = () => {
|
||||||
|
setIsPlaying(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 (
|
||||||
|
<RecordModeContext.Provider
|
||||||
|
value={{
|
||||||
|
isActive,
|
||||||
|
setIsActive,
|
||||||
|
isRecording,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
events,
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
removeEvent,
|
||||||
|
clearEvents,
|
||||||
|
isPlaying,
|
||||||
|
playEvents,
|
||||||
|
stopPlayback,
|
||||||
|
currentSession,
|
||||||
|
saveSession,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RecordModeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
components/record-mode/RecordModeOverlay.tsx
Normal file
352
components/record-mode/RecordModeOverlay.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRecordMode } from './RecordModeContext';
|
||||||
|
import { finder } from '@medv/finder';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
MousePointer2,
|
||||||
|
Scroll,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { RecordEvent } from '@/types/record-mode';
|
||||||
|
|
||||||
|
export function RecordModeOverlay() {
|
||||||
|
const {
|
||||||
|
isActive,
|
||||||
|
setIsActive,
|
||||||
|
isRecording,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
events,
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
removeEvent,
|
||||||
|
isPlaying,
|
||||||
|
playEvents,
|
||||||
|
saveSession,
|
||||||
|
clearEvents,
|
||||||
|
} = useRecordMode();
|
||||||
|
|
||||||
|
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
||||||
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
const handleMouseOver = (e: MouseEvent) => {
|
||||||
|
if (pickingMode) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.record-mode-ui')) return;
|
||||||
|
setHoveredElement(target);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (pickingMode && hoveredElement) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const selector = finder(hoveredElement);
|
||||||
|
|
||||||
|
if (pickingMode === 'click') {
|
||||||
|
addEvent({
|
||||||
|
type: 'click',
|
||||||
|
selector,
|
||||||
|
duration: 1000,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Click on ${hoveredElement.tagName.toLowerCase()}`,
|
||||||
|
motionBlur: false,
|
||||||
|
});
|
||||||
|
} else if (pickingMode === 'scroll') {
|
||||||
|
addEvent({
|
||||||
|
type: 'scroll',
|
||||||
|
selector,
|
||||||
|
duration: 1000,
|
||||||
|
zoom: 1,
|
||||||
|
description: `Scroll to ${hoveredElement.tagName.toLowerCase()}`,
|
||||||
|
motionBlur: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setPickingMode(null);
|
||||||
|
setHoveredElement(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pickingMode) {
|
||||||
|
window.addEventListener('mouseover', handleMouseOver);
|
||||||
|
window.addEventListener('click', handleClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mouseover', handleMouseOver);
|
||||||
|
window.removeEventListener('click', handleClick, true);
|
||||||
|
};
|
||||||
|
}, [isActive, pickingMode, hoveredElement, addEvent]);
|
||||||
|
|
||||||
|
const startEditing = (event: RecordEvent) => {
|
||||||
|
setEditingEventId(event.id);
|
||||||
|
setEditForm({ ...event });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (editingEventId && editForm) {
|
||||||
|
updateEvent(editingEventId, editForm);
|
||||||
|
setEditingEventId(null);
|
||||||
|
setEditForm({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingEventId(null);
|
||||||
|
setEditForm({});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 rounded-full bg-white" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
||||||
|
{/* Hover Highlighter */}
|
||||||
|
{pickingMode && hoveredElement && (
|
||||||
|
<div
|
||||||
|
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 */}
|
||||||
|
<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 justify-between mb-4">
|
||||||
|
<h3 className="font-bold flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-500'}`}
|
||||||
|
/>
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Timeline */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-2 flex-1 overflow-y-auto space-y-2 min-h-[200px]">
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="text-center text-white/30 text-sm py-4">No events recorded yet.</div>
|
||||||
|
)}
|
||||||
|
{events.map((event, index) => (
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
{event.type === 'click' && <MousePointer2 size={14} className="text-blue-400" />}
|
||||||
|
{event.type === 'scroll' && <Scroll size={14} className="text-green-400" />}
|
||||||
|
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
<span className="font-mono text-white/50 text-xs mr-2">{event.selector}</span>
|
||||||
|
{event.motionBlur && (
|
||||||
|
<span className="text-[10px] bg-purple-500/20 text-purple-300 px-1 rounded ml-1">
|
||||||
|
Blur
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{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={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeEvent(event.id);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-white/10 rounded text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picking Instructions */}
|
||||||
|
{pickingMode && (
|
||||||
|
<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">
|
||||||
|
Select element to {pickingMode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
contact.html
Normal file
4
contact.html
Normal file
File diff suppressed because one or more lines are too long
@@ -22,7 +22,7 @@ services:
|
|||||||
- "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\") || PathPrefix(\"/de/opengraph-image\", \"/en/opengraph-image\", \"/de/blog/opengraph-image\", \"/en/blog/opengraph-image\", \"/de/products/opengraph-image\", \"/en/products/opengraph-image\") || PathRegexp(\"^/.*opengraph-image.*$\"))"
|
- "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.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=true"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^21.0.0",
|
"@directus/sdk": "^21.0.0",
|
||||||
|
"@medv/finder": "^4.0.2",
|
||||||
"@mintel/mail": "1.7.12",
|
"@mintel/mail": "1.7.12",
|
||||||
"@mintel/next-config": "1.7.12",
|
"@mintel/next-config": "1.7.12",
|
||||||
"@mintel/next-feedback": "1.7.12",
|
"@mintel/next-feedback": "1.7.12",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@directus/sdk':
|
'@directus/sdk':
|
||||||
specifier: ^21.0.0
|
specifier: ^21.0.0
|
||||||
version: 21.1.0
|
version: 21.1.0
|
||||||
|
'@medv/finder':
|
||||||
|
specifier: ^4.0.2
|
||||||
|
version: 4.0.2
|
||||||
'@mintel/mail':
|
'@mintel/mail':
|
||||||
specifier: 1.7.12
|
specifier: 1.7.12
|
||||||
version: 1.7.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.7.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -1028,6 +1031,9 @@ packages:
|
|||||||
'@types/react': '>=16'
|
'@types/react': '>=16'
|
||||||
react: '>=16'
|
react: '>=16'
|
||||||
|
|
||||||
|
'@medv/finder@4.0.2':
|
||||||
|
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||||
|
|
||||||
'@mintel/eslint-config@1.7.12':
|
'@mintel/eslint-config@1.7.12':
|
||||||
resolution: {integrity: sha512-ofX68JWCW8ztD9tt/1MDb6pSr9MJKq3js3Vny7VoT/bObjpR/iO9tJp0ekiq12Ps8VTEgDh1qwwmf2wrJJBRpQ==}
|
resolution: {integrity: sha512-ofX68JWCW8ztD9tt/1MDb6pSr9MJKq3js3Vny7VoT/bObjpR/iO9tJp0ekiq12Ps8VTEgDh1qwwmf2wrJJBRpQ==}
|
||||||
|
|
||||||
@@ -7473,6 +7479,8 @@ snapshots:
|
|||||||
'@types/react': 19.2.13
|
'@types/react': 19.2.13
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
'@medv/finder@4.0.2': {}
|
||||||
|
|
||||||
'@mintel/eslint-config@1.7.12(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@mintel/eslint-config@1.7.12(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/eslintrc': 3.3.3
|
'@eslint/eslintrc': 3.3.3
|
||||||
|
|||||||
17
types/record-mode.ts
Normal file
17
types/record-mode.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface RecordEvent {
|
||||||
|
id: string;
|
||||||
|
type: 'click' | 'scroll' | 'wait' | 'hover';
|
||||||
|
selector?: string; // CSS selector
|
||||||
|
timestamp: number; // Time in ms since start of recording
|
||||||
|
duration: number; // Duration of the action (e.g. scroll duration)
|
||||||
|
zoom?: number; // Zoom level during event
|
||||||
|
description?: string; // Optional label
|
||||||
|
motionBlur?: boolean; // Enable motion blur effect
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingSession {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
events: RecordEvent[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user